diff --git a/src/cli.ts b/src/cli.ts index 41a8294f0..b459cde3d 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -27,7 +27,7 @@ for (const op of operations) { } // CLI-only commands that bypass the operation layer -const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder']); +const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'search', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'calibration']); // CLI-only commands whose handlers print their own --help text. These are // excluded from the generic short-circuit so detailed per-command and // per-subcommand usage stays reachable. @@ -76,6 +76,11 @@ async function main() { // Per-command --help if (hasHelpFlag(subArgs)) { + if (command === 'search' && isSearchCliOnlySubcommand(subArgs)) { + const { SEARCH_USAGE } = await import('./commands/search.ts'); + console.log(SEARCH_USAGE); + return; + } const op = cliOps.get(command); if (op) { printOpHelp(op); @@ -87,8 +92,11 @@ async function main() { } } - // CLI-only commands - if (CLI_ONLY.has(command)) { + // CLI-only commands. `search` is special: it is both a shared keyword-search + // operation (`gbrain search `) and a CLI-only dashboard namespace + // (`gbrain search modes|stats|tune`). Route only the dashboard subcommands + // here so ordinary keyword search keeps using the operation layer. + if (CLI_ONLY.has(command) && (command !== 'search' || isSearchCliOnlySubcommand(subArgs))) { await handleCliOnly(command, subArgs); return; } @@ -196,6 +204,10 @@ function printCliOnlyHelp(command: string) { console.log(`gbrain ${command} - run gbrain --help for the full command list.`); } +function isSearchCliOnlySubcommand(args: string[]): boolean { + return args[0] === 'modes' || args[0] === 'stats' || args[0] === 'tune'; +} + /** * v0.31.1 (Issue #734, CDX-1): route a shared op through the remote MCP * server instead of running it locally. Called from main() when diff --git a/src/commands/search.ts b/src/commands/search.ts index 6957f08cc..236571f7a 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -409,7 +409,7 @@ function buildRevertCommand(r: TuneRecommendation): string { return r.apply_command; } -const USAGE = `Usage: gbrain search [flags] +export const SEARCH_USAGE = `Usage: gbrain search [flags] Subcommands: modes [--json] Show active mode, bundles, and per-knob source. @@ -430,8 +430,8 @@ export async function runSearch(engine: BrainEngine, args: string[]): Promise { expect(cliSource).toContain("'export'"); expect(cliSource).toContain("'embed'"); expect(cliSource).toContain("'files'"); + expect(cliSource).toContain("'calibration'"); + }); + + test('search dashboard subcommands are routed without shadowing keyword search', () => { + expect(cliSource).toContain("'search'"); + expect(cliSource).toContain("function isSearchCliOnlySubcommand"); + expect(cliSource).toContain("args[0] === 'modes' || args[0] === 'stats' || args[0] === 'tune'"); + expect(cliSource).toContain("command !== 'search' || isSearchCliOnlySubcommand(subArgs)"); }); test('has formatResult function for CLI output', () => { @@ -185,6 +193,70 @@ describe('CLI dispatch integration', () => { } }); + test('calibration --help is routed as CLI-only without requiring a brain', async () => { + const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-help-')); + try { + const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'calibration', '--help'], { + cwd: repoRoot, + stdout: 'pipe', + stderr: 'pipe', + env: isolatedEnv(home), + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + expect(stdout).toContain('Usage: gbrain calibration'); + expect(stderr).not.toContain('Unknown command: calibration'); + expect(stderr).not.toContain('No brain configured'); + expect(exitCode).toBe(0); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test('search dashboard help does not fall through to keyword-search op help', async () => { + const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-help-')); + try { + const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'search', 'modes', '--help'], { + cwd: repoRoot, + stdout: 'pipe', + stderr: 'pipe', + env: isolatedEnv(home), + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + expect(stdout).toContain('Usage: gbrain search [flags]'); + expect(stdout).toContain('gbrain search modes --reset'); + expect(stdout).not.toContain('Usage: gbrain search '); + expect(stderr).not.toContain('No brain configured'); + expect(exitCode).toBe(0); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test('search query help still routes to the shared keyword-search operation', async () => { + const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-help-')); + try { + const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'search', 'needle', '--help'], { + cwd: repoRoot, + stdout: 'pipe', + stderr: 'pipe', + env: isolatedEnv(home), + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + expect(stdout).toContain('Usage: gbrain search '); + expect(stdout).not.toContain('Usage: gbrain search [flags]'); + expect(stderr).not.toContain('No brain configured'); + expect(exitCode).toBe(0); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + test('--help prints global help', async () => { const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', '--help'], { cwd: repoRoot,