import readline from "node:readline/promises"; import fs from "node:fs/promises"; import path from "node:path"; import { spawn } from "node:child_process"; import { stdin as processStdin, stdout as processStdout, stderr as processStderr } from "node:process"; import { assertPathInside, resolveGstackHome, resolveRuntimePaths, shellQuote } from "./paths.js"; import { readJson } from "./storage.js"; import { setupRuntime } from "./setup.js"; import { configGet, configSet, configSetBrowserChoice, configSetNetworkChoice, parseConfigValue, secretSet, } from "./config.js"; import { resolveBrowserChoice } from "./browser-choice.mjs"; import { discoverProjectIdentity } from "./identity.js"; import { beginRun, completeRun, inspectProject, inspectRun, markEffectApplied, markEffectNotApplied, resumeRun, runExternalEffect, updateRunWorkflow, } from "./state.js"; import { runDoctor, formatDoctor } from "./doctor.js"; import { cleanupRuntime } from "./cleanup.js"; import { ContextClient, contextStatus, readContextKey, redactSensitiveText, validateContextKey, } from "./context.js"; import { rollbackUpgrade } from "./upgrade.js"; import { installManagedRuntime, uninstallManagedRuntime } from "./install.js"; import { assertManagedHome, withRuntimeLifecycleLock } from "./managed-home.js"; import { errorWithCode as cliError } from "./errors.js"; import { RUNTIME_VERSION } from "./index.js"; export async function main(argv = process.argv.slice(2), options = {}) { const env = options.env ?? process.env; const cwd = options.cwd ?? process.cwd(); const stdin = options.stdin ?? processStdin; const stdout = options.stdout ?? processStdout; const stderr = options.stderr ?? processStderr; const home = resolveGstackHome({ env, cwd, homeDir: options.homeDir }); const [command, ...args] = argv; if (!command || ["help", "--help", "-h"].includes(command)) { write(stdout, usage()); return 0; } if (["--version", "version", "-v"].includes(command)) { write(stdout, `gstack runtime ${RUNTIME_VERSION}\n`); return 0; } try { switch (command) { case "setup": case "init": return await initCommand({ args, home, cwd, stdout, legacyAlias: command === "setup" }); case "doctor": return await doctorCommand({ args, home, cwd, stdout }); case "paths": return await pathsCommand({ args, home, stdout }); case "runtime": return await runtimeCommand({ args, home, stdout }); case "config": return await configCommand({ args, home, cwd, stdout }); case "state": return await stateCommand({ args, home, cwd, env, stdout, stderr }); case "context": return await contextCommand({ args, home, cwd, env, stdin, stdout, stderr, clientFactory: options.contextClientFactory ?? ((clientOptions) => new ContextClient(clientOptions)), }); case "cleanup": return await cleanupCommand({ args, home, stdout }); case "upgrade": return await upgradeCommand({ args, home, stdout, installOptions: options.installOptions }); case "uninstall": return await uninstallCommand({ args, home, stdout }); default: throw cliError(`Unknown command: ${command}`, "USAGE"); } } catch (error) { const json = args.includes("--json"); const safeMessage = redactSensitiveText(error?.message ?? String(error)); if (json) { write(stderr, `${JSON.stringify({ ok: false, error: error?.code ?? "ERROR", message: safeMessage })}\n`); } else { write(stderr, `gstack: ${safeMessage}\n`); } return exitCodeFor(error); } } async function runtimeCommand({ args, home, stdout }) { const [action, relative, ...rest] = args; if (action !== "path" || !relative || rest.length > 0) { throw cliError("Usage: gstack runtime path ", "USAGE"); } if (relative.includes("\0") || path.isAbsolute(relative) || relative.split(/[\\/]+/).some((part) => part === ".." || part === "")) { throw cliError("Runtime bundle path must be a safe relative path", "USAGE"); } const paths = resolveRuntimePaths({ home }); const pointer = await readJson(paths.versionPointer, null); const version = pointer?.current; if (!version) throw cliError("No active managed runtime; run `gstack upgrade --source --version `", "RUNTIME_NOT_INSTALLED"); if (typeof version !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(version)) { throw cliError("Managed runtime pointer contains an invalid version", "RUNTIME_POINTER_INVALID"); } const versionRoot = assertPathInside(paths.versions, path.join(paths.versions, version)); const target = assertPathInside(versionRoot, path.join(versionRoot, relative)); const stat = await fs.lstat(target).catch((error) => { if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null; throw error; }); if (!stat || stat.isSymbolicLink()) throw cliError(`Managed runtime asset is unavailable: ${relative}`, "RUNTIME_ASSET_MISSING"); write(stdout, `${target}\n`); return 0; } async function pathsCommand({ args, home, stdout }) { rejectUnknown(args, ["--json", "--shell"]); if (args.includes("--json") && args.includes("--shell")) { throw cliError("Choose either --json or --shell", "USAGE"); } const paths = resolveRuntimePaths({ home }); const result = { GSTACK_STATE_ROOT: paths.home, PLAN_ROOT: paths.plans, TMP_ROOT: paths.tmp, }; if (args.includes("--shell")) { for (const [key, value] of Object.entries(result)) write(stdout, `${key}=${shellQuote(value)}\n`); } else { write(stdout, `${JSON.stringify(result, null, 2)}\n`); } return 0; } async function initCommand({ args, home, cwd, stdout, legacyAlias = false }) { rejectUnknown(args, []); const result = await setupRuntime({ home, cwd }); write(stdout, `${legacyAlias ? "Note: `gstack setup` initializes state only; use `gstack init` for this operation.\n" : ""}GStack state initialized\nhome: ${result.paths.home}\nproject: ${result.identity.projectId}\nnetwork: ${result.config.network.mode}\noptional runtime: unchanged (run \`gstack doctor\` for capability readiness)\n`); return 0; } async function doctorCommand({ args, home, cwd, stdout }) { const parsed = parseFlags(args, new Set(["--json", "--skill-api"])); if (parsed.positionals.length) throw cliError("Doctor accepts only named options", "USAGE"); const report = await runDoctor({ home, cwd, expectedSkillApi: parsed.values.get("--skill-api") }); write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(report, null, 2)}\n` : formatDoctor(report)); return report.ok ? 0 : 1; } async function configCommand({ args, home, cwd, stdout }) { const [action, ...tail] = args; if (action === "get") { const key = tail.find((arg) => !arg.startsWith("--")); rejectUnknown(tail.filter((arg) => arg !== key), ["--json"]); const result = await configGet(home, key); if (result === undefined) throw cliError(`Config key not found: ${key}`, "CONFIG_KEY_NOT_FOUND"); write(stdout, `${typeof result === "string" ? result : JSON.stringify(result, null, 2)}\n`); return 0; } if (action === "set") { const [key, value, ...rest] = tail; if (!key || value === undefined || rest.length) throw cliError("Usage: gstack config set ", "USAGE"); if (key === "browser" || key.startsWith("browser.")) { throw cliError( "Browser selection is coherent state; use `gstack config browser managed`, `gstack config browser installed `, or `gstack config browser clear`.", "CONFIG_BROWSER_COMMAND_REQUIRED", ); } await setupRuntime({ home, cwd }); const result = await withOwnedRuntimeMutation(home, () => configSet(home, key, parseConfigValue(value))); write(stdout, `${key} = ${typeof result === "string" ? result : JSON.stringify(result)}\n`); return 0; } if (action === "browser") { const [provider, executablePath, ...rest] = tail; if (rest.length || !["managed", "installed", "clear"].includes(provider) || (provider === "installed" ? !executablePath : executablePath != null)) { throw cliError("Usage: gstack config browser managed | installed | clear", "USAGE"); } await setupRuntime({ home, cwd }); const choice = provider === "clear" ? null : await resolveBrowserChoice({ provider, executablePath }); await assertBrowserChoiceCompatibleWithActiveRuntime(home, choice); const result = await withOwnedRuntimeMutation(home, () => configSetBrowserChoice(home, choice)); write(stdout, provider === "clear" ? "browser selection cleared\n" : `browser = ${JSON.stringify(result)}\n`); return 0; } throw cliError("Usage: gstack config get [key] | gstack config set | gstack config browser managed | installed | clear", "USAGE"); } async function activeRuntimeBrowserChoice(home) { const paths = resolveRuntimePaths({ home }); const pointer = await readJson(paths.versionPointer, null); if (typeof pointer?.current !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(pointer.current)) return null; return runtimeBrowserChoiceAtPath(path.join(paths.versions, pointer.current)); } async function runtimeBrowserChoiceAtPath(runtimePath) { const manifest = await readJson(path.join(runtimePath, ".gstack-bundle.json"), null); if (!manifest || typeof manifest !== "object") return null; const selected = Array.isArray(manifest.selectedCapabilities) ? manifest.selectedCapabilities : []; const components = Array.isArray(manifest.runtimeComponents) ? manifest.runtimeComponents : []; if (!selected.includes("browser") && !selected.includes("browser-visible")) return null; const explicit = manifest.browserChoice; const provider = explicit?.provider ?? ( components.includes("browser-headless") || components.includes("browser-visible") ? "managed" : components.includes("browser-code") ? "installed" : null ); return provider ? { provider, executablePath: provider === "installed" ? explicit?.executablePath ?? null : null, visible: selected.includes("browser-visible"), } : null; } async function assertBrowserChoiceCompatibleWithActiveRuntime(home, choice) { if (!choice) return; const active = await activeRuntimeBrowserChoice(home); if (!active) return; if (choice.provider !== active.provider) { throw cliError( `The active runtime was installed for ${active.provider} Chromium. Use the signed capability bootstrap to install a ${choice.provider} browser slot before switching providers.`, "BROWSER_PROVIDER_SLOT_MISMATCH", ); } if (choice.provider === "installed" && active.visible) { throw cliError("Visible GStack Browser is managed-only; install a managed browser slot before selecting it", "BROWSER_PROVIDER_UNSUPPORTED"); } } async function resolvedBrowserChoiceForRuntimePath(runtimePath) { const choice = await runtimeBrowserChoiceAtPath(runtimePath); if (!choice) return null; if (choice.provider === "managed") return { provider: "managed", executablePath: null }; if (typeof choice.executablePath !== "string") { throw cliError("The rollback slot does not record its installed browser executable", "BROWSER_PATH_REQUIRED"); } return resolveBrowserChoice({ provider: "installed", executablePath: choice.executablePath }); } async function stateCommand({ args, home, cwd, env, stdout, stderr }) { const [action, ...rest] = args; const identity = await discoverProjectIdentity(cwd); if (action === "inspect") { const parsed = parseStateArguments(rest, { flags: ["--json"] }); if (parsed.positionals.length > 1) throw cliError("Usage: gstack state inspect [run-id] [--json]", "USAGE"); const runId = parsed.positionals[0]; const result = runId ? await inspectRun(home, identity.projectId, runId) : await inspectProject(home, identity); write(stdout, `${JSON.stringify(runId ? { projectId: identity.projectId, run: result.run, reconstruction: result.reconstruction, } : result.state, null, 2)}\n`); return 0; } if (action === "resume") { const parsed = parseStateArguments(rest, { flags: ["--json"] }); if (parsed.positionals.length > 1) throw cliError("Usage: gstack state resume [run-id] [--json]", "USAGE"); const runId = parsed.positionals[0]; const result = await withOwnedRuntimeMutation(home, () => resumeRun(home, identity.projectId, runId)); const output = { projectId: identity.projectId, run: result.run, reconstruction: result.reconstruction }; write(stdout, `${JSON.stringify(output, null, 2)}\n`); return 0; } if (action === "begin") { const parsed = parseStateArguments(rest, { flags: ["--json"], values: ["--run-id", "--goal", "--plan", "--stage", "--depth", "--mutation", "--modules"], }); if (parsed.positionals.length !== 1) { throw cliError("Usage: gstack state begin [metadata options] [--json]", "USAGE"); } const [workflow] = parsed.positionals; const options = { runId: parsed.values.get("--run-id"), originalGoal: parsed.values.get("--goal"), currentPlanPointer: parsed.values.get("--plan"), currentWorkflowStage: parsed.values.get("--stage"), selectedDepth: parsed.values.get("--depth"), mutationAuthority: parsed.values.get("--mutation"), activeModules: parsed.values.has("--modules") ? parseModuleList(parsed.values.get("--modules")) : undefined, }; await setupRuntime({ home, cwd }); const result = await withOwnedRuntimeMutation(home, () => beginRun(home, identity.projectId, workflow, options)); const output = { projectId: identity.projectId, run: result.run, reconstruction: result.reconstruction }; write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(output, null, 2)}\n` : `${result.run.id}\n`); return 0; } if (action === "update") { const parsed = parseStateArguments(rest, { flags: ["--json", "--clear-plan", "--pop-detour"], values: [ "--plan", "--stage", "--depth", "--mutation", "--modules", "--push-detour", "--evidence-freshness", "--evidence-source", "--evidence-reference", "--evidence-captured-at", "--add-approval", "--approval-summary", "--resolve-approval", ], }); if (parsed.positionals.length !== 1) { throw cliError("Usage: gstack state update [workflow transition options] [--json]", "USAGE"); } if (parsed.flags.has("--clear-plan") && parsed.values.has("--plan")) { throw cliError("Choose either --plan or --clear-plan", "USAGE"); } const transition = {}; if (parsed.values.has("--plan")) transition.currentPlanPointer = parsed.values.get("--plan"); if (parsed.flags.has("--clear-plan")) transition.currentPlanPointer = null; if (parsed.values.has("--stage")) transition.currentWorkflowStage = parsed.values.get("--stage"); if (parsed.values.has("--depth")) transition.selectedDepth = parsed.values.get("--depth"); if (parsed.values.has("--mutation")) transition.mutationAuthority = parsed.values.get("--mutation"); if (parsed.values.has("--modules")) transition.activeModules = parseModuleList(parsed.values.get("--modules")); if (parsed.values.has("--push-detour")) transition.pushDetour = parsed.values.get("--push-detour"); if (parsed.flags.has("--pop-detour")) transition.popDetour = true; if (parsed.values.has("--evidence-freshness")) { transition.evidenceFreshness = parsed.values.get("--evidence-freshness"); } const evidenceFields = ["--evidence-source", "--evidence-reference", "--evidence-captured-at"]; const hasEvidence = evidenceFields.some((flag) => parsed.values.has(flag)); if (hasEvidence) { if (!parsed.values.has("--evidence-source") || !parsed.values.has("--evidence-reference")) { throw cliError("Evidence provenance requires --evidence-source and --evidence-reference", "USAGE"); } transition.addEvidenceProvenance = { source: parsed.values.get("--evidence-source"), reference: parsed.values.get("--evidence-reference"), capturedAt: parsed.values.get("--evidence-captured-at"), }; if (transition.addEvidenceProvenance.capturedAt === undefined) { delete transition.addEvidenceProvenance.capturedAt; } } if (parsed.values.has("--approval-summary") && !parsed.values.has("--add-approval")) { throw cliError("--approval-summary requires --add-approval", "USAGE"); } if (parsed.values.has("--add-approval")) { const summary = parsed.values.get("--approval-summary"); if (!summary) throw cliError("--add-approval requires --approval-summary", "USAGE"); transition.addApprovalGate = { id: parsed.values.get("--add-approval"), summary }; } if (parsed.values.has("--resolve-approval")) { transition.resolveApprovalGate = parsed.values.get("--resolve-approval"); } const [runId] = parsed.positionals; const result = await withOwnedRuntimeMutation(home, () => updateRunWorkflow(home, identity.projectId, runId, transition)); write(stdout, `${JSON.stringify({ projectId: identity.projectId, run: result.run, reconstruction: result.reconstruction, }, null, 2)}\n`); return 0; } if (action === "effect") { const delimiter = rest.indexOf("--"); if (delimiter !== 2 || rest.length < 4) { throw cliError("Usage: gstack state effect -- [args...]", "USAGE"); } const [runId, effectKey] = rest; const command = rest.slice(delimiter + 1); const result = await withOwnedRuntimeMutation(home, () => runExternalEffect(home, identity.projectId, runId, effectKey, async ({ idempotencyKey }) => runExternalCommand(command, { cwd, env: { ...env, GSTACK_IDEMPOTENCY_KEY: idempotencyKey }, stdout, stderr, }))); if (result.status === "uncertain") { throw cliError( `External effect ${effectKey} was already claimed. Inspect the external system, then reconcile explicitly; it was not repeated.`, "EXTERNAL_EFFECT_UNCERTAIN", ); } write(stdout, `${JSON.stringify({ status: result.status, effectKey, idempotencyKey: result.idempotencyKey ?? null, result: result.result })}\n`); return 0; } if (action === "reconcile-not-applied") { const [runId, effectKey, confirmation, ...tail] = rest; if (!runId || !effectKey || confirmation !== "--confirm-not-applied" || tail.length) { throw cliError("Usage: gstack state reconcile-not-applied --confirm-not-applied", "USAGE"); } const result = await withOwnedRuntimeMutation(home, () => markEffectNotApplied(home, identity.projectId, runId, effectKey)); write(stdout, `${JSON.stringify(result.result)}\n`); return 0; } if (action === "reconcile-applied") { const [runId, effectKey, confirmation, evidenceFlag, evidence, ...tail] = rest; if (!runId || !effectKey || confirmation !== "--confirm-applied" || evidenceFlag !== "--evidence" || !evidence || tail.length) { throw cliError("Usage: gstack state reconcile-applied --confirm-applied --evidence ", "USAGE"); } const result = await withOwnedRuntimeMutation(home, () => markEffectApplied(home, identity.projectId, runId, effectKey, evidence)); write(stdout, `${JSON.stringify(result.result)}\n`); return 0; } if (action === "complete") { const [runId, ...tail] = rest; if (!runId || tail.length) throw cliError("Usage: gstack state complete ", "USAGE"); const result = await withOwnedRuntimeMutation(home, () => completeRun(home, identity.projectId, runId)); write(stdout, `${JSON.stringify({ projectId: identity.projectId, run: result.run })}\n`); return 0; } throw cliError("Usage: gstack state inspect|begin|update|effect|resume|reconcile-applied|reconcile-not-applied|complete", "USAGE"); } async function runExternalCommand(command, { cwd, env, stdout, stderr }) { const [executable, ...args] = command; return new Promise((resolve, reject) => { const child = spawn(executable, args, { cwd, env, shell: false, stdio: ["inherit", "pipe", "pipe"], }); child.stdout?.on("data", (chunk) => write(stdout, chunk)); child.stderr?.on("data", (chunk) => write(stderr, chunk)); child.once("error", reject); child.once("close", (code, signal) => { if (code === 0) { resolve({ exitCode: 0, executable: path.basename(executable) }); return; } const error = cliError( `External command ${path.basename(executable)} ${signal ? `ended by ${signal}` : `exited ${code}`}`, "EXTERNAL_COMMAND_FAILED", ); reject(error); }); }); } async function withOwnedRuntimeMutation(home, callback) { return withRuntimeLifecycleLock(home, async () => { await assertManagedHome(home); return callback(); }); } async function contextCommand({ args, home, cwd, env, stdin, stdout, stderr, clientFactory }) { const [action, ...rest] = args; if (action === "status") { rejectUnknown(rest, ["--json"]); const status = await contextStatus(home, env); if (rest.includes("--json")) write(stdout, `${JSON.stringify(status, null, 2)}\n`); else { write(stdout, `Context.dev: ${status.contextReady ? "ready" : "not ready"}\nkey: ${status.configured ? `configured (${status.keySource})` : "missing"}\nvalidation: ${status.validation}\nweb context: ${status.selection ?? "not selected"}\nconsent: ${status.consent ? "yes" : "no"}\n`); } return status.ready ? 0 : 1; } if (action === "options") { rejectUnknown(rest, []); write(stdout, "GStack needs public web context.\n\nA) Set up Context.dev free (recommended)\nB) Use this host's built-in public web search, if available\nC) Use GStack's local browser\nD) Continue without web research\n\nNo URL or credential is sent until Context.dev is explicitly selected and consented.\n"); return 0; } if (action === "select") { const [choice, ...tail] = rest; rejectUnknown(tail, []); const modes = { host: "host", browser: "local-browser", "local-browser": "local-browser", none: "off", off: "off" }; const mode = modes[choice]; if (!mode) throw cliError("Usage: gstack context select host|local-browser|none", "USAGE"); await setupRuntime({ home, cwd }); await withOwnedRuntimeMutation(home, () => configSetNetworkChoice(home, { mode, consent: false, selection: mode === "off" ? "none" : mode, })); write(stdout, `Web context mode set to ${mode}; Context.dev network export remains off.\n`); return 0; } if (action === "setup") { if (rest.some((arg) => /key|token|secret/i.test(arg) || /^ctxt_secret_/i.test(arg))) { throw cliError("API keys must be supplied through hidden stdin or CONTEXT_DEV_API_KEY, never argv", "KEY_ON_COMMAND_LINE"); } rejectUnknown(rest, ["--consent", "--offline"]); await setupRuntime({ home, cwd }); let consent = rest.includes("--consent"); if (!consent) { if (!stdin.isTTY) { throw cliError("Explicit consent is required; rerun with --consent when piping a key", "CONSENT_REQUIRED"); } const answer = await askLine(stdin, stderr, "Enable Context.dev network requests? This may consume API credits. Type yes to continue: "); consent = answer.trim().toLowerCase() === "yes"; } if (!consent) throw cliError("Context.dev setup cancelled; network remains off", "CONSENT_REQUIRED"); let key; try { key = (await readContextKey({ home, env })).key; } catch (error) { if (error?.code !== "CONTEXT_KEY_MISSING") throw error; key = stdin.isTTY ? await readHidden(stdin, stderr, "Context.dev API key: ") : (await readStream(stdin)).trim(); } validateContextKey(key); const offline = rest.includes("--offline"); let verification = { status: "unverified", checkedAt: null }; if (!offline) { const client = clientFactory({ home, env, key, config: { network: { mode: "context", consent: true, selection: "context" }, context: { baseUrl: "https://api.context.dev/v1", validation: { status: "verified" } }, }, }); await client.scrapeMarkdown("https://www.context.dev", { useMainContentOnly: true, maxAgeMs: 86_400_000, }); verification = { status: "verified", checkedAt: new Date().toISOString() }; } await withOwnedRuntimeMutation(home, async () => { await secretSet(home, "context.apiKey", key); await configSetNetworkChoice(home, { mode: "context", consent: true, selection: "context", }); await configSet(home, "context.validation", verification); }); write(stdout, offline ? "Context.dev key saved but unverified. Provider operations remain blocked until `gstack context setup --consent` verifies it online.\n" : "Context.dev configured and verified. The key is stored privately; network mode is context.\nKey source: https://www.context.dev/auth.md\n"); return 0; } if (action === "smoke") { const parsed = parseFlags(rest, new Set(["--url", "--json"])); if (parsed.positionals.length) throw cliError("Context smoke accepts only --url and --json", "USAGE"); const url = parsed.values.get("--url") ?? "https://www.context.dev"; const client = clientFactory({ home, env }); const response = await client.scrapeMarkdown(url, { useMainContentOnly: true, maxAgeMs: 86_400_000 }); const result = { ok: true, endpoint: "/web/scrape/markdown", url, creditsRemaining: response.key_metadata?.credits_remaining ?? null, }; write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(result, null, 2)}\n` : `Context.dev smoke test passed for ${url}${result.creditsRemaining == null ? "" : ` (${result.creditsRemaining} credits remaining)`}\n`); return 0; } if (["scrape-markdown", "scrape-html", "crawl", "sitemap", "screenshot"].includes(action)) { return contextOperation({ action, args: rest, home, env, stdout, clientFactory }); } throw cliError("Usage: gstack context status|options|select|setup|smoke|scrape-markdown|scrape-html|crawl|sitemap|screenshot", "USAGE"); } async function contextOperation({ action, args, home, env, stdout, clientFactory }) { const parsed = parseFlags(args, new Set([ "--json", "--main-content", "--full-page", "--max-pages", "--max-depth", "--max-links", ])); const target = parsed.positionals?.[0]; if (!target || parsed.positionals.length !== 1) { throw cliError(`Usage: gstack context ${action} [options]`, "USAGE"); } const client = clientFactory({ home, env }); let response; if (action === "scrape-markdown") { response = await client.scrapeMarkdown(target, { useMainContentOnly: parsed.flags.has("--main-content") }); } else if (action === "scrape-html") { response = await client.scrapeHtml(target, { useMainContentOnly: parsed.flags.has("--main-content") }); } else if (action === "crawl") { response = await client.crawl(target, numericContextOptions(parsed.values, [["--max-pages", "maxPages"], ["--max-depth", "maxDepth"]])); } else if (action === "sitemap") { response = await client.sitemap(target, numericContextOptions(parsed.values, [["--max-links", "maxLinks"]])); } else { response = await client.screenshot(target, { fullScreenshot: parsed.flags.has("--full-page") }); } if (parsed.flags.has("--json")) write(stdout, `${JSON.stringify(response, null, 2)}\n`); else { const preferred = action === "scrape-markdown" ? response.markdown : action === "scrape-html" ? response.html : null; write(stdout, typeof preferred === "string" ? `${preferred}\n` : `${JSON.stringify(response, null, 2)}\n`); } return 0; } function numericContextOptions(values, mappings) { const result = {}; for (const [flag, key] of mappings) { if (!values.has(flag)) continue; const value = Number(values.get(flag)); if (!Number.isSafeInteger(value) || value < 1) throw cliError(`${flag} must be a positive integer`, "USAGE"); result[key] = value; } return result; } async function cleanupCommand({ args, home, stdout }) { const parsed = parseFlags(args, new Set(["--dry-run", "--older-than-hours", "--json"])); if (parsed.positionals.length) throw cliError("Cleanup accepts only named options", "USAGE"); const hoursRaw = parsed.values.get("--older-than-hours"); const hours = hoursRaw == null ? 24 : Number(hoursRaw); if (!Number.isFinite(hours) || hours < 0) throw cliError("--older-than-hours must be a non-negative number", "USAGE"); const result = await cleanupRuntime(home, { dryRun: parsed.flags.has("--dry-run"), olderThanMs: hours * 60 * 60 * 1000, }); if (parsed.flags.has("--json")) write(stdout, `${JSON.stringify(result, null, 2)}\n`); else write(stdout, `${result.dryRun ? "Would remove" : "Removed"} ${result.removed.length} stale item(s), ${result.bytesReclaimed} byte(s)\n`); return 0; } async function upgradeCommand({ args, home, stdout, installOptions = {} }) { const parsed = parseFlags(args, new Set(["--source", "--version", "--rollback", "--json"])); if (parsed.positionals.length) throw cliError("Upgrade accepts only named options", "USAGE"); if (parsed.flags.has("--rollback")) { if (parsed.values.has("--source") || parsed.values.has("--version")) throw cliError("--rollback cannot be combined with staging options", "USAGE"); const pointer = await rollbackUpgrade(home, { prepareActivation: async (fallbackPath) => ({ browserChoice: await resolvedBrowserChoiceForRuntimePath(fallbackPath), }), }); write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(pointer, null, 2)}\n` : `Rolled back to ${pointer.current}\n`); return 0; } const sourceDir = parsed.values.get("--source"); const version = parsed.values.get("--version"); if (!sourceDir || !version) { throw cliError("Usage: gstack upgrade --source --version | --rollback", "USAGE"); } let browserChoice; if (installOptions.entries == null) { const configuredBrowser = await configGet(home, "browser"); if (!configuredBrowser?.provider) { throw cliError( "Upgrade needs the browser choice that setup normally records. Run `gstack config browser managed` or `gstack config browser installed ` first.", "BROWSER_CHOICE_REQUIRED", ); } browserChoice = await resolveBrowserChoice(configuredBrowser); } const result = await installManagedRuntime({ home, sourceDir, version, ...(browserChoice ? { browserChoice } : {}), ...installOptions, buildMissing: false, rejectSourceRootLink: true, requirePackageIdentity: true, }); write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(result, null, 2)}\n` : `Activated ${result.pointer.current}\n`); return 0; } async function uninstallCommand({ args, home, stdout }) { rejectUnknown(args, ["--purge", "--yes", "--json"]); const purge = args.includes("--purge"); if (purge && !args.includes("--yes")) { throw cliError("Purging config, secrets, and project state requires both --purge and --yes", "CONFIRMATION_REQUIRED"); } const result = await uninstallManagedRuntime(home, { purge }); write(stdout, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : purge ? `Purged gstack state at ${home}\n` : "Removed managed runtime versions; config and project state were preserved.\n"); return 0; } function parseStateArguments(args, options = {}) { const valueFlags = new Set(options.values ?? []); const booleanFlags = new Set(options.flags ?? []); const values = new Map(); const flags = new Set(); const positionals = []; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (!arg.startsWith("--")) { positionals.push(arg); continue; } if (valueFlags.has(arg)) { if (values.has(arg)) throw cliError(`Duplicate option: ${arg}`, "USAGE"); const value = args[++index]; if (value == null || value.startsWith("--")) throw cliError(`${arg} requires a value`, "USAGE"); values.set(arg, value); continue; } if (booleanFlags.has(arg)) { if (flags.has(arg)) throw cliError(`Duplicate option: ${arg}`, "USAGE"); flags.add(arg); continue; } throw cliError(`Unknown option: ${arg}`, "USAGE"); } return { flags, values, positionals }; } function parseModuleList(value) { if (value === "") return []; const modules = value.split(",").map((entry) => entry.trim()); if (modules.some((entry) => !entry)) throw cliError("--modules must be a comma-separated list", "USAGE"); return modules; } function parseFlags(args, allowed) { const flags = new Set(); const values = new Map(); const positionals = []; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (!arg.startsWith("--")) { positionals.push(arg); continue; } if (!allowed.has(arg)) throw cliError(`Unknown option: ${arg}`, "USAGE"); if (["--source", "--version", "--url", "--older-than-hours", "--max-pages", "--max-depth", "--max-links", "--skill-api"].includes(arg)) { const value = args[++index]; if (value == null || value.startsWith("--")) throw cliError(`${arg} requires a value`, "USAGE"); values.set(arg, value); } else flags.add(arg); } return { flags, values, positionals }; } function rejectUnknown(args, allowed) { for (const arg of args) if (!allowed.includes(arg)) throw cliError(`Unknown option: ${arg}`, "USAGE"); } async function askLine(input, output, prompt) { const interface_ = readline.createInterface({ input, output, terminal: true }); try { return await interface_.question(prompt); } finally { interface_.close(); } } async function readHidden(input, output, prompt) { if (!input.isTTY || typeof input.setRawMode !== "function") return (await readStream(input)).trim(); write(output, prompt); input.setRawMode(true); input.resume(); return new Promise((resolve, reject) => { let value = ""; const cleanup = () => { input.off("data", onData); input.setRawMode(false); input.pause(); write(output, "\n"); }; const onData = (chunk) => { const text = chunk.toString("utf8"); for (const character of text) { if (character === "\u0003") { cleanup(); reject(cliError("Context.dev setup cancelled", "CANCELLED")); return; } if (character === "\r" || character === "\n") { cleanup(); resolve(value.trim()); return; } if (character === "\u007f" || character === "\b") value = value.slice(0, -1); else value += character; } }; input.on("data", onData); }); } async function readStream(stream) { let value = ""; for await (const chunk of stream) value += chunk.toString("utf8"); return value; } function write(stream, value) { stream.write(value); } function exitCodeFor(error) { if (error?.code === "USAGE") return 2; if (["CONTEXT_KEY_MISSING", "CONTEXT_KEY_INVALID", "CONTEXT_EMAIL_UNVERIFIED", "CONTEXT_CREDITS_EXHAUSTED", "CONTEXT_RATE_LIMITED", "CONTEXT_TIMEOUT", "CONTEXT_BLOCKED", "CONTEXT_BAD_RESPONSE"].includes(error?.code)) return 3; return 1; } function usage() { return `gstack ${RUNTIME_VERSION}\n\n` + "Usage:\n" + " gstack init # initialize state only\n" + " gstack setup # compatibility alias for init\n" + " gstack doctor [--skill-api ] [--json]\n" + " gstack paths [--json|--shell]\n" + " gstack runtime path \n" + " gstack config get [key]\n" + " gstack config set \n" + " gstack config browser managed|installed |clear\n" + " gstack state inspect [run-id]\n" + " gstack state begin [--run-id ] [--goal ] [--plan ] [--stage ] [--depth quick|standard|deep] [--mutation ] [--modules ]\n" + " gstack state update [--plan |--clear-plan] [--stage ] [--depth quick|standard|deep] [--mutation ] [--modules ] [--push-detour |--pop-detour]\n" + " [--evidence-freshness unknown|fresh|stale] [--evidence-source --evidence-reference [--evidence-captured-at ]]\n" + " [--add-approval --approval-summary |--resolve-approval ]\n" + " gstack state effect -- [args...]\n" + " gstack state resume [run-id]\n" + " gstack state reconcile-applied --confirm-applied --evidence \n" + " gstack state reconcile-not-applied --confirm-not-applied\n" + " gstack state complete \n" + " gstack context status\n" + " gstack context options\n" + " gstack context select host|local-browser|none\n" + " gstack context setup [--consent] # key from hidden stdin or env\n" + " gstack context smoke [--url ]\n" + " gstack context scrape-markdown|scrape-html [--main-content] [--json]\n" + " gstack context crawl [--max-pages N] [--max-depth N] [--json]\n" + " gstack context sitemap [--max-links N] [--json]\n" + " gstack context screenshot [--full-page] [--json]\n" + " gstack cleanup [--dry-run] [--older-than-hours N]\n" + " gstack upgrade --source --version | --rollback\n" + " gstack uninstall [--purge --yes]\n"; }