/** * Commander program definition. * Wires all CLI commands with lazy imports for fast --help. * * @module src/cli/program */ import { Command, Option } from "commander"; // node:fs sync unlink — Bun has no equivalent; used only in the detached- // child signal handler where we need a synchronous cleanup on the signal // path. import { unlinkSync } from "node:fs"; import { CLI_NAME, DOCS_URL, ISSUES_URL, PRODUCT_NAME, VERSION, } from "../app/constants"; import { INDEX_NAME_REQUIREMENTS, isValidIndexName } from "../app/index-name"; import { resolveDepthPolicy } from "../core/depth-policy"; import { findingsRunStatePathForIndex, formatFindingsRunStatusLine, readFindingsRunStatus, } from "../core/findings-run-state"; import { parseAndValidateTagFilter } from "../core/tags"; import { formatWriteLeaseBusyJson, formatWriteLeaseBusyMessage, isWriteLeaseBusyResult, parseLockWaitMs, withCliWriteLease, type WriteLeaseBusyFailure, } from "../core/write-lease"; import { type McpToolProfile, parseMcpToolProfile } from "../mcp/tool-profile"; import { setColorsEnabled } from "./colors"; import { applyGlobalOptions, type GlobalOptions, parseGlobalOptions, } from "./context"; import { DETACHED_CHILD_FLAG } from "./detach"; import { CliError } from "./errors"; import { assertFormatSupported, CMD, collectRepeatableValue, getDefaultLimit, parseCliProjectAffinityOptions, parseCliMetadataFilter, parseOptionalFloat, parsePositiveInt, } from "./options"; import { resolveCliQueryText } from "./query-text"; // ───────────────────────────────────────────────────────────────────────────── // Global State (set by preAction hook) // ───────────────────────────────────────────────────────────────────────────── // Using object wrapper to allow mutation while satisfying linter const globalState: { current: GlobalOptions | null } = { current: null }; /** * Get resolved global options. Must be called after command parsing. * Throws if called before preAction hook runs. */ export function getGlobals(): GlobalOptions { if (!globalState.current) { throw new Error("Global options not resolved - called before preAction?"); } return globalState.current; } /** * Reset global state (for testing). * Resets both option state and color state to avoid test pollution. */ export function resetGlobals(): void { globalState.current = null; // Reset colors to default (true) - will be set by applyGlobalOptions on next run setColorsEnabled(true); } /** * Resolve the user-facing argv slice (everything after `[execPath, scriptPath]`) * from a Commander Command instance. Walks up to the root via `.parent` so we * get the original argv passed to `parseAsync()` regardless of which * sub-command's action handler invoked us. * * Exported for tests. Production callers (runDaemonDetach / runServeDetach) * call this with `cmd` from their action handler. */ export function resolveCliArgv(cmd: Command): string[] { let root: Command = cmd; while (root.parent) { root = root.parent; } // Commander's Command.rawArgs is the full argv passed into parseAsync() // (including [execPath, scriptPath]). Drop the first two so the slice // matches what we'd build from `process.argv.slice(2)` in the legacy // path — that's what the detach child re-exec wants. // // Note: rawArgs is documented in Commander's source // (`node_modules/commander/lib/command.js:1028` — `this.rawArgs = // argv.slice()`) but is not on its public TypeScript type, hence the // narrow cast. const rawArgs = (root as unknown as { rawArgs: string[] }).rawArgs; return rawArgs.slice(2); } /** * Select output format with explicit precedence. * Precedence: local non-json format > local --json > global --json > terminal */ function getFormat( cmdOpts: Record ): "terminal" | "json" | "files" | "csv" | "md" | "xml" { const globals = getGlobals(); const local = { json: Boolean(cmdOpts.json), files: Boolean(cmdOpts.files), csv: Boolean(cmdOpts.csv), md: Boolean(cmdOpts.md), xml: Boolean(cmdOpts.xml), }; // Count local format flags const localFormats = Object.entries(local).filter(([_, v]) => v); if (localFormats.length > 1) { throw new CliError( "VALIDATION", `Conflicting output formats: ${localFormats.map(([k]) => k).join(", ")}. Choose one.` ); } // Local non-json format wins (--md, --csv, --files, --xml) if (local.files) { return "files"; } if (local.csv) { return "csv"; } if (local.md) { return "md"; } if (local.xml) { return "xml"; } // Local --json wins over global if (local.json) { return "json"; } // Global --json as fallback if (globals.json) { return "json"; } return "terminal"; } /** * Write output with optional paging for terminal format. * Paging only applies to terminal format with list-like output. */ async function writeOutput( content: string, format: "terminal" | "json" | "files" | "csv" | "md" | "xml" ): Promise { const globals = getGlobals(); // Only page terminal output when paging enabled if (format === "terminal" && !globals.noPager && process.stdout.isTTY) { const { pageContent } = await import("./pager.js"); await pageContent(content); } else { process.stdout.write(content + "\n"); } } /** Emit opt-in trace identity without changing command stdout payloads. */ export function writeRetrievalTraceReceipt( metadata: { traceId: string } | undefined ): void { if (metadata) { process.stderr.write(`Trace: ${metadata.traceId}\n`); } } async function resolveTerminalLinkPolicy( format: "terminal" | "json" | "files" | "csv" | "md" | "xml" ): Promise< | { isTTY: boolean; editorUriTemplate?: string | null; } | undefined > { if (format !== "terminal") { return undefined; } const globals = getGlobals(); const envTemplate = process.env.GNO_EDITOR_URI_TEMPLATE?.trim(); if (envTemplate) { return { isTTY: process.stdout.isTTY ?? false, editorUriTemplate: envTemplate, }; } const { loadConfig } = await import("../config"); const configResult = await loadConfig(globals.config); const configTemplate = configResult.ok ? configResult.value.editorUriTemplate?.trim() : undefined; return { isTTY: process.stdout.isTTY ?? false, editorUriTemplate: configTemplate && configTemplate.length > 0 ? configTemplate : null, }; } function parseCsvValues(raw: unknown): string[] | undefined { if (typeof raw !== "string") { return undefined; } const values = raw .split(",") .map((v) => v.trim().toLowerCase()) .filter((v) => v.length > 0); return values.length > 0 ? values : undefined; } function addWriteLeaseFlags(command: Command): Command { return command .option( "--lock-wait ", "how long to wait for the index write lease (default: 120s)", "120s" ) .option("--no-wait", "fail immediately if the index write lease is held"); } function parseWriteLeaseFlags(cmdOpts: Record): { lockWaitMs: number; noWait: boolean; } { const parsed = parseLockWaitMs(cmdOpts.lockWait); if (parsed === null) { throw new CliError( "VALIDATION", `Invalid --lock-wait duration: ${String(cmdOpts.lockWait)}. Use seconds ("120"), "120s", or "2m".` ); } return { lockWaitMs: parsed, noWait: cmdOpts.wait === false, }; } function throwIfWriteLeaseBusy< T extends { success: boolean; error?: string; contention?: unknown }, >(result: T | WriteLeaseBusyFailure, json: boolean): asserts result is T { if (!isWriteLeaseBusyResult(result)) { return; } if (json) { process.stdout.write( `${JSON.stringify(formatWriteLeaseBusyJson(result.contention))}\n` ); } else { process.stderr.write(`${formatWriteLeaseBusyMessage(result.contention)}\n`); } throw new CliError("BUSY", result.error, { silent: true }); } function parseContextInteger( name: string, value: unknown, allowZero = false ): number { const raw = typeof value === "string" ? value : String(value); if (!/^\d+$/.test(raw)) { throw new CliError("VALIDATION", `--${name} must be an integer`, { details: { contextCode: "invalid_budget" }, }); } const parsed = Number(raw); if (!Number.isSafeInteger(parsed) || (allowZero ? parsed < 0 : parsed < 1)) { throw new CliError( "VALIDATION", `--${name} must be ${allowZero ? "non-negative" : "positive"}`, { details: { contextCode: "invalid_budget" } } ); } return parsed; } function validateContextOutputPath(value: unknown): string | undefined { if (value === undefined) return undefined; if (typeof value !== "string" || !value.trim() || value === "-") { throw new CliError( "VALIDATION", "--output requires an explicit file path", { details: { contextCode: "invalid_filter" }, } ); } return value; } // ───────────────────────────────────────────────────────────────────────────── // Program Factory // ───────────────────────────────────────────────────────────────────────────── export function createProgram(): Command { const program = new Command(); program .name(CLI_NAME) .description(`${PRODUCT_NAME} - Local Knowledge Index and Retrieval`) .version(VERSION, "-V, --version", "show version") .exitOverride() // Prevent Commander from calling process.exit() .showSuggestionAfterError(true) .showHelpAfterError("(Use --help for available options)"); // Global flags - resolved via preAction hook program .option("--index ", "index name", "default") .option("--config ", "config file path") .option("--no-color", "disable colors") .option("--verbose", "verbose logging") .option("--yes", "non-interactive mode") .option("-q, --quiet", "suppress non-essential output") .option("--json", "JSON output (for errors and supported commands)") .option("--offline", "offline mode (use cached models only)") .option("--no-pager", "disable automatic paging of long output"); // Resolve globals ONCE before any command runs (ensures consistency) program.hook("preAction", (thisCommand) => { const rootOpts = thisCommand.optsWithGlobals(); const globals = parseGlobalOptions(rootOpts); if (!isValidIndexName(globals.index)) { throw new CliError( "VALIDATION", `Invalid index name: ${INDEX_NAME_REQUIREMENTS}.` ); } applyGlobalOptions(globals); globalState.current = globals; }); // Wire command groups wireSearchCommands(program); wireOnboardingCommands(program); wireCaptureCommand(program); wireMemoryCommands(program); wireManagementCommands(program); wireTraceCommands(program); wirePublishCommand(program); wireVecCommands(program); wireRetrievalCommands(program); wireTagsCommands(program); wireLinksCommands(program); wireGraphCommand(program); wireKnowledgeDeltaCommands(program); wireMcpCommand(program); wireSkillCommands(program); wireAgentsCommands(program); wireDaemonCommand(program); wireServeCommand(program); wireCompletionCommand(program); // Add docs/support links to help footer program.addHelpText( "after", ` Documentation: ${DOCS_URL} Report issues: ${ISSUES_URL}` ); return program; } function wireTraceCommands(program: Command): void { const traceCmd = program .command("trace") .description("Inspect and manage private local retrieval traces"); const outputFormat = (options: Record): "json" | "md" => getFormat(options) === "json" ? "json" : "md"; traceCmd .command("list") .description("List bounded, redacted retrieval trace summaries") .option("-n, --limit ", "maximum traces", "50") .option("--cursor ", "continue from a previous list receipt") .option("--json", "JSON output") .option("--md", "Markdown output") .action(async (cmdOpts: Record) => { const globals = getGlobals(); const { traceList } = await import("./commands/trace"); const output = await traceList( { limit: parsePositiveInt("limit", cmdOpts.limit), cursor: cmdOpts.cursor as string | undefined, }, { configPath: globals.config, indexName: globals.index, format: outputFormat(cmdOpts), } ); process.stdout.write(output); }); traceCmd .command("show ") .description("Inspect one bounded retrieval trace receipt") .option("--detail-limit ", "maximum records per detail section", "500") .option("--json", "JSON output") .option("--md", "Markdown output") .action(async (traceId: string, cmdOpts: Record) => { const globals = getGlobals(); const { traceShow } = await import("./commands/trace"); const output = await traceShow( traceId, { detailLimit: parsePositiveInt("detail-limit", cmdOpts.detailLimit), }, { configPath: globals.config, indexName: globals.index, format: outputFormat(cmdOpts), } ); process.stdout.write(output); }); traceCmd .command("label ") .description("Append an explicit retrieval relevance judgment") .requiredOption( "--label