/**
 * 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 { resolveDepthPolicy } from "../core/depth-policy";
import { parseAndValidateTagFilter } from "../core/tags";
import { setColorsEnabled } from "./colors";
import {
  applyGlobalOptions,
  type GlobalOptions,
  parseGlobalOptions,
} from "./context";
import { DETACHED_CHILD_FLAG } from "./detach";
import { CliError } from "./errors";
import {
  assertFormatSupported,
  CMD,
  getDefaultLimit,
  parseOptionalFloat,
  parsePositiveInt,
} from "./options";

// ─────────────────────────────────────────────────────────────────────────────
// 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<string, unknown>
): "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<void> {
  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");
  }
}

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;
}

// ─────────────────────────────────────────────────────────────────────────────
// 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 <name>", "index name", "default")
    .option("--config <path>", "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);
    applyGlobalOptions(globals);
    globalState.current = globals;
  });

  // Wire command groups
  wireSearchCommands(program);
  wireOnboardingCommands(program);
  wireManagementCommands(program);
  wirePublishCommand(program);
  wireVecCommands(program);
  wireRetrievalCommands(program);
  wireTagsCommands(program);
  wireLinksCommands(program);
  wireGraphCommand(program);
  wireMcpCommand(program);
  wireSkillCommands(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;
}

// ─────────────────────────────────────────────────────────────────────────────
// Search Commands (search, vsearch, query, ask)
// ─────────────────────────────────────────────────────────────────────────────

function wireSearchCommands(program: Command): void {
  // search - BM25 keyword search
  program
    .command("search <query>")
    .description("BM25 keyword search")
    .option("-n, --limit <num>", "max results")
    .option("--min-score <num>", "minimum score threshold")
    .option("-c, --collection <name>", "filter by collection")
    .option("--lang <code>", "language filter/hint (BCP-47)")
    .option(
      "--since <date>",
      "modified-at lower bound (ISO date/time or token)"
    )
    .option(
      "--until <date>",
      "modified-at upper bound (ISO date/time or token)"
    )
    .option("--category <values>", "require category match (comma-separated)")
    .option("--author <text>", "filter by author (case-insensitive contains)")
    .option("--intent <text>", "disambiguating context for ambiguous queries")
    .option(
      "--exclude <values>",
      "exclude docs containing any term (comma-separated)"
    )
    .option("--tags-all <tags>", "require ALL tags (comma-separated)")
    .option("--tags-any <tags>", "require ANY tag (comma-separated)")
    .option("--full", "include full content")
    .option("--line-numbers", "include line numbers in output")
    .option("--json", "JSON output")
    .option("--md", "Markdown output")
    .option("--csv", "CSV output")
    .option("--xml", "XML output")
    .option("--files", "file paths only")
    .action(async (queryText: string, cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.search, format);
      const globals = getGlobals();

      // Validate empty query
      if (!queryText.trim()) {
        throw new CliError("VALIDATION", "Query cannot be empty");
      }

      // Validate minScore range
      const minScore = parseOptionalFloat("min-score", cmdOpts.minScore);
      if (minScore !== undefined && (minScore < 0 || minScore > 1)) {
        throw new CliError("VALIDATION", "--min-score must be between 0 and 1");
      }

      // Parse and validate tag filters
      let tagsAll: string[] | undefined;
      let tagsAny: string[] | undefined;
      try {
        tagsAll = cmdOpts.tagsAll
          ? parseAndValidateTagFilter(cmdOpts.tagsAll as string)
          : undefined;
        tagsAny = cmdOpts.tagsAny
          ? parseAndValidateTagFilter(cmdOpts.tagsAny as string)
          : undefined;
      } catch (error) {
        throw new CliError(
          "VALIDATION",
          error instanceof Error ? error.message : "Invalid tag filter"
        );
      }

      const limit = cmdOpts.limit
        ? parsePositiveInt("limit", cmdOpts.limit)
        : getDefaultLimit(format);
      const categories = parseCsvValues(cmdOpts.category);
      const exclude = parseCsvValues(cmdOpts.exclude);

      const { search, formatSearch } = await import("./commands/search");
      const result = await search(queryText, {
        configPath: globals.config,
        indexName: globals.index,
        limit,
        minScore,
        collection: cmdOpts.collection as string | undefined,
        lang: cmdOpts.lang as string | undefined,
        since: cmdOpts.since as string | undefined,
        until: cmdOpts.until as string | undefined,
        categories,
        author: cmdOpts.author as string | undefined,
        intent: cmdOpts.intent as string | undefined,
        exclude,
        tagsAll,
        tagsAny,
        full: Boolean(cmdOpts.full),
        lineNumbers: Boolean(cmdOpts.lineNumbers),
        json: format === "json",
        md: format === "md",
        csv: format === "csv",
        xml: format === "xml",
        files: format === "files",
      });

      // Check success before printing - stdout is for successful outputs only
      if (!result.success) {
        // Map validation errors to exit code 1
        throw new CliError(
          result.isValidation ? "VALIDATION" : "RUNTIME",
          result.error
        );
      }
      const output = formatSearch(result, {
        json: format === "json",
        md: format === "md",
        csv: format === "csv",
        xml: format === "xml",
        files: format === "files",
        full: Boolean(cmdOpts.full),
        lineNumbers: Boolean(cmdOpts.lineNumbers),
        terminalLinks: await resolveTerminalLinkPolicy(format),
      });
      await writeOutput(output, format);
    });

  // vsearch - Vector similarity search
  program
    .command("vsearch <query>")
    .description("Vector similarity search")
    .option("-n, --limit <num>", "max results")
    .option("--min-score <num>", "minimum score threshold")
    .option("-c, --collection <name>", "filter by collection")
    .option("--lang <code>", "language filter/hint (BCP-47)")
    .option(
      "--since <date>",
      "modified-at lower bound (ISO date/time or token)"
    )
    .option(
      "--until <date>",
      "modified-at upper bound (ISO date/time or token)"
    )
    .option("--category <values>", "require category match (comma-separated)")
    .option("--author <text>", "filter by author (case-insensitive contains)")
    .option("--intent <text>", "disambiguating context for ambiguous queries")
    .option(
      "--exclude <values>",
      "exclude docs containing any term (comma-separated)"
    )
    .option("--tags-all <tags>", "require ALL tags (comma-separated)")
    .option("--tags-any <tags>", "require ANY tag (comma-separated)")
    .option("--full", "include full content")
    .option("--line-numbers", "include line numbers in output")
    .option("--json", "JSON output")
    .option("--md", "Markdown output")
    .option("--csv", "CSV output")
    .option("--xml", "XML output")
    .option("--files", "file paths only")
    .action(async (queryText: string, cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.vsearch, format);
      const globals = getGlobals();

      // Validate empty query
      if (!queryText.trim()) {
        throw new CliError("VALIDATION", "Query cannot be empty");
      }

      // Validate minScore range
      const minScore = parseOptionalFloat("min-score", cmdOpts.minScore);
      if (minScore !== undefined && (minScore < 0 || minScore > 1)) {
        throw new CliError("VALIDATION", "--min-score must be between 0 and 1");
      }

      // Parse and validate tag filters
      let tagsAll: string[] | undefined;
      let tagsAny: string[] | undefined;
      try {
        tagsAll = cmdOpts.tagsAll
          ? parseAndValidateTagFilter(cmdOpts.tagsAll as string)
          : undefined;
        tagsAny = cmdOpts.tagsAny
          ? parseAndValidateTagFilter(cmdOpts.tagsAny as string)
          : undefined;
      } catch (error) {
        throw new CliError(
          "VALIDATION",
          error instanceof Error ? error.message : "Invalid tag filter"
        );
      }

      const limit = cmdOpts.limit
        ? parsePositiveInt("limit", cmdOpts.limit)
        : getDefaultLimit(format);
      const categories = parseCsvValues(cmdOpts.category);
      const exclude = parseCsvValues(cmdOpts.exclude);

      const { vsearch, formatVsearch } = await import("./commands/vsearch");
      const result = await vsearch(queryText, {
        configPath: globals.config,
        indexName: globals.index,
        limit,
        minScore,
        collection: cmdOpts.collection as string | undefined,
        lang: cmdOpts.lang as string | undefined,
        since: cmdOpts.since as string | undefined,
        until: cmdOpts.until as string | undefined,
        categories,
        author: cmdOpts.author as string | undefined,
        intent: cmdOpts.intent as string | undefined,
        exclude,
        tagsAll,
        tagsAny,
        full: Boolean(cmdOpts.full),
        lineNumbers: Boolean(cmdOpts.lineNumbers),
        json: format === "json",
        md: format === "md",
        csv: format === "csv",
        xml: format === "xml",
        files: format === "files",
      });

      if (!result.success) {
        throw new CliError("RUNTIME", result.error);
      }
      const output = formatVsearch(result, {
        json: format === "json",
        md: format === "md",
        csv: format === "csv",
        xml: format === "xml",
        files: format === "files",
        full: Boolean(cmdOpts.full),
        lineNumbers: Boolean(cmdOpts.lineNumbers),
        terminalLinks: await resolveTerminalLinkPolicy(format),
      });
      await writeOutput(output, format);
    });

  // query - Hybrid search with expansion and reranking
  program
    .command("query <query>")
    .description("Hybrid search with expansion and reranking")
    .option("-n, --limit <num>", "max results")
    .option("--min-score <num>", "minimum score threshold")
    .option("-c, --collection <name>", "filter by collection")
    .option("--lang <code>", "language hint (BCP-47)")
    .option(
      "--since <date>",
      "modified-at lower bound (ISO date/time or token)"
    )
    .option(
      "--until <date>",
      "modified-at upper bound (ISO date/time or token)"
    )
    .option("--category <values>", "require category match (comma-separated)")
    .option("--author <text>", "filter by author (case-insensitive contains)")
    .option("--intent <text>", "disambiguating context for ambiguous queries")
    .option(
      "--exclude <values>",
      "exclude docs containing any term (comma-separated)"
    )
    .option("--tags-all <tags>", "require ALL tags (comma-separated)")
    .option("--tags-any <tags>", "require ANY tag (comma-separated)")
    .option("--full", "include full content")
    .option("--line-numbers", "include line numbers in output")
    .option("--fast", "skip expansion and reranking (fastest, ~0.7s)")
    .option(
      "--thorough",
      "use expansion with a wider rerank pool (slowest, best recall)"
    )
    .option("--no-expand", "disable query expansion")
    .option("--no-rerank", "disable reranking")
    .option("--no-graph", "disable graph neighbor expansion")
    .option(
      "--query-mode <mode:text>",
      "structured mode entry (repeatable): term:<text>, intent:<text>, or hyde:<text>",
      (value: string, previous: string[] = []) => [...previous, value],
      []
    )
    .option("-C, --candidate-limit <num>", "max candidates passed to reranking")
    .option("--explain", "include scoring explanation")
    .option("--json", "JSON output")
    .option("--md", "Markdown output")
    .option("--csv", "CSV output")
    .option("--xml", "XML output")
    .option("--files", "file paths only")
    .action(async (queryText: string, cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.query, format);
      const globals = getGlobals();

      // Validate empty query
      if (!queryText.trim()) {
        throw new CliError("VALIDATION", "Query cannot be empty");
      }

      // Validate minScore range
      const minScore = parseOptionalFloat("min-score", cmdOpts.minScore);
      if (minScore !== undefined && (minScore < 0 || minScore > 1)) {
        throw new CliError("VALIDATION", "--min-score must be between 0 and 1");
      }

      // Parse optional structured query modes
      let queryModes: import("../pipeline/types").QueryModeInput[] | undefined;
      if (Array.isArray(cmdOpts.queryMode) && cmdOpts.queryMode.length > 0) {
        const { parseQueryModeSpecs } = await import("../pipeline/query-modes");
        const parsed = parseQueryModeSpecs(cmdOpts.queryMode as string[]);
        if (!parsed.ok) {
          throw new CliError("VALIDATION", parsed.error.message);
        }
        queryModes = parsed.value;
      }

      const { normalizeStructuredQueryInput } =
        await import("../core/structured-query");
      const normalizedInput = normalizeStructuredQueryInput(
        queryText,
        queryModes ?? []
      );
      if (!normalizedInput.ok) {
        throw new CliError("VALIDATION", normalizedInput.error.message);
      }
      queryText = normalizedInput.value.query;
      queryModes =
        normalizedInput.value.queryModes.length > 0
          ? normalizedInput.value.queryModes
          : undefined;

      // Parse and validate tag filters
      let tagsAll: string[] | undefined;
      let tagsAny: string[] | undefined;
      try {
        tagsAll = cmdOpts.tagsAll
          ? parseAndValidateTagFilter(cmdOpts.tagsAll as string)
          : undefined;
        tagsAny = cmdOpts.tagsAny
          ? parseAndValidateTagFilter(cmdOpts.tagsAny as string)
          : undefined;
      } catch (error) {
        throw new CliError(
          "VALIDATION",
          error instanceof Error ? error.message : "Invalid tag filter"
        );
      }

      const limit = cmdOpts.limit
        ? parsePositiveInt("limit", cmdOpts.limit)
        : getDefaultLimit(format);
      const { loadConfig } = await import("../config");
      const { getActivePreset } = await import("../llm/registry");
      const configResult = await loadConfig(globals.config);
      const activePresetId = configResult.ok
        ? getActivePreset(configResult.value).id
        : "slim-tuned";
      const candidateLimit = cmdOpts.candidateLimit
        ? parsePositiveInt("candidate-limit", cmdOpts.candidateLimit)
        : undefined;
      const categories = parseCsvValues(cmdOpts.category);
      const exclude = parseCsvValues(cmdOpts.exclude);

      const depthPolicy = resolveDepthPolicy({
        presetId: activePresetId,
        fast: Boolean(cmdOpts.fast),
        thorough: Boolean(cmdOpts.thorough),
        expand: cmdOpts.expand === false ? false : undefined,
        rerank: cmdOpts.rerank === false ? false : undefined,
        candidateLimit,
      });

      const { query, formatQuery } = await import("./commands/query");
      const result = await query(queryText, {
        configPath: globals.config,
        indexName: globals.index,
        limit,
        minScore,
        collection: cmdOpts.collection as string | undefined,
        lang: cmdOpts.lang as string | undefined,
        since: cmdOpts.since as string | undefined,
        until: cmdOpts.until as string | undefined,
        categories,
        author: cmdOpts.author as string | undefined,
        intent: cmdOpts.intent as string | undefined,
        exclude,
        tagsAll,
        tagsAny,
        full: Boolean(cmdOpts.full),
        lineNumbers: Boolean(cmdOpts.lineNumbers),
        noExpand: depthPolicy.noExpand,
        noRerank: depthPolicy.noRerank,
        noGraph: Boolean(cmdOpts.fast) || cmdOpts.graph === false,
        candidateLimit: depthPolicy.candidateLimit,
        queryModes,
        explain: Boolean(cmdOpts.explain),
        json: format === "json",
        md: format === "md",
        csv: format === "csv",
        xml: format === "xml",
        files: format === "files",
      });

      if (!result.success) {
        throw new CliError("RUNTIME", result.error);
      }
      const output = formatQuery(result, {
        format,
        full: Boolean(cmdOpts.full),
        lineNumbers: Boolean(cmdOpts.lineNumbers),
        terminalLinks: await resolveTerminalLinkPolicy(format),
      });
      await writeOutput(output, format);
    });

  // bench - Retrieval benchmark fixture runner
  program
    .command("bench <fixture>")
    .description("Run retrieval quality benchmarks from a fixture")
    .option("-c, --collection <name>", "override fixture collection")
    .option("-k, --top-k <num>", "override top-k metric cutoff")
    .option(
      "--mode <name>",
      "benchmark mode (repeatable): bm25, vector, hybrid, fast, no-rerank, thorough",
      (value: string, previous: string[] = []) => [...previous, value],
      []
    )
    .option("-C, --candidate-limit <num>", "max candidates passed to reranking")
    .option("--json", "JSON output")
    .action(async (fixture: string, cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.bench, format);
      const globals = getGlobals();
      const topK = cmdOpts.topK
        ? parsePositiveInt("top-k", cmdOpts.topK)
        : undefined;
      const candidateLimit = cmdOpts.candidateLimit
        ? parsePositiveInt("candidate-limit", cmdOpts.candidateLimit)
        : undefined;

      const { bench, formatBench } = await import("./commands/bench");
      const result = await bench(fixture, {
        configPath: globals.config,
        indexName: globals.index,
        collection: cmdOpts.collection as string | undefined,
        topK,
        candidateLimit,
        modes:
          Array.isArray(cmdOpts.mode) && cmdOpts.mode.length > 0
            ? (cmdOpts.mode as string[])
            : undefined,
        json: format === "json",
      });

      if (!result.success) {
        throw new CliError(
          result.isValidation ? "VALIDATION" : "RUNTIME",
          result.error
        );
      }

      await writeOutput(
        formatBench(result, { json: format === "json" }),
        format
      );
    });

  // ask - Human-friendly query with grounded answer
  program
    .command("ask <query>")
    .description("Human-friendly query with grounded answer")
    .option("-n, --limit <num>", "max source results")
    .option("-c, --collection <name>", "filter by collection")
    .option("--lang <code>", "language hint (BCP-47)")
    .option(
      "--since <date>",
      "modified-at lower bound (ISO date/time or token)"
    )
    .option(
      "--until <date>",
      "modified-at upper bound (ISO date/time or token)"
    )
    .option("--category <values>", "require category match (comma-separated)")
    .option("--author <text>", "filter by author (case-insensitive contains)")
    .option("--intent <text>", "disambiguating context for ambiguous queries")
    .option(
      "--exclude <values>",
      "exclude docs containing any term (comma-separated)"
    )
    .option(
      "--query-mode <mode:text>",
      "structured mode entry (repeatable): term:<text>, intent:<text>, or hyde:<text>",
      (value: string, previous: string[] = []) => [...previous, value],
      []
    )
    .option("--fast", "skip expansion and reranking (fastest)")
    .option(
      "--thorough",
      "use expansion with a wider rerank pool (slowest, best recall)"
    )
    .option("-C, --candidate-limit <num>", "max candidates passed to reranking")
    .option("--answer", "generate short grounded answer")
    .option("--no-answer", "force retrieval-only output")
    .option("--max-answer-tokens <num>", "max answer tokens")
    .option("--show-sources", "show all retrieved sources (not just cited)")
    .option("--json", "JSON output")
    .option("--md", "Markdown output")
    .action(async (queryText: string, cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.ask, format);
      const globals = getGlobals();

      // Validate empty query
      if (!queryText.trim()) {
        throw new CliError("VALIDATION", "Query cannot be empty");
      }

      const limit = cmdOpts.limit
        ? parsePositiveInt("limit", cmdOpts.limit)
        : getDefaultLimit(format);
      const { loadConfig } = await import("../config");
      const { getActivePreset } = await import("../llm/registry");
      const configResult = await loadConfig(globals.config);
      const activePresetId = configResult.ok
        ? getActivePreset(configResult.value).id
        : "slim-tuned";
      const candidateLimit = cmdOpts.candidateLimit
        ? parsePositiveInt("candidate-limit", cmdOpts.candidateLimit)
        : undefined;

      // Parse max-answer-tokens (optional, defaults to 512 in command impl)
      const maxAnswerTokens = cmdOpts.maxAnswerTokens
        ? parsePositiveInt("max-answer-tokens", cmdOpts.maxAnswerTokens)
        : undefined;
      const categories = parseCsvValues(cmdOpts.category);
      const exclude = parseCsvValues(cmdOpts.exclude);

      let queryModes: import("../pipeline/types").QueryModeInput[] | undefined;
      if (Array.isArray(cmdOpts.queryMode) && cmdOpts.queryMode.length > 0) {
        const { parseQueryModeSpecs } = await import("../pipeline/query-modes");
        const parsed = parseQueryModeSpecs(cmdOpts.queryMode as string[]);
        if (!parsed.ok) {
          throw new CliError("VALIDATION", parsed.error.message);
        }
        queryModes = parsed.value;
      }

      const { normalizeStructuredQueryInput } =
        await import("../core/structured-query");
      const normalizedInput = normalizeStructuredQueryInput(
        queryText,
        queryModes ?? []
      );
      if (!normalizedInput.ok) {
        throw new CliError("VALIDATION", normalizedInput.error.message);
      }
      queryText = normalizedInput.value.query;
      queryModes =
        normalizedInput.value.queryModes.length > 0
          ? normalizedInput.value.queryModes
          : undefined;

      const depthPolicy = resolveDepthPolicy({
        presetId: activePresetId,
        fast: Boolean(cmdOpts.fast),
        thorough: Boolean(cmdOpts.thorough),
        candidateLimit,
        hasStructuredModes: Boolean(queryModes?.length),
      });

      const { ask, formatAsk } = await import("./commands/ask");
      const showSources = Boolean(cmdOpts.showSources);
      const result = await ask(queryText, {
        limit,
        collection: cmdOpts.collection as string | undefined,
        lang: cmdOpts.lang as string | undefined,
        since: cmdOpts.since as string | undefined,
        until: cmdOpts.until as string | undefined,
        categories,
        author: cmdOpts.author as string | undefined,
        intent: cmdOpts.intent as string | undefined,
        exclude,
        queryModes,
        noExpand: depthPolicy.noExpand,
        noRerank: depthPolicy.noRerank,
        candidateLimit: depthPolicy.candidateLimit,
        // Per spec: --answer defaults to false, --no-answer forces retrieval-only
        // Commander creates separate cmdOpts.noAnswer for --no-answer flag
        answer: Boolean(cmdOpts.answer),
        noAnswer: Boolean(cmdOpts.noAnswer),
        maxAnswerTokens,
        showSources,
        json: format === "json",
        md: format === "md",
      });

      if (!result.success) {
        throw new CliError("RUNTIME", result.error);
      }
      const output = formatAsk(result, {
        json: format === "json",
        md: format === "md",
        showSources,
      });
      await writeOutput(output, format);
    });
}

// ─────────────────────────────────────────────────────────────────────────────
// Onboarding Commands (init, index, status, doctor)
// ─────────────────────────────────────────────────────────────────────────────

function wireOnboardingCommands(program: Command): void {
  // init - Initialize GNO
  program
    .command("init [path]")
    .description("Initialize GNO configuration")
    .option("-n, --name <name>", "collection name")
    .option("--pattern <glob>", "file matching pattern")
    .option("--include <exts>", "extension allowlist (CSV)")
    .option("--exclude <patterns>", "exclude patterns (CSV)")
    .option("--update <cmd>", "shell command to run before indexing")
    .option("--tokenizer <type>", "FTS tokenizer (unicode61, porter, trigram)")
    .option("--language <code>", "language hint (BCP-47)")
    .action(
      async (path: string | undefined, cmdOpts: Record<string, unknown>) => {
        const globals = getGlobals();
        const { init } = await import("./commands/init");
        const result = await init({
          path,
          name: cmdOpts.name as string | undefined,
          pattern: cmdOpts.pattern as string | undefined,
          include: cmdOpts.include as string | undefined,
          exclude: cmdOpts.exclude as string | undefined,
          update: cmdOpts.update as string | undefined,
          tokenizer: cmdOpts.tokenizer as
            | "unicode61"
            | "porter"
            | "trigram"
            | undefined,
          language: cmdOpts.language as string | undefined,
          yes: globals.yes,
        });

        if (!result.success) {
          throw new CliError("RUNTIME", result.error ?? "Init failed");
        }

        if (result.alreadyInitialized) {
          process.stdout.write("GNO already initialized.\n");
          if (result.collectionAdded) {
            process.stdout.write(
              `Collection "${result.collectionAdded}" added.\n`
            );
          }
        } else {
          process.stdout.write("GNO initialized successfully.\n");
          process.stdout.write(`Config: ${result.configPath}\n`);
          process.stdout.write(`Database: ${result.dbPath}\n`);
          if (result.collectionAdded) {
            process.stdout.write(
              `Collection "${result.collectionAdded}" added.\n`
            );
          }
        }
      }
    );

  // index - Index collections
  program
    .command("index [collection]")
    .description("Index files from collections")
    .option("--no-embed", "skip embedding after sync")
    .option("--git-pull", "run git pull in git repositories")
    .option("--models-pull", "download models if missing")
    .action(
      async (
        collection: string | undefined,
        cmdOpts: Record<string, unknown>
      ) => {
        const globals = getGlobals();
        const { index, formatIndex } = await import("./commands/index-cmd");
        const opts = {
          configPath: globals.config,
          indexName: globals.index,
          collection,
          noEmbed: cmdOpts.embed === false,
          gitPull: Boolean(cmdOpts.gitPull),
          modelsPull: Boolean(cmdOpts.modelsPull),
          yes: globals.yes,
          verbose: globals.verbose,
        };
        const result = await index(opts);

        if (!result.success) {
          throw new CliError("RUNTIME", result.error ?? "Index failed");
        }
        process.stdout.write(`${formatIndex(result, opts)}\n`);
      }
    );

  // status - Show index status
  program
    .command("status")
    .description("Show index status")
    .option("--json", "JSON output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.status, format);

      const { status, formatStatus } = await import("./commands/status");
      const globals = getGlobals();
      const result = await status({
        configPath: globals.config,
        indexName: globals.index,
        json: format === "json",
      });

      if (!result.success) {
        throw new CliError("RUNTIME", result.error ?? "Status failed");
      }
      process.stdout.write(
        `${formatStatus(result, { json: format === "json" })}\n`
      );
    });

  // doctor - Diagnose configuration issues
  program
    .command("doctor")
    .description("Diagnose configuration issues")
    .option("--json", "JSON output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      const { doctor, formatDoctor } = await import("./commands/doctor");
      const result = await doctor({ json: format === "json" });

      // Doctor always succeeds but may report issues
      process.stdout.write(
        `${formatDoctor(result, { json: format === "json" })}\n`
      );
    });
}

// ─────────────────────────────────────────────────────────────────────────────
// Retrieval Commands (get, multi-get, ls)
// ─────────────────────────────────────────────────────────────────────────────

function wireRetrievalCommands(program: Command): void {
  // get - Retrieve document by URI or docid
  program
    .command("get <ref>")
    .description("Get document by URI or docid")
    .option(
      "--from <line>",
      "Start at line number",
      parsePositiveInt.bind(null, "from")
    )
    .option(
      "-l, --limit <lines>",
      "Limit to N lines",
      parsePositiveInt.bind(null, "limit")
    )
    .option("--line-numbers", "Prefix lines with numbers")
    .option("--source", "Include source metadata")
    .option("--json", "JSON output")
    .option("--md", "Markdown output")
    .action(async (ref: string, cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.get, format);
      const globals = getGlobals();

      const { get, formatGet } = await import("./commands/get");
      const result = await get(ref, {
        configPath: globals.config,
        indexName: globals.index,
        from: cmdOpts.from as number | undefined,
        limit: cmdOpts.limit as number | undefined,
        lineNumbers: Boolean(cmdOpts.lineNumbers),
        source: Boolean(cmdOpts.source),
        json: format === "json",
        md: format === "md",
      });

      if (!result.success) {
        throw new CliError(
          result.isValidation ? "VALIDATION" : "RUNTIME",
          result.error
        );
      }

      process.stdout.write(
        `${formatGet(result, {
          lineNumbers: Boolean(cmdOpts.lineNumbers),
          json: format === "json",
          md: format === "md",
        })}\n`
      );
    });

  // multi-get - Retrieve multiple documents
  program
    .command("multi-get <refs...>")
    .description("Get multiple documents by URI or docid")
    .option(
      "--max-bytes <n>",
      "Max bytes per document",
      parsePositiveInt.bind(null, "max-bytes")
    )
    .option("--line-numbers", "Include line numbers")
    .option("--json", "JSON output")
    .option("--files", "File protocol output")
    .option("--md", "Markdown output")
    .action(async (refs: string[], cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.multiGet, format);
      const globals = getGlobals();

      const { multiGet, formatMultiGet } = await import("./commands/multi-get");
      const result = await multiGet(refs, {
        configPath: globals.config,
        indexName: globals.index,
        maxBytes: cmdOpts.maxBytes as number | undefined,
        lineNumbers: Boolean(cmdOpts.lineNumbers),
        json: format === "json",
        files: format === "files",
        md: format === "md",
      });

      if (!result.success) {
        throw new CliError(
          result.isValidation ? "VALIDATION" : "RUNTIME",
          result.error
        );
      }

      process.stdout.write(
        `${formatMultiGet(result, {
          lineNumbers: Boolean(cmdOpts.lineNumbers),
          json: format === "json",
          files: format === "files",
          md: format === "md",
        })}\n`
      );
    });

  // ls - List indexed documents
  program
    .command("ls [scope]")
    .description("List indexed documents")
    .option(
      "-n, --limit <num>",
      "Max results",
      parsePositiveInt.bind(null, "limit")
    )
    .option(
      "--offset <num>",
      "Skip first N results",
      parsePositiveInt.bind(null, "offset")
    )
    .option("--json", "JSON output")
    .option("--files", "File protocol output")
    .option("--md", "Markdown output")
    .action(
      async (scope: string | undefined, cmdOpts: Record<string, unknown>) => {
        const format = getFormat(cmdOpts);
        assertFormatSupported(CMD.ls, format);
        const globals = getGlobals();

        const { ls, formatLs } = await import("./commands/ls");
        const result = await ls(scope, {
          configPath: globals.config,
          limit: cmdOpts.limit as number | undefined,
          offset: cmdOpts.offset as number | undefined,
          json: format === "json",
          files: format === "files",
          md: format === "md",
        });

        if (!result.success) {
          throw new CliError(
            result.isValidation ? "VALIDATION" : "RUNTIME",
            result.error
          );
        }

        const output = formatLs(result, {
          json: format === "json",
          files: format === "files",
          md: format === "md",
        });
        await writeOutput(output, format);
      }
    );
}

// ─────────────────────────────────────────────────────────────────────────────
// MCP Commands
// ─────────────────────────────────────────────────────────────────────────────

function wireMcpCommand(program: Command): void {
  // mcp - Start MCP server (stdio transport) or manage MCP configuration
  const mcpCmd = program
    .command("mcp")
    .description("MCP server and configuration");

  // Default action: start MCP server
  mcpCmd
    .command("serve", { isDefault: true })
    .description("Start MCP server (stdio transport)")
    .option(
      "--enable-write",
      "Enable write operations (capture, add-collection, sync, remove-collection)"
    )
    .action(async (cmdOpts: Record<string, unknown>) => {
      const { mcpCommand } = await import("./commands/mcp.js");
      const globalOpts = program.opts();
      const globals = parseGlobalOptions(globalOpts);
      await mcpCommand(globals, {
        enableWrite: cmdOpts.enableWrite === true ? true : undefined,
      });
    });

  // install - Install gno MCP server to client configs
  mcpCmd
    .command("install")
    .description("Install gno as MCP server in client configuration")
    .option(
      "-t, --target <target>",
      "target client (claude-desktop, cursor, zed, windsurf, opencode, amp, lmstudio, librechat, claude-code, codex)",
      "claude-desktop"
    )
    .option(
      "-s, --scope <scope>",
      "scope (user, project) - project only for claude-code/codex/cursor/opencode",
      "user"
    )
    .option("-f, --force", "overwrite existing configuration")
    .option("--dry-run", "show what would be done without making changes")
    .option(
      "--enable-write",
      "Enable write operations in installed MCP configuration"
    )
    .option("--json", "JSON output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const target = cmdOpts.target as string;
      const scope = cmdOpts.scope as string;

      // Import MCP_TARGETS for validation
      const { MCP_TARGETS } = await import("./commands/mcp/paths.js");

      // Validate target
      if (!(MCP_TARGETS as string[]).includes(target)) {
        throw new CliError(
          "VALIDATION",
          `Invalid target: ${target}. Must be one of: ${MCP_TARGETS.join(", ")}.`
        );
      }
      // Validate scope
      if (!["user", "project"].includes(scope)) {
        throw new CliError(
          "VALIDATION",
          `Invalid scope: ${scope}. Must be 'user' or 'project'.`
        );
      }

      const { installMcp } = await import("./commands/mcp/install.js");
      await installMcp({
        target: target as NonNullable<
          Parameters<typeof installMcp>[0]
        >["target"],
        scope: scope as "user" | "project",
        force: Boolean(cmdOpts.force),
        dryRun: Boolean(cmdOpts.dryRun),
        enableWrite: Boolean(cmdOpts.enableWrite),
        // Pass undefined if not set, so global --json can take effect
        json: cmdOpts.json === true ? true : undefined,
      });
    });

  // uninstall - Remove gno MCP server from client configs
  mcpCmd
    .command("uninstall")
    .description("Remove gno MCP server from client configuration")
    .option(
      "-t, --target <target>",
      "target client (claude-desktop, cursor, zed, windsurf, opencode, amp, lmstudio, librechat, claude-code, codex)",
      "claude-desktop"
    )
    .option("-s, --scope <scope>", "scope (user, project)", "user")
    .option("--json", "JSON output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const target = cmdOpts.target as string;
      const scope = cmdOpts.scope as string;

      // Import MCP_TARGETS for validation
      const { MCP_TARGETS } = await import("./commands/mcp/paths.js");

      // Validate target
      if (!(MCP_TARGETS as string[]).includes(target)) {
        throw new CliError(
          "VALIDATION",
          `Invalid target: ${target}. Must be one of: ${MCP_TARGETS.join(", ")}.`
        );
      }
      // Validate scope
      if (!["user", "project"].includes(scope)) {
        throw new CliError(
          "VALIDATION",
          `Invalid scope: ${scope}. Must be 'user' or 'project'.`
        );
      }

      const { uninstallMcp } = await import("./commands/mcp/uninstall.js");
      await uninstallMcp({
        target: target as NonNullable<
          Parameters<typeof uninstallMcp>[0]
        >["target"],
        scope: scope as "user" | "project",
        // Pass undefined if not set, so global --json can take effect
        json: cmdOpts.json === true ? true : undefined,
      });
    });

  // status - Show MCP installation status
  mcpCmd
    .command("status")
    .description("Show MCP server installation status")
    .option(
      "-t, --target <target>",
      "filter by target (claude-desktop, cursor, zed, windsurf, opencode, amp, lmstudio, librechat, claude-code, codex, all)",
      "all"
    )
    .option(
      "-s, --scope <scope>",
      "filter by scope (user, project, all)",
      "all"
    )
    .option("--json", "JSON output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const target = cmdOpts.target as string;
      const scope = cmdOpts.scope as string;

      // Import MCP_TARGETS for validation
      const { MCP_TARGETS, TARGETS_WITH_PROJECT_SCOPE } =
        await import("./commands/mcp/paths.js");

      // Validate target
      if (target !== "all" && !(MCP_TARGETS as string[]).includes(target)) {
        throw new CliError(
          "VALIDATION",
          `Invalid target: ${target}. Must be one of: ${MCP_TARGETS.join(", ")}, all.`
        );
      }
      // Validate scope
      if (!["user", "project", "all"].includes(scope)) {
        throw new CliError(
          "VALIDATION",
          `Invalid scope: ${scope}. Must be 'user', 'project', or 'all'.`
        );
      }
      // Validate target/scope combination
      if (
        target !== "all" &&
        scope === "project" &&
        !(TARGETS_WITH_PROJECT_SCOPE as string[]).includes(target)
      ) {
        throw new CliError(
          "VALIDATION",
          `${target} does not support project scope.`
        );
      }

      const { statusMcp } = await import("./commands/mcp/status.js");
      await statusMcp({
        target: target as NonNullable<
          Parameters<typeof statusMcp>[0]
        >["target"],
        scope: scope as "user" | "project" | "all",
        // Pass undefined if not set, so global --json can take effect
        json: cmdOpts.json === true ? true : undefined,
      });
    });
}

// ─────────────────────────────────────────────────────────────────────────────
// Management Commands (collection, context, models, update, embed, cleanup)
// ─────────────────────────────────────────────────────────────────────────────

function wireManagementCommands(program: Command): void {
  // collection subcommands
  const collectionCmd = program
    .command("collection")
    .description("Manage collections");

  collectionCmd
    .command("add <path>")
    .description("Add a collection")
    .requiredOption("-n, --name <name>", "collection name")
    .option("--pattern <glob>", "file matching pattern")
    .option("--include <exts>", "extension allowlist (CSV)")
    .option("--exclude <patterns>", "exclude patterns (CSV)")
    .option("--embed-model <uri>", "collection-specific embedding model URI")
    .option("--update <cmd>", "shell command to run before indexing")
    .action(async (path: string, cmdOpts: Record<string, unknown>) => {
      const { collectionAdd } = await import("./commands/collection");
      await collectionAdd(path, {
        embedModel: cmdOpts.embedModel as string | undefined,
        name: cmdOpts.name as string,
        pattern: cmdOpts.pattern as string | undefined,
        include: cmdOpts.include as string | undefined,
        exclude: cmdOpts.exclude as string | undefined,
        update: cmdOpts.update as string | undefined,
      });
    });

  collectionCmd
    .command("list")
    .description("List collections")
    .option("--json", "JSON output")
    .option("--md", "Markdown output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.collectionList, format);

      const { collectionList } = await import("./commands/collection");
      await collectionList({
        json: format === "json",
        md: format === "md",
      });
    });

  collectionCmd
    .command("remove <name>")
    .description("Remove a collection")
    .action(async (name: string) => {
      const { collectionRemove } = await import("./commands/collection");
      await collectionRemove(name);
    });

  collectionCmd
    .command("rename <old> <new>")
    .description("Rename a collection")
    .action(async (oldName: string, newName: string) => {
      const { collectionRename } = await import("./commands/collection");
      await collectionRename(oldName, newName);
    });

  collectionCmd
    .command("clear-embeddings <name>")
    .description("Clear stale or all embeddings for a collection")
    .option("--all", "remove all embeddings for the collection")
    .option("--json", "JSON output")
    .action(async (name: string, cmdOpts: Record<string, unknown>) => {
      const { collectionClearEmbeddings } =
        await import("./commands/collection");
      await collectionClearEmbeddings(name, {
        all: Boolean(cmdOpts.all),
        json: Boolean(cmdOpts.json),
      });
    });

  // context subcommands
  const contextCmd = program
    .command("context")
    .description("Manage context items");

  contextCmd
    .command("add <scope> <text>")
    .description("Add context metadata for a scope")
    .action(async (scope: string, text: string) => {
      const { contextAdd } = await import("./commands/context");
      const exitCode = await contextAdd(scope, text);
      if (exitCode !== 0) {
        throw new CliError("RUNTIME", "Failed to add context");
      }
    });

  contextCmd
    .command("list")
    .description("List context items")
    .option("--json", "JSON output")
    .option("--md", "Markdown output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.contextList, format);

      const { contextList } = await import("./commands/context");
      await contextList(format as "terminal" | "json" | "md");
    });

  contextCmd
    .command("check")
    .description("Check context configuration")
    .option("--json", "JSON output")
    .option("--md", "Markdown output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.contextCheck, format);

      const { contextCheck } = await import("./commands/context");
      await contextCheck(format as "terminal" | "json" | "md");
    });

  contextCmd
    .command("rm <uri>")
    .description("Remove context item")
    .action(async (uri: string) => {
      const { contextRm } = await import("./commands/context");
      await contextRm(uri);
    });

  // models subcommands
  const modelsCmd = program.command("models").description("Manage LLM models");

  modelsCmd
    .command("list")
    .description("List available models")
    .option("--json", "JSON output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.modelsList, format);

      const { modelsList, formatModelsList } =
        await import("./commands/models");
      const result = await modelsList({ json: format === "json" });
      process.stdout.write(
        `${formatModelsList(result, { json: format === "json" })}\n`
      );
    });

  modelsCmd
    .command("use")
    .description("Switch active model preset")
    .argument("<preset>", "preset ID (slim, balanced, quality)")
    .action(async (preset: string) => {
      const globals = getGlobals();
      const { modelsUse, formatModelsUse } =
        await import("./commands/models/use");
      const result = await modelsUse(preset, { configPath: globals.config });
      if (!result.success) {
        throw new CliError("VALIDATION", result.error);
      }
      process.stdout.write(`${formatModelsUse(result)}\n`);
    });

  modelsCmd
    .command("pull")
    .description("Download models")
    .option("--all", "download all configured models")
    .option("--embed", "download embedding model")
    .option("--rerank", "download reranker model")
    .option("--expand", "download expansion model")
    .option("--gen", "download answer generation model")
    .option("--force", "force re-download")
    .option("--no-progress", "disable download progress")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const globals = getGlobals();
      const { modelsPull, formatModelsPull, createProgressRenderer } =
        await import("./commands/models");

      // Merge global quiet/json with local --no-progress
      const showProgress =
        (process.stderr.isTTY ?? false) &&
        !globals.quiet &&
        !globals.json &&
        cmdOpts.progress !== false;

      const result = await modelsPull({
        all: Boolean(cmdOpts.all),
        embed: Boolean(cmdOpts.embed),
        rerank: Boolean(cmdOpts.rerank),
        expand: Boolean(cmdOpts.expand),
        gen: Boolean(cmdOpts.gen),
        force: Boolean(cmdOpts.force),
        onProgress: showProgress ? createProgressRenderer() : undefined,
      });

      // For models pull, print result first, then check for failures
      // This allows partial success output before throwing
      process.stdout.write(`${formatModelsPull(result)}\n`);
      if (result.failed > 0) {
        throw new CliError("RUNTIME", `${result.failed} model(s) failed`);
      }
    });

  modelsCmd
    .command("clear")
    .description("Clear model cache")
    .action(async () => {
      const globals = getGlobals();
      const { modelsClear, formatModelsClear } =
        await import("./commands/models");
      const result = await modelsClear({ yes: globals.yes });
      process.stdout.write(`${formatModelsClear(result)}\n`);
    });

  modelsCmd
    .command("path")
    .description("Show model cache path")
    .option("--json", "JSON output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      const { modelsPath, formatModelsPath } =
        await import("./commands/models");
      const result = modelsPath();
      process.stdout.write(
        `${formatModelsPath(result, { json: format === "json" })}\n`
      );
    });

  // update - Sync files from disk
  program
    .command("update")
    .description("Sync files from disk into the index")
    .option("--git-pull", "run git pull in git repositories")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const globals = getGlobals();
      const { update, formatUpdate } = await import("./commands/update");
      const opts = {
        configPath: globals.config,
        indexName: globals.index,
        gitPull: Boolean(cmdOpts.gitPull),
        verbose: globals.verbose,
      };
      const result = await update(opts);

      if (!result.success) {
        throw new CliError("RUNTIME", result.error ?? "Update failed");
      }
      process.stdout.write(`${formatUpdate(result, opts)}\n`);
    });

  // embed - Generate embeddings
  program
    .command("embed [collection]")
    .description("Generate embeddings for indexed documents")
    .option("--collection <name>", "restrict to one collection")
    .option("--model <uri>", "embedding model URI")
    .option("--batch-size <num>", "batch size", "32")
    .option("--force", "regenerate all embeddings")
    .option("--dry-run", "show what would be done")
    .option("--json", "JSON output")
    .action(
      async (
        collectionArg: string | undefined,
        cmdOpts: Record<string, unknown>
      ) => {
        const globals = getGlobals();
        const format = getFormat(cmdOpts);

        const { embed, formatEmbed } = await import("./commands/embed");
        const collection =
          collectionArg ?? (cmdOpts.collection as string | undefined);
        const opts = {
          configPath: globals.config,
          indexName: globals.index,
          collection,
          model: cmdOpts.model as string | undefined,
          batchSize: parsePositiveInt("batch-size", cmdOpts.batchSize),
          force: Boolean(cmdOpts.force),
          dryRun: Boolean(cmdOpts.dryRun),
          yes: globals.yes,
          json: format === "json",
          verbose: globals.verbose,
        };
        const result = await embed(opts);

        if (!result.success) {
          throw new CliError("RUNTIME", result.error ?? "Embed failed");
        }
        process.stdout.write(`${formatEmbed(result, opts)}\n`);
      }
    );

  // cleanup - Clean stale data
  program
    .command("cleanup")
    .description("Clean orphaned data from index")
    .action(async () => {
      const { cleanup, formatCleanup } = await import("./commands/cleanup");
      const result = await cleanup();

      if (!result.success) {
        throw new CliError("RUNTIME", result.error ?? "Cleanup failed");
      }
      process.stdout.write(`${formatCleanup(result)}\n`);
    });

  // reset - Reset GNO to fresh state
  program
    .command("reset")
    .description("Delete all GNO data and start fresh")
    .option("--confirm", "confirm destructive operation")
    .option("--keep-config", "preserve config file")
    .option("--keep-cache", "preserve model cache")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const { reset, formatReset } = await import("./commands/reset");
      const globals = getGlobals();
      const result = await reset({
        // Accept either --confirm or global --yes
        confirm: Boolean(cmdOpts.confirm) || globals.yes,
        keepConfig: Boolean(cmdOpts.keepConfig),
        keepCache: Boolean(cmdOpts.keepCache),
      });
      process.stdout.write(`${formatReset(result)}\n`);
    });
}

// ─────────────────────────────────────────────────────────────────────────────
// Vec Commands (sync, rebuild)
// ─────────────────────────────────────────────────────────────────────────────

function wireVecCommands(program: Command): void {
  const vecCmd = program.command("vec").description("Vector index maintenance");

  // vec sync
  vecCmd
    .command("sync")
    .description("Sync vec0 index with content_vectors")
    .option("--json", "JSON output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      const globals = getGlobals();

      const { vecSync, formatVecSync } = await import("./commands/vec");
      const result = await vecSync({
        configPath: globals.config,
        json: format === "json",
      });

      if (!result.success) {
        throw new CliError("RUNTIME", result.error);
      }

      process.stdout.write(
        `${formatVecSync(result, { json: format === "json" })}\n`
      );
    });

  // vec rebuild
  vecCmd
    .command("rebuild")
    .description("Rebuild vec0 index from content_vectors")
    .option("--json", "JSON output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      const globals = getGlobals();

      const { vecRebuild, formatVecRebuild } = await import("./commands/vec");
      const result = await vecRebuild({
        configPath: globals.config,
        json: format === "json",
      });

      if (!result.success) {
        throw new CliError("RUNTIME", result.error);
      }

      process.stdout.write(
        `${formatVecRebuild(result, { json: format === "json" })}\n`
      );
    });
}

function wirePublishCommand(program: Command): void {
  const visibilityValues = [
    "public",
    "secret-link",
    "invite-only",
    "encrypted",
  ] as const;
  const publishCmd = program
    .command("publish")
    .description("Export publish artifacts for gno.sh");

  publishCmd
    .command("export <target>")
    .description("Export a collection or document ref as a gno.sh artifact")
    .option("--out <path>", "output artifact path")
    .option(
      "--visibility <mode>",
      "visibility mode (public, secret-link, invite-only, encrypted)",
      "public"
    )
    .option(
      "--passphrase <value>",
      "required for encrypted export; encrypts locally before upload"
    )
    .option("--slug <slug>", "route slug override")
    .option("--title <title>", "space title override")
    .option("--summary <summary>", "space summary override")
    .option(
      "--preview",
      "print sanitized markdown + warnings instead of writing the artifact"
    )
    .option("--json", "JSON output")
    .action(async (target: string, cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.publishExport, format);
      const globals = getGlobals();

      const visibility = cmdOpts.visibility as string;
      const passphrase =
        typeof cmdOpts.passphrase === "string" ? cmdOpts.passphrase : undefined;
      if (
        !visibilityValues.includes(
          visibility as (typeof visibilityValues)[number]
        )
      ) {
        throw new CliError(
          "VALIDATION",
          `Invalid visibility: ${visibility}. Must be public, secret-link, invite-only, or encrypted.`
        );
      }
      if (visibility === "encrypted" && !passphrase?.trim()) {
        throw new CliError(
          "VALIDATION",
          "Encrypted publish export requires --passphrase."
        );
      }

      const { formatPublishExport, publishExport } =
        await import("./commands/publish");
      const result = await publishExport(target, {
        configPath: globals.config,
        json: format === "json",
        out: typeof cmdOpts.out === "string" ? cmdOpts.out : undefined,
        encryptionPassphrase: passphrase,
        preview: cmdOpts.preview === true,
        slug: cmdOpts.slug as string | undefined,
        summary: cmdOpts.summary as string | undefined,
        title: cmdOpts.title as string | undefined,
        visibility: visibility as
          | "encrypted"
          | "invite-only"
          | "public"
          | "secret-link",
      });

      if (!result.success) {
        throw new CliError(
          result.isValidation ? "VALIDATION" : "RUNTIME",
          result.error
        );
      }

      await writeOutput(
        formatPublishExport(result, { json: format === "json" }),
        format
      );
    });
}

// ─────────────────────────────────────────────────────────────────────────────
// Skill Commands (install, uninstall, show, paths)
// ─────────────────────────────────────────────────────────────────────────────

function wireSkillCommands(program: Command): void {
  const skillCmd = program
    .command("skill")
    .description("Manage GNO agent skill");

  skillCmd
    .command("install")
    .description("Install GNO skill to Claude Code or Codex")
    .option(
      "-s, --scope <scope>",
      "installation scope (project, user)",
      "project"
    )
    .option(
      "-t, --target <target>",
      "target agent (claude, codex, opencode, openclaw, all)",
      "claude"
    )
    .option("-f, --force", "overwrite existing installation")
    .option("--json", "JSON output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const scope = cmdOpts.scope as string;
      const target = cmdOpts.target as string;

      // Validate scope
      if (!["project", "user"].includes(scope)) {
        throw new CliError(
          "VALIDATION",
          `Invalid scope: ${scope}. Must be 'project' or 'user'.`
        );
      }
      // Validate target
      if (
        !["claude", "codex", "opencode", "openclaw", "all"].includes(target)
      ) {
        throw new CliError(
          "VALIDATION",
          `Invalid target: ${target}. Must be 'claude', 'codex', 'opencode', 'openclaw', or 'all'.`
        );
      }

      const { installSkill } = await import("./commands/skill/install.js");
      await installSkill({
        scope: scope as "project" | "user",
        target: target as "claude" | "codex" | "opencode" | "openclaw" | "all",
        force: Boolean(cmdOpts.force),
        json: Boolean(cmdOpts.json),
      });
    });

  skillCmd
    .command("uninstall")
    .description("Uninstall GNO skill")
    .option(
      "-s, --scope <scope>",
      "installation scope (project, user)",
      "project"
    )
    .option(
      "-t, --target <target>",
      "target agent (claude, codex, opencode, openclaw, all)",
      "claude"
    )
    .option("--json", "JSON output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const scope = cmdOpts.scope as string;
      const target = cmdOpts.target as string;

      // Validate scope
      if (!["project", "user"].includes(scope)) {
        throw new CliError(
          "VALIDATION",
          `Invalid scope: ${scope}. Must be 'project' or 'user'.`
        );
      }
      // Validate target
      if (
        !["claude", "codex", "opencode", "openclaw", "all"].includes(target)
      ) {
        throw new CliError(
          "VALIDATION",
          `Invalid target: ${target}. Must be 'claude', 'codex', 'opencode', 'openclaw', or 'all'.`
        );
      }

      const { uninstallSkill } = await import("./commands/skill/uninstall.js");
      await uninstallSkill({
        scope: scope as "project" | "user",
        target: target as "claude" | "codex" | "opencode" | "openclaw" | "all",
        json: Boolean(cmdOpts.json),
      });
    });

  skillCmd
    .command("show")
    .description("Preview skill files without installing")
    .option("--file <name>", "specific file to show")
    .option("--all", "show all skill files")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const { showSkill } = await import("./commands/skill/show.js");
      await showSkill({
        file: cmdOpts.file as string | undefined,
        all: Boolean(cmdOpts.all),
      });
    });

  skillCmd
    .command("paths")
    .description("Show resolved skill installation paths")
    .option(
      "-s, --scope <scope>",
      "filter by scope (project, user, all)",
      "all"
    )
    .option(
      "-t, --target <target>",
      "filter by target (claude, codex, opencode, openclaw, all)",
      "all"
    )
    .option("--json", "JSON output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const scope = cmdOpts.scope as string;
      const target = cmdOpts.target as string;

      // Validate scope
      if (!["project", "user", "all"].includes(scope)) {
        throw new CliError(
          "VALIDATION",
          `Invalid scope: ${scope}. Must be 'project', 'user', or 'all'.`
        );
      }
      // Validate target
      if (
        !["claude", "codex", "opencode", "openclaw", "all"].includes(target)
      ) {
        throw new CliError(
          "VALIDATION",
          `Invalid target: ${target}. Must be 'claude', 'codex', 'opencode', 'openclaw', or 'all'.`
        );
      }

      const { showPaths } = await import("./commands/skill/paths-cmd.js");
      await showPaths({
        scope: scope as "project" | "user" | "all",
        target: target as "claude" | "codex" | "opencode" | "openclaw" | "all",
        json: Boolean(cmdOpts.json),
      });
    });
}

// ─────────────────────────────────────────────────────────────────────────────
// Tags Commands (list, add, rm)
// ─────────────────────────────────────────────────────────────────────────────

function wireTagsCommands(program: Command): void {
  const tagsCmd = program.command("tags").description("Manage document tags");

  // tags list
  tagsCmd
    .command("list", { isDefault: true })
    .description("List all tags with document counts")
    .option("-c, --collection <name>", "filter by collection")
    .option("--prefix <prefix>", "filter by tag prefix")
    .option("--json", "JSON output")
    .option("--md", "Markdown output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.tagsList, format);
      const globals = getGlobals();

      const { tagsList, formatTagsList } = await import("./commands/tags");
      const result = await tagsList({
        configPath: globals.config,
        collection: cmdOpts.collection as string | undefined,
        prefix: cmdOpts.prefix as string | undefined,
        json: format === "json",
        md: format === "md",
      });

      if (!result.success) {
        throw new CliError(
          result.isValidation ? "VALIDATION" : "RUNTIME",
          result.error
        );
      }

      const output = formatTagsList(result, {
        json: format === "json",
        md: format === "md",
      });
      await writeOutput(output, format);
    });

  // tags add <doc> <tag>
  tagsCmd
    .command("add <doc> <tag>")
    .description("Add a tag to a document")
    .option("--json", "JSON output")
    .action(
      async (doc: string, tag: string, cmdOpts: Record<string, unknown>) => {
        const format = getFormat(cmdOpts);
        const globals = getGlobals();

        const { tagsAdd, formatTagsAdd } = await import("./commands/tags");
        const result = await tagsAdd(doc, tag, {
          configPath: globals.config,
          json: format === "json",
        });

        if (!result.success) {
          throw new CliError(
            result.isValidation ? "VALIDATION" : "RUNTIME",
            result.error
          );
        }

        process.stdout.write(
          `${formatTagsAdd(result, { json: format === "json" })}\n`
        );
      }
    );

  // tags rm <doc> <tag>
  tagsCmd
    .command("rm <doc> <tag>")
    .description("Remove a tag from a document")
    .option("--json", "JSON output")
    .action(
      async (doc: string, tag: string, cmdOpts: Record<string, unknown>) => {
        const format = getFormat(cmdOpts);
        const globals = getGlobals();

        const { tagsRm, formatTagsRm } = await import("./commands/tags");
        const result = await tagsRm(doc, tag, {
          configPath: globals.config,
          json: format === "json",
        });

        if (!result.success) {
          throw new CliError(
            result.isValidation ? "VALIDATION" : "RUNTIME",
            result.error
          );
        }

        process.stdout.write(
          `${formatTagsRm(result, { json: format === "json" })}\n`
        );
      }
    );
}

// ─────────────────────────────────────────────────────────────────────────────
// Links Commands (links, backlinks, similar)
// ─────────────────────────────────────────────────────────────────────────────

function wireLinksCommands(program: Command): void {
  // links - List outgoing links from a document
  const linksCmd = program
    .command("links")
    .description("Document link commands");

  linksCmd
    .command("list <doc>", { isDefault: true })
    .description("List outgoing links from a document")
    .option("--type <type>", "filter by link type (wiki, markdown)")
    .option("--json", "JSON output")
    .option("--md", "Markdown output")
    .action(async (doc: string, cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.linksList, format);
      const globals = getGlobals();

      // Validate type option
      const linkType = cmdOpts.type as string | undefined;
      if (linkType && !["wiki", "markdown"].includes(linkType)) {
        throw new CliError(
          "VALIDATION",
          `Invalid link type: ${linkType}. Must be 'wiki' or 'markdown'.`
        );
      }

      const { linksList, formatLinksList } = await import("./commands/links");
      const result = await linksList(doc, {
        configPath: globals.config,
        type: linkType as "wiki" | "markdown" | undefined,
        json: format === "json",
        md: format === "md",
      });

      if (!result.success) {
        throw new CliError(
          result.isValidation ? "VALIDATION" : "RUNTIME",
          result.error
        );
      }

      const output = formatLinksList(result, {
        json: format === "json",
        md: format === "md",
      });
      await writeOutput(output, format);
    });

  // backlinks - List documents linking TO this document
  program
    .command("backlinks <doc>")
    .description("List documents linking to this document")
    .option("-c, --collection <name>", "filter by collection")
    .option("--json", "JSON output")
    .option("--md", "Markdown output")
    .action(async (doc: string, cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.backlinks, format);
      const globals = getGlobals();

      const { backlinks, formatBacklinks } = await import("./commands/links");
      const result = await backlinks(doc, {
        configPath: globals.config,
        collection: cmdOpts.collection as string | undefined,
        json: format === "json",
        md: format === "md",
      });

      if (!result.success) {
        throw new CliError(
          result.isValidation ? "VALIDATION" : "RUNTIME",
          result.error
        );
      }

      const output = formatBacklinks(result, {
        json: format === "json",
        md: format === "md",
      });
      await writeOutput(output, format);
    });

  // similar - Find semantically similar documents
  program
    .command("similar <doc>")
    .description("Find semantically similar documents")
    .option("-n, --limit <num>", "max results", "5")
    .option("--threshold <num>", "minimum similarity (0-1)", "0.7")
    .option("--cross-collection", "search across all collections")
    .option("--json", "JSON output")
    .option("--md", "Markdown output")
    .action(async (doc: string, cmdOpts: Record<string, unknown>) => {
      const format = getFormat(cmdOpts);
      assertFormatSupported(CMD.similar, format);
      const globals = getGlobals();

      const limit = cmdOpts.limit
        ? parsePositiveInt("limit", cmdOpts.limit)
        : 5;

      const threshold = parseOptionalFloat("threshold", cmdOpts.threshold);
      if (threshold !== undefined && (threshold < 0 || threshold > 1)) {
        throw new CliError("VALIDATION", "--threshold must be between 0 and 1");
      }

      const { similar, formatSimilar } = await import("./commands/links");
      const result = await similar(doc, {
        configPath: globals.config,
        limit,
        threshold,
        crossCollection: Boolean(cmdOpts.crossCollection),
        json: format === "json",
        md: format === "md",
      });

      if (!result.success) {
        throw new CliError(
          result.isValidation ? "VALIDATION" : "RUNTIME",
          result.error
        );
      }

      const output = formatSimilar(result, {
        json: format === "json",
        md: format === "md",
      });
      await writeOutput(output, format);
    });
}

// ─────────────────────────────────────────────────────────────────────────────
// Graph Command
// ─────────────────────────────────────────────────────────────────────────────

function wireGraphCommand(program: Command): void {
  program
    .command("graph")
    .description("Output knowledge graph of document links")
    .option("-c, --collection <name>", "filter by collection")
    .option("--limit <n>", "max nodes (default 2000)")
    .option("--edge-limit <n>", "max edges (default 10000)")
    .option("--include-similar", "include similarity edges")
    .option("--threshold <n>", "similarity threshold (default 0.7)")
    .option("--include-isolated", "include nodes with no links")
    .option("--similar-top-k <n>", "similar docs per node (default 5)")
    .option("--neighbors <ref>", "show graph neighbors for document/node ref")
    .option("--direction <dir>", "neighbor direction: both, out, in", "both")
    .option("--from <ref>", "path start document/node ref")
    .option("--to <ref>", "path target document/node ref")
    .option("--max-depth <n>", "max path hops (default 6)")
    .option("--json", "JSON output (default)")
    .option("--dot", "Graphviz DOT output")
    .option("--mermaid", "Mermaid diagram output")
    .action(async (cmdOpts: Record<string, unknown>) => {
      const globals = getGlobals();

      // Validate mutually exclusive format flags
      const formatFlags = [cmdOpts.json, cmdOpts.dot, cmdOpts.mermaid].filter(
        Boolean
      );
      if (formatFlags.length > 1) {
        throw new CliError(
          "VALIDATION",
          "Choose one format: --json, --dot, or --mermaid"
        );
      }

      const format: "json" | "dot" | "mermaid" = cmdOpts.dot
        ? "dot"
        : cmdOpts.mermaid
          ? "mermaid"
          : "json";

      const limitNodes = cmdOpts.limit
        ? parsePositiveInt("limit", cmdOpts.limit)
        : undefined;
      const limitEdges = cmdOpts.edgeLimit
        ? parsePositiveInt("edge-limit", cmdOpts.edgeLimit)
        : undefined;
      const threshold = parseOptionalFloat("threshold", cmdOpts.threshold);
      if (threshold !== undefined && (threshold < 0 || threshold > 1)) {
        throw new CliError("VALIDATION", "--threshold must be between 0 and 1");
      }
      const similarTopK = cmdOpts.similarTopK
        ? parsePositiveInt("similar-top-k", cmdOpts.similarTopK)
        : undefined;
      const maxDepth = cmdOpts.maxDepth
        ? parsePositiveInt("max-depth", cmdOpts.maxDepth)
        : undefined;
      if (
        cmdOpts.direction !== "both" &&
        cmdOpts.direction !== "out" &&
        cmdOpts.direction !== "in"
      ) {
        throw new CliError(
          "VALIDATION",
          "--direction must be one of: both, out, in"
        );
      }
      if (cmdOpts.neighbors && (cmdOpts.from || cmdOpts.to)) {
        throw new CliError(
          "VALIDATION",
          "--neighbors cannot be combined with --from/--to"
        );
      }

      const { graph, formatGraph } = await import("./commands/graph.js");
      const result = await graph({
        configPath: globals.config,
        collection: cmdOpts.collection as string | undefined,
        limitNodes,
        limitEdges,
        includeSimilar: Boolean(cmdOpts.includeSimilar),
        threshold,
        includeIsolated: Boolean(cmdOpts.includeIsolated),
        similarTopK,
        format,
        neighbors: cmdOpts.neighbors as string | undefined,
        direction: cmdOpts.direction as "both" | "out" | "in",
        from: cmdOpts.from as string | undefined,
        to: cmdOpts.to as string | undefined,
        maxDepth,
      });

      if (!result.success) {
        throw new CliError(
          result.isValidation ? "VALIDATION" : "RUNTIME",
          result.error
        );
      }

      const output = formatGraph(result, { format });

      // Print truncation warning to stderr if applicable
      if (result.data.meta.truncated) {
        for (const warning of result.data.meta.warnings) {
          console.error(`Warning: ${warning}`);
        }
      }

      await writeOutput(output, format === "json" ? "json" : "terminal");
    });
}

// ─────────────────────────────────────────────────────────────────────────────
// Serve Command (web UI)
// ─────────────────────────────────────────────────────────────────────────────

function wireDaemonCommand(program: Command): void {
  const daemonCmd = program
    .command("daemon")
    .description("Start headless continuous indexing")
    .option(
      "--no-sync-on-start",
      "skip initial sync and only watch future file changes"
    )
    // Local `--json` so `gno daemon --status --json` parses cleanly in any
    // argv position. Mirrors the serve wiring: gated to --status only.
    .option(
      "--json",
      "JSON output (applies to --status; see process-status schema)"
    );

  // --detach / --status / --stop are mutually exclusive. Use Commander's
  // Option API so the conflict error is the native "option '--status'
  // cannot be used with option '--detach'" surface.
  daemonCmd.addOption(
    new Option(
      "--detach",
      "run as a detached background process (macOS/Linux only)"
    ).conflicts(["status", "stop"])
  );
  daemonCmd.addOption(
    new Option(
      "--status",
      "show status of the detached daemon process (use --json for machine output)"
    ).conflicts(["detach", "stop"])
  );
  daemonCmd.addOption(
    new Option(
      "--stop",
      "stop the detached daemon process (SIGTERM then SIGKILL fallback)"
    ).conflicts(["detach", "status"])
  );
  daemonCmd.addOption(
    new Option("--pid-file <path>", "override pid-file path")
  );
  daemonCmd.addOption(
    new Option("--log-file <path>", "override log-file path (append-only)")
  );
  // Sentinel flag set by the parent when re-exec'ing the detached child.
  // Hidden from --help; Commander consumes it via addOption so it doesn't
  // leak into user-visible argv.
  daemonCmd.addOption(
    new Option(
      `${DETACHED_CHILD_FLAG}`,
      "internal detached-child marker"
    ).hideHelp()
  );

  daemonCmd.action(async (cmdOpts: Record<string, unknown>, cmd: Command) => {
    await handleDaemonAction(cmdOpts, cmd);
  });
}

// ─────────────────────────────────────────────────────────────────────────────
// Daemon lifecycle branching (detach / status / stop / detached-child / fg)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Route `gno daemon` through the detach helpers based on which mutex flag the
 * user set. The four early branches return without touching `daemon()`; only
 * the foreground + detached-child paths boot the runtime.
 *
 * Mirrors `handleServeAction` exactly — same helper imports, same JSON
 * gating, same detached-child verification + pid-file cleanup. Daemon has no
 * `--port`, so the foreground path skips port validation entirely.
 */
async function handleDaemonAction(
  cmdOpts: Record<string, unknown>,
  cmd: Command
): Promise<void> {
  const globals = getGlobals();

  const {
    resolveProcessPaths,
    statusProcess,
    stopProcess,
    spawnDetached,
    inspectForeignLive,
    verifyPidFileMatchesSelf,
    DETACHED_CHILD_FLAG: childFlag,
  } = await import("./detach.js");

  const paths = resolveProcessPaths("daemon", {
    pidFile: cmdOpts.pidFile as string | undefined,
    logFile: cmdOpts.logFile as string | undefined,
    cwd: process.cwd(),
  });

  // Per `spec/cli.md`, `--json` is only defined for `gno daemon --status`.
  // Silently accepting `--json` on --detach / --stop / foreground would let
  // users think they'll get structured output; fail fast instead. Mirrors
  // the serve wiring at handleServeAction.
  const jsonRequested = Boolean(cmdOpts.json) || globals.json;
  if (jsonRequested && !cmdOpts.status) {
    throw new CliError(
      "VALIDATION",
      "--json is only supported with `gno daemon --status`"
    );
  }

  if (cmdOpts.status) {
    const json = Boolean(cmdOpts.json) || globals.json;
    await runDaemonStatus({
      paths,
      json,
      statusProcess,
      inspectForeignLive,
    });
    return;
  }

  if (cmdOpts.stop) {
    await runDaemonStop({ pidFile: paths.pidFile, stopProcess });
    return;
  }

  if (cmdOpts.detach) {
    await runDaemonDetach({
      paths,
      spawnDetached,
      argv: resolveCliArgv(cmd),
    });
    return;
  }

  // Detached-child path: the parent spawned us with DETACHED_CHILD_FLAG.
  // Confirm the pid-file points at us before booting; if the parent never
  // registered us, exit cleanly rather than run unmanaged. On success,
  // install a shutdown hook that unlinks the pid-file. The daemon's own
  // `createSignalPromise` (in commands/daemon.ts) already drains SIGINT/
  // SIGTERM cleanly; `installPidFileCleanup` adds the unlink on top so the
  // pid-file disappears even if the runtime teardown misbehaves.
  const isDetachedChild = Boolean(
    (cmdOpts as Record<string, unknown>)[toCamelCase(childFlag)]
  );
  if (isDetachedChild) {
    const matched = await verifyPidFileMatchesSelf({ pidFile: paths.pidFile });
    if (!matched) {
      // Parent crashed before registering us, or another racer won.
      return;
    }
    installPidFileCleanup(paths.pidFile);
  }

  const { daemon } = await import("./commands/daemon.js");
  const result = await daemon({
    configPath: globals.config,
    index: globals.index,
    offline: globals.offline,
    verbose: globals.verbose,
    quiet: globals.quiet,
    noSyncOnStart: cmdOpts.syncOnStart === false,
  });
  if (!result.success) {
    throw new CliError("RUNTIME", result.error);
  }
}

interface DaemonStatusDeps {
  paths: { pidFile: string; logFile: string };
  json: boolean;
  statusProcess: typeof import("./detach.js").statusProcess;
  inspectForeignLive: typeof import("./detach.js").inspectForeignLive;
}

async function runDaemonStatus(deps: DaemonStatusDeps): Promise<void> {
  const status = await deps.statusProcess({
    kind: "daemon",
    pidFile: deps.paths.pidFile,
    logFile: deps.paths.logFile,
  });
  const foreign = await deps.inspectForeignLive({
    kind: "daemon",
    pidFile: deps.paths.pidFile,
  });

  if (deps.json) {
    process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
    // In JSON mode, foreign-live metadata flows into the NOT_RUNNING
    // envelope's `details` payload below so stderr stays a single JSON
    // object that machine clients can parse deterministically.
  } else {
    process.stdout.write("gno daemon status\n");
    process.stdout.write(`${"─".repeat(50)}\n`);
    if (status.running) {
      // Daemon is headless — no port to print.
      process.stdout.write(`  running  yes (pid ${status.pid})\n`);
      if (status.version) {
        process.stdout.write(`  version  ${status.version}\n`);
      }
      if (status.started_at) {
        process.stdout.write(
          `  started  ${status.started_at} (uptime ${status.uptime_seconds ?? 0}s)\n`
        );
      }
    } else {
      process.stdout.write("  running  no\n");
    }
    process.stdout.write(`  pid-file ${status.pid_file}\n`);
    process.stdout.write(`  log-file ${status.log_file}`);
    if (status.log_size_bytes === null) {
      process.stdout.write(" (missing)\n");
    } else {
      process.stdout.write(` (${status.log_size_bytes} bytes)\n`);
    }

    if (foreign) {
      // Terminal mode: emit the operator-facing warning on stderr. JSON
      // clients get the same data via the NOT_RUNNING envelope's details.
      process.stderr.write(
        `Warning: pid ${foreign.pid} is live but recorded gno version ${foreign.recordedVersion} differs from current ${foreign.currentVersion}; refusing to claim ownership.\n`
      );
    }
  }

  // Spec: `--status` exits 3 (NOT_RUNNING) when no live matching process is
  // found. The stdout payload stays schema-clean — we've already written it —
  // so throw a NOT_RUNNING *after* output so the envelope only hits stderr
  // and the exit code propagates through `runCli -> exitCodeFor`.
  if (!status.running) {
    throw new CliError(
      "NOT_RUNNING",
      `gno daemon is not running (pid-file ${status.pid_file}${foreign ? `; live-foreign pid ${foreign.pid}` : ""})`,
      {
        details: foreign
          ? {
              foreign_live: {
                pid: foreign.pid,
                recorded_version: foreign.recordedVersion,
                current_version: foreign.currentVersion,
              },
            }
          : undefined,
      }
    );
  }
}

interface DaemonStopDeps {
  pidFile: string;
  stopProcess: typeof import("./detach.js").stopProcess;
}

async function runDaemonStop(deps: DaemonStopDeps): Promise<void> {
  const outcome = await deps.stopProcess({
    kind: "daemon",
    pidFile: deps.pidFile,
  });

  switch (outcome.kind) {
    case "stopped":
      process.stdout.write(
        `Stopped gno daemon (pid ${outcome.pid}, ${outcome.signal})\n`
      );
      return;
    case "not-running":
      // Per spec/cli.md: `--stop` with no pid-file exits 3 silently (no
      // error envelope on either stream, no `--json` support).
      throw new CliError(
        "NOT_RUNNING",
        `gno daemon is not running (pid-file ${outcome.pidFile} missing or stale)`,
        { silent: true }
      );
    case "timeout":
      throw new CliError(
        "RUNTIME",
        `gno daemon (pid ${outcome.pid}) did not exit after SIGTERM + SIGKILL. Investigate manually.`
      );
    case "foreign-live":
      throw new CliError(
        "VALIDATION",
        `gno daemon (pid ${outcome.pid}) is live but was started by gno ${outcome.payload.version}; this binary is ${VERSION}. Refusing to signal pid ${outcome.pid}; terminate it manually and delete ${deps.pidFile}.`
      );
    default: {
      const exhaustive: never = outcome;
      throw new Error(`unreachable stop outcome: ${String(exhaustive)}`);
    }
  }
}

interface DaemonDetachDeps {
  paths: { pidFile: string; logFile: string };
  spawnDetached: typeof import("./detach.js").spawnDetached;
  /**
   * The user-facing argv slice (everything after `[execPath, scriptPath]`)
   * sourced from `Command.rawArgs` via `resolveCliArgv()`. Per-invocation,
   * not process-global, so back-to-back `runCli([...])` calls in the same
   * process don't taint each other.
   */
  argv: string[];
}

async function runDaemonDetach(deps: DaemonDetachDeps): Promise<void> {
  // Strip --detach from the re-exec argv so the child takes the foreground /
  // detached-child branch instead of re-spawning itself in an infinite loop.
  const childArgv = stripDetachFlag(deps.argv);
  const result = await deps.spawnDetached({
    kind: "daemon",
    argv: childArgv,
    pidFile: deps.paths.pidFile,
    logFile: deps.paths.logFile,
  });
  // Daemon is headless — print pid only (no URL).
  process.stdout.write(`PID ${result.pid}\n`);
}

function wireServeCommand(program: Command): void {
  const serveCmd = program
    .command("serve")
    .description("Start web UI server")
    .option("-p, --port <num>", "port to listen on", "3000")
    // Local `--json` so `gno serve --status --json` parses cleanly in any
    // argv position. Global `--json` already exists on the root program
    // (see line 208); resolution goes through `getFormat()` /
    // `getGlobals()` which precedence-merges local over global.
    .option(
      "--json",
      "JSON output (applies to --status; see process-status schema)"
    );

  // --detach / --status / --stop are mutually exclusive. Use Commander's
  // Option API so the conflict error is the native "option '--status'
  // cannot be used with option '--detach'" surface.
  serveCmd.addOption(
    new Option(
      "--detach",
      "run as a detached background process (macOS/Linux only)"
    ).conflicts(["status", "stop"])
  );
  serveCmd.addOption(
    new Option(
      "--status",
      "show status of the detached serve process (use --json for machine output)"
    ).conflicts(["detach", "stop"])
  );
  serveCmd.addOption(
    new Option(
      "--stop",
      "stop the detached serve process (SIGTERM then SIGKILL fallback)"
    ).conflicts(["detach", "status"])
  );
  serveCmd.addOption(new Option("--pid-file <path>", "override pid-file path"));
  serveCmd.addOption(
    new Option("--log-file <path>", "override log-file path (append-only)")
  );
  // Sentinel flag set by the parent when re-exec'ing the detached child.
  // Hidden from --help; Commander consumes it via addOption so it doesn't
  // leak into user-visible argv.
  serveCmd.addOption(
    new Option(
      `${DETACHED_CHILD_FLAG}`,
      "internal detached-child marker"
    ).hideHelp()
  );

  serveCmd.action(async (cmdOpts: Record<string, unknown>, cmd: Command) => {
    await handleServeAction(cmdOpts, cmd);
  });
}

// ─────────────────────────────────────────────────────────────────────────────
// Serve lifecycle branching (detach / status / stop / detached-child / fg)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Route `gno serve` through the detach helpers based on which mutex flag the
 * user set. The four early branches return without touching `startServer()`;
 * only the foreground + detached-child paths boot the runtime.
 */
async function handleServeAction(
  cmdOpts: Record<string, unknown>,
  cmd: Command
): Promise<void> {
  const globals = getGlobals();
  // NB: do NOT parse --port here. The status/stop branches don't need it
  // and rejecting `gno serve --status --port nope` on irrelevant input
  // would break management commands. parsePositiveInt is called inside
  // the foreground + detach paths where the port actually matters.

  const {
    resolveProcessPaths,
    statusProcess,
    stopProcess,
    spawnDetached,
    inspectForeignLive,
    verifyPidFileMatchesSelf,
    DETACHED_CHILD_FLAG: childFlag,
  } = await import("./detach.js");

  const paths = resolveProcessPaths("serve", {
    pidFile: cmdOpts.pidFile as string | undefined,
    logFile: cmdOpts.logFile as string | undefined,
    cwd: process.cwd(),
  });

  // Per `spec/cli.md`, `--json` is only defined for `gno serve --status`.
  // Silently accepting `--json` on --detach / --stop / foreground would let
  // users think they'll get structured output; fail fast instead. Commander
  // hoists `--json` to the root when both root and sub-command declare it
  // (verified against commander@14.0.2), so we consult the local flag first
  // and fall back to the global flag only if the user is mixing `serve`
  // with anything but `--status`.
  const jsonRequested = Boolean(cmdOpts.json) || globals.json;
  if (jsonRequested && !cmdOpts.status) {
    throw new CliError(
      "VALIDATION",
      "--json is only supported with `gno serve --status`"
    );
  }

  if (cmdOpts.status) {
    // Local --json wins over global so `gno serve --status --json` and
    // `gno --json serve --status` both produce the same result.
    const json = Boolean(cmdOpts.json) || globals.json;
    await runServeStatus({
      paths,
      json,
      statusProcess,
      inspectForeignLive,
    });
    return;
  }

  if (cmdOpts.stop) {
    await runServeStop({ pidFile: paths.pidFile, stopProcess });
    return;
  }

  if (cmdOpts.detach) {
    const port = parsePositiveInt("port", cmdOpts.port);
    await runServeDetach({
      port,
      paths,
      spawnDetached,
      argv: resolveCliArgv(cmd),
    });
    return;
  }

  // Detached-child path: the parent spawned us with DETACHED_CHILD_FLAG.
  // Confirm the pid-file points at us before booting; if the parent never
  // registered us, exit cleanly rather than run unmanaged. On success,
  // install a shutdown hook that unlinks the pid-file.
  const isDetachedChild = Boolean(
    (cmdOpts as Record<string, unknown>)[toCamelCase(childFlag)]
  );
  if (isDetachedChild) {
    const matched = await verifyPidFileMatchesSelf({ pidFile: paths.pidFile });
    if (!matched) {
      // Parent crashed before registering us, or another racer won.
      return;
    }
    installPidFileCleanup(paths.pidFile);
  }

  // Foreground + detached-child both need to actually bind a port; validate
  // here (after the management branches have returned) so a bad --port only
  // breaks invocations that would have used it.
  const port = parsePositiveInt("port", cmdOpts.port);

  const { serve } = await import("./commands/serve.js");
  const result = await serve({
    port,
    configPath: globals.config,
    index: globals.index,
  });

  if (!result.success) {
    throw new CliError("RUNTIME", result.error ?? "Server failed to start");
  }
  // Server runs until SIGINT/SIGTERM - no output needed here
}

interface ServeStatusDeps {
  paths: { pidFile: string; logFile: string };
  json: boolean;
  statusProcess: typeof import("./detach.js").statusProcess;
  inspectForeignLive: typeof import("./detach.js").inspectForeignLive;
}

async function runServeStatus(deps: ServeStatusDeps): Promise<void> {
  const status = await deps.statusProcess({
    kind: "serve",
    pidFile: deps.paths.pidFile,
    logFile: deps.paths.logFile,
  });
  const foreign = await deps.inspectForeignLive({
    kind: "serve",
    pidFile: deps.paths.pidFile,
  });

  if (deps.json) {
    process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
    // In JSON mode, foreign-live metadata flows into the NOT_RUNNING
    // envelope's `details` payload below so stderr stays a single JSON
    // object that machine clients can parse deterministically.
  } else {
    process.stdout.write("gno serve status\n");
    process.stdout.write(`${"─".repeat(50)}\n`);
    if (status.running) {
      const portText =
        status.port === null ? "(unknown port)" : `port ${status.port}`;
      process.stdout.write(`  running  yes (pid ${status.pid}, ${portText})\n`);
      if (status.version) {
        process.stdout.write(`  version  ${status.version}\n`);
      }
      if (status.started_at) {
        process.stdout.write(
          `  started  ${status.started_at} (uptime ${status.uptime_seconds ?? 0}s)\n`
        );
      }
    } else {
      process.stdout.write("  running  no\n");
    }
    process.stdout.write(`  pid-file ${status.pid_file}\n`);
    process.stdout.write(`  log-file ${status.log_file}`);
    if (status.log_size_bytes === null) {
      process.stdout.write(" (missing)\n");
    } else {
      process.stdout.write(` (${status.log_size_bytes} bytes)\n`);
    }

    if (foreign) {
      // Terminal mode: emit the operator-facing warning on stderr. JSON
      // clients get the same data via the NOT_RUNNING envelope's details.
      process.stderr.write(
        `Warning: pid ${foreign.pid} is live but recorded gno version ${foreign.recordedVersion} differs from current ${foreign.currentVersion}; refusing to claim ownership.\n`
      );
    }
  }

  // Spec: `--status` exits 3 (NOT_RUNNING) when no live matching process is
  // found. The stdout payload stays schema-clean — we've already written it —
  // so throw a NOT_RUNNING *after* output so the envelope only hits stderr
  // and the exit code propagates through `runCli -> exitCodeFor`. A
  // live-foreign pid is also "not ours", so we cannot claim running:true for
  // it; `statusProcess` already reports `running:false` in that case.
  if (!status.running) {
    throw new CliError(
      "NOT_RUNNING",
      `gno serve is not running (pid-file ${status.pid_file}${foreign ? `; live-foreign pid ${foreign.pid}` : ""})`,
      {
        details: foreign
          ? {
              foreign_live: {
                pid: foreign.pid,
                recorded_version: foreign.recordedVersion,
                current_version: foreign.currentVersion,
              },
            }
          : undefined,
      }
    );
  }
}

interface ServeStopDeps {
  pidFile: string;
  stopProcess: typeof import("./detach.js").stopProcess;
}

async function runServeStop(deps: ServeStopDeps): Promise<void> {
  const outcome = await deps.stopProcess({
    kind: "serve",
    pidFile: deps.pidFile,
  });

  switch (outcome.kind) {
    case "stopped":
      process.stdout.write(
        `Stopped gno serve (pid ${outcome.pid}, ${outcome.signal})\n`
      );
      return;
    case "not-running":
      // Per spec/cli.md: `--stop` with no pid-file exits 3 silently (no
      // error envelope on either stream, no `--json` support).
      throw new CliError(
        "NOT_RUNNING",
        `gno serve is not running (pid-file ${outcome.pidFile} missing or stale)`,
        { silent: true }
      );
    case "timeout":
      throw new CliError(
        "RUNTIME",
        `gno serve (pid ${outcome.pid}) did not exit after SIGTERM + SIGKILL. Investigate manually.`
      );
    case "foreign-live":
      throw new CliError(
        "VALIDATION",
        `gno serve (pid ${outcome.pid}) is live but was started by gno ${outcome.payload.version}; this binary is ${VERSION}. Refusing to signal pid ${outcome.pid}; terminate it manually and delete ${deps.pidFile}.`
      );
    default: {
      const exhaustive: never = outcome;
      throw new Error(`unreachable stop outcome: ${String(exhaustive)}`);
    }
  }
}

interface ServeDetachDeps {
  port: number;
  paths: { pidFile: string; logFile: string };
  spawnDetached: typeof import("./detach.js").spawnDetached;
  /**
   * The user-facing argv slice from `Command.rawArgs` via `resolveCliArgv()`.
   * Per-invocation so back-to-back `runCli([...])` calls in the same process
   * can't taint each other's child argv.
   */
  argv: string[];
}

async function runServeDetach(deps: ServeDetachDeps): Promise<void> {
  // Strip --detach from the re-exec argv so the child takes the foreground /
  // detached-child branch instead of re-spawning itself in an infinite loop.
  const childArgv = stripDetachFlag(deps.argv);
  const result = await deps.spawnDetached({
    kind: "serve",
    argv: childArgv,
    pidFile: deps.paths.pidFile,
    logFile: deps.paths.logFile,
    port: deps.port,
  });
  process.stdout.write(
    `PID ${result.pid} listening on http://localhost:${deps.port}\n`
  );
}

/**
 * Remove `--detach` (and its short/long variants) from an argv slice while
 * preserving the order and value of every other argument. We only strip the
 * literal long form because that's the only form we register.
 */
function stripDetachFlag(argv: string[]): string[] {
  return argv.filter((a) => a !== "--detach");
}

/**
 * Install a one-shot SIGINT/SIGTERM handler that unlinks the pid-file before
 * the default signal handling (startServer's own shutdown) runs. Uses a sync
 * unlink so the pid-file is gone even if the subsequent async teardown
 * misbehaves.
 */
function installPidFileCleanup(pidFile: string): void {
  const cleanup = () => {
    try {
      unlinkSync(pidFile);
    } catch {
      // Already gone or permission-denied — nothing actionable here.
    }
  };
  process.once("SIGINT", cleanup);
  process.once("SIGTERM", cleanup);
  // Also run on a clean (`exit(0)`) path so crashes leave a stale pid-file
  // that `--status` can detect via liveness check, but orderly shutdown
  // (e.g. startServer returned) still cleans up.
  process.once("beforeExit", cleanup);
}

/**
 * Commander lowercases a long option name into camelCase for `cmdOpts`. The
 * sentinel flag `--__detached-child` maps to `__detachedChild` on the opts
 * bag; we derive that programmatically so the constant stays the single
 * source of truth.
 */
function toCamelCase(longFlag: string): string {
  return longFlag
    .replace(/^-+/, "")
    .replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}

// ─────────────────────────────────────────────────────────────────────────────
// Completion Command (shell completions)
// ─────────────────────────────────────────────────────────────────────────────

function wireCompletionCommand(program: Command): void {
  const completionCmd = program
    .command("completion")
    .description("Shell completion scripts");

  // Output completion script
  completionCmd
    .command("output <shell>", { isDefault: true })
    .description("Output completion script for a shell (bash, zsh, fish)")
    .action(async (shell: string) => {
      const { completionOutput, SUPPORTED_SHELLS } =
        await import("./commands/completion/index.js");

      if (!SUPPORTED_SHELLS.includes(shell as "bash" | "zsh" | "fish")) {
        throw new CliError(
          "VALIDATION",
          `Unsupported shell: ${shell}. Supported: ${SUPPORTED_SHELLS.join(", ")}`
        );
      }

      const script = completionOutput({
        shell: shell as "bash" | "zsh" | "fish",
      });
      process.stdout.write(script);
    });

  // Install completion to user's shell config
  completionCmd
    .command("install")
    .description("Auto-install completion to shell config")
    .option(
      "-s, --shell <shell>",
      "shell to install for (bash, zsh, fish) - auto-detected if omitted"
    )
    .option("--json", "JSON output")
    .action(async (...args: unknown[]) => {
      // Last arg is the Command object - use optsWithGlobals for subcommands
      const cmd = args[args.length - 1] as Command;
      const cmdOpts = cmd.optsWithGlobals<{ shell?: string; json?: boolean }>();
      const { shell, json } = cmdOpts;

      const { completionInstall, SUPPORTED_SHELLS } =
        await import("./commands/completion/index.js");

      if (
        shell &&
        !SUPPORTED_SHELLS.includes(shell as "bash" | "zsh" | "fish")
      ) {
        throw new CliError(
          "VALIDATION",
          `Unsupported shell: ${shell}. Supported: ${SUPPORTED_SHELLS.join(", ")}`
        );
      }

      await completionInstall({
        shell: shell as "bash" | "zsh" | "fish" | undefined,
        json: Boolean(json),
      });
    });
}
