#!/usr/bin/env node // Vloopex setup: checks this computer, installs what Vloopex needs, the AI // harnesses the person picks and a `vloopex` command, then shows plan options. // install.sh and install.ps1 provide Node.js first, then run this file. import { spawn } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import fs from "node:fs"; import http from "node:http"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; export const NODE_MIN = "24.17.0"; export const VLOOPEX_REPO = "https://gitlab.com/Neurochrom/Vloopex.git"; // Plan links go through this redirect service, so their targets can change // without a new setup release. Each plan's `url` is the direct fallback. export const GO_BASE = "https://vloopex.com/go/"; const LAUNCHER_MARK = "Vloopex launcher, written by Vloopex setup."; const RC_COMMENT = "# Added by Vloopex setup"; export class SetupError extends Error {} // --------------------------------------------------------------------------- // Harnesses and plans const curlPipe = (url, shell) => ["bash", ["-c", `set -o pipefail; curl -fsSL ${url} | ${shell}`]]; const powershellPipe = url => ["powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", `irm ${url} | iex`]]; export const HARNESSES = [ { id: "claude", name: "Claude Code", command: "claude", blurb: "Anthropic's coding agent", install: { unix: curlPipe("https://claude.ai/install.sh", "bash"), win32: powershellPipe("https://claude.ai/install.ps1") }, update: ["update"], npmPackage: "@anthropic-ai/claude-code", }, { id: "codex", name: "Codex", command: "codex", blurb: "OpenAI's coding agent", install: { unix: curlPipe("https://chatgpt.com/codex/install.sh", "sh"), win32: powershellPipe("https://chatgpt.com/codex/install.ps1") }, // The Codex installers otherwise offer to start Codex when they finish. installEnv: { CODEX_NON_INTERACTIVE: "1" }, update: ["update"], npmPackage: "@openai/codex", }, { id: "kilo", name: "Kilo", command: "kilo", blurb: "open-source agent for many models", install: { unix: curlPipe("https://kilo.ai/cli/install", "bash"), win32: ["npm", ["install", "-g", "@kilocode/cli"]] }, update: ["upgrade"], npmPackage: "@kilocode/cli", }, ]; // The accounts that the harnesses sign in to. Kilo can use its own account, // OpenRouter, or both. export const ACCOUNTS = [ { id: "claude", harness: "claude", name: "Claude Code", signIn: "claude auth login", plans: [ { slug: "claude-pro", label: "Claude Pro", hint: "for regular use", url: "https://claude.com/pricing" }, { slug: "claude-max", label: "Claude Max", hint: "higher limits for long loops", url: "https://claude.com/pricing/max" }, { slug: "anthropic-api", label: "Anthropic API", hint: "pay per use", url: "https://platform.claude.com/" }, ], }, { id: "codex", harness: "codex", name: "Codex", signIn: "codex login", plans: [ { slug: "chatgpt-plus", label: "ChatGPT Plus", hint: "for regular use", url: "https://chatgpt.com/pricing" }, { slug: "chatgpt-pro", label: "ChatGPT Pro", hint: "higher limits for long loops", url: "https://chatgpt.com/pricing" }, { slug: "openai-api", label: "OpenAI API", hint: "pay per use", url: "https://platform.openai.com/" }, ], }, { id: "kilo", harness: "kilo", name: "Kilo", signIn: "kilo auth login -p kilo", plans: [ { slug: "kilo-pass", label: "Kilo Pass", hint: "monthly credit subscription", url: "https://kilo.ai/pricing" }, { slug: "kilo-credits", label: "Kilo credits", hint: "pay per use, many models", url: "https://app.kilo.ai/" }, ], }, { id: "openrouter", harness: "kilo", name: "OpenRouter", signIn: "kilo auth login -p openrouter", plans: [ { slug: "openrouter", label: "OpenRouter credits", hint: "pay per use, many models", url: "https://openrouter.ai/settings/credits" }, ], }, ]; export function planUrl(plan, goLinksLive) { return goLinksLive ? GO_BASE + plan.slug : plan.url; } // --------------------------------------------------------------------------- // Options const USAGE = `Usage: node setup.mjs [options] --check Report what is installed and what is missing. Change nothing. --yes, -y Ask nothing and take the defaults (for scripts and CI). --dir Vloopex folder (default: ${defaultInstallDir()}). --source Git repository or local folder to clone Vloopex from. --harnesses Harnesses to install when missing: claude,codex,kilo or none. --update-harnesses Also update the harnesses that are installed. --no-path Do not change PATH or shell startup files. --desktop With --yes: add the Start menu, app and right-click entries. --uninstall Remove what setup installed. Settings, chats and harnesses stay. --dry-run Show every step and what it would change. Change nothing. --pretend-missing With --dry-run, treat these parts as missing to show their install steps: git, vloopex, claude, codex, kilo. --help, -h Show this help.`; export function parseOptions(argv) { const { values } = parseArgs({ args: argv, strict: true, options: { check: { type: "boolean" }, yes: { type: "boolean", short: "y" }, dir: { type: "string" }, source: { type: "string" }, harnesses: { type: "string" }, "update-harnesses": { type: "boolean" }, "no-path": { type: "boolean" }, desktop: { type: "boolean" }, uninstall: { type: "boolean" }, "dry-run": { type: "boolean" }, "pretend-missing": { type: "string" }, help: { type: "boolean", short: "h" }, }, }); const pretendMissing = values["pretend-missing"]?.split(",").map(part => part.trim()).filter(Boolean) ?? []; if (pretendMissing.length && !values["dry-run"]) throw new SetupError("--pretend-missing works only with --dry-run."); const unknownPart = pretendMissing.find(part => !PRETEND_PARTS.includes(part)); if (unknownPart) throw new SetupError(`Unknown part "${unknownPart}". Use ${PRETEND_PARTS.join(", ")}.`); let harnesses = null; if (values.harnesses !== undefined) { harnesses = values.harnesses === "none" ? [] : values.harnesses.split(",").map(id => id.trim()).filter(Boolean); const unknown = harnesses.find(id => !HARNESSES.some(harness => harness.id === id)); if (unknown) throw new SetupError(`Unknown harness "${unknown}". Use claude, codex, kilo or none.`); } const source = values.source ?? VLOOPEX_REPO; return { check: Boolean(values.check), yes: Boolean(values.yes), help: Boolean(values.help), dir: values.dir ? path.resolve(values.dir) : null, // A relative local folder must still resolve after git changes directory. source: fs.existsSync(source) ? path.resolve(source) : source, harnesses, updateHarnesses: Boolean(values["update-harnesses"]), changePath: !values["no-path"], desktop: Boolean(values.desktop), uninstall: Boolean(values.uninstall), dryRun: Boolean(values["dry-run"]), pretendMissing, }; } export function defaultInstallDir(platform = process.platform, env = process.env, home = os.homedir()) { if (platform === "win32") return path.win32.join(env.LOCALAPPDATA || path.win32.join(home, "AppData", "Local"), "Vloopex"); return path.posix.join(env.XDG_DATA_HOME || path.posix.join(home, ".local", "share"), "vloopex"); } // --------------------------------------------------------------------------- // Terminal UI const out = process.stdout; const useColor = Boolean(out.isTTY) && !("NO_COLOR" in process.env); const style = code => text => (useColor ? `\x1b[${code}m${text}\x1b[0m` : String(text)); const dim = style(2), bold = style(1), red = style(31), green = style(32), yellow = style(33); const cyan = style(36), magenta = style(35), gray = style(90), inverse = style(7); // The classic Windows console font has no glyphs for the box symbols. export function supportsUnicode(env = process.env, platform = process.platform) { if (platform !== "win32") return env.TERM !== "linux"; return Boolean(env.WT_SESSION || env.TERM_PROGRAM || env.MSYSTEM || env.ConEmuTask || env.TERMINAL_EMULATOR || env.CI) || env.TERM === "xterm-256color" || env.TERM === "alacritty"; } // The second set uses only glyphs that the fonts of the classic Windows // console and of the Linux console have. A check mark reads as "selected"; // the x that stood there read as "crossed out". const S = supportsUnicode() ? { bar: "│", start: "┌", end: "└", done: "◇", active: "◆", ok: "✓", fail: "✖", warn: "▲", off: "○", on: "●", checked: "[✓]", unchecked: "[ ]", h: "─", corner: "╮", foot: "╯", tee: "├", updown: "↑/↓", leftright: "←/→", spin: ["◒", "◐", "◓", "◑"] } : { bar: "│", start: "┌", end: "└", done: "o", active: "*", ok: "√", fail: "×", warn: "!", off: "-", on: ">", checked: "[√]", unchecked: "[ ]", h: "─", corner: "┐", foot: "┘", tee: "├", updown: "up/down", leftright: "left/right", spin: ["-", "\\", "|", "/"] }; const stripAnsi = text => String(text).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""); const visibleLength = text => stripAnsi(text).length; // The rows that a line takes on the screen, with wrapping. const rowsOf = text => Math.max(1, Math.ceil(visibleLength(text) / (out.columns || 80))); // Going back to an earlier question clears what came after it, so the rows // written so far are counted. let writtenRows = 0; const print = (text = "") => { out.write(`${text}\n`); writtenRows += rowsOf(text); }; const ui = { intro(title) { print(); print(`${gray(S.start)} ${inverse(` ${title} `)}`); print(gray(S.bar)); }, step(text) { print(`${green(S.done)} ${text}`); }, info(text) { for (const row of String(text).split("\n")) print(`${gray(S.bar)} ${row}`); }, warn(text) { print(`${yellow(S.warn)} ${text}`); }, error(text) { print(`${red(S.fail)} ${text}`); }, dry(text) { ui.info(`${yellow("dry run")} ${dim(text)}`); }, box(title, rows) { const width = Math.max(visibleLength(title) + 2, ...rows.map(visibleLength)) + 2; // A box wider than the terminal wraps, so it stays open on the right. if (width + 4 > (out.columns || 80)) { print(`${green(S.done)} ${bold(title)}`); for (const row of rows) print(`${gray(S.bar)} ${row}`); return; } print(`${green(S.done)} ${bold(title)} ${gray(S.h.repeat(width - visibleLength(title) - 1) + S.corner)}`); for (const row of rows) print(`${gray(S.bar)} ${row}${" ".repeat(width - visibleLength(row))}${gray(S.bar)}`); print(gray(S.tee + S.h.repeat(width + 2) + S.foot)); }, outro(text) { print(gray(S.bar)); print(`${gray(S.end)} ${text}`); print(); }, // The row that the next line goes to, and the way back to it: rewind // clears everything written since. mark() { return writtenRows; }, rewind(row) { const up = writtenRows - row; if (up > 0) out.write(`\x1b[${up}F\x1b[J`); writtenRows = row; }, }; const MARKS = { ok: () => green(S.ok), missing: () => red(S.fail), warn: () => yellow(S.warn), off: () => gray(S.off) }; const statusRow = (state, name, detail) => `${MARKS[state]()} ${name.padEnd(12)} ${dim(detail)}`; async function withSpinner(running, done, task) { if (!out.isTTY) { const result = await task(); ui.step(done); return result; } let frame = 0; const draw = () => out.write(`\r${magenta(S.spin[frame++ % S.spin.length])} ${running}`); out.write("\x1b[?25l"); draw(); const timer = setInterval(draw, 90); try { return await task(); } finally { clearInterval(timer); out.write("\r\x1b[K\x1b[?25h"); ui.step(done); } } // A prompt with `back` on resolves to this when the person presses esc. export const BACK = Symbol("back"); // With these on, the terminal reports mouse clicks and wheel moves (xterm's // button tracking with the SGR encoding). const MOUSE_ON = "\x1b[?1000h\x1b[?1006h", MOUSE_OFF = "\x1b[?1006l\x1b[?1000l"; const RESTORE = `\x1b[0m\x1b[?25h${MOUSE_OFF}`; const CSI_KEYS = { A: "up", B: "down", C: "right", D: "left", H: "home", F: "end" }; const TILDE_KEYS = { 1: "home", 7: "home", 4: "end", 8: "end", 3: "delete", 5: "pageup", 6: "pagedown" }; // Turns what the terminal sends into events: { name, text, ctrl } for a key, // { name: "mouse", button, x, y, press } for a click, "wheelup" and // "wheeldown" for the wheel, and { name: "cursor", row, column } for the // answer to a cursor position request. `rest` is an unfinished escape // sequence, or a lone escape, which the next chunk may continue. export function parseKeys(text) { const events = []; let rest = ""; for (let index = 0; index < text.length;) { const char = text[index]; if (char !== "\x1b") { const code = char.charCodeAt(0); // A character outside the basic plane takes two code units. const length = code >= 0xd800 && code <= 0xdbff ? 2 : 1; const typed = text.slice(index, index + length); index += length; if (char === "\r" || char === "\n") events.push({ name: "return" }); else if (char === "\t") events.push({ name: "tab" }); else if (char === "\x7f" || char === "\b") events.push({ name: "backspace" }); else if (char === " ") events.push({ name: "space", text: " " }); else if (code < 32) events.push({ name: String.fromCharCode(code + 96), ctrl: true }); else events.push({ name: typed.toLowerCase(), text: typed }); continue; } const tail = text.slice(index); let match; if ((match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(tail))) { const button = Number(match[1]); const where = { x: Number(match[2]), y: Number(match[3]), press: match[4] === "M" }; if (button & 64) events.push({ name: button & 1 ? "wheeldown" : "wheelup", ...where }); else events.push({ name: "mouse", button: button & 3, ...where }); } else if ((match = /^\x1b\[(\d+);(\d+)R/.exec(tail))) { events.push({ name: "cursor", row: Number(match[1]), column: Number(match[2]) }); } else if ((match = /^\x1b\[([0-9;]*)([A-Za-z~])/.exec(tail))) { const [, params, final] = match; const first = params.split(";")[0]; if (final === "~") { if (TILDE_KEYS[first]) events.push({ name: TILDE_KEYS[first] }); } else if (CSI_KEYS[final] && (first === "" || first === "1")) { events.push({ name: CSI_KEYS[final] }); } // Other reports, such as focus changes, mean nothing here. } else if ((match = /^\x1bO([A-Za-z])/.exec(tail))) { if (CSI_KEYS[match[1]]) events.push({ name: CSI_KEYS[match[1]] }); } else if (/^\x1b(\[[0-9;<]*|O)?$/.test(tail)) { rest = tail; break; } else { events.push({ name: "escape" }); index++; continue; } index += match[0].length; } return { events, rest }; } // Counts the key readers, so that a reader's end knows whether the next one // has started already. let readers = 0; // Reads keys from the terminal in raw mode and hands each event to `handle`; // the returned function stops that. A lone escape key arrives after a short // wait, because the sequences of other keys start with the same byte. function readKeys(handle) { const input = process.stdin; const generation = ++readers; let pending = "", timer = null, stopped = false; const onData = chunk => { clearTimeout(timer); const { events, rest } = parseKeys(pending + chunk); pending = rest.length > 32 ? "" : rest; for (const event of events) { if (stopped) return; handle(event); } if (pending === "\x1b" && !stopped) { timer = setTimeout(() => { pending = ""; handle({ name: "escape" }); }, 300); } }; input.setEncoding("utf8"); if (!input.isRaw) input.setRawMode(true); input.resume(); input.on("data", onData); return () => { stopped = true; clearTimeout(timer); input.off("data", onData); input.pause(); // Raw mode ends in the next turn of the event loop, not in the key // event that ends the prompt: on Windows, leaving it from inside a read // callback after an earlier read was cancelled stops every later read // from returning. A reader that has started in the meantime keeps it. setImmediate(() => { if (readers === generation) input.setRawMode(false); }); }; } // Waits for the turn of the event loop in which a prompt's raw mode ends, // so that a program started after a prompt gets a normal terminal. const terminalSettled = () => new Promise(resolve => setImmediate(resolve)); // The footer of a prompt: the keys that work, each with what it does. A // click on "enter" or "esc" there works like the key. function footerLine(hints) { const spans = []; let column = 3; // after the frame's corner and two spaces const parts = hints.map(([keys, action], index) => { if (index) column += 3; // the separator const text = `${keys} ${action}`; spans.push({ keys, from: column + 1, to: column + text.length }); column += text.length; return `${cyan(keys)} ${dim(action)}`; }); return { text: `${cyan(S.end)} ${parts.join(dim(" · "))}`, // The keys named at a column, or null. keysAt: x => spans.find(span => x >= span.from && x <= span.to)?.keys ?? null, }; } // Draws a prompt and redraws it on each key until `key` or `click` returns // { value }. `active` gives the lines of the prompt, `done` the lines that // stay once it is answered, and `hints` the keys for the footer. With `back` // on, esc resolves to BACK; ctrl+c cancels setup. The mouse works too: a // click goes to `click` with the line of the prompt and the column, and the // wheel moves like the arrow keys. function interact({ active, done, key, click, hints, back = false }) { return new Promise(resolve => { const footer = footerLine(back ? [...hints, ["esc", "back"]] : hints); let lines = [], shown = 0, origin = null, stop = () => {}; const draw = (next, locate = false) => { if (shown) out.write(`\x1b[${shown}F\x1b[J`); lines = next; out.write(`${lines.join("\n")}\n`); shown = lines.reduce((rows, line) => rows + rowsOf(line), 0); // The first draw asks where the cursor is; the terminal answers with // a cursor event, which locates the lines for clicks. A later draw // that grows at the bottom of the screen scrolls them up. if (locate) out.write("\x1b[6n"); else if (origin !== null) origin = Math.min(origin, (out.rows || 24) + 1 - shown); }; const finish = (value, how) => { stop(); out.write(MOUSE_OFF); if (how === "back") { if (shown) out.write(`\x1b[${shown}F\x1b[J`); } else { draw(done(value, how === "cancel")); writtenRows += shown; } out.write("\x1b[?25h"); if (how === "cancel") { ui.outro(red("Setup cancelled.")); process.exit(130); } resolve(how === "back" ? BACK : value); }; // The line of the prompt on a row of the screen, or -1. const lineAt = row => { let top = origin; for (let index = 0; index < lines.length; index++) { const rows = rowsOf(lines[index]); if (row >= top && row < top + rows) return index; top += rows; } return -1; }; const handle = event => { if (event.name === "cursor") { origin = event.row - shown; return; } if (event.ctrl && event.name === "c") return finish(null, "cancel"); if (event.name === "escape") return back ? finish(null, "back") : undefined; let result = null; if (event.name === "mouse") { if (origin === null || !event.press || event.button !== 0) return; const line = lineAt(event.y); if (line === lines.length - 1) { const keys = footer.keysAt(event.x); if (keys === "esc") return finish(null, "back"); if (keys !== "enter") return; result = key({ name: "return" }); } else if (line > 0 && click) { result = click(line, event.x); } else { return; } } else if (event.name === "wheelup") { result = key({ name: "up" }); } else if (event.name === "wheeldown") { result = key({ name: "down" }); } else { result = key(event, event.text); } if (result) return finish(result.value, "done"); draw([...active(), footer.text]); }; stop = readKeys(handle); out.write(`\x1b[?25l${MOUSE_ON}`); draw([...active(), footer.text], true); }); } const answered = (message, answer) => [`${green(S.done)} ${message}`, `${gray(S.bar)} ${dim(answer)}`]; const cancelledLines = message => [`${red(S.fail)} ${message}`]; // Each prompt takes the answer that --yes uses. With `back` on, esc resolves // to BACK instead of an answer. function createPrompts(interactive) { const preset = (message, answer, label) => { for (const line of answered(message, label(answer))) print(line); return answer; }; const record = (message, label) => (answer, cancelled) => (cancelled ? cancelledLines(message) : answered(message, label(answer))); const moved = (pressed, cursor, count) => { if (pressed.name === "up" || pressed.name === "k") return (cursor + count - 1) % count; if (pressed.name === "down" || pressed.name === "j") return (cursor + 1) % count; if (pressed.name === "home") return 0; if (pressed.name === "end") return count - 1; return cursor; }; return { interactive, confirm(message, initial, { back = false } = {}) { const label = value => (value ? "Yes" : "No"); if (!interactive) return Promise.resolve(preset(message, initial, label)); let value = initial; // "No" starts after the frame, "Yes" and the slash between them. const noColumn = 3 + `${S.on} Yes`.length + 4; return interact({ active: () => [ `${cyan(S.active)} ${message}`, `${cyan(S.bar)} ${value ? `${green(S.on)} Yes` : dim(`${S.off} Yes`)} ${dim("/")} ${value ? dim(`${S.off} No`) : `${green(S.on)} No`}`, ], hints: [[`${S.leftright} or y/n`, "choose"], ["enter", "confirm"]], done: record(message, label), key: (pressed, text) => { if (["left", "right", "tab", "h", "l"].includes(pressed.name)) value = !value; if (text === "y" || text === "Y") return { value: true }; if (text === "n" || text === "N") return { value: false }; if (pressed.name === "return") return { value }; return null; }, click: (line, x) => (line === 1 ? { value: x < noColumn } : null), back, }); }, select(message, options, initial, { back = false } = {}) { const label = value => options.find(option => option.value === value)?.label ?? ""; if (!interactive) return Promise.resolve(preset(message, initial, label)); let cursor = Math.max(0, options.findIndex(option => option.value === initial)); const width = Math.max(...options.map(option => option.label.length)); return interact({ active: () => [ `${cyan(S.active)} ${message}`, ...options.map((option, index) => { const hint = option.hint ? ` ${dim(option.hint)}` : ""; return index === cursor ? `${cyan(S.bar)} ${green(S.on)} ${option.label.padEnd(width)}${hint}` : `${cyan(S.bar)} ${dim(`${S.off} ${option.label}`)}`; }), ], hints: [[S.updown, "move"], ["enter", "choose"]], done: record(message, label), key: pressed => { cursor = moved(pressed, cursor, options.length); if (pressed.name === "return") return { value: options[cursor].value }; return null; }, click: line => (options[line - 1] ? { value: options[line - 1].value } : null), back, }); }, multiselect(message, options, initial, { back = false } = {}) { const label = values => options.filter(option => values.includes(option.value)).map(option => option.label).join(", ") || "none"; if (!interactive) return Promise.resolve(preset(message, initial, label)); const chosen = new Set(initial); let cursor = 0; const width = Math.max(...options.map(option => option.label.length)); const toggle = value => (chosen.has(value) ? chosen.delete(value) : chosen.add(value)); return interact({ active: () => [ `${cyan(S.active)} ${message}`, ...options.map((option, index) => { const box = chosen.has(option.value) ? green(S.checked) : index === cursor ? cyan(S.unchecked) : gray(S.unchecked); const name = option.label.padEnd(width); return `${cyan(S.bar)} ${box} ${index === cursor ? cyan(name) : name}${option.hint ? ` ${dim(option.hint)}` : ""}`; }), ], hints: [[S.updown, "move"], ["space", "select or unselect"], ["enter", "confirm"]], done: record(message, label), key: pressed => { cursor = moved(pressed, cursor, options.length); if (pressed.name === "space") toggle(options[cursor].value); if (pressed.name === "a") { const all = chosen.size === options.length; for (const option of options) all ? chosen.delete(option.value) : chosen.add(option.value); } if (pressed.name === "return") return { value: options.map(option => option.value).filter(item => chosen.has(item)) }; return null; }, click: line => { if (!options[line - 1]) return null; cursor = line - 1; toggle(options[cursor].value); return null; }, back, }); }, text(message, initial, { back = false } = {}) { if (!interactive) return Promise.resolve(preset(message, initial, value => value)); let value = initial; return interact({ active: () => [ `${cyan(S.active)} ${message}`, `${cyan(S.bar)} ${value}${inverse(" ")}`, ], hints: [["enter", "confirm"], ["ctrl+u", "clear"]], done: record(message, answer => answer), key: (pressed, text) => { if (pressed.name === "return") return { value: value.trim() || initial }; if (pressed.name === "backspace") value = value.slice(0, -1); else if (pressed.ctrl && pressed.name === "u") value = ""; else if (text && !pressed.ctrl && text >= " ") value += text; return null; }, back, }); }, }; } // --------------------------------------------------------------------------- // The logo // The mark of Vloopex, two rings joined by a tube, drawn with half-block // characters and lit from the upper left, with the name in a block font. The // mark condenses out of white sparks, then a light runs through it, as in // the splash of Vloopex itself. const MARK_STOPS = ["#31b4d1", "#3a6ad1", "#4840c5", "#6129b8", "#8121ab", "#a62290", "#c82c75"] .map(hex => [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16))); const WHITE = [255, 255, 255]; const mix = (from, to, amount) => from.map((channel, index) => channel + (to[index] - channel) * amount); const markColor = position => { const at = Math.min(Math.max(position, 0), 1) * (MARK_STOPS.length - 1); const index = Math.min(Math.floor(at), MARK_STOPS.length - 2); return mix(MARK_STOPS[index], MARK_STOPS[index + 1], at - index); }; const unit = vector => { const length = Math.hypot(...vector); return vector.map(part => part / length); }; const LIGHT = unit([-0.45, -0.6, 0.66]); const HALFWAY = unit([LIGHT[0], LIGHT[1], LIGHT[2] + 1]); const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; // The pixels of the mark, cols wide and two per row, each a color or null. export function markPixels(cols, rows) { const width = cols, height = rows * 2; const radius = (height - 1) / 2, centerX = width / 2, centerY = height / 2; const offset = 1.05 * radius, ring = 0.72 * radius, thickness = 0.3 * radius, tube = 0.4 * radius; const left = centerX - offset - radius, span = 2 * (offset + radius); // The surface under a point: its normal, and whether it is a ring, which // hides the tube. const surface = (x, y) => { for (const ringX of [centerX - offset, centerX + offset]) { const dx = x - ringX, dy = y - centerY, distance = Math.hypot(dx, dy), depth = (distance - ring) / thickness; if (Math.abs(depth) <= 1) return { normal: [depth * dx / distance, depth * dy / distance, Math.sqrt(1 - depth * depth)], ring: true }; } const depth = (y - centerY) / tube; if (Math.abs(depth) <= 1 && x >= centerX - offset && x <= centerX + offset) { return { normal: [0, depth, Math.sqrt(1 - depth * depth)], ring: false }; } return null; }; return Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => { // Four samples per pixel smooth the edges. const hits = [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]].map(([sx, sy]) => surface(x + sx, y + sy)).filter(Boolean); if (hits.length < 2) return null; const used = hits.some(hit => hit.ring) ? hits.filter(hit => hit.ring) : hits; const diffuse = used.reduce((sum, hit) => sum + Math.max(0, dot(hit.normal, LIGHT)), 0) / used.length; const shine = used.reduce((sum, hit) => sum + Math.max(0, dot(hit.normal, HALFWAY)) ** 24, 0) / used.length; const base = markColor((x + 0.5 - left) / span).map(channel => channel * (0.3 + 0.8 * diffuse)); return mix(base, WHITE, 0.85 * shine); })); } const FONT = { V: ["# #", "# #", "# #", " # # ", " # "], L: ["# ", "# ", "# ", "# ", "#####"], O: [" ### ", "# #", "# #", "# #", " ### "], P: ["#### ", "# #", "#### ", "# ", "# "], E: ["#####", "# ", "#### ", "# ", "#####"], X: ["# #", " # # ", " # ", " # # ", "# #"], }; // The cells of the name: five rows of the index of the letter, or null. export function wordCells(word) { return FONT.V.map((_, row) => [...word].flatMap((letter, index) => [...(index ? " " : ""), ...FONT[letter][row]].map(cell => (cell === "#" ? index : null)))); } // One moment of the intro, t seconds in: the mark as rows of { top, bottom } // pixel colors, and the name as rows of cell colors. export function splashFrame(t, mark, order, word) { const width = mark[0].length; const REVEAL = 0.55, SETTLE = 0.25, SWEEP = [0.6, 1.15]; const front = width * (t - SWEEP[0]) / (SWEEP[1] - SWEEP[0]); const pixel = (x, y) => { const color = mark[y][x]; if (!color) return null; const since = t - order[y][x] * REVEAL; if (since < 0) return null; let shown = mix(WHITE, color, Math.min(1, since / SETTLE)); const behind = front - x; if (t >= SWEEP[0] && behind >= -1.5) { const glow = Math.exp(-((behind / 3.5) ** 2)) * (behind >= 0 ? 1 : Math.exp(-(behind ** 2))); shown = mix(shown, WHITE, 0.9 * glow); } return shown; }; const markRows = []; for (let y = 0; y < mark.length; y += 2) markRows.push(mark[y].map((_, x) => ({ top: pixel(x, y), bottom: pixel(x, y + 1) }))); const wordRows = word.map(cells => cells.map((letter, x) => { if (letter === null) return null; const since = t - 0.3 - letter * 0.09; if (since < 0) return null; const color = markColor(x / (cells.length - 1)).map(channel => Math.min(255, channel * 1.15)); return mix(WHITE, color, Math.min(1, since / 0.3)); })); return { markRows, wordRows }; } // The escape code for a color, as 24-bit or as the nearest of the 256. function colorCode(depth) { return (rgb, background) => { const [r, g, b] = rgb.map(channel => Math.round(Math.min(255, Math.max(0, channel)))); const layer = background ? 48 : 38; if (depth >= 24) return `\x1b[${layer};2;${r};${g};${b}m`; return `\x1b[${layer};5;${16 + 36 * Math.round(r / 51) + 6 * Math.round(g / 51) + Math.round(b / 51)}m`; }; } // The lines of a frame. ▀ shows two pixels, the upper one as its color and // the lower one as its background; a cell of the name has both the same. // Codes repeat only where a color changes. function splashLines({ markRows, wordRows }, color, stacked) { const paint = cells => { let text = "", fg = "", bg = ""; const set = (nextFg, nextBg) => { if (nextFg !== fg) text += (fg = nextFg) || "\x1b[39m"; if (nextBg !== bg) text += (bg = nextBg) || "\x1b[49m"; }; for (const cell of cells) { if (cell.top) { set(color(cell.top, false), cell.bottom ? color(cell.bottom, true) : ""); text += "▀"; } else if (cell.bottom) { set(color(cell.bottom, false), ""); text += "▄"; } else { set(fg, ""); text += " "; } } return `${text}\x1b[0m`; }; const full = cells => cells.map(cell => ({ top: cell, bottom: cell })); if (stacked) return [...markRows.map(paint), "", ...wordRows.map(cells => paint(full(cells)))]; const wordTop = Math.floor((markRows.length - wordRows.length) / 2); const blank = wordRows[0].map(() => null); return markRows.map((cells, row) => `${paint(cells)} ${paint(full(wordRows[row - wordTop] ?? blank))}`); } // Plays the intro, about a second and a half; any key skips to its end. async function splash() { const depth = out.getColorDepth(); const columns = out.columns || 80, word = wordCells("VLOOPEX"); const size = [[43, 11], [31, 8]].find(([cols]) => 3 + cols + 3 + word[0].length + 1 <= columns); const stacked = !size && 3 + word[0].length + 1 <= columns; const [cols, rows] = size ?? [31, 8]; const height = stacked ? rows + 1 + word.length : rows; if (depth < 8 || (!size && !stacked) || (out.rows || 24) < height + 8) return; const mark = markPixels(cols, rows), order = mark.map(row => row.map(() => Math.random())), color = colorCode(depth); const LENGTH = 1.5; let skipped = false, drawn = 0, stop = () => {}; stop = readKeys(event => { if (event.ctrl && event.name === "c") { stop(); out.write(RESTORE); process.exit(130); } if (!["mouse", "wheelup", "wheeldown", "cursor"].includes(event.name)) skipped = true; }); out.write("\x1b[?25l\n"); const started = Date.now(); try { for (;;) { const t = skipped ? LENGTH : Math.min(LENGTH, (Date.now() - started) / 1000); const lines = splashLines(splashFrame(t, mark, order, word), color, stacked); if (drawn) out.write(`\x1b[${drawn}A`); out.write(`${lines.map(line => ` ${line}`).join("\n")}\n`); drawn = lines.length; if (t >= LENGTH) break; await new Promise(resolve => setTimeout(resolve, 40)); } } finally { stop(); out.write("\x1b[?25h"); } writtenRows += drawn + 1; } // --------------------------------------------------------------------------- // Processes and PATH const pathEntries = (value = process.env.PATH) => (value ?? "").split(path.delimiter).filter(Boolean); const pathKey = dir => (process.platform === "win32" ? dir.toLowerCase() : dir).replace(/[\\/]+$/, ""); const INHERITED_PATH = process.env.PATH ?? ""; export function which(name, env = process.env, platform = process.platform) { // Windows can start only files with a PATHEXT extension, not npm's // extensionless shell shims. const extensions = platform === "win32" && !path.extname(name) ? (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).map(extension => extension.toLowerCase()) : [""]; for (const dir of pathEntries(env.PATH)) { for (const extension of extensions) { const candidate = path.join(dir, name + extension); if (isProgramFile(candidate)) return candidate; } } return null; } function isProgramFile(file) { try { return fs.statSync(file).isFile(); } catch (error) { // Windows app execution aliases, such as winget.exe, cannot be opened, // only started. if (error.code !== "EACCES") return false; try { return fs.lstatSync(file).isSymbolicLink(); } catch { return false; } } } // Windows starts .cmd and .bat files only through cmd.exe. function spawnSpec(command, args) { const resolved = path.isAbsolute(command) ? command : which(command); if (!resolved) return null; if (process.platform === "win32" && /\.(cmd|bat)$/i.test(resolved)) { const quote = arg => (/[\s"&|<>^()]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg); return { file: process.env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", `"${[resolved, ...args].map(quote).join(" ")}"`], options: { windowsVerbatimArguments: true }, }; } return { file: resolved, args, options: {} }; } // Resolves with the exit code and output; a missing program gives code null. // `input` goes to the program's standard input. export function capture(command, args = [], { input, ...options } = {}) { const spec = spawnSpec(command, args); if (!spec) return Promise.resolve({ code: null, stdout: "", stderr: `${command} was not found` }); return new Promise(resolve => { let stdout = "", stderr = ""; const stdin = input === undefined ? "ignore" : "pipe"; const child = spawn(spec.file, spec.args, { ...spec.options, windowsHide: true, timeout: 30_000, stdio: [stdin, "pipe", "pipe"], ...options }); if (input !== undefined) child.stdin.end(input); child.stdout.on("data", data => { stdout += data; }); child.stderr.on("data", data => { stderr += data; }); child.on("error", error => resolve({ code: null, stdout, stderr: error.message })); child.on("close", code => resolve({ code, stdout, stderr })); }); } // --dry-run: run() and the launcher and PATH steps only show their changes. let dryRun = false; // Runs an install step and shows its output under the step. Input stays with // the terminal, so sudo and installers can still ask questions. An // `interactive` program, such as a sign-in, gets the whole terminal. Ctrl+c // ends the program, not setup: the step then fails with INTERRUPTED. async function run(command, args = [], { interactive = false, ...options } = {}) { if (dryRun) { ui.dry(`would run: ${[command, ...args].join(" ")}`); return 0; } const spec = spawnSpec(command, args); ui.info(dim(`$ ${[command, ...args].join(" ")}`)); if (!spec) { ui.info(red(`${command} was not found.`)); return 127; } await terminalSettled(); const interrupt = watchInterrupt(); const end = (resolve, code) => { interrupt.stop(); resolve(interrupt.happened() ? INTERRUPTED : code); }; if (interactive) { return new Promise(resolve => { const child = spawn(spec.file, spec.args, { ...spec.options, stdio: "inherit", ...options }); child.on("error", () => end(resolve, 127)); child.on("close", code => end(resolve, code ?? 1)); }); } return new Promise(resolve => { const child = spawn(spec.file, spec.args, { ...spec.options, stdio: ["inherit", "pipe", "pipe"], ...options }); let atLineStart = true; const show = chunk => { for (const part of chunk.split(/(\r\n|\n|\r)/)) { if (!part) continue; if (/^[\r\n]+$/.test(part)) { if (atLineStart && part !== "\r") out.write(gray(S.bar)); out.write(part); atLineStart = true; } else { if (atLineStart) out.write(`${gray(S.bar)} `); out.write(dim(part)); atLineStart = false; } } }; child.stdout.setEncoding("utf8").on("data", show); child.stderr.setEncoding("utf8").on("data", show); child.on("error", error => { show(`${error.message}\n`); end(resolve, 127); }); child.on("close", code => { if (!atLineStart) out.write("\n"); end(resolve, code ?? 1); }); }); } export const INTERRUPTED = 130; // Notes a ctrl+c while a program runs, instead of ending setup on it. function watchInterrupt() { let happened = false; const note = () => { happened = true; }; process.on("SIGINT", note); return { happened: () => happened, stop: () => process.off("SIGINT", note) }; } // The failure of a step from its exit code, or null for success. const failed = (code, doing) => (code === 0 ? null : code === INTERRUPTED ? `${doing} was interrupted.` : `${doing} failed.`); // Runs a step whose `task` returns null, or the failure. In the wizard a // failure offers to try again, to skip the step (when `skip` says what then // happens) or to stop setup. A script skips what it can and stops otherwise. async function attempt(ask, task, { skip = null } = {}) { for (;;) { const failure = await task(); if (!failure) return true; ui.warn(failure); let choice = skip ? "skip" : "stop"; if (ask.interactive) { choice = await ask.select("What now?", [ { value: "retry", label: "Try again" }, ...(skip ? [{ value: "skip", label: "Skip this step", hint: skip }] : []), { value: "stop", label: "Stop setup" }, ], "retry"); } if (choice === "retry") continue; if (choice === "skip") { ui.info(dim(skip)); return false; } throw new SetupError(failure); } } async function windowsPersistentPath() { const script = "[Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User')"; const result = await capture("powershell.exe", ["-NoProfile", "-Command", script]); return pathEntries(result.stdout.trim()); } // Installers add their folders to PATH for new terminals only. Adding them here // lets this run find what it just installed. async function refreshPath() { const home = os.homedir(); const extra = [path.join(home, ".local", "bin")]; if (process.platform === "win32") { extra.push(...await windowsPersistentPath()); if (process.env.LOCALAPPDATA) extra.push(path.join(process.env.LOCALAPPDATA, "Programs", "OpenAI", "Codex", "bin")); if (process.env.APPDATA) extra.push(path.join(process.env.APPDATA, "npm")); } else { extra.push(path.join(home, ".kilo", "bin"), "/opt/homebrew/bin", "/usr/local/bin"); } const entries = pathEntries(); const known = new Set(entries.map(pathKey)); for (const dir of extra) { if (!known.has(pathKey(dir)) && fs.existsSync(dir)) { entries.push(dir); known.add(pathKey(dir)); } } process.env.PATH = entries.join(path.delimiter); } // Whether new terminals will have `dir` on PATH. async function persistentPathHas(dir) { const entries = process.platform === "win32" ? await windowsPersistentPath() : pathEntries(INHERITED_PATH); return entries.some(entry => pathKey(entry) === pathKey(dir)); } // --------------------------------------------------------------------------- // Checks export function parseVersion(text) { const match = /(\d+)\.(\d+)\.(\d+)/.exec(text ?? ""); return match ? match.slice(1, 4).join(".") : null; } export function compareVersions(a, b) { const left = a.split(".").map(Number), right = b.split(".").map(Number); for (let index = 0; index < 3; index++) { if ((left[index] ?? 0) !== (right[index] ?? 0)) return Math.sign((left[index] ?? 0) - (right[index] ?? 0)); } return 0; } async function probeVersion(command, args = ["--version"]) { const result = await capture(command, args); const version = result.code === 0 ? parseVersion(result.stdout + result.stderr) : null; return version ? { version, path: which(command) } : null; } // Git for Windows keeps bash.exe in \bin; git.exe can sit in \cmd, // \bin or \mingw64\bin. export function findGitBash(gitPath, env = process.env, exists = fs.existsSync) { const roots = []; for (let dir = gitPath && path.win32.dirname(gitPath), level = 0; dir && level < 3; dir = path.win32.dirname(dir), level++) roots.push(dir); for (const base of [env.ProgramFiles, env["ProgramFiles(x86)"], env.LOCALAPPDATA && path.win32.join(env.LOCALAPPDATA, "Programs")]) { if (base) roots.push(path.win32.join(base, "Git")); } return roots.map(root => path.win32.join(root, "bin", "bash.exe")).find(candidate => exists(candidate)) ?? null; } async function probeBash() { if (process.platform !== "win32") return probeVersion("bash"); const bash = findGitBash(which("git")); return bash ? { path: bash } : null; } async function probeBuildTools() { const missing = []; if (!which("python3")) missing.push("python3"); if (!which("make")) missing.push("make"); if (!which("c++") && !which("g++")) missing.push("a C++ compiler"); return { missing }; } async function probeVloopex(dir) { if (!fs.existsSync(dir)) return { kind: "missing" }; if (fs.readdirSync(dir).length === 0) return { kind: "empty" }; if (!fs.existsSync(path.join(dir, ".git")) || !fs.existsSync(path.join(dir, "vloopex.js"))) return { kind: "foreign" }; const [log, status] = await Promise.all([ capture("git", ["-C", dir, "log", "-1", "--format=%h %cs"]), capture("git", ["-C", dir, "status", "--porcelain"]), ]); const lines = status.stdout.split(/\r?\n/).filter(Boolean); return { kind: "git", revision: log.stdout.trim() || "unknown version", // Git answered: without that, nothing below is known, so setup neither updates nor removes the checkout inspected: status.code === 0, // Changed tracked files stop an update; untracked files (ignored ones aside) only stop an uninstall dirty: lines.some(line => !line.startsWith("??")), untracked: lines.some(line => line.startsWith("??")), ready: fs.existsSync(path.join(dir, "node_modules")), }; } // Vloopex on Windows runs Codex through its WSL wrapper with the Linux binary // of the VS Code Codex extension, unless CODEX_RUNNER names another command. // The launcher sets CODEX_RUNNER only when the native CLI is the only route. export function windowsCodexRoute({ codexRunner, wslCodex, nativeCodex }) { if (codexRunner) return "custom"; if (wslCodex) return "wsl"; return nativeCodex ? "native" : null; } function findWslCodex(env = process.env) { if (env.CODEXW_CODEX) return env.CODEXW_CODEX; if (!which("wsl")) return null; const root = path.join(os.homedir(), ".vscode", "extensions"); let folders = []; try { folders = fs.readdirSync(root).filter(name => name.startsWith("openai.chatgpt-")); } catch { return null; } const binary = folders.map(folder => path.join(root, folder, "bin", "linux-x86_64", "codex")).find(file => fs.existsSync(file)); return binary ?? null; } async function probeHarness(harness) { const found = await probeVersion(harness.command); if (harness.id !== "codex" || process.platform !== "win32") return found; const wslCodex = findWslCodex(); const route = windowsCodexRoute({ codexRunner: process.env.CODEX_RUNNER, wslCodex, nativeCodex: found }); // The version on PATH belongs to the native CLI, which the other routes do not run. if (route === "native") return { ...found, route }; if (!route) return null; return { route, path: route === "wsl" ? wslCodex : process.env.CODEX_RUNNER, nativeVersion: found?.version }; } async function probeHarnesses() { const list = await Promise.all(HARNESSES.map(probeHarness)); return Object.fromEntries(HARNESSES.map((harness, index) => [harness.id, list[index]])); } async function probe(dir) { const [node, git, bash, build, vloopex, harnesses] = await Promise.all([ probeVersion("node"), probeVersion("git"), probeBash(), process.platform === "linux" ? probeBuildTools() : null, probeVloopex(dir), probeHarnesses(), ]); return { dir, node, git, bash, build, vloopex, harnesses }; } export const PRETEND_PARTS = ["git", "vloopex", ...HARNESSES.map(harness => harness.id)]; // Hides found parts, so that a dry run shows the steps that install them. export function pretendMissing(found, parts, platform = process.platform) { const result = { ...found, harnesses: { ...found.harnesses } }; for (const part of parts) { if (part in result.harnesses) result.harnesses[part] = null; } if (parts.includes("git")) { result.git = null; // Git for Windows provides Git Bash. if (platform === "win32") result.bash = null; } if (parts.includes("vloopex")) result.vloopex = { kind: "missing" }; return result; } const nodeOk = found => Boolean(found.node) && compareVersions(found.node.version, NODE_MIN) >= 0; const prerequisitesOk = found => nodeOk(found) && Boolean(found.git) && Boolean(found.bash) && !found.build?.missing.length; export function shortPath(file, home = os.homedir(), platform = process.platform) { if (!file) return file; const same = platform === "win32" ? file.toLowerCase().startsWith(home.toLowerCase()) : file.startsWith(home); return same ? `~${file.slice(home.length)}` : file; } // The version, notes and location of a found part. const details = (...parts) => parts.filter(Boolean).join(" · "); function systemRows(found) { const node = !found.node ? "not on PATH" : details(nodeOk(found) ? found.node.version : `${found.node.version}, needs ${NODE_MIN} or newer`, privateNodeBin(found.node.path ?? "") && "downloaded by setup", shortPath(found.node.path)); const rows = [ statusRow(nodeOk(found) ? "ok" : "missing", "Node.js", node), statusRow(found.git ? "ok" : "missing", "Git", found.git ? details(found.git.version, shortPath(found.git.path)) : "not found"), statusRow(found.bash ? "ok" : "missing", process.platform === "win32" ? "Git Bash" : "Bash", found.bash ? details(found.bash.version, shortPath(found.bash.path)) : "not found"), ]; if (found.build) { rows.push(found.build.missing.length ? statusRow("missing", "Build tools", `${found.build.missing.join(", ")} missing (to build node-pty)`) : statusRow("ok", "Build tools", "python3, make, C++")); } const vloopex = found.vloopex; const dir = shortPath(found.dir); if (vloopex.kind === "git") { const notes = [vloopex.revision, !vloopex.inspected && "state unknown: git failed", vloopex.dirty && "local changes", !vloopex.ready && "dependencies missing"].filter(Boolean); rows.push(statusRow(vloopex.ready ? "ok" : "warn", "Vloopex", details(notes.join(", "), dir))); } else if (vloopex.kind === "foreign") { rows.push(statusRow("missing", "Vloopex", `${dir} has other files`)); } else { rows.push(statusRow("off", "Vloopex", details("not installed", dir))); } return rows; } const ROUTES = { native: "native Windows", wsl: "WSL, VS Code extension", custom: "CODEX_RUNNER" }; function harnessRows(found) { return HARNESSES.map(harness => { const item = found.harnesses[harness.id]; if (!item) return statusRow("off", harness.name, "not installed"); const native = item.nativeVersion && `native ${item.nativeVersion} not used`; return statusRow("ok", harness.name, details(item.version, ROUTES[item.route], native, shortPath(item.path))); }); } function showStatus(found) { ui.box("This computer", systemRows(found)); ui.box("AI harnesses", harnessRows(found)); } // --------------------------------------------------------------------------- // Prerequisites const SYSTEM_PACKAGES = { git: { apt: ["git"], dnf: ["git"], pacman: ["git"], zypper: ["git"], apk: ["git"], brew: ["git"], winget: ["Git.Git"] }, build: { apt: ["build-essential", "python3"], dnf: ["gcc-c++", "make", "python3"], pacman: ["base-devel", "python"], zypper: ["gcc-c++", "make", "python3"], apk: ["build-base", "python3", "linux-headers"], }, }; // An unattended run fails where it would wait for a password or a dialog. export function systemInstallCommands(manager, packages, { isRoot = false, unattended = false } = {}) { const sudo = args => (isRoot ? args : ["sudo", ...(unattended ? ["-n"] : []), ...args]); switch (manager) { case "apt": return [sudo(["apt-get", "update"]), sudo(["apt-get", "install", "-y", ...packages])]; case "dnf": return [sudo(["dnf", "install", "-y", ...packages])]; case "pacman": return [sudo(["pacman", "-S", "--needed", "--noconfirm", ...packages])]; case "zypper": return [sudo(["zypper", "--non-interactive", "install", ...packages])]; case "apk": return [sudo(["apk", "add", ...packages])]; case "brew": return [["brew", "install", ...packages]]; case "winget": return packages.map(id => ["winget", "install", "--id", id, "-e", "--source", "winget", "--accept-package-agreements", "--accept-source-agreements", ...(unattended ? ["--disable-interactivity"] : [])]); default: throw new Error(`Unknown package manager ${manager}`); } } function packageManager() { if (process.platform === "win32") return which("winget") ? "winget" : null; if (process.platform === "darwin") return which("brew") ? "brew" : null; const managers = [["apt-get", "apt"], ["dnf", "dnf"], ["pacman", "pacman"], ["zypper", "zypper"], ["apk", "apk"]]; return managers.find(([command]) => which(command))?.[1] ?? null; } // Puts the missing requirements into the plan: the summary shows them, and // starting setup agrees to them. Without a package manager setup stops here. async function planPrerequisites(plan) { const { found } = plan; if (!nodeOk(found)) { throw new SetupError(`Vloopex needs Node.js ${NODE_MIN} or newer on PATH. Run install.sh or install.ps1, ` + "which installs it, or install Node.js 24 LTS from https://nodejs.org."); } // Git for Windows provides both Git and Git Bash. const needs = []; if (!found.git || (process.platform === "win32" && !found.bash)) needs.push("git"); if (found.build?.missing.length) needs.push("build"); if (!needs.length) return; const names = needs.map(need => (need === "build" ? "build tools" : process.platform === "win32" ? "Git for Windows" : "Git")).join(" and "); const manager = packageManager(); if (!manager) { if (process.platform === "darwin" && needs.includes("git")) { await run("xcode-select", ["--install"]); throw new SetupError("Finish the Command Line Tools install that macOS opened, then run setup again."); } throw new SetupError(`Install ${names}, then run setup again.`); } const isRoot = process.getuid?.() === 0; plan.prerequisites = { names, manager, isRoot, packages: needs.flatMap(need => SYSTEM_PACKAGES[need][manager] ?? []), how: ["brew", "winget"].includes(manager) || isRoot ? manager : `sudo ${manager}`, }; } async function installPrerequisites(plan, ask, unattended) { const { names, manager, packages, isRoot } = plan.prerequisites; ui.step(`Installing ${names}`); const commands = systemInstallCommands(manager, packages, { isRoot, unattended }); await attempt(ask, async () => { for (const [command, ...args] of commands) { const code = await run(command, args); if (code !== 0) return failed(code, `Installing ${names}`); } return null; }); } // --------------------------------------------------------------------------- // Vloopex function pnpmPackage(dir) { try { const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")); const match = /^pnpm@([^+]+)/.exec(manifest.packageManager ?? ""); if (match) return `pnpm@${match[1]}`; } catch { // Use the default below. } return "pnpm@10"; } const lastLine = result => (result.stderr || result.stdout).trim().split("\n").at(-1); // Installs the dependencies and checks that Vloopex starts and that its // terminal module works. Returns the failure, or null. async function prepareVloopex(dir) { ui.step("Installing the Vloopex dependencies"); // pnpm runs no build scripts of dependencies unless allowed. node-pty has no // prebuilt binary for Linux, so its build must run; the install scripts use // the prebuilt binaries where they exist. const builds = path.join(os.tmpdir(), `vloopex-builds-${process.pid}.json`); if (!dryRun) fs.writeFileSync(builds, JSON.stringify(["node-pty", "koffi"])); try { const args = ["--yes", pnpmPackage(dir), "install", "--prod", "--frozen-lockfile", `--config.only-built-dependencies-file=${builds}`]; if (await run("npx", args, { cwd: dir }) !== 0) return "Installing the Vloopex dependencies failed."; } finally { fs.rmSync(builds, { force: true }); } if (dryRun) return null; // --help does not load node-pty, so a module that did not build passes it. const terminal = "import pty from 'node-pty'; import 'koffi'; " + "pty.spawn(process.execPath, ['-e', '0'], {}).onExit(({ exitCode }) => process.exit(exitCode));"; const terminalCheck = await capture("node", ["--input-type=module", "-e", terminal], { cwd: dir }); if (terminalCheck.code !== 0) return `The terminal module of Vloopex does not work: ${lastLine(terminalCheck)}`; const start = await capture("node", [path.join(dir, "vloopex.js"), "--help"]); return start.code === 0 ? null : `Vloopex does not start: ${lastLine(start)}`; } // Downloads or updates Vloopex as the plan says, and installs its // dependencies. Returns whether it downloaded the checkout. async function installVloopex(plan, opts, ask) { const { dir, vloopex } = plan; // An unattended run fails where Git would ask for a password. const gitEnv = opts.yes ? { env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" } } : {}; let previous = null; let cloned = false; if (vloopex.kind === "git") { if (plan.update) { const head = async () => (await capture("git", ["-C", dir, "rev-parse", "HEAD"])).stdout.trim(); const before = await head(); ui.step("Updating Vloopex"); const pulled = await attempt(ask, async () => failed(await run("git", ["-C", dir, "pull", "--ff-only"], gitEnv), "Updating Vloopex"), { skip: "Vloopex keeps its current version." }); if (pulled && await head() !== before) previous = before; } } else { ui.step(`Downloading Vloopex into ${dir}`); await attempt(ask, async () => failed(await run("git", ["clone", opts.source, dir], gitEnv), `Downloading Vloopex from ${opts.source}`)); cloned = true; } await attempt(ask, async () => { let failure = await prepareVloopex(dir); // Only a clean checkout is updated, so going back loses no changes. if (failure && previous) { ui.warn(`${failure} Setup goes back to the version that was installed.`); await run("git", ["-C", dir, "reset", "--hard", previous]); previous = null; failure = await prepareVloopex(dir); } return failure; }); ui.step(dryRun ? "Vloopex would be installed" : "Vloopex is installed"); return cloned; } // --------------------------------------------------------------------------- // Harnesses // The newest published version of a harness, or null when npm does not answer. // The CLIs publish the same versions to npm as through their own installers. async function latestVersion(harness) { try { const response = await fetch(`https://registry.npmjs.org/${harness.npmPackage}/latest`, { signal: AbortSignal.timeout(5000) }); return response.ok ? parseVersion((await response.json()).version) : null; } catch { return null; } } export function updateHint(current, latest) { if (!latest) return `${current} · newest version unknown`; return compareVersions(latest, current) > 0 ? `${current} → ${latest}` : `${current} · up to date`; } async function askHarnesses(plan, opts, ask, back) { const { found } = plan; const installed = HARNESSES.filter(harness => found.harnesses[harness.id]).map(harness => harness.id); if (opts.harnesses) { plan.harnesses = [...new Set([...installed, ...opts.harnesses])]; return false; } const options = HARNESSES.map(harness => { const item = found.harnesses[harness.id]; return { value: harness.id, label: harness.name, hint: item ? `installed ${item.version ?? ROUTES[item.route]}` : `not installed · ${harness.blurb}` }; }); // The wizard preselects every harness; --yes keeps the installed ones. const initial = plan.harnessPick ?? (opts.yes ? installed : HARNESSES.map(harness => harness.id)); const answer = await ask.multiselect("Which AI harnesses do you want to use with Vloopex?", options, initial, { back }); if (answer === BACK) return BACK; plan.harnessPick = answer; plan.harnesses = answer; return true; } async function askHarnessUpdates(plan, opts, ask, back) { const { found } = plan; // Only the CLI's own installs can update themselves; a WSL route updates // with its VS Code extension. const updatable = HARNESSES.filter(harness => plan.harnesses.includes(harness.id) && found.harnesses[harness.id]?.version && found.harnesses[harness.id].route !== "wsl"); plan.updates = []; // A script updates only with --update-harnesses. if (opts.updateHarnesses) { plan.updates = updatable; return false; } if (opts.yes || !updatable.length) return false; plan.latest ??= await withSpinner("Checking for harness updates", "Checked for harness updates", async () => Object.fromEntries(await Promise.all(updatable.map(async harness => [harness.id, await latestVersion(harness)])))); const current = harness => found.harnesses[harness.id].version; const upToDate = harness => plan.latest[harness.id] && compareVersions(plan.latest[harness.id], current(harness)) <= 0; for (const harness of updatable.filter(upToDate)) ui.info(dim(`${harness.name} ${current(harness)} is up to date.`)); // Offered: the harnesses with a newer version, preselected, and the ones // whose newest version npm did not tell. const offered = updatable.filter(harness => !upToDate(harness)); if (!offered.length) return false; const options = offered.map(harness => ({ value: harness.id, label: harness.name, hint: updateHint(current(harness), plan.latest[harness.id]) })); const initial = plan.harnessUpdatePick ?? offered.filter(harness => plan.latest[harness.id]).map(harness => harness.id); const answer = await ask.multiselect("Update these harnesses?", options, initial, { back }); if (answer === BACK) return BACK; plan.harnessUpdatePick = answer; plan.updates = offered.filter(harness => answer.includes(harness.id)); return true; } async function installHarnesses(plan, ask) { const toInstall = HARNESSES.filter(harness => plan.harnesses.includes(harness.id) && !plan.found.harnesses[harness.id]); for (const harness of toInstall) { ui.step(`Installing ${harness.name}`); const [command, args] = harness.install[process.platform === "win32" ? "win32" : "unix"]; await attempt(ask, async () => failed(await run(command, args, { env: { ...process.env, ...harness.installEnv } }), `Installing ${harness.name}`), { skip: `Setup goes on without ${harness.name}.` }); } for (const harness of plan.updates) { ui.step(`Updating ${harness.name}`); await attempt(ask, async () => failed(await run(harness.command, harness.update), `Updating ${harness.name}`), { skip: `${harness.name} keeps its current version.` }); } } // --------------------------------------------------------------------------- // Launcher const shellQuote = text => `'${text.replace(/'/g, "'\\''")}'`; const gitBashPath = windowsPath => windowsPath.replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`).replace(/\\/g, "/"); // The bin folder of the Node.js that install.sh or install.ps1 downloaded, when // setup runs on it; null for a Node.js that the person installed. export function privateNodeBin(execPath = process.execPath, home = os.homedir()) { const relative = path.relative(path.join(home, ".vloopex", "runtime"), execPath); return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? path.dirname(execPath) : null; } // The launcher runs Vloopex with the downloaded Node.js first on PATH, because // the harness wrappers run `node`. On Windows it also puts Git Bash first, // because Vloopex runs `bash` and the bash.exe of WSL can come first otherwise. export function launcherFiles({ platform, appDir, gitBin = null, codexRunner = null, nodeBin = null }) { if (platform !== "win32") { const lines = [ "#!/bin/sh", `# ${LAUNCHER_MARK}`, nodeBin && `PATH=${shellQuote(nodeBin)}:"$PATH"; export PATH`, `exec ${nodeBin ? shellQuote(`${nodeBin}/node`) : "node"} ${shellQuote(`${appDir}/vloopex.js`)} "$@"`, ]; return [{ name: "vloopex", content: `${lines.filter(Boolean).join("\n")}\n` }]; } const entry = path.win32.join(appDir, "vloopex.js"); const node = nodeBin ? path.win32.join(nodeBin, "node.exe") : null; const cmd = [ "@echo off", `rem ${LAUNCHER_MARK}`, "setlocal", nodeBin && `set "PATH=${nodeBin};%PATH%"`, gitBin && `set "PATH=${gitBin};%PATH%"`, codexRunner && `if not defined CODEX_RUNNER set "CODEX_RUNNER=${codexRunner}"`, `${node ? `"${node}"` : "node"} "${entry}" %*`, ]; // Git Bash runs extensionless shell launchers, not .cmd files. const sh = [ "#!/usr/bin/env bash", `# ${LAUNCHER_MARK}`, nodeBin && `export PATH=${shellQuote(gitBashPath(nodeBin))}:"$PATH"`, codexRunner && `[ -n "\${CODEX_RUNNER:-}" ] || export CODEX_RUNNER=${shellQuote(codexRunner)}`, `exec ${node ? shellQuote(node.replace(/\\/g, "/")) : "node"} ${shellQuote(entry.replace(/\\/g, "/"))} "$@"`, ]; return [ { name: "vloopex.cmd", content: `${cmd.filter(Boolean).join("\r\n")}\r\n` }, { name: "vloopex", content: `${sh.filter(Boolean).join("\n")}\n` }, ]; } export function shellStartupFile(shell = process.env.SHELL ?? "", platform = process.platform, home = os.homedir()) { const name = path.posix.basename(shell); if (name === "zsh") return path.join(home, platform === "darwin" ? ".zprofile" : ".zshrc"); if (name === "bash") return path.join(home, platform === "darwin" ? ".bash_profile" : ".bashrc"); if (name === "fish") return path.join(home, ".config", "fish", "conf.d", "vloopex.fish"); return path.join(home, ".profile"); } // One line for each folder, so that a later run adds only what is missing. export function startupLine(dir, { fish = false, append = false, home = os.homedir() } = {}) { const shown = dir.startsWith(home + path.sep) ? `$HOME/${dir.slice(home.length + 1).split(path.sep).join("/")}` : dir; if (fish) return `fish_add_path${append ? " --append" : ""} "${shown}"`; return append ? `export PATH="$PATH:${shown}"` : `export PATH="${shown}:$PATH"`; } // The registry keeps %VARIABLES% in the user PATH unexpanded. Setting a // variable through .NET tells open programs that the environment changed. async function changeWindowsPath(dirs, add) { const script = [ "$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)", "$path = $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames')", "$dirs = @($env:VLOOPEX_PATH_DIRS -split ';')", "$add = $env:VLOOPEX_PATH_ADD -eq '1'", "$parts = @($path -split ';' | Where-Object { $_ -and ($add -or $dirs -notcontains $_) })", "if ($add) { foreach ($dir in $dirs) { if ($parts -notcontains $dir) { $parts += $dir } } }", "$key.SetValue('Path', ($parts -join ';'), 'ExpandString')", "[Environment]::SetEnvironmentVariable('VLOOPEX_SETUP_NOTIFY', '1', 'User')", "[Environment]::SetEnvironmentVariable('VLOOPEX_SETUP_NOTIFY', $null, 'User')", ].join("; "); const env = { ...process.env, VLOOPEX_PATH_DIRS: dirs.join(";"), VLOOPEX_PATH_ADD: add ? "1" : "0" }; const result = await capture("powershell.exe", ["-NoProfile", "-Command", script], { env }); if (result.code !== 0) throw new SetupError(`Could not change PATH: ${result.stderr.trim()}`); } // Returns the shell startup file that it changed, or null on Windows. async function addToPath(entries) { if (process.platform === "win32") { await changeWindowsPath(entries.map(entry => entry.dir), true); return null; } const file = shellStartupFile(); const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : ""; const added = entries .map(({ dir, append }) => startupLine(dir, { fish: file.endsWith(".fish"), append })) .filter(line => !current.includes(line)) .map(line => `${RC_COMMENT}\n${line}\n`) .join(""); if (added) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.appendFileSync(file, `${current && !current.endsWith("\n") ? "\n" : ""}${added}`); } return file; } // Removes the lines that addToPath added, each after RC_COMMENT, except the // ones that keep(line) spares. export function removeStartupLines(text, keep = () => false) { const lines = text.split("\n"); const drop = new Set(); lines.forEach((line, index) => { if (line === RC_COMMENT && index + 1 < lines.length && !keep(lines[index + 1])) drop.add(index).add(index + 1); }); return lines.filter((line, index) => !drop.has(index)).join("\n"); } // What the launcher step would do: the folder of the vloopex command, a file // there that is not a Vloopex launcher (setup keeps it and adds none), and // the folders that new terminals still need on PATH. async function launcherPlan() { const binDir = path.join(os.homedir(), ".local", "bin"); const nodeBin = privateNodeBin(); const names = process.platform === "win32" ? ["vloopex.cmd", "vloopex"] : ["vloopex"]; const foreign = names.map(name => path.join(binDir, name)) .find(file => fs.existsSync(file) && !fs.readFileSync(file, "utf8").includes(LAUNCHER_MARK)) ?? null; // A downloaded Node.js goes last on PATH, so that an installed one keeps its place. const missing = []; if (!await persistentPathHas(binDir)) missing.push({ dir: binDir, append: false, name: "vloopex" }); if (nodeBin && !await persistentPathHas(nodeBin)) missing.push({ dir: nodeBin, append: true, name: "Node.js" }); return { binDir, nodeBin, foreign, missing }; } async function askPath(plan, opts, ask, back) { plan.launcher ??= await launcherPlan(); const { foreign, missing } = plan.launcher; plan.path = false; if (foreign) { ui.warn(`${foreign} is not a Vloopex launcher, so setup keeps it and adds no vloopex command.`); return false; } if (!missing.length) return false; const dirs = missing.map(entry => entry.dir).join(" and "); if (!opts.changePath) { ui.warn(`${dirs} ${missing.length > 1 ? "are" : "is"} not on PATH.`); return false; } const names = missing.map(entry => entry.name).join(" and "); const answer = await ask.confirm(`Add ${dirs} to PATH, so that ${names} ${missing.length > 1 ? "work" : "works"} in new terminals?`, plan.pathPick ?? true, { back }); if (answer === BACK) return BACK; plan.pathPick = answer; plan.path = answer; return true; } // Writes the launcher and, when the plan says so, puts its folders on PATH. async function installLauncher(plan, found) { const { binDir, nodeBin, foreign, missing } = plan.launcher; if (foreign) return { command: null, file: null, pathChanged: false }; const route = found.harnesses.codex?.route; const files = launcherFiles({ platform: process.platform, appDir: plan.dir, gitBin: found.bash?.path && process.platform === "win32" ? path.win32.dirname(found.bash.path) : null, codexRunner: route === "native" ? "codex" : null, nodeBin, }); if (!dryRun) fs.mkdirSync(binDir, { recursive: true }); for (const file of files) { const target = path.join(binDir, file.name); if (dryRun) ui.dry(`would write ${target}`); else fs.writeFileSync(target, file.content, { mode: 0o755 }); } if (!dryRun) ui.step(`Added the vloopex command to ${binDir}`); if (route === "native") ui.info(dim("It runs Codex with the native Windows CLI (CODEX_RUNNER=codex).")); const file = path.join(binDir, process.platform === "win32" ? "vloopex.cmd" : "vloopex"); const keep = { command: missing[0]?.dir === binDir ? path.join(binDir, "vloopex") : "vloopex", file, pathChanged: false }; if (!plan.path) return keep; const dirs = missing.map(entry => entry.dir).join(" and "); if (dryRun) { ui.dry(`would add ${dirs} to PATH`); return { command: "vloopex", file, pathChanged: true }; } try { const startupFile = await addToPath(missing); ui.step(`Added ${dirs} to PATH in ${startupFile ? shortPath(startupFile) : "your user PATH"}`); return { command: "vloopex", file, pathChanged: true, pathDirs: missing.map(entry => entry.dir), pathFile: startupFile }; } catch (error) { if (!(error instanceof SetupError)) throw error; ui.warn(error.message); return keep; } } // --------------------------------------------------------------------------- // Desktop entries // Setup keeps a copy of itself for the uninstall, its icon, the scripts that the // desktop entries run, and installed.json, the record of what it installed. const setupHome = () => path.join(os.homedir(), ".vloopex", "setup"); const ICON_URL = "https://vloopex.com/icon.png"; const PICK_PROMPT = "Open a project folder in Vloopex"; // Each entry opens a project folder in Vloopex; one that starts without a // folder asks for it. export function desktopEntries(platform = process.platform) { if (platform === "win32") { return [ { id: "start-menu", label: "Start menu entry", hint: "asks for a project folder", preselected: true, use: "Vloopex in the Start menu" }, { id: "folder-menu", label: "Open in Vloopex", hint: "in the right-click menu of folders", preselected: true, use: "Open in Vloopex in the right-click menu of a folder (Show more options on Windows 11)" }, { id: "desktop", label: "Desktop shortcut", hint: "asks for a project folder", preselected: false, use: "the Vloopex desktop shortcut" }, ]; } if (platform === "darwin") { return [ { id: "app", label: "Vloopex app", hint: "in Applications and Launchpad; asks for a folder or takes a dropped one", preselected: true, use: "the Vloopex app in Launchpad" }, { id: "folder-menu", label: "Open in Vloopex", hint: "Quick Action in the right-click menu of Finder", preselected: true, use: "Quick Actions > Open in Vloopex in the right-click menu of a folder" }, ]; } return [ { id: "app", label: "App menu entry", hint: "asks for a project folder", preselected: true, use: "Vloopex in the app menu" }, { id: "folder-menu", label: "Open in Vloopex", hint: "in the right-click menu of Files, Dolphin and Nemo", preselected: true, use: "Open in Vloopex in the right-click menu of a folder (Scripts in Files)" }, ]; } // .ico and .icns files can hold a PNG as it is. The icon is 256x256. export function icoFromPng(png) { const header = Buffer.alloc(22); header.writeUInt16LE(1, 2); // icon type header.writeUInt16LE(1, 4); // one image; width and height 0 mean 256 header.writeUInt16LE(1, 10); // color planes header.writeUInt16LE(32, 12); // bits per pixel header.writeUInt32LE(png.length, 14); header.writeUInt32LE(22, 18); // offset of the PNG return Buffer.concat([header, png]); } export function icnsFromPng(png) { const header = Buffer.alloc(16); header.write("icns", 0, "ascii"); header.writeUInt32BE(png.length + 16, 4); header.write("ic08", 8, "ascii"); // 256x256 header.writeUInt32BE(png.length + 8, 12); return Buffer.concat([header, png]); } // The icon next to setup.mjs, or the one in the setup repository. async function iconPng() { const local = path.join(path.dirname(fileURLToPath(import.meta.url)), "icon.png"); if (fs.existsSync(local)) return fs.readFileSync(local); try { const response = await fetch(ICON_URL, { signal: AbortSignal.timeout(10_000) }); return response.ok ? Buffer.from(await response.arrayBuffer()) : null; } catch { return null; } } // Runs Vloopex for a folder in a console window. Without a folder, it asks for // one first. The window stays open on an error so that it can be read. export function windowsOpenScript(launcher) { return [ "@echo off", `rem ${LAUNCHER_MARK}`, "setlocal", 'set "folder=%~1"', 'if not defined folder for /f "usebackq delims=" %%F in (`powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0pick-folder.ps1"`) do set "folder=%%F"', "if not defined folder exit /b 0", "title Vloopex", `call "${launcher}" "%folder%"`, "if errorlevel 1 pause", ].join("\r\n") + "\r\n"; } // The current Windows folder dialog (IFileOpenDialog), which .NET Framework // does not wrap. export const WINDOWS_PICK_FOLDER = `Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; public static class VloopexFolderPicker { [ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] private class FileOpenDialog {} [ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IFileOpenDialog { [PreserveSig] int Show(IntPtr parent); void SetFileTypes(); void SetFileTypeIndex(); void GetFileTypeIndex(); void Advise(); void Unadvise(); void SetOptions(uint options); void GetOptions(); void SetDefaultFolder(); void SetFolder(); void GetFolder(); void GetCurrentSelection(); void SetFileName(); void GetFileName(); void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string title); void SetOkButtonLabel(); void SetFileNameLabel(); void GetResult(out IShellItem item); } [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IShellItem { void BindToHandler(); void GetParent(); void GetDisplayName(uint type, [MarshalAs(UnmanagedType.LPWStr)] out string name); } public static string Pick(string title) { var dialog = (IFileOpenDialog)new FileOpenDialog(); dialog.SetOptions(0x20 | 0x40); // folders only, file system only dialog.SetTitle(title); if (dialog.Show(IntPtr.Zero) != 0) return null; IShellItem item; dialog.GetResult(out item); string folder; item.GetDisplayName(0x80058000, out folder); // the file system path return folder; } } "@ $folder = [VloopexFolderPicker]::Pick('${PICK_PROMPT}') if ($folder) { $folder } `; // Runs Vloopex for a folder in a terminal window. Without a folder, it asks // for one first. From a terminal, it runs Vloopex there. export function unixOpenScript(platform, launcher) { const quoted = shellQuote(launcher); const pick = platform === "darwin" ? [` folder="$(osascript -e 'POSIX path of (choose folder with prompt "${PICK_PROMPT}")' 2>/dev/null)" || exit 0`] : [ " if command -v zenity >/dev/null 2>&1; then", ` folder="$(zenity --file-selection --directory --title='${PICK_PROMPT}')" || exit 0`, " elif command -v kdialog >/dev/null 2>&1; then", ` folder="$(kdialog --getexistingdirectory "$HOME" --title '${PICK_PROMPT}')" || exit 0`, " elif [ -t 0 ]; then", " printf 'Project folder: '", " read -r folder || exit 0", " fi", ]; const terminal = platform === "darwin" ? [ "# Started from Finder: run Vloopex in a Terminal window.", `exec osascript -e 'on run argv' -e 'tell application "Terminal"' -e 'activate' -e 'do script "exec " & quoted form of item 1 of argv & " " & quoted form of item 2 of argv' -e 'end tell' -e 'end run' ${quoted} "$folder"`, ] : [ "# Started from a menu or a file manager: run Vloopex in a terminal window.", 'for terminal in "$TERMINAL" x-terminal-emulator gnome-terminal konsole xfce4-terminal kgx ptyxis mate-terminal tilix kitty alacritty wezterm xterm; do', ' [ -n "$terminal" ] && command -v "$terminal" >/dev/null 2>&1 || continue', ' case "$terminal" in', ` gnome-terminal|kgx|ptyxis) exec "$terminal" -- ${quoted} "$folder" ;;`, ` xfce4-terminal|mate-terminal) exec "$terminal" -x ${quoted} "$folder" ;;`, ` kitty) exec kitty ${quoted} "$folder" ;;`, ` wezterm) exec wezterm start -- ${quoted} "$folder" ;;`, ` *) exec "$terminal" -e ${quoted} "$folder" ;;`, " esac", "done", 'echo "Vloopex found no terminal program." >&2', "exit 1", ]; return [ "#!/bin/sh", `# ${LAUNCHER_MARK}`, 'folder="$1"', 'if [ -z "$folder" ]; then', ...pick, "fi", '[ -n "$folder" ] || exit 0', `if [ -t 1 ]; then exec ${quoted} "$folder"; fi`, ...terminal, ].join("\n") + "\n"; } // Exec values of desktop entry files quote with double quotes and escape twice. const desktopQuote = file => `"${file.replace(/(["`$\\])/g, "\\\\$1")}"`; const xdgData = () => process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"); // The files of each Linux desktop entry. Files, Dolphin and Nemo each have // their own way to add an item to the right-click menu of folders. export function linuxDesktopFiles(openScript, data = xdgData()) { const dolphin = [ "[Desktop Entry]", "Type=Service", "MimeType=inode/directory;", "X-KDE-ServiceTypes=KonqPopupMenu/Plugin", "Actions=openInVloopex", "", "[Desktop Action openInVloopex]", "Name=Open in Vloopex", "Icon=vloopex", `Exec=${desktopQuote(openScript)} %f`, ].join("\n") + "\n"; return { app: [{ file: path.join(data, "applications", "vloopex.desktop"), content: [ "[Desktop Entry]", "Type=Application", "Name=Vloopex", `Comment=${PICK_PROMPT}`, `Exec=${desktopQuote(openScript)} %f`, "Icon=vloopex", "Terminal=false", "Categories=Development;", ].join("\n") + "\n", }], "folder-menu": [ { file: path.join(data, "nautilus", "scripts", "Open in Vloopex"), mode: 0o755, content: [ "#!/bin/sh", `# ${LAUNCHER_MARK}`, "# Files runs this in the open folder, with the selected items as arguments.", 'target="$(cd "${1:-.}" 2>/dev/null && pwd)" || exit 1', `exec ${shellQuote(openScript)} "$target"`, ].join("\n") + "\n", }, { file: path.join(data, "kio", "servicemenus", "vloopex.desktop"), mode: 0o755, content: dolphin }, { file: path.join(data, "kservices5", "ServiceMenus", "vloopex.desktop"), content: dolphin }, { file: path.join(data, "nemo", "actions", "vloopex.nemo_action"), content: [ "[Nemo Action]", "Name=Open in Vloopex", "Comment=Open this folder in Vloopex", `Exec=${shellQuote(openScript)} %F`, "Icon-Name=vloopex", "Selection=s", "Extensions=dir;", ].join("\n") + "\n", }, ], }; } // The AppleScript of the Vloopex app: it asks for a folder, or takes the // folders that are dropped on it. export function macAppScript(openScript) { const script = `"${openScript.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; return [ "on run", " try", ` set picked to choose folder with prompt "${PICK_PROMPT}"`, " on error", " return", " end try", " openFolder(POSIX path of picked)", "end run", "", "on open droppedItems", " repeat with droppedItem in droppedItems", " openFolder(POSIX path of droppedItem)", " end repeat", "end open", "", "on openFolder(folderPath)", ` do shell script quoted form of ${script} & " " & quoted form of folderPath & " > /dev/null 2>&1 &"`, "end openFolder", ].join("\n") + "\n"; } const xmlEscape = text => text.replace(/&/g, "&").replace(//g, ">"); // A Finder Quick Action (an Automator service workflow) with one Run Shell // Script action that passes the selected folders to the open script. export function macQuickActionFiles(openScript) { const plist = body => ` ${body} `; const command = xmlEscape(`for folder in "$@"; do ${shellQuote(openScript)} "$folder" >/dev/null 2>&1 & done`); const uuid = () => randomBytes(16).toString("hex").toUpperCase().replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, "$1-$2-$3-$4-$5"); return { "Contents/Info.plist": plist(` NSServices NSMenuItem defaultOpen in Vloopex NSMessagerunWorkflowAsService NSRequiredContext NSApplicationIdentifiercom.apple.finder NSSendFileTypes public.folder `), "Contents/document.wflow": plist(` AMApplicationBuild523 AMApplicationVersion2.10 AMDocumentVersion2 actions action AMAccepts ContainerList Optional Typescom.apple.cocoa.string AMActionVersion2.0.3 AMApplicationAutomator AMParameterProperties COMMAND_STRING CheckedForUserDefaultShell inputMethod shell source AMProvides ContainerList Typescom.apple.cocoa.string ActionBundlePath/System/Library/Automator/Run Shell Script.action ActionNameRun Shell Script ActionParameters COMMAND_STRING${command} CheckedForUserDefaultShell inputMethod1 shell/bin/sh source BundleIdentifiercom.apple.RunShellScript CFBundleVersion2.0.3 CanShowSelectedItemsWhenRun CanShowWhenRun CategoryAMCategoryUtilities Class NameRunShellScriptAction InputUUID${uuid()} KeywordsShellScript OutputUUID${uuid()} UUID${uuid()} UnlocalizedApplicationsAutomator isViewVisible1 isViewVisible1 connectors workflowMetaData serviceInputTypeIdentifiercom.apple.Automator.fileSystemObject.folder serviceOutputTypeIdentifiercom.apple.Automator.nothing serviceProcessesInput0 workflowTypeIdentifiercom.apple.Automator.servicesMenu `), }; } function writeFile(file, content, mode) { if (dryRun) return ui.dry(`would write ${shortPath(file)}`); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content, mode === undefined ? {} : { mode }); } function removeFile(file) { if (!fs.existsSync(file)) return; if (dryRun) return ui.dry(`would remove ${shortPath(file)}`); fs.rmSync(file, { recursive: true, force: true }); } export function windowsMenuKeys(classesRoot = "HKCU\\Software\\Classes") { return [`${classesRoot}\\Directory\\shell\\Vloopex`, `${classesRoot}\\Directory\\Background\\shell\\Vloopex`]; } // "Open in Vloopex" in the right-click menu of a folder and of a folder's // background. Windows 11 lists it under "Show more options". async function setWindowsFolderMenu(enable, { openScript, icon }, classesRoot) { for (const key of windowsMenuKeys(classesRoot)) { if (dryRun) { ui.dry(`would ${enable ? "set" : "remove"} ${key}`); continue; } await capture("reg", ["delete", key, "/f"]); if (!enable) continue; for (const args of [ ["add", key, "/ve", "/d", "Open in Vloopex", "/f"], ["add", key, "/v", "Icon", "/d", icon, "/f"], ["add", `${key}\\command`, "/ve", "/d", `"${openScript}" "%V"`, "/f"], ]) { const result = await capture("reg", args); if (result.code !== 0) throw new SetupError(`Could not add the folder menu entry: ${lastLine(result)}`); } } } // folder: a special folder name, such as Programs or Desktop, or a path. async function setWindowsShortcut(folder, enable, { openScript, icon }) { if (dryRun) return ui.dry(`would ${enable ? "create" : "remove"} Vloopex.lnk in ${folder}`); const script = [ // Without Create, GetFolderPath returns an empty path for a folder that does not exist yet. "$dir = if ([IO.Path]::IsPathRooted($env:VX_FOLDER)) { $env:VX_FOLDER } else { [Environment]::GetFolderPath($env:VX_FOLDER, 'Create') }", "$link = Join-Path $dir 'Vloopex.lnk'", "if ($env:VX_ENABLE -ne '1') { Remove-Item -Force -ErrorAction SilentlyContinue $link; exit 0 }", "$shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($link)", "$shortcut.TargetPath = $env:VX_TARGET", "$shortcut.IconLocation = $env:VX_ICON", "$shortcut.WorkingDirectory = $env:USERPROFILE", `$shortcut.Description = '${PICK_PROMPT}'`, "$shortcut.Save()", ].join("; "); const env = { ...process.env, VX_FOLDER: folder, VX_ENABLE: enable ? "1" : "0", VX_TARGET: openScript ?? "", VX_ICON: icon ?? "" }; const result = await capture("powershell.exe", ["-NoProfile", "-Command", script], { env }); if (result.code !== 0) throw new SetupError(`Could not change the ${folder} shortcut: ${lastLine(result)}`); } export const UNINSTALL_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Vloopex"; // The entry in Settings > Apps, which runs the copy of setup with --uninstall. async function setWindowsAppsEntry(entry, key = UNINSTALL_KEY) { if (dryRun) return ui.dry(`would ${entry ? "set" : "remove"} ${key}`); await capture("reg", ["delete", key, "/f"]); if (!entry) return; const values = [ ["DisplayName", "Vloopex"], ["DisplayIcon", entry.icon], ["DisplayVersion", entry.version], ["Publisher", "Neurochrom"], ["URLInfoAbout", "https://vloopex.com"], ["InstallLocation", entry.dir], ["UninstallString", entry.uninstall], ["QuietUninstallString", `${entry.uninstall} --yes`], ["NoModify", "1", "REG_DWORD"], ["NoRepair", "1", "REG_DWORD"], ]; for (const [name, value, type = "REG_SZ"] of values) { const result = await capture("reg", ["add", key, "/v", name, "/t", type, "/d", value, "/f"]); if (result.code !== 0) throw new SetupError(`Could not add Vloopex to Settings > Apps: ${lastLine(result)}`); } } const macApp = () => path.join(os.homedir(), "Applications", "Vloopex.app"); const macQuickAction = () => path.join(os.homedir(), "Library", "Services", "Open in Vloopex.workflow"); async function setMacApp(enable, { openScript, png }) { const app = macApp(); if (!enable) return removeFile(app); if (dryRun) return ui.dry(`would create ${shortPath(app)}`); fs.rmSync(app, { recursive: true, force: true }); const source = path.join(setupHome(), "Vloopex.applescript"); fs.writeFileSync(source, macAppScript(openScript)); const result = await capture("osacompile", ["-o", app, source]); if (result.code !== 0) throw new SetupError(`Could not create the Vloopex app: ${lastLine(result)}`); if (png) fs.writeFileSync(path.join(app, "Contents", "Resources", "applet.icns"), icnsFromPng(png)); await capture("touch", [app]); } async function setMacQuickAction(enable, { openScript }) { const workflow = macQuickAction(); if (!enable) return removeFile(workflow); for (const [name, content] of Object.entries(macQuickActionFiles(openScript))) writeFile(path.join(workflow, name), content); // Finder lists a new service after this refresh. if (!dryRun && fs.existsSync("/System/Library/CoreServices/pbs")) await capture("/System/Library/CoreServices/pbs", ["-update"]); } async function setLinuxEntries(chosen, { openScript, png }) { for (const [id, files] of Object.entries(linuxDesktopFiles(openScript))) { for (const { file, content, mode } of files) { if (chosen.includes(id)) writeFile(file, content, mode); else removeFile(file); } } const icon = path.join(xdgData(), "icons", "hicolor", "256x256", "apps", "vloopex.png"); if (chosen.length && png) writeFile(icon, png); else removeFile(icon); if (dryRun) return; // Menus and icons show changes after their caches update. if (which("update-desktop-database")) await capture("update-desktop-database", [path.join(xdgData(), "applications")]); if (which("gtk-update-icon-cache")) await capture("gtk-update-icon-cache", ["-q", "-t", path.join(xdgData(), "icons", "hicolor")]); } // Adds the chosen desktop entries and removes the others. async function applyDesktop(chosen, files) { const on = id => chosen.includes(id); if (process.platform === "win32") { await setWindowsShortcut("Programs", on("start-menu"), files); await setWindowsShortcut("Desktop", on("desktop"), files); await setWindowsFolderMenu(on("folder-menu"), files); } else if (process.platform === "darwin") { await setMacApp(on("app"), files); await setMacQuickAction(on("folder-menu"), files); } else { await setLinuxEntries(chosen, files); } } // Writes the copy of setup, the icon and the open scripts into setupHome. async function prepareSetupHome(launcher) { const home = setupHome(); const png = await iconPng(); const windows = process.platform === "win32"; const files = { png, icon: windows ? path.join(home, "vloopex.ico") : path.join(home, "vloopex.png"), openScript: path.join(home, windows ? "vloopex-open.cmd" : "vloopex-open"), }; const self = fileURLToPath(import.meta.url); if (path.resolve(self) !== path.resolve(path.join(home, "setup.mjs"))) writeFile(path.join(home, "setup.mjs"), fs.readFileSync(self)); if (png) writeFile(files.icon, windows ? icoFromPng(png) : png); if (windows) { writeFile(files.openScript, windowsOpenScript(launcher)); writeFile(path.join(home, "pick-folder.ps1"), WINDOWS_PICK_FOLDER); } else { writeFile(files.openScript, unixOpenScript(process.platform, launcher), 0o755); } return files; } export function readInstalled(home = setupHome()) { try { return JSON.parse(fs.readFileSync(path.join(home, "installed.json"), "utf8")); } catch { return null; } } async function askDesktop(plan, opts, ask, back) { plan.desktop = null; if (plan.launcher.foreign) return false; const entries = desktopEntries(); const defaults = entries.filter(entry => entry.preselected).map(entry => entry.id); plan.installed ??= readInstalled(); const previous = plan.installed?.desktop ?? null; // A script adds the default entries only with --desktop, and keeps what an // earlier run added. const initial = opts.yes ? (opts.desktop ? defaults : previous ?? []) : plan.desktopPick ?? previous ?? defaults; const answer = await ask.multiselect("Add Vloopex to your desktop?", entries.map(entry => ({ value: entry.id, label: entry.label, hint: entry.hint })), initial, { back }); if (answer === BACK) return BACK; plan.desktopPick = answer; plan.desktop = answer; return true; } // Adds the chosen desktop entries and removes the others. Vloopex works // without them, so a failure does not stop setup. async function applyDesktopEntries(plan, files) { const chosen = plan.desktop ?? []; if (!chosen.length && !plan.installed?.desktop?.length) return []; try { await applyDesktop(chosen, files); } catch (error) { if (!(error instanceof SetupError)) throw error; ui.warn(error.message); return chosen; } const names = desktopEntries().filter(entry => chosen.includes(entry.id)).map(entry => entry.label); if (names.length && !dryRun) ui.step(`Added ${names.join(", ")}`); return chosen; } // Saves what this run installed, merged with earlier runs, and on Windows the // entry in Settings > Apps. async function recordInstall({ found, launcher, desktop, installed, files, cloned }) { if (dryRun) return; const record = { format: 1, dir: found.dir, // Whether setup downloaded the checkout: an uninstall removes only that one cloned: cloned || (installed?.dir === found.dir && installed?.cloned === true), pathDirs: [...new Set([...(installed?.pathDirs ?? []), ...(launcher.pathDirs ?? [])])], pathFiles: [...new Set([...(installed?.pathFiles ?? []), ...(launcher.pathFile ? [launcher.pathFile] : [])])], desktop, }; writeFile(path.join(setupHome(), "installed.json"), `${JSON.stringify(record, null, 3)}\n`); if (process.platform !== "win32") return; const version = (await capture("git", ["-C", found.dir, "log", "-1", "--format=%cs %h"])).stdout.trim(); await setWindowsAppsEntry({ dir: found.dir, icon: files.icon, version: version || "unknown", uninstall: `"${process.execPath}" "${path.join(setupHome(), "setup.mjs")}" --uninstall`, }); } // --------------------------------------------------------------------------- // Uninstall // The Node.js that setup downloaded stays when npm installed a program into // it, such as Kilo on Windows. function runtimeInUse(runtime) { let versions = []; try { versions = fs.readdirSync(runtime); } catch { return false; } return versions.some(version => { const modules = process.platform === "win32" ? path.join(runtime, version, "node_modules") : path.join(runtime, version, "lib", "node_modules"); try { return fs.readdirSync(modules).some(name => !["npm", "corepack", ".bin"].includes(name)); } catch { return false; } }); } // Windows cannot delete the node.exe that runs this, so a detached cmd.exe // deletes the folder after setup exits. function removeAfterExit(dir) { spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `"ping -n 3 127.0.0.1 >nul & rmdir /s /q "${dir}""`], { detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true }).unref(); } function removeFolder(dir, name) { if (dryRun) return ui.dry(`would remove ${shortPath(dir)}`); try { fs.rmSync(dir, { recursive: true, force: true }); ui.step(`Removed ${name}`); } catch (error) { ui.warn(`Could not remove ${shortPath(dir)} (${error.code}). Close Vloopex, then run the uninstall again.`); } } async function uninstall(opts, ask) { ui.intro(dryRun ? "Vloopex uninstall · dry run" : "Vloopex uninstall"); const installed = readInstalled(); const dir = opts.dir ?? installed?.dir ?? defaultInstallDir(); const binDir = path.join(os.homedir(), ".local", "bin"); const runtime = path.join(os.homedir(), ".vloopex", "runtime"); const keepRuntime = runtimeInUse(runtime); const app = await probeVloopex(dir); // Setup removes only the checkout it downloaded, never one it found at --dir and updated const downloaded = installed?.cloned === true && installed.dir === dir; const removable = app.kind === "git" && app.inspected && downloaded; const changes = app.dirty ? "has local changes" : app.untracked ? "has files Git does not track" : null; const launchers = ["vloopex", "vloopex.cmd"].map(name => path.join(binDir, name)) .filter(file => fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(LAUNCHER_MARK)); // Other programs, such as Claude Code, can use the launcher folder too. let binShared = false; try { binShared = fs.readdirSync(binDir).some(name => !launchers.includes(path.join(binDir, name))); } catch { // The folder is gone. } const keepOnPath = entry => (keepRuntime && entry.includes(".vloopex")) || (binShared && entry.includes(".local")); const pathDirs = (installed?.pathDirs ?? []).filter(entry => !keepOnPath(entry)); const rows = [ removable && `Vloopex in ${shortPath(dir)}${changes ? ` (it ${changes})` : ""}`, launchers.length && `the vloopex command in ${shortPath(binDir)}`, installed?.desktop?.length && `the desktop entries: ${desktopEntries().filter(entry => installed.desktop.includes(entry.id)).map(entry => entry.label).join(", ")}`, pathDirs.length && `${pathDirs.map(shortPath).join(" and ")} from PATH`, fs.existsSync(runtime) && !keepRuntime && `the Node.js that setup downloaded, in ${shortPath(runtime)}`, process.platform === "win32" && "the entry in Settings > Apps", ].filter(Boolean); ui.box("Setup removes", rows.length ? rows : ["nothing that setup installed"]); if (app.kind === "git" && !removable) { ui.info(dim(`Vloopex in ${shortPath(dir)} stays: ${downloaded ? "Git could not read its state" : "setup did not download it"}.`)); } ui.info(dim("Your settings and chats in ~/.vloopex, your projects and the AI harnesses stay.")); if (keepRuntime) ui.info(dim(`The Node.js in ${shortPath(runtime)} stays, because programs were installed into it.`)); // The wizard defaults to keeping everything; --yes removes. if (!await ask.confirm("Remove Vloopex from this computer?", opts.yes)) { ui.outro("Nothing was removed."); return 1; } if (!installed || installed.desktop?.length) await applyDesktop([], { openScript: null, png: null, icon: null }); for (const file of launchers) removeFile(file); if (pathDirs.length && process.platform === "win32") { if (dryRun) ui.dry(`would remove ${pathDirs.join(", ")} from PATH`); else await changeWindowsPath(pathDirs, false); } for (const file of installed?.pathFiles ?? []) { if (!fs.existsSync(file)) continue; const text = fs.readFileSync(file, "utf8"); const cleaned = removeStartupLines(text, keepOnPath); if (cleaned === text) continue; if (dryRun) ui.dry(`would remove the PATH lines from ${shortPath(file)}`); else fs.writeFileSync(file, cleaned); } if (process.platform === "win32") await setWindowsAppsEntry(null); if (removable && (!changes || await ask.confirm(`${shortPath(dir)} ${changes}. Remove it anyway?`, false))) { removeFolder(dir, "Vloopex"); } if (fs.existsSync(runtime) && !keepRuntime) { const runningFromIt = !path.relative(runtime, process.execPath).startsWith(".."); if (runningFromIt && process.platform === "win32" && !dryRun) removeAfterExit(runtime); else removeFolder(runtime, "the downloaded Node.js"); } removeFolder(setupHome(), "the setup files"); ui.outro(green(dryRun ? "Dry run finished. Setup changed nothing." : "Vloopex is removed.")); return 0; } // --------------------------------------------------------------------------- // Accounts and plans export function claudeStatus(json) { try { const status = JSON.parse(json); if (!status.loggedIn) return null; return status.subscriptionType ? `signed in · ${status.subscriptionType} plan` : "signed in"; } catch { return null; } } export function codexStatus(code, output) { const line = output.split(/\r?\n/).map(text => text.trim()).find(text => /^Logged in/i.test(text)); // An API key login prints a masked key after " - ". return code === 0 && line ? line.replace(/^Logged in using /i, "signed in with ").replace(/ - .*$/, "") : null; } export function kiloStatus(account, credentials) { const plain = stripAnsi(credentials); if (account === "kilo") return /Kilo Gateway/i.test(plain) ? "signed in" : null; return /OpenRouter/i.test(plain) ? "connected to Kilo" : null; } // Codex runs the way that Vloopex runs it: the WSL route goes through // Vloopex's own wrapper. function codexCommand(found) { const route = found.harnesses.codex?.route; if (route === "wsl" && found.bash) return [found.bash.path, [path.join(found.dir, "wrappers", "codexw.sh").replace(/\\/g, "/")]]; if (route === "custom") { const runner = process.env.CODEX_RUNNER; return runner.endsWith(".sh") && found.bash ? [found.bash.path, [runner]] : [runner, []]; } return ["codex", []]; } async function accountStatus(account, found) { if (!found.harnesses[account.harness]) return null; if (account.id === "claude") return claudeStatus((await capture("claude", ["auth", "status", "--json"])).stdout); if (account.id === "codex") { const [command, prefix] = codexCommand(found); const result = await capture(command, [...prefix, "login", "status"]); return codexStatus(result.code, `${result.stdout}\n${result.stderr}`); } return kiloStatus(account.id, (await capture("kilo", ["auth", "list"])).stdout); } function signInCommand(account, found) { return account.id === "codex" && found.harnesses.codex?.route === "wsl" ? "codex login inside WSL" : account.signIn; } export function openRouterAuthUrl(callbackUrl, codeChallenge) { const params = new URLSearchParams({ callback_url: callbackUrl, code_challenge: codeChallenge, code_challenge_method: "S256", key_label: `Kilo on ${os.hostname()}`, }); return `https://openrouter.ai/auth?${params}`; } // OpenRouter's authorization for apps (OAuth with PKCE) gives an API key, and // Kilo stores it with its other credentials. Kilo's key prompt submits on \r. async function connectOpenRouter() { if (dryRun) { ui.dry("would open OpenRouter's authorization page and store its key with kilo auth login -p openrouter"); return; } const verifier = randomBytes(32).toString("base64url"); const challenge = createHash("sha256").update(verifier).digest("base64url"); const server = http.createServer(); await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); try { const callback = `http://localhost:${server.address().port}/callback`; let cleanup = () => {}; const code = new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("OpenRouter did not answer within 10 minutes.")), 600_000); // Ctrl+c gives up on the authorization, not on setup. const onInterrupt = () => reject(new Error("The OpenRouter authorization was interrupted.")); process.on("SIGINT", onInterrupt); cleanup = () => { clearTimeout(timer); process.off("SIGINT", onInterrupt); }; server.on("request", (request, response) => { const value = new URL(request.url, callback).searchParams.get("code"); if (!value) return response.writeHead(404).end(); response.writeHead(200, { "content-type": "text/html; charset=utf-8" }) .end("

OpenRouter is connected. You can close this tab and go back to the terminal.

"); resolve(value); }); }).finally(() => cleanup()); const url = openRouterAuthUrl(callback, challenge); ui.info("Authorize Kilo on OpenRouter in your browser. Setup waits here."); if (!openUrl(url)) ui.info(`Open ${url}`); const response = await fetch("https://openrouter.ai/api/v1/auth/keys", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code: await code, code_verifier: verifier, code_challenge_method: "S256" }), signal: AbortSignal.timeout(30_000), }); const { key } = response.ok ? await response.json() : {}; if (typeof key !== "string" || !key) throw new Error(`OpenRouter did not give a key (HTTP ${response.status}).`); const stored = await capture("kilo", ["auth", "login", "-p", "openrouter"], { input: `${key}\r` }); if (stored.code !== 0) throw new Error("Kilo did not store the OpenRouter key."); } finally { server.close(); } } async function signIn(account, found) { if (account.id === "openrouter") return connectOpenRouter(); const [command, args] = account.id === "codex" ? [codexCommand(found)[0], [...codexCommand(found)[1], "login"]] : [account.harness, account.signIn.split(" ").slice(1)]; const code = await run(command, args, { interactive: true }); if (code === INTERRUPTED) throw new Error(`${account.signIn} was interrupted.`); if (code !== 0) throw new Error(`${account.signIn} ended with exit code ${code}.`); } async function goLinksLive() { try { const response = await fetch(GO_BASE + ACCOUNTS[0].plans[0].slug, { method: "HEAD", redirect: "manual", signal: AbortSignal.timeout(4000) }); return response.status >= 300 && response.status < 400; } catch { return false; } } function openUrl(url) { const [command, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]]; const file = which(command); if (!file) return false; spawn(file, args, { detached: true, stdio: "ignore" }).on("error", () => {}).unref(); return true; } async function showPlans(account, goLive, ask) { const width = Math.max(...account.plans.map(plan => plan.label.length)); ui.box(`Plans for ${account.name}`, account.plans.map(plan => `${plan.label.padEnd(width)} ${dim(plan.hint)}`)); const opened = new Set(); let last = account.plans[0].slug; for (;;) { const options = [ ...account.plans.map(plan => ({ value: plan.slug, label: plan.label, hint: opened.has(plan.slug) ? "opened" : planUrl(plan, goLive) })), { value: null, label: "Done" }, ]; const slug = await ask.select("Open a plan page in your browser?", options, last); if (!slug) return; const url = planUrl(account.plans.find(plan => plan.slug === slug), goLive); if (!openUrl(url)) ui.info(`Open ${url}`); opened.add(slug); last = slug; } } // Checks each account of the ready harnesses and offers to sign in to the // ones that are not signed in. A sign-in that fails or that the person // interrupts with ctrl+c can be tried again or left for later. Returns the // accounts that stay signed out. async function setupAccounts(harnessIds, found, ask) { const accounts = ACCOUNTS.filter(account => harnessIds.includes(account.harness)); if (!accounts.length) return []; const states = await withSpinner("Checking the sign-ins", "Checked the sign-ins", () => Promise.all(accounts.map(account => accountStatus(account, found)))); ui.box("Accounts", accounts.map((account, index) => states[index] ? statusRow("ok", account.name, states[index]) : statusRow("off", account.name, "not signed in"))); const signedOut = accounts.filter((account, index) => !states[index]); if (!ask.interactive) return signedOut; let goLive; const later = []; for (const account of signedOut) { const connect = account.id === "openrouter"; const verb = connect ? "Connect" : "Sign in to"; let choice = await ask.select(connect ? "Connect OpenRouter to Kilo?" : `Sign in to ${account.name}?`, [ { value: "now", label: connect ? "Connect now" : "Sign in now", hint: connect ? "authorize Kilo on openrouter.ai" : "opens your browser" }, { value: "plans", label: "Show plans first" }, { value: "later", label: "Later" }, ], "now"); for (;;) { if (choice === "plans") { goLive ??= await goLinksLive(); await showPlans(account, goLive, ask); choice = await ask.confirm(`${verb} ${account.name} now?`, true) ? "now" : "later"; } if (choice === "later") { later.push(account); break; } ui.info(`${connect ? "The authorization runs here." : "The sign-in runs here and opens your browser."} ${bold("Ctrl+c skips it")}; setup goes on.`); try { await signIn(account, found); } catch (error) { ui.warn(error.message); } if (dryRun) break; const status = await accountStatus(account, found); if (status) { ui.step(`${account.name}: ${status}`); break; } choice = await ask.select(`${account.name} is not ${connect ? "connected" : "signed in"}. What now?`, [ { value: "now", label: connect ? "Connect again" : "Sign in again" }, { value: "plans", label: "Show plans first" }, { value: "later", label: "Later", hint: "the next steps show the command" }, ], "now"); } } return later; } // --------------------------------------------------------------------------- // The plan // The wizard asks its questions first, each of which esc leaves for the one // before, then sums up what setup will do; nothing changes before the person // starts it. A script takes the defaults. A question returns true when it // was asked, false when it did not apply, or BACK. function showNextSteps({ harnessIds, signedOut, found, launcher, appDir, desktop }) { const command = launcher.command === "vloopex" ? "vloopex" : launcher.command ? `"${launcher.command}"` : `node "${path.join(appDir, "vloopex.js")}"`; const signIns = signedOut.map(account => signInCommand(account, found)); const uses = desktopEntries().filter(entry => desktop.includes(entry.id)).map(entry => entry.use); const steps = [ launcher.pathChanged && "Open a new terminal, so that it has the new PATH.", !harnessIds.length && "Run setup again to install an AI harness.", signIns.length && `Sign in later: ${signIns.join(", ")}`, `Start Vloopex in a project: ${command} `, uses.length && `Or use ${uses.join(", or ")}.`, ].filter(Boolean); ui.box("Next steps", steps.map((step, index) => `${index + 1}. ${step}`)); } async function askDir(plan, opts, ask, back) { if (opts.dir || !["missing", "empty", "foreign"].includes(plan.vloopex.kind)) return false; for (;;) { if (plan.vloopex.kind === "foreign") ui.warn(`${plan.dir} has other files.`); const answer = await ask.text("Where should Vloopex go?", plan.dir, { back }); if (answer === BACK) return BACK; const dir = path.resolve(answer); plan.dir = dir; plan.vloopex = dir === plan.found.dir ? plan.found.vloopex : await probeVloopex(dir); if (plan.vloopex.kind !== "foreign") return true; } } async function askUpdate(plan, opts, ask, back) { const { vloopex } = plan; plan.update = false; if (vloopex.kind !== "git") return false; if (!vloopex.inspected) { ui.warn("Git could not read Vloopex's state, so setup does not update it."); return false; } if (vloopex.dirty) { ui.warn("Vloopex has local changes, so setup does not update it."); return false; } const answer = await ask.confirm("Update Vloopex to the latest version?", plan.updatePick ?? true, { back }); if (answer === BACK) return BACK; plan.updatePick = answer; plan.update = answer; return true; } // What setup will do, in order, as the summary shows it. export function planRows(plan, { home = os.homedir(), platform = process.platform } = {}) { const { found } = plan; const list = items => items.map(item => item.name).join(", ").replace(/, ([^,]*)$/, " and $1"); const where = file => shortPath(file, home, platform); const rows = []; if (plan.prerequisites) rows.push(`Install ${plan.prerequisites.names} with ${plan.prerequisites.how}`); if (plan.vloopex.kind !== "git") rows.push(`Download Vloopex into ${where(plan.dir)} and install its dependencies`); else if (plan.update) rows.push(`Update Vloopex in ${where(plan.dir)}`); else rows.push(`${plan.vloopex.ready ? "Check" : "Install the dependencies of"} Vloopex in ${where(plan.dir)}`); const installs = HARNESSES.filter(harness => plan.harnesses.includes(harness.id) && !found.harnesses[harness.id]); if (installs.length) rows.push(`Install ${list(installs)}`); else if (!plan.harnesses.length) rows.push("Install no AI harness"); if (plan.updates.length) { rows.push(`Update ${list(plan.updates.map(harness => ({ name: plan.latest ? `${harness.name} ${updateHint(found.harnesses[harness.id].version, plan.latest[harness.id])}` : harness.name, })))}`); } if (plan.launcher && !plan.launcher.foreign) { const onPath = plan.path ? `, and put ${plan.launcher.missing.map(entry => where(entry.dir)).join(" and ")} on PATH` : ""; rows.push(`Add the vloopex command to ${where(plan.launcher.binDir)}${onPath}`); } if (plan.desktop) { const entries = desktopEntries(platform); const labels = ids => entries.filter(entry => ids.includes(entry.id)).map(entry => entry.label).join(", "); const removed = (plan.installed?.desktop ?? []).filter(id => !plan.desktop.includes(id)); if (plan.desktop.length) rows.push(`Add ${labels(plan.desktop)}`); if (removed.length) rows.push(`Remove ${labels(removed)}`); } const accounts = ACCOUNTS.filter(account => plan.harnesses.includes(account.harness)); if (accounts.length) rows.push(`Check the sign-ins of ${list(accounts)}`); return rows; } async function askStart(plan, opts, ask, back) { ui.box(dryRun ? "Setup would" : "Setup will", planRows(plan).map((row, index) => `${index + 1}. ${row}`)); if (!ask.interactive) return false; const answer = await ask.select(dryRun ? "Start the dry run?" : "Start setup?", [ { value: "start", label: "Start" }, ...(back ? [{ value: "back", label: "Go back", hint: "change an answer" }] : []), { value: "cancel", label: "Cancel", hint: "nothing changes" }, ], "start", { back }); if (answer === BACK || answer === "back") return BACK; if (answer === "cancel") throw Object.assign(new SetupError("Setup cancelled."), { code: 130 }); return true; } async function planSetup(found, opts, ask) { const plan = { found, dir: found.dir, vloopex: found.vloopex, prerequisites: null, update: false, harnesses: [], updates: [], latest: null, launcher: null, path: false, desktop: null, installed: null, }; await planPrerequisites(plan); if (opts.dir && plan.vloopex.kind === "foreign") throw new SetupError(`${plan.dir} has other files. Choose another folder with --dir.`); const questions = [askDir, askUpdate, askHarnesses, askHarnessUpdates, askPath, askDesktop, askStart]; // The questions that were asked, each with the row it started at. const asked = []; for (let index = 0; index < questions.length;) { const row = ui.mark(); const result = await questions[index](plan, opts, ask, asked.length > 0); if (result === BACK) { const previous = asked.pop(); ui.rewind(previous.row); index = previous.index; continue; } if (result) asked.push({ index, row }); index++; } return plan; } // Runs the plan, then offers the sign-ins. async function runPlan(plan, opts, ask) { let found = { ...plan.found, dir: plan.dir, vloopex: plan.vloopex }; if (plan.prerequisites) { await installPrerequisites(plan, ask, opts.yes); // A dry run installed nothing, so checking again would only fail. if (!dryRun) { await refreshPath(); found = await withSpinner("Checking again", "Checked again", () => probe(plan.dir)); if (!prerequisitesOk(found)) { ui.box("This computer", systemRows(found)); throw new SetupError("Some requirements are still missing."); } } } const cloned = await installVloopex(plan, opts, ask); await installHarnesses(plan, ask); // A dry run continues as if the picked harnesses were installed. let readyHarnesses = plan.harnesses; if (!dryRun) { await refreshPath(); const harnesses = await withSpinner("Checking the harnesses", "Checked the harnesses", probeHarnesses); found = { ...found, harnesses }; ui.box("AI harnesses", harnessRows(found)); readyHarnesses = plan.harnesses.filter(id => harnesses[id]); } const launcher = await installLauncher(plan, found); const files = launcher.file ? await prepareSetupHome(launcher.file) : null; const desktop = files ? await applyDesktopEntries(plan, files) : []; // Recorded before the sign-ins, so that stopping there loses nothing. if (files) await recordInstall({ found, launcher, desktop, installed: plan.installed, files, cloned }); const signedOut = await setupAccounts(readyHarnesses, found, ask); showNextSteps({ harnessIds: readyHarnesses, signedOut, found, launcher, appDir: plan.dir, desktop }); const notReady = HARNESSES.filter(harness => plan.harnesses.includes(harness.id) && !readyHarnesses.includes(harness.id)); if (notReady.length) { ui.outro(yellow(`Vloopex is ready, but ${notReady.map(harness => harness.name).join(" and ")} did not install.`)); return 3; } ui.outro(green(dryRun ? "Dry run finished. Setup changed nothing." : "Vloopex is ready.")); return 0; } // --------------------------------------------------------------------------- // Main export async function main(argv) { let opts; try { opts = parseOptions(argv); } catch (error) { console.error(`${error.message}\n\n${USAGE}`); return 2; } if (opts.help) { print(USAGE); return 0; } const interactive = !opts.yes && !opts.check; if (interactive && !process.stdin.isTTY) { console.error("Setup asks questions, but its input is not a terminal. Run it with --yes, or from a terminal."); return 2; } const ask = createPrompts(interactive); if (out.isTTY) process.on("exit", () => out.write(RESTORE)); dryRun = opts.dryRun; if (opts.uninstall) { try { return await uninstall(opts, ask); } catch (error) { if (!(error instanceof SetupError)) throw error; ui.outro(red(error.message)); return 1; } } if (interactive && out.isTTY && useColor) await splash(); ui.intro(opts.check ? "Vloopex setup · check only" : dryRun ? "Vloopex setup · dry run" : "Vloopex setup"); await refreshPath(); let found = await withSpinner("Checking this computer", "Checked this computer", () => probe(opts.dir ?? defaultInstallDir())); found = pretendMissing(found, opts.pretendMissing); showStatus(found); if (opts.check) { const ready = prerequisitesOk(found) && found.vloopex.ready; ui.outro(ready ? green("Vloopex is ready.") : yellow("Some parts are missing. Run setup without --check to install them.")); return ready ? 0 : 1; } try { const plan = await planSetup(found, opts, ask); return await runPlan(plan, opts, ask); } catch (error) { if (!(error instanceof SetupError)) throw error; ui.outro(red(error.message)); return error.code ?? 1; } } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { main(process.argv.slice(2)).then(code => { process.exitCode = code; }); }