GitNexus/gitnexus/src/cli/index.ts
Malik 859e4b75a4
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(cli): --limit i18n, 0/negative guard, and correct truncation paths (#2310)
* fix: add --limit i18n, negative guard, correct property paths, and zh-CN translations

- Add i18n keys for context/impact/cypher/detect-changes --limit options
- Add zh-CN translations for all 4 --limit option descriptions
- Add Math.max(0, parseInt()) guard to prevent negative --limit
- Fix ALL property path mismatches discovered by audit:
  - context: callers/callees → incoming.calls/outgoing.calls+accesses
  - impact: upstream/downstream → affected_processes/affected_modules/byDepth
  - cypher: rows → row_count cap (rows embedded in markdown string)
  - detect-changes: affected_flows → affected_processes
- Change query command from required to optional positional arg with -q alias
- Update @ladybugdb/core from ^0.16.1 to ^0.17.1
- Update typescript from ^5.4.5 to ^5.9.3

* test: add E2E tests for --limit flag across all 5 CLI commands

Tests context, impact, cypher, detect-changes, and query with
--limit 1, baseline comparison, and --limit 0 (falsy/no-op).

detect-changes output is formatted text (not JSON), so those
tests count symbol lines matching 'Type name -> filePath' pattern.

14 tests, all passing. No regressions in 6455 existing tests.

* fix: address Copilot review feedback on --limit guards

- Add Math.max(0, ...) guard to queryCommand limit parsing
- Change if(limit) to if(limit !== undefined) in all 5 commands
  (prevents --limit 0 from being treated as falsy/no-op)
- Make queryText parameter optional (Commander may pass undefined)
- Fix usage error strings: --search to -q, --query (en + zh-CN)

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(cli): centralize --limit parsing, slice cypher markdown, fix usage text

Address PR review feedback on --limit handling:

- Add a shared parseLimit() helper (Number.isInteger(n) && n > 0), used by all
  5 tool commands. Non-numeric / 0 / negative --limit now means "no limit"
  instead of the `options.limit ? Math.max(0, parseInt(...)) : undefined` path,
  where a string like "abc" is truthy and yields NaN -> slice(0, NaN) -> the
  guardrail commands (impact/context/detect-changes) silently emptied results
  with exit 0.
- cypher: slice the markdown table to --limit data rows so the reported
  row_count matches what is actually printed (was capping row_count while
  printing every row).
- Fix query usage string: [search_query] (optional positional) and
  `--query <text>` invocation form, not the option-definition
  `-q, --query <search_query>` syntax (en + zh-CN).
- Add an E2E regression test for non-numeric --limit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): escape newlines in cypher markdown cells

A multi-line cell value (e.g. a symbol's `content`) was rendered with raw
newlines via String(v), so one logical row spanned multiple physical lines.
That corrupts the markdown table and breaks `cypher --limit`'s line-based
slice (it kept the wrong number of rows, often zero, while row_count
over-claimed). Collapse newlines in formatCypherAsMarkdown so one physical
line == one row; the existing CLI slice is now correct and the pre-existing
un---limited corruption is fixed too. (#2310 review)

* test(cli): de-vacuum the --limit truncation tests

The truncation it()s used the repo-banned vacuous-pass pattern (early-return
on status===null, assertions guarded by if(Array.isArray), bounds-only
toBeLessThanOrEqual — DoD.md:82) against `validateInput`, which has only 1
caller, so context/impact/query --limit 1 compared 1>=1 and stayed green even
if the slice were deleted. Rewrite with unconditional, exact assertions and
target `logMessage` (2 callers, 4 processes) so the no-limit baseline truly
exceeds the limit; detect-changes now mutates two real function bodies (two
changed symbols). Adds a multi-line-cell cypher --limit regression. (#2310)

* test(ci): run cli-limit-e2e in the cross-platform matrix

The --limit E2E suite spawns the real CLI (child_process) but was not in
SPAWN_CLI, so it ran only on Ubuntu — the cross-platform check only fails on
listed-but-missing files, not the reverse (TESTING.md §Cross-platform). Register
it so the --limit regression guard also runs on Windows/macOS, where path
separators, CRLF and the formatted-output arrow differ. (#2310)

* fix(cli): document impact --limit affected-list cap, drop dead byDepth re-slice

`impact --limit` also caps affected_processes/modules, but the help only
mentioned the per-depth cap — so JSON consumers reading the affected lists got
a silently-truncated array. Update en + zh-CN + the command description to say
so. Also remove the client-side byDepth re-slice: the backend already
paginates byDepth to the same limit (paginationLimit = clamp(limit,1,10000),
offset applied backend-side), so the client slice was a guaranteed no-op. (#2310)

* fix(cli): reconcile detect-changes --limit summary, list, and overflow

formatDetectChangesResult computed the "... and N more" overflow from the
already---limit-sliced array length, so under `--limit` the header (true
summary total), the listed rows, and the marker disagreed — e.g. "2 symbols"
in the header but a list of 1 with no marker. Base the overflow on the true
summary.changed_count / affected_count instead, and add the same marker to the
affected-processes list, so header + list + marker stay consistent. (#2310)

* feat(cli): add -l shorthand to impact --limit

The PR added the -l alias to context/cypher/detect-changes but left impact on
the long --limit only, so `impact -l 5` errored while `context -l 5` worked.
Add -l for parity and update the help-i18n OPTION_DESCRIPTION_KEYS key to the
new `-l, --limit <n>` flag string so the description still resolves. (#2310)

* fix(cli): bound all context --limit array categories

context --limit sliced only incoming.calls / outgoing.calls / outgoing.accesses
/ processes, leaving the other relType buckets unbounded — notably
incoming.accesses (bounded on outgoing but not incoming) plus imports/extends/
uses/… and typed_properties. Replace the hardcoded slices with a generic loop
over every array-valued bucket under incoming/outgoing, plus typed_properties
and processes, so --limit caps the whole context payload. (#2310)

* refactor(cli): parse --offset with a parseLimit-style helper

impactCommand parsed --offset with the legacy parseInt/Number.isFinite idiom
while --limit had moved to parseLimit, leaving two parsing styles side by side.
Add a sibling parseOffset helper (non-negative — offset 0 is valid) and use it,
so both options share one idiom; as a bonus it now rejects negative/fractional
offsets instead of forwarding them to the backend. (#2310)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:16:36 +01:00

427 lines
19 KiB
JavaScript

#!/usr/bin/env node
// Heap re-spawn removed — only analyze.ts needs the 8GB heap (via its own ensureHeap()).
// Removing it from here improves MCP server startup time significantly.
import { Command } from 'commander';
import { createRequire } from 'node:module';
import { createLazyAction, createLbugLazyAction } from './lazy-action.js';
import { EMBEDDING_DIMS_ERROR, normalizeEmbeddingDims } from './embedding-dims.js';
import { registerGroupCommands } from './group.js';
import { localizeCliHelp } from './help-i18n.js';
import { t } from './i18n/index.js';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json');
const program = new Command();
function collectCodingAgents(value: string, previous: string[] | undefined): string[] {
return [...(previous ?? []), ...value.split(',')];
}
program.name('gitnexus').description('GitNexus local CLI and MCP server').version(pkg.version);
program
.command('setup')
.description(
'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, Codex',
)
.option(
'-c, --coding-agent <agents>',
'Configure only these coding agents (comma-separated or repeatable)',
collectCodingAgents,
)
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
program
.command('uninstall')
.description(
'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
)
.option('-f, --force', 'Apply the changes (default is a dry-run preview)')
.action(createLazyAction(() => import('./uninstall.js'), 'uninstallCommand'));
// Baseline of GITNEXUS_EMBEDDING_DIMS captured by the analyze preAction hook
// before it overwrites the var, so the postAction hook can restore it. The
// analyzeCommand env snapshot is taken AFTER this hook runs, so it cannot undo
// the hook's write on its own — without this restore a CLI --embedding-dims
// would leak into a later in-process program.parseAsync (tests / long-running
// hosts). Single-shot CLI exits the process, making the restore a no-op there.
let dimsEnvBaseline: string | undefined;
let dimsEnvCaptured = false;
program
.command('analyze [path]')
.description('Index a repository (full analysis)')
.option('-f, --force', 'Force full re-index even if up to date')
.option('--repair-fts', 'Repair/rebuild search FTS indexes without full re-analysis')
.option(
'--embeddings [limit]',
'Enable embedding generation for semantic search (off by default). ' +
'Optional [limit] overrides the 50,000-node safety cap; pass 0 to disable the cap entirely.',
)
.option(
'--drop-embeddings',
'Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` ' +
'preserves any embeddings already present in the index.',
)
.option(
'--skills',
'Generate repo-specific skill files from detected communities ' +
'(no-op when --index-only is also set).',
)
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
.option(
'--pdg',
'Build the control-flow-graph / PDG substrate (BasicBlock nodes + CFG edges) ' +
'for supported languages. Opt-in; off by default. (#2081 M1)',
)
.option(
'--default-branch <branch>',
'Default branch used in the generated regression-compare example (base_ref). ' +
'Falls back to .gitnexusrc, then auto-detected origin/HEAD, then "main".',
)
.option(
'--branch <name>',
'Index the working tree under a specific branch slot (multi-branch indexing). ' +
'Defaults to the checked-out branch; the primary/first-indexed branch keeps the ' +
'flat index and others get their own. Distinct from --default-branch (cosmetic base_ref).',
)
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
.option(
'--skip-skills',
'Skip installing standard GitNexus skill files under .claude/skills/gitnexus/. ' +
'Does not suppress community skills from --skills (those use .claude/skills/generated/). ' +
'Use --index-only to skip all AI-context file injection.',
)
.option('--index-only', 'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)')
.option(
'--skip-git',
'Treat the provided path/cwd as the index root and skip parent git-root discovery',
)
.option(
'--name <alias>',
'Register this repo under a custom name in ~/.gitnexus/registry.json ' +
'(disambiguates repos whose paths share a basename, e.g. two different .../app folders)',
)
.option(
'--allow-duplicate-name',
'Register this repo even if another path already uses the same --name alias. ' +
'Leaves `-r <name>` ambiguous for the two paths; use -r <path> to disambiguate.',
)
.option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
.option(
'--max-file-size <kb>',
'Skip files larger than this (KB). Default: 512. Hard cap: 32768 (tree-sitter limit).',
)
.option(
'--worker-timeout <seconds>',
'Worker sub-batch idle timeout before retry/fallback. Default: 30.',
)
.option(
'--wal-checkpoint-threshold <bytes>',
'LadybugDB WAL auto-checkpoint threshold in bytes during analyze ' +
'(integer >= -1; default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).',
)
.option(
'--workers <n>',
'Parse worker pool size (>=1). Default: cores-1 capped at 16, auto-sized to the repo.',
)
.option('--embedding-threads <n>', 'Limit local ONNX embedding CPU threads')
.option('--embedding-batch-size <n>', 'Number of nodes per embedding batch')
.option('--embedding-sub-batch-size <n>', 'Number of chunks per embedding model call')
.option('--embedding-device <device>', 'Embedding device: auto, cpu, dml, cuda, or wasm')
.option(
'--embedding-base-url <url>',
'OpenAI-compatible embeddings base URL including the /v1 suffix ' +
'(e.g. http://10.219.32.29:11434/v1 for Ollama). Overrides GITNEXUS_EMBEDDING_URL.',
)
.option(
'--embedding-model <model>',
'Embedding model name (e.g. qwen3-embedding:8b). Overrides GITNEXUS_EMBEDDING_MODEL.',
)
.option(
'--embedding-auth-token <token>',
'Bearer token for the embeddings endpoint (omit for unauthenticated servers like Ollama). ' +
'Overrides GITNEXUS_EMBEDDING_API_KEY.',
)
.option(
'--embedding-dims <number>',
'Embedding vector dimensions (positive integer; e.g. 4096 for Qwen3-Embedding-8B). ' +
'Must match what the index was built with. Overrides GITNEXUS_EMBEDDING_DIMS.',
)
.addHelpText('after', () => t('help.analyze.environment'))
.hook('preAction', (thisCommand: Command) => {
// ONLY GITNEXUS_EMBEDDING_DIMS must be set here: schema.ts reads it at
// module-load time during the lazy import('./analyze.js') below (via the
// static chain analyze.ts → run-analyze.ts → schema.ts), so deferring to
// analyzeCommandImpl would be too late. URL / MODEL / API_KEY are read
// lazily at runtime (readConfig), so analyzeCommandImpl is their sole
// setter — keeping them out of this hook means they fall under the impl's
// env snapshot/restore and don't leak across in-process invocations.
const dimsOpt = thisCommand.opts()['embeddingDims'];
if (dimsOpt !== undefined) {
// Validate + normalize BEFORE writing the env var: schema.ts throws on a
// bad value at module-load, which — on the synchronous program.parse()
// path, before the analyze fatal-handlers are installed — would surface
// as a raw unhandled rejection instead of this friendly message.
const dims = normalizeEmbeddingDims(String(dimsOpt));
if (dims === null) {
process.stderr.write(`\n ${EMBEDDING_DIMS_ERROR}\n\n`);
process.exit(1);
}
dimsEnvBaseline = process.env.GITNEXUS_EMBEDDING_DIMS;
dimsEnvCaptured = true;
process.env.GITNEXUS_EMBEDDING_DIMS = dims;
}
})
.hook('postAction', () => {
// Restore the pre-hook GITNEXUS_EMBEDDING_DIMS so a CLI override doesn't
// persist into a later program.parseAsync in the same process. (Fires on a
// microtask after a successful parse; the crash path never reaches here,
// but the hook validates dims before writing, so there's nothing to undo.)
if (!dimsEnvCaptured) return;
dimsEnvCaptured = false;
if (dimsEnvBaseline === undefined) {
delete process.env.GITNEXUS_EMBEDDING_DIMS;
} else {
process.env.GITNEXUS_EMBEDDING_DIMS = dimsEnvBaseline;
}
})
.action(createLbugLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
program
.command('index [path...]')
.description(
'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
)
.option('-f, --force', 'Register even if meta.json is missing (stats will be empty)')
.option('--allow-non-git', 'Allow registering folders that are not Git repositories')
.action(createLazyAction(() => import('./index-repo.js'), 'indexCommand'));
program
.command('serve')
.description('Start local HTTP server for web UI connection')
.option('-p, --port <port>', 'Port number', '4747')
.option('--host <host>', 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)')
.action(createLbugLazyAction(() => import('./serve.js'), 'serveCommand'));
program
.command('mcp')
.description(
'Start MCP server. Default: stdio. Use --http for a remote HTTP server ' +
'(Streamable HTTP at POST /mcp + legacy SSE at GET /sse, POST /messages).',
)
.option('--http', 'Serve MCP over HTTP instead of stdio (for remote clients)')
.option('-p, --port <port>', 'HTTP port (only with --http). Default: 3000', '3000')
.option(
'--host <host>',
'HTTP bind address (only with --http). Default: 127.0.0.1 (loopback). Use 0.0.0.0 to expose to all interfaces.',
'127.0.0.1',
)
.option(
'--auth-token <token>',
'Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.',
)
.action(createLbugLazyAction(() => import('./mcp.js'), 'mcpCommand'));
program
.command('list')
.description('List all indexed repositories')
.action(createLazyAction(() => import('./list.js'), 'listCommand'));
program
.command('status')
.description('Show index status for current repo')
.action(createLazyAction(() => import('./status.js'), 'statusCommand'));
program
.command('doctor')
.description('Show runtime platform capabilities and embedding configuration')
.action(createLazyAction(() => import('./doctor.js'), 'doctorCommand'));
program
.command('clean')
.description('Delete GitNexus index for current repo')
.option('-f, --force', 'Skip confirmation prompt')
.option('--all', 'Clean all indexed repos')
.option('--branch <name>', 'Delete only the named branch index (not the primary)')
.option('--lbug-sidecars', 'Clean quarantined LadybugDB missing-shadow WAL sidecars')
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
program
.command('remove <target>')
.description(
'Delete the GitNexus index for a registered repo (by alias, name, or absolute path). ' +
'Unlike `clean`, does not require being inside the repo. Idempotent on unknown targets.',
)
.option('-f, --force', 'Skip confirmation prompt')
.action(createLazyAction(() => import('./remove.js'), 'removeCommand'));
program
.command('wiki [path]')
.description('Generate repository wiki from knowledge graph')
.option('-f, --force', 'Force full regeneration even if up to date')
.option(
'--provider <provider>',
'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)',
)
.option('--model <model>', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)')
.option(
'--base-url <url>',
'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1',
)
.option('--api-key <key>', 'LLM API key or Azure api-key (saved to ~/.gitnexus/config.json)')
.option(
'--api-version <version>',
'Azure api-version query param, e.g. 2024-10-21 (legacy Azure API only)',
)
.option(
'--reasoning-model',
'Mark deployment as reasoning model (o1/o3/o4-mini) — strips temperature, uses max_completion_tokens',
)
.option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)')
.option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
.option('--timeout <seconds>', 'LLM request timeout in seconds (default: disabled)')
.option('--retries <n>', 'Max LLM retry attempts per request (default: 3)')
.option('--gist', 'Publish wiki as a public GitHub Gist after generation')
.option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)')
.option('--review', 'Stop after grouping to review module structure before generating pages')
.option(
'--lang <lang>',
'Output language for generated documentation (e.g. english, chinese, spanish, japanese)',
)
.action(createLbugLazyAction(() => import('./wiki.js'), 'wikiCommand'));
program
.command('augment <pattern>')
.description('Augment a search pattern with knowledge graph context (used by hooks)')
.action(createLbugLazyAction(() => import('./augment.js'), 'augmentCommand'));
program
.command('publish [path]')
.description(
'Notify the understand-quickly registry that this repo has a fresh GitNexus index. ' +
'Opt-in: requires UNDERSTAND_QUICKLY_TOKEN (fine-grained PAT with ' +
'`Repository dispatches: write` on looptech-ai/understand-quickly). ' +
'No-op without the token. See https://github.com/looptech-ai/understand-quickly.',
)
.option('--id <owner/repo>', 'Override the registry id (defaults to the origin remote)')
.option('--skip-git', 'Treat cwd as the repo root and skip parent git-root discovery')
.action(createLazyAction(() => import('./publish.js'), 'publishCommand'));
// ─── Direct Tool Commands (no MCP overhead) ────────────────────────
// These invoke LocalBackend directly for use in eval, scripts, and CI.
program
.command('query [search_query]')
.description('Search the knowledge graph for execution flows related to a concept')
.option('-q, --query <text>', 'Search query (alias for positional argument)')
.option('-r, --repo <name>', 'Target repository (omit if only one indexed)')
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
.option('-c, --context <text>', 'Task context to improve ranking')
.option('-g, --goal <text>', 'What you want to find')
.option('-l, --limit <n>', 'Max processes to return (default: 5)')
.option('--content', 'Include full symbol source code')
.action(createLbugLazyAction(() => import('./tool.js'), 'queryCommand'));
program
.command('context [name]')
.description('360-degree view of a code symbol: callers, callees, processes')
.option('-r, --repo <name>', 'Target repository')
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
.option('-f, --file <path>', 'File path to disambiguate common names')
.option('-l, --limit <n>', 'Max callers/callees/processes to return')
.option('--content', 'Include full symbol source code')
.action(createLbugLazyAction(() => import('./tool.js'), 'contextCommand'));
program
.command('impact [target]')
.description('Blast radius analysis: what breaks if you change a symbol')
.option('-d, --direction <dir>', 'upstream (dependants) or downstream (dependencies)', 'upstream')
.option(
'--mode <mode>',
'Engine: callgraph (default) or pdg (opt-in, intra-procedural; needs analyze --pdg)',
'callgraph',
)
.option(
'--line <number>',
'1-based source line — PDG-only statement anchor (--mode pdg): slice the dependence from the statement at this line and show what depends on it',
)
.option('-r, --repo <name>', 'Target repository')
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
.option('-f, --file <path>', 'File path to disambiguate common names')
.option(
'--kind <kind>',
'Kind filter to disambiguate common names (e.g. Function, Class, Method)',
)
.option('--depth <n>', 'Max relationship depth (default: 3)')
.option('--include-tests', 'Include test files in results')
.option(
'-l, --limit <n>',
'Max symbols per depth level and affected processes/modules to return (default: 100)',
)
.option('--offset <n>', 'Skip N symbols per depth level for pagination')
.option('--summary-only', 'Return counts and risk only, omit symbol list')
.action(createLbugLazyAction(() => import('./tool.js'), 'impactCommand'));
program
.command('trace <from> <to>')
.description('Find the shortest directed path between two symbols (call + class-member edges)')
.option('--from-uid <uid>', 'Source symbol UID (zero-ambiguity)')
.option('--from-file <path>', 'Source file path hint')
.option('--to-uid <uid>', 'Target symbol UID (zero-ambiguity)')
.option('--to-file <path>', 'Target file path hint')
.option('--depth <n>', 'Max path length in hops (default: 10)')
.option('--include-tests', 'Include test files in results')
.option('-r, --repo <name>', 'Target repository')
.option('--branch <name>', 'Scope to a specific branch index')
.action(createLbugLazyAction(() => import('./tool.js'), 'traceCommand'));
program
.command('cypher <query>')
.description('Execute raw Cypher query against the knowledge graph')
.option('-r, --repo <name>', 'Target repository')
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
.option('-l, --limit <n>', 'Max result rows to return')
.action(createLbugLazyAction(() => import('./tool.js'), 'cypherCommand'));
program
.command('detect-changes')
.alias('detect_changes')
.description('Map git diff hunks to indexed symbols and affected execution flows')
.option('-s, --scope <scope>', 'What to analyze: unstaged, staged, all, or compare', 'unstaged')
.option('-b, --base-ref <ref>', 'Branch/commit for compare scope (e.g. main)')
.option('-r, --repo <name>', 'Target repository')
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
.option('-l, --limit <n>', 'Max changed symbols to return')
.action(createLbugLazyAction(() => import('./tool.js'), 'detectChangesCommand'));
program
.command('check')
.description('Run structural checks against the indexed graph')
.option('--cycles', 'Detect circular imports and fail when any are found')
.option('--json', 'Emit machine-readable JSON')
.option('-r, --repo <name>', 'Target repository')
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
.action(createLbugLazyAction(() => import('./tool.js'), 'checkCommand'));
// ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
program
.command('eval-server')
.description('Start lightweight HTTP server for fast tool calls during evaluation')
.option('-p, --port <port>', 'Port number', '4848')
.option(
'--host <host>',
'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)',
)
.option('--idle-timeout <seconds>', 'Auto-shutdown after N seconds idle (0 = disabled)', '0')
.action(createLbugLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
registerGroupCommands(program);
localizeCliHelp(program);
program.parse(process.argv);