import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getKtxCliPackageInfo, rendererUnavailableVizFallback, renderMemoryFlowTui, resolveVizFallback, runKtxCli, sanitizeMemoryFlowTuiError, startLiveMemoryFlowTui, warnVizFallbackOnce, } from './index.js'; const require = createRequire(import.meta.url); function makeIo(options: { stdoutIsTty?: boolean } = {}) { let stdout = ''; let stderr = ''; return { io: { stdout: { isTTY: options.stdoutIsTty, write: (chunk: string) => { stdout += chunk; }, }, stderr: { write: (chunk: string) => { stderr += chunk; }, }, }, stdout: () => stdout, stderr: () => stderr, }; } describe('getKtxCliPackageInfo', () => { it('identifies the CLI package and its context dependency', () => { expect(getKtxCliPackageInfo()).toEqual({ name: '@ktx/cli', version: '0.0.0-private', contextPackageName: '@ktx/context', }); }); it('exports package metadata for package managers and runtime diagnostics', () => { const packageJson = require('@ktx/cli/package.json') as { name: string; version: string }; expect(packageJson).toMatchObject({ name: '@ktx/cli', version: '0.0.0-private', }); }); }); describe('memory-flow renderer exports', () => { it('exports runtime-agnostic renderer entry points for hosted terminal clients', () => { expect(renderMemoryFlowTui).toBeTypeOf('function'); expect(startLiveMemoryFlowTui).toBeTypeOf('function'); expect(sanitizeMemoryFlowTuiError('token=abc123')).toBe('[redacted]'); }); it('exports shared visualization fallback helpers for hosted terminal clients', () => { const fallback = resolveVizFallback({ stdout: { isTTY: true }, stderr: { write: vi.fn() } }, { TERM: 'dumb' }); expect(fallback).toEqual({ shouldDegrade: true, reason: 'term-dumb', message: 'TERM=dumb does not support the visual renderer', }); expect(rendererUnavailableVizFallback()).toEqual({ shouldDegrade: true, reason: 'renderer-unavailable', message: 'the terminal renderer is unavailable', }); expect(warnVizFallbackOnce).toBeTypeOf('function'); }); }); describe('runKtxCli', () => { let tempDir: string; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-')); }); afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); it('prints version information', async () => { const testIo = makeIo(); await expect(runKtxCli(['--version'], testIo.io)).resolves.toBe(0); expect(testIo.stdout()).toBe('@ktx/cli 0.0.0-private\n'); expect(testIo.stderr()).toBe(''); }); it('prints the May 6 public command surface in root help', async () => { const testIo = makeIo(); await expect(runKtxCli(['--help'], testIo.io)).resolves.toBe(0); expect(testIo.stdout()).toContain('Usage: ktx [options] [command]'); for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'serve', 'status']) { expect(testIo.stdout()).toContain(`${command}`); } for (const removed of ['demo', 'init', 'connect', 'scan', 'ask', 'knowledge', 'agent', 'completion']) { expect(testIo.stdout()).not.toContain(`${removed} [`); expect(testIo.stdout()).not.toContain(`${removed} `); } expect(testIo.stdout()).toContain('--project-dir '); expect(testIo.stdout()).toContain('KTX_PROJECT_DIR'); expect(testIo.stdout()).toContain('--debug'); expect(testIo.stdout()).not.toContain('--' + 'verbose'); expect(testIo.stdout()).toContain('Advanced:'); expect(testIo.stdout()).toContain('ktx dev'); expect(testIo.stderr()).toBe(''); }); it('exposes demo under setup help instead of root help', async () => { const testIo = makeIo(); await expect(runKtxCli(['setup', '--help'], testIo.io)).resolves.toBe(0); expect(testIo.stdout()).toContain('Usage: ktx setup [options] [command]'); expect(testIo.stdout()).toContain('demo'); expect(testIo.stdout()).toContain('Run the packaged KTX demo from setup'); expect(testIo.stdout()).not.toContain('--skip-llm'); expect(testIo.stdout()).not.toContain('--skip-embeddings'); expect(testIo.stdout()).not.toContain('--embedding-model'); expect(testIo.stdout()).not.toContain('--embedding-dimensions'); expect(testIo.stdout()).not.toContain('--embedding-base-url'); expect(testIo.stderr()).toBe(''); }); it('prints help for bare ktx outside a TTY', async () => { const setup = vi.fn(async () => 0); const testIo = makeIo({ stdoutIsTty: false }); await expect(runKtxCli([], testIo.io, { setup })).resolves.toBe(0); expect(testIo.stdout()).toContain('Usage: ktx [options] [command]'); expect(setup).not.toHaveBeenCalled(); expect(testIo.stderr()).toBe(''); }); it('starts setup for bare ktx in a TTY when no project is discoverable', async () => { const { mkdtemp, realpath, rm } = await import('node:fs/promises'); const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const originalCwd = process.cwd(); const tempDir = await mkdtemp(join(tmpdir(), 'ktx-bare-setup-')); const setup = vi.fn(async () => 0); const testIo = makeIo({ stdoutIsTty: true }); const previousProjectDir = process.env.KTX_PROJECT_DIR; const expectedProjectDir = await realpath(tempDir); try { delete process.env.KTX_PROJECT_DIR; process.chdir(tempDir); await expect(runKtxCli([], testIo.io, { setup })).resolves.toBe(0); expect(setup).toHaveBeenCalledWith( { command: 'run', projectDir: expectedProjectDir, mode: 'auto', agents: false, agentScope: 'project', agentInstallMode: 'cli', skipAgents: false, inputMode: 'auto', yes: false, skipLlm: false, skipEmbeddings: false, databaseSchemas: [], skipDatabases: false, skipSources: false, }, testIo.io, ); expect(testIo.stdout()).not.toContain('Usage: ktx [options] [command]'); expect(testIo.stderr()).toBe(''); } finally { process.chdir(originalCwd); if (previousProjectDir === undefined) { delete process.env.KTX_PROJECT_DIR; } else { process.env.KTX_PROJECT_DIR = previousProjectDir; } await rm(tempDir, { recursive: true, force: true }); } }); it('prints help without project status for bare ktx in a TTY when a project is discoverable', async () => { const { mkdtemp, realpath, rm, writeFile } = await import('node:fs/promises'); const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const originalCwd = process.cwd(); const previousProjectDir = process.env.KTX_PROJECT_DIR; const tempDir = await mkdtemp(join(tmpdir(), 'ktx-bare-existing-')); const setup = vi.fn(async () => 0); const testIo = makeIo({ stdoutIsTty: true }); const expectedProjectDir = await realpath(tempDir); try { delete process.env.KTX_PROJECT_DIR; await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8'); process.chdir(tempDir); await expect(runKtxCli([], testIo.io, { setup })).resolves.toBe(0); expect(testIo.stdout()).toContain('Usage: ktx [options] [command]'); expect(testIo.stdout()).not.toContain(`Project: ${expectedProjectDir}`); expect(setup).not.toHaveBeenCalled(); } finally { process.chdir(originalCwd); if (previousProjectDir === undefined) { delete process.env.KTX_PROJECT_DIR; } else { process.env.KTX_PROJECT_DIR = previousProjectDir; } await rm(tempDir, { recursive: true, force: true }); } }); it('does not invoke status for bare ktx in a TTY when status would fail', async () => { const setup = vi.fn(async () => { throw new Error('Unsupported ingest.llm: use top-level llm.provider, llm.models, and ingest.workUnits'); }); const testIo = makeIo({ stdoutIsTty: true }); const previousProjectDir = process.env.KTX_PROJECT_DIR; try { process.env.KTX_PROJECT_DIR = tempDir; await expect(runKtxCli([], testIo.io, { setup })).resolves.toBe(0); expect(testIo.stdout()).toContain('Usage: ktx [options] [command]'); expect(setup).not.toHaveBeenCalled(); expect(testIo.stderr()).toBe(''); } finally { if (previousProjectDir === undefined) { delete process.env.KTX_PROJECT_DIR; } else { process.env.KTX_PROJECT_DIR = previousProjectDir; } } }); it('rejects removed verbose global option through Commander', async () => { const testIo = makeIo(); const removedVerboseOption = '--' + 'verbose'; await expect(runKtxCli([removedVerboseOption, 'connection', 'list'], testIo.io)).resolves.toBe(1); expect(testIo.stderr()).toContain(`unknown option '${removedVerboseOption}'`); expect(testIo.stdout()).toBe(''); }); it('prints a zsh completion function', async () => { const testIo = makeIo(); const zshWords = '$' + '{words[@]}'; await expect(runKtxCli(['dev', 'completion', 'zsh'], testIo.io)).resolves.toBe(0); expect(testIo.stdout()).toContain('#compdef ktx'); expect(testIo.stdout()).toContain('KTX_COMPLETION_COMMAND:-ktx'); expect(testIo.stdout()).toContain(`dev __complete --shell zsh --position "$CURRENT" -- "${zshWords}"`); expect(testIo.stdout()).toContain('compdef _ktx ktx'); expect(testIo.stderr()).toBe(''); }); it('installs zsh completions into the user zsh config directory', async () => { const testIo = makeIo(); const previousHome = process.env.HOME; const previousZdotdir = process.env.ZDOTDIR; const tempHome = await mkdtemp(join(tmpdir(), 'ktx-completion-home-')); try { process.env.HOME = tempHome; delete process.env.ZDOTDIR; await expect(runKtxCli(['dev', 'completion', 'zsh', '--install'], testIo.io)).resolves.toBe(0); const completionFile = await readFile(join(tempHome, '.zfunc', '_ktx'), 'utf-8'); const zshrc = await readFile(join(tempHome, '.zshrc'), 'utf-8'); expect(completionFile).toContain('#compdef ktx'); expect(zshrc).toContain('# >>> ktx completion >>>'); expect(zshrc).toContain('_ktx_completion_command()'); expect(zshrc).toContain('"name": "ktx-workspace"'); expect(zshrc).toContain('scripts/run-ktx.mjs'); expect(zshrc).toContain("export KTX_COMPLETION_COMMAND='$(_ktx_completion_command)'"); expect(zshrc).toContain('setopt complete_aliases'); expect(zshrc).toContain('fpath=("$HOME/.zfunc" $fpath)'); expect(zshrc).toContain('autoload -Uz compinit'); expect(zshrc).toContain('compinit'); expect(testIo.stdout()).toContain('Installed zsh completion:'); expect(testIo.stdout()).toContain('Restart your shell or run: source ~/.zshrc'); expect(testIo.stderr()).toBe(''); } finally { if (previousHome === undefined) { delete process.env.HOME; } else { process.env.HOME = previousHome; } if (previousZdotdir === undefined) { delete process.env.ZDOTDIR; } else { process.env.ZDOTDIR = previousZdotdir; } await rm(tempHome, { recursive: true, force: true }); } }); it('updates zsh completion install block idempotently before existing compinit', async () => { const firstIo = makeIo(); const secondIo = makeIo(); const previousHome = process.env.HOME; const previousZdotdir = process.env.ZDOTDIR; const tempHome = await mkdtemp(join(tmpdir(), 'ktx-completion-home-')); try { process.env.HOME = tempHome; delete process.env.ZDOTDIR; await writeFile(join(tempHome, '.zshrc'), 'export EDITOR=vim\nautoload -Uz compinit\ncompinit\n', 'utf-8'); await expect(runKtxCli(['dev', 'completion', 'zsh', '--install'], firstIo.io)).resolves.toBe(0); await expect(runKtxCli(['dev', 'completion', 'zsh', '--install'], secondIo.io)).resolves.toBe(0); const zshrc = await readFile(join(tempHome, '.zshrc'), 'utf-8'); expect(zshrc.match(/# >>> ktx completion >>>/g)).toHaveLength(1); expect(zshrc.indexOf('fpath=("$HOME/.zfunc" $fpath)')).toBeLessThan(zshrc.indexOf('autoload -Uz compinit')); expect(zshrc.match(/_ktx_completion_command\(\)/g)).toHaveLength(1); expect(zshrc.match(/^compinit$/gm)).toHaveLength(1); expect(secondIo.stdout()).toContain('Updated zsh config:'); expect(firstIo.stderr()).toBe(''); expect(secondIo.stderr()).toBe(''); } finally { if (previousHome === undefined) { delete process.env.HOME; } else { process.env.HOME = previousHome; } if (previousZdotdir === undefined) { delete process.env.ZDOTDIR; } else { process.env.ZDOTDIR = previousZdotdir; } await rm(tempHome, { recursive: true, force: true }); } }); it('completes root and nested Commander command names', async () => { const rootIo = makeIo(); const connectionIo = makeIo(); await expect( runKtxCli(['dev', '__complete', '--shell', 'zsh', '--position', '2', '--', 'ktx', 'co'], rootIo.io), ).resolves.toBe(0); await expect( runKtxCli( ['dev', '__complete', '--shell', 'zsh', '--position', '3', '--', 'ktx', 'connection', 'm'], connectionIo.io, ), ).resolves.toBe(0); expect(rootIo.stdout()).toContain('connection:Add, list, test, and map data sources'); expect(rootIo.stdout()).not.toContain('__complete'); expect(connectionIo.stdout()).toContain('map:Refresh and validate BI-to-warehouse mappings'); expect(connectionIo.stdout()).toContain('mapping:Manage Metabase warehouse mappings'); expect(rootIo.stderr()).toBe(''); expect(connectionIo.stderr()).toBe(''); }); it('completes options and Commander choices', async () => { const optionIo = makeIo(); const choiceIo = makeIo(); await expect( runKtxCli( ['dev', '__complete', '--shell', 'zsh', '--position', '4', '--', 'ktx', 'connection', 'add', '--cr'], optionIo.io, ), ).resolves.toBe(0); await expect( runKtxCli( [ 'dev', '__complete', '--shell', 'zsh', '--position', '7', '--', 'ktx', 'connection', 'add', 'notion', 'docs', '--crawl-mode', '', ], choiceIo.io, ), ).resolves.toBe(0); expect(optionIo.stdout()).toContain('--crawl-mode:Notion crawl mode'); expect(choiceIo.stdout()).toContain('all_accessible'); expect(choiceIo.stdout()).toContain('selected_roots'); expect(optionIo.stderr()).toBe(''); expect(choiceIo.stderr()).toBe(''); }); it('dispatches serve stdio commands', async () => { const testIo = makeIo(); const serveStdio = vi.fn().mockResolvedValue(0); await expect( runKtxCli(['--project-dir', tempDir, 'serve', '--mcp', 'stdio', '--user-id', 'agent'], testIo.io, { serveStdio, }), ).resolves.toBe(0); expect(serveStdio).toHaveBeenCalledWith({ mcp: 'stdio', projectDir: tempDir, userId: 'agent', semanticCompute: false, semanticComputeUrl: undefined, executeQueries: false, memoryCapture: false, memoryModel: undefined, }); }); it('routes public ingest through the public ingest parser', async () => { const testIo = makeIo(); const ingest = vi.fn().mockResolvedValue(0); await expect( runKtxCli(['--project-dir', '/tmp/project', 'ingest', 'warehouse'], testIo.io, { publicIngest: ingest }), ).resolves.toBe(0); expect(ingest).toHaveBeenCalledWith( { command: 'run', projectDir: '/tmp/project', targetConnectionId: 'warehouse', all: false, json: false, inputMode: 'auto', }, testIo.io, ); }); it('prints public ingest watch help from Commander', async () => { const testIo = makeIo(); const publicIngest = vi.fn(async () => 0); const lowLevelIngest = vi.fn(async () => 0); await expect( runKtxCli(['ingest', 'watch', '--help'], testIo.io, { publicIngest, ingest: lowLevelIngest }), ).resolves.toBe(0); expect(testIo.stdout()).toContain('Usage: ktx ingest watch [options] [runId]'); expect(testIo.stdout()).toContain('[runId]'); expect(testIo.stdout()).toContain('--project-dir '); expect(testIo.stdout()).toContain('--json'); expect(testIo.stdout()).toContain('--no-input'); expect(testIo.stderr()).toBe(''); expect(publicIngest).not.toHaveBeenCalled(); expect(lowLevelIngest).not.toHaveBeenCalled(); }); it('dispatches public ingest status and watch through Commander', async () => { const statusIo = makeIo(); const watchIo = makeIo(); const publicIngest = vi.fn(async () => 0); await expect( runKtxCli(['--project-dir', tempDir, 'ingest', 'status', 'run-1', '--json', '--no-input'], statusIo.io, { publicIngest, }), ).resolves.toBe(0); await expect( runKtxCli(['--project-dir', tempDir, 'ingest', 'watch', '--no-input'], watchIo.io, { publicIngest, }), ).resolves.toBe(0); expect(publicIngest).toHaveBeenNthCalledWith( 1, { command: 'status', projectDir: tempDir, runId: 'run-1', json: true, inputMode: 'disabled', }, statusIo.io, ); expect(publicIngest).toHaveBeenNthCalledWith( 2, { command: 'watch', projectDir: tempDir, json: false, inputMode: 'disabled', }, watchIo.io, ); expect(statusIo.stderr()).toBe(''); expect(watchIo.stderr()).toBe(''); }); it('rejects standalone demo commands', async () => { const testIo = makeIo(); const demo = vi.fn().mockResolvedValue(0); await expect(runKtxCli(['demo', '--mode', 'replay', '--no-input'], testIo.io, { demo })).resolves.toBe(1); expect(testIo.stderr()).toMatch(/unknown command|error:/i); expect(demo).not.toHaveBeenCalled(); }); it('dispatches setup demo commands', async () => { const testIo = makeIo(); const demo = vi.fn().mockResolvedValue(0); await expect( runKtxCli(['--project-dir', tempDir, 'setup', 'demo', '--mode', 'replay', '--no-input'], testIo.io, { demo }), ).resolves.toBe(0); expect(demo).toHaveBeenCalledWith( { command: 'replay', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled', }, testIo.io, ); demo.mockClear(); await expect( runKtxCli(['--project-dir', tempDir, 'setup', 'demo', '--mode', 'seeded', '--no-input'], testIo.io, { demo, }), ).resolves.toBe(0); expect(demo).toHaveBeenCalledWith( { command: 'seeded', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled', }, testIo.io, ); demo.mockClear(); await expect( runKtxCli(['--project-dir', tempDir, 'setup', '--no-input', 'demo', '--mode', 'seeded'], testIo.io, { demo, }), ).resolves.toBe(0); expect(demo).toHaveBeenCalledWith( { command: 'seeded', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled', }, testIo.io, ); demo.mockClear(); await expect( runKtxCli(['--project-dir', tempDir, 'setup', 'demo', 'inspect', '--no-input'], testIo.io, { demo }), ).resolves.toBe(0); expect(demo).toHaveBeenCalledWith( { command: 'inspect', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled', }, testIo.io, ); }); it('dispatches demo ingest argv', async () => { const testIo = makeIo(); const demo = vi.fn().mockResolvedValue(0); await expect( runKtxCli(['--project-dir', tempDir, 'setup', 'demo', 'ingest', '--mode', 'full', '--no-input'], testIo.io, { demo, }), ).resolves.toBe(0); expect(demo).toHaveBeenCalledWith( { command: 'ingest', mode: 'full', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled', }, testIo.io, ); demo.mockClear(); await expect( runKtxCli(['--project-dir', tempDir, 'setup', '--no-input', 'demo', 'ingest', '--mode', 'seeded'], testIo.io, { demo, }), ).resolves.toBe(0); expect(demo).toHaveBeenCalledWith( { command: 'ingest', mode: 'seeded', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled', }, testIo.io, ); demo.mockClear(); await expect( runKtxCli( ['--project-dir', tempDir, 'setup', 'demo', 'ingest', '--mode', 'full', '--no-input', '--plain'], testIo.io, { demo, }, ), ).resolves.toBe(0); expect(demo).toHaveBeenCalledWith( { command: 'ingest', mode: 'full', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled', }, testIo.io, ); }); it('prints public ingest help without invoking ingest execution', async () => { const testIo = makeIo(); const publicIngest = vi.fn(); const lowLevelIngest = vi.fn(); await expect(runKtxCli(['ingest', '--help'], testIo.io, { publicIngest, ingest: lowLevelIngest })).resolves.toBe(0); expect(testIo.stdout()).toContain('Usage: ktx ingest [options] [connectionId]'); expect(testIo.stdout()).toContain('Build and refresh KTX context from configured sources'); expect(testIo.stdout()).toContain('status'); expect(testIo.stdout()).toContain('watch'); expect(testIo.stdout()).toContain('ktx ingest --all [options]'); expect(testIo.stdout()).toContain('ktx ingest status [runId] [options]'); expect(testIo.stdout()).toContain('ktx ingest watch [runId] [options]'); expect(testIo.stdout()).not.toContain('ktx ingest replay [options]'); expect(testIo.stdout()).toContain('--no-input'); expect(testIo.stdout()).not.toContain('--adapter'); expect(testIo.stderr()).toBe(''); expect(publicIngest).not.toHaveBeenCalled(); expect(lowLevelIngest).not.toHaveBeenCalled(); }); it('reserves public ingest run while keeping dev ingest run available', async () => { const publicRunIo = makeIo(); const publicHelpIo = makeIo(); const devRunIo = makeIo(); const publicIngest = vi.fn(async () => 0); const lowLevelIngest = vi.fn(async () => 0); await expect(runKtxCli(['ingest', 'run'], publicRunIo.io, { publicIngest, ingest: lowLevelIngest })).resolves.toBe( 1, ); expect(publicRunIo.stderr()).toMatch(/invalid argument|reserved|run/i); expect(publicIngest).not.toHaveBeenCalled(); await expect( runKtxCli(['ingest', 'run', '--help'], publicHelpIo.io, { publicIngest, ingest: lowLevelIngest }), ).resolves.toBe(0); expect(publicHelpIo.stdout()).toContain('Usage: ktx ingest [options] [connectionId]'); expect(publicHelpIo.stdout()).not.toContain('Usage: ktx ingest ' + 'run'); await expect( runKtxCli(['dev', 'ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'metabase'], devRunIo.io, { publicIngest, ingest: lowLevelIngest, }), ).resolves.toBe(0); expect(lowLevelIngest).toHaveBeenCalledWith( expect.objectContaining({ command: 'run', connectionId: 'warehouse', adapter: 'metabase' }), expect.anything(), ); }); it('dispatches dev doctor and ingest parser cases through Commander', async () => { const doctor = vi.fn(async () => 0); const ingest = vi.fn(async () => 0); const doctorIo = makeIo(); const ingestRunIo = makeIo(); const ingestReplayHelpIo = makeIo(); await expect(runKtxCli(['dev', 'doctor', 'setup', '--json', '--no-input'], doctorIo.io, { doctor })).resolves.toBe( 0, ); await expect( runKtxCli( [ 'dev', 'ingest', 'run', '--project-dir', tempDir, '--connection-id', 'warehouse', '--adapter', 'fake', '--source-dir', tempDir, '--debug-llm-request-file', `${tempDir}/debug.jsonl`, '--json', '--no-input', ], ingestRunIo.io, { ingest }, ), ).resolves.toBe(0); await expect(runKtxCli(['dev', 'ingest', 'replay', '--help'], ingestReplayHelpIo.io, { ingest })).resolves.toBe(0); expect(doctor).toHaveBeenCalledWith({ command: 'setup', outputMode: 'json', inputMode: 'disabled' }, doctorIo.io); expect(ingest).toHaveBeenCalledWith( { command: 'run', projectDir: tempDir, connectionId: 'warehouse', adapter: 'fake', sourceDir: tempDir, databaseIntrospectionUrl: undefined, debugLlmRequestFile: `${tempDir}/debug.jsonl`, outputMode: 'json', inputMode: 'disabled', }, ingestRunIo.io, ); expect(ingestReplayHelpIo.stdout()).toContain('Usage: ktx dev ingest replay [options] '); expect(ingestReplayHelpIo.stdout()).toContain(''); expect(doctorIo.stderr()).toBe(''); expect(ingestRunIo.stderr()).toBe(''); expect(ingestReplayHelpIo.stderr()).toBe(''); }); it('dispatches public connection through the existing connection implementation', async () => { const testIo = makeIo(); const connection = vi.fn(async () => 0); await expect(runKtxCli(['--project-dir', tempDir, 'connection', 'list'], testIo.io, { connection })).resolves.toBe( 0, ); expect(connection).toHaveBeenCalledWith({ command: 'list', projectDir: tempDir }, testIo.io); expect(testIo.stderr()).toBe(''); }); it('dispatches setup status and top-level status through the setup runner', async () => { const setup = vi.fn(async () => 0); const setupIo = makeIo(); const statusIo = makeIo(); await expect( runKtxCli(['--project-dir', tempDir, 'setup', 'status', '--json'], setupIo.io, { setup }), ).resolves.toBe(0); await expect(runKtxCli(['--project-dir', tempDir, 'status', '--json'], statusIo.io, { setup })).resolves.toBe(0); expect(setup).toHaveBeenNthCalledWith(1, { command: 'status', projectDir: tempDir, json: true }, setupIo.io); expect(setup).toHaveBeenNthCalledWith(2, { command: 'status', projectDir: tempDir, json: true }, statusIo.io); }); it('dispatches setup context recovery commands through the setup runner', async () => { const setup = vi.fn(async () => 0); const buildIo = makeIo(); const watchIo = makeIo(); const statusIo = makeIo(); const stopIo = makeIo(); await expect(runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'build'], buildIo.io, { setup })).resolves.toBe( 0, ); await expect( runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'watch', 'setup-context-local-1'], watchIo.io, { setup, }), ).resolves.toBe(0); await expect( runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'status', 'setup-context-local-1', '--json'], statusIo.io, { setup, }), ).resolves.toBe(0); await expect( runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'stop', 'setup-context-local-1'], stopIo.io, { setup, }), ).resolves.toBe(0); expect(setup).toHaveBeenNthCalledWith( 1, { command: 'context-build', projectDir: tempDir, inputMode: 'auto' }, buildIo.io, ); expect(setup).toHaveBeenNthCalledWith( 2, { command: 'context-watch', projectDir: tempDir, runId: 'setup-context-local-1', inputMode: 'auto' }, watchIo.io, ); expect(setup).toHaveBeenNthCalledWith( 3, { command: 'context-status', projectDir: tempDir, runId: 'setup-context-local-1', json: true }, statusIo.io, ); expect(setup).toHaveBeenNthCalledWith( 4, { command: 'context-stop', projectDir: tempDir, runId: 'setup-context-local-1' }, stopIo.io, ); }); it('dispatches Anthropic setup flags to the setup runner', async () => { const setup = vi.fn(async () => 0); const setupIo = makeIo(); await expect( runKtxCli( [ '--project-dir', tempDir, 'setup', '--no-input', '--anthropic-api-key-env', 'ANTHROPIC_API_KEY', '--anthropic-model', 'claude-sonnet-4-6', ], setupIo.io, { setup }, ), ).resolves.toBe(0); expect(setup).toHaveBeenCalledWith( expect.objectContaining({ command: 'run', projectDir: tempDir, inputMode: 'disabled', anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', anthropicModel: 'claude-sonnet-4-6', skipLlm: false, }), setupIo.io, ); }); it('rejects conflicting Anthropic credential setup flags', async () => { const setup = vi.fn(async () => 0); const setupIo = makeIo(); await expect( runKtxCli( [ '--project-dir', tempDir, 'setup', '--anthropic-api-key-env', 'ANTHROPIC_API_KEY', '--anthropic-api-key-file', '/tmp/anthropic-key', ], setupIo.io, { setup }, ), ).resolves.toBe(1); expect(setup).not.toHaveBeenCalled(); expect(setupIo.stderr()).toContain('Choose only one Anthropic credential source'); }); it('dispatches embedding setup flags to the setup runner', async () => { const setup = vi.fn(async () => 0); const setupIo = makeIo(); await expect( runKtxCli( [ '--project-dir', tempDir, 'setup', '--no-input', '--skip-llm', '--embedding-backend', 'openai', '--embedding-api-key-env', 'OPENAI_API_KEY', ], setupIo.io, { setup }, ), ).resolves.toBe(0); expect(setup).toHaveBeenCalledWith( expect.objectContaining({ command: 'run', projectDir: tempDir, inputMode: 'disabled', skipLlm: true, embeddingBackend: 'openai', embeddingApiKeyEnv: 'OPENAI_API_KEY', skipEmbeddings: false, }), setupIo.io, ); }); it('dispatches database setup flags to the setup runner', async () => { const setup = vi.fn(async () => 0); const setupIo = makeIo(); await expect( runKtxCli( [ 'setup', '--project-dir', '/tmp/project', '--no-input', '--yes', '--skip-llm', '--skip-embeddings', '--database', 'postgres', '--new-database-connection-id', 'warehouse', '--database-url', 'env:DATABASE_URL', '--database-schema', 'public', '--enable-historic-sql', '--historic-sql-window-days', '30', '--historic-sql-min-calls', '12', ], setupIo.io, { setup }, ), ).resolves.toBe(0); expect(setup).toHaveBeenCalledWith( expect.objectContaining({ command: 'run', projectDir: '/tmp/project', inputMode: 'disabled', yes: true, skipLlm: true, skipEmbeddings: true, databaseDrivers: ['postgres'], databaseConnectionId: 'warehouse', databaseUrl: 'env:DATABASE_URL', databaseSchemas: ['public'], enableHistoricSql: true, historicSqlWindowDays: 30, historicSqlMinCalls: 12, skipDatabases: false, }), setupIo.io, ); }); it('dispatches setup source flags', async () => { const setup = vi.fn(async () => 0); const testIo = makeIo(); await expect( runKtxCli( [ '--project-dir', tempDir, 'setup', '--no-input', '--source', 'metabase', '--source-connection-id', 'prod_metabase', '--source-url', 'https://metabase.example.com', '--source-api-key-ref', 'env:METABASE_API_KEY', '--source-warehouse-connection-id', 'warehouse', '--metabase-database-id', '1', '--skip-llm', '--skip-embeddings', '--skip-databases', ], testIo.io, { setup }, ), ).resolves.toBe(0); expect(setup).toHaveBeenCalledWith( expect.objectContaining({ command: 'run', projectDir: tempDir, source: 'metabase', sourceConnectionId: 'prod_metabase', sourceUrl: 'https://metabase.example.com', sourceApiKeyRef: 'env:METABASE_API_KEY', sourceWarehouseConnectionId: 'warehouse', metabaseDatabaseId: 1, }), testIo.io, ); }); it('dispatches setup agent flags and removal', async () => { const setup = vi.fn(async () => 0); const setupIo = makeIo(); const removeIo = makeIo(); await expect( runKtxCli( [ '--project-dir', tempDir, 'setup', '--agents', '--target', 'codex', '--project', '--agent-install-mode', 'both', '--no-input', '--yes', ], setupIo.io, { setup }, ), ).resolves.toBe(0); await expect( runKtxCli(['--project-dir', tempDir, 'setup', 'remove', '--agents'], removeIo.io, { setup }), ).resolves.toBe(0); expect(setup).toHaveBeenNthCalledWith( 1, expect.objectContaining({ command: 'run', agents: true, target: 'codex', agentScope: 'project', agentInstallMode: 'both', inputMode: 'disabled', yes: true, }), setupIo.io, ); expect(setup).toHaveBeenNthCalledWith(2, { command: 'remove-agents', projectDir: tempDir }, removeIo.io); }); it('rejects source-path with source-git-url', async () => { const setup = vi.fn(async () => 0); const testIo = makeIo(); await expect( runKtxCli( [ '--project-dir', tempDir, 'setup', '--no-input', '--source', 'dbt', '--source-path', '/repo/dbt', '--source-git-url', 'https://github.com/acme/dbt.git', ], testIo.io, { setup }, ), ).resolves.toBe(1); expect(setup).not.toHaveBeenCalled(); expect(testIo.stderr()).toContain('Choose only one source location'); }); it('rejects deterministic as a setup embedding backend', async () => { const setup = vi.fn(async () => 0); const setupIo = makeIo(); await expect( runKtxCli(['--project-dir', tempDir, 'setup', '--embedding-backend', 'deterministic'], setupIo.io, { setup }), ).resolves.toBe(1); expect(setup).not.toHaveBeenCalled(); expect(setupIo.stderr()).toContain("invalid choice 'deterministic'"); }); it('rejects gateway as a setup embedding backend', async () => { const setup = vi.fn(async () => 0); const setupIo = makeIo(); await expect( runKtxCli(['--project-dir', tempDir, 'setup', '--embedding-backend', 'gateway'], setupIo.io, { setup }), ).resolves.toBe(1); expect(setup).not.toHaveBeenCalled(); expect(setupIo.stderr()).toContain("invalid choice 'gateway'"); }); it('rejects conflicting embedding credential setup flags', async () => { const setup = vi.fn(async () => 0); const setupIo = makeIo(); await expect( runKtxCli( [ '--project-dir', tempDir, 'setup', '--embedding-backend', 'openai', '--embedding-api-key-env', 'OPENAI_API_KEY', '--embedding-api-key-file', '/tmp/openai-key', ], setupIo.io, { setup }, ), ).resolves.toBe(1); expect(setup).not.toHaveBeenCalled(); expect(setupIo.stderr()).toContain('Choose only one embedding credential source'); }); it('rejects conflicting Historic SQL setup flags', async () => { const setup = vi.fn(async () => 0); const setupIo = makeIo(); await expect( runKtxCli(['--project-dir', tempDir, 'setup', '--enable-historic-sql', '--disable-historic-sql'], setupIo.io, { setup, }), ).resolves.toBe(1); expect(setup).not.toHaveBeenCalled(); expect(setupIo.stderr()).toContain('Choose only one Historic SQL action'); }); it('registers hidden agent help and tools discovery without showing agent in root help', async () => { const helpIo = makeIo(); const toolsIo = makeIo(); const agent = vi.fn(async () => 0); await expect(runKtxCli(['agent', '--help'], helpIo.io, { agent })).resolves.toBe(0); await expect( runKtxCli(['--project-dir', tempDir, 'agent', 'tools', '--json'], toolsIo.io, { agent }), ).resolves.toBe(0); expect(helpIo.stdout()).toContain('Usage: ktx agent'); expect(toolsIo.stderr()).toBe(''); expect(agent).toHaveBeenCalledWith({ command: 'tools', projectDir: tempDir, json: true }, toolsIo.io); }); it('dispatches full hidden agent commands without exposing agent in root help', async () => { const agent = vi.fn(async () => 0); const cases = [ { argv: ['--project-dir', tempDir, 'agent', 'context', '--json'], args: { command: 'context', projectDir: tempDir, json: true }, }, { argv: [ '--project-dir', tempDir, 'agent', 'sl', 'list', '--json', '--connection-id', 'warehouse', '--query', 'orders', ], args: { command: 'sl-list', projectDir: tempDir, json: true, connectionId: 'warehouse', query: 'orders' }, }, { argv: ['--project-dir', tempDir, 'agent', 'sl', 'read', 'orders', '--json', '--connection-id', 'warehouse'], args: { command: 'sl-read', projectDir: tempDir, json: true, sourceName: 'orders', connectionId: 'warehouse' }, }, { argv: [ '--project-dir', tempDir, 'agent', 'sl', 'query', '--json', '--connection-id', 'warehouse', '--query-file', '/tmp/query.json', '--execute', '--max-rows', '100', ], args: { command: 'sl-query', projectDir: tempDir, json: true, connectionId: 'warehouse', queryFile: '/tmp/query.json', execute: true, maxRows: 100, }, }, { argv: ['--project-dir', tempDir, 'agent', 'wiki', 'search', 'revenue', '--json', '--limit', '5'], args: { command: 'wiki-search', projectDir: tempDir, json: true, query: 'revenue', limit: 5 }, }, { argv: ['--project-dir', tempDir, 'agent', 'wiki', 'read', 'page-1', '--json'], args: { command: 'wiki-read', projectDir: tempDir, json: true, pageId: 'page-1' }, }, { argv: [ '--project-dir', tempDir, 'agent', 'sql', 'execute', '--json', '--connection-id', 'warehouse', '--sql-file', '/tmp/query.sql', '--max-rows', '100', ], args: { command: 'sql-execute', projectDir: tempDir, json: true, connectionId: 'warehouse', sqlFile: '/tmp/query.sql', maxRows: 100, }, }, ]; for (const entry of cases) { const io = makeIo(); await expect(runKtxCli(entry.argv, io.io, { agent })).resolves.toBe(0); expect(agent).toHaveBeenLastCalledWith(entry.args, io.io); expect(io.stderr()).toBe(''); } const helpIo = makeIo(); await expect(runKtxCli(['--help'], helpIo.io, { agent })).resolves.toBe(0); expect(helpIo.stdout()).not.toContain('agent '); }); it('prints semantic-layer hybrid search metadata from the hidden agent sl list command', async () => { const agent = vi.fn(async (args, io) => { expect(args).toEqual({ command: 'sl-list', projectDir: tempDir, json: true, connectionId: 'warehouse', query: 'paid', }); io.stdout.write( `${JSON.stringify( { sources: [ { connectionId: 'warehouse', connectionName: 'warehouse', name: 'orders', columnCount: 2, measureCount: 1, joinCount: 0, score: 0.03278688524590164, matchReasons: ['dictionary'], dictionaryMatches: [{ column: 'status', values: ['paid'] }], }, ], totalSources: 1, }, null, 2, )}\n`, ); return 0; }); const io = makeIo(); await expect( runKtxCli( ['--project-dir', tempDir, 'agent', 'sl', 'list', '--json', '--connection-id', 'warehouse', '--query', 'paid'], io.io, { agent }, ), ).resolves.toBe(0); expect(JSON.parse(io.stdout())).toEqual({ sources: [ expect.objectContaining({ connectionId: 'warehouse', name: 'orders', matchReasons: ['dictionary'], dictionaryMatches: [{ column: 'status', values: ['paid'] }], }), ], totalSources: 1, }); }); it('prints wiki hybrid search metadata from the hidden agent wiki search command', async () => { const agent = vi.fn(async (args, io) => { expect(args).toEqual({ command: 'wiki-search', projectDir: tempDir, json: true, query: 'paid order', limit: 5, }); io.stdout.write( `${JSON.stringify( { results: [ { key: 'metrics/revenue', path: 'knowledge/global/metrics/revenue.md', scope: 'GLOBAL', summary: 'Revenue metric definition', score: 0.02459016393442623, matchReasons: ['lexical', 'token'], }, ], totalFound: 1, }, null, 2, )}\n`, ); return 0; }); const io = makeIo(); await expect( runKtxCli(['--project-dir', tempDir, 'agent', 'wiki', 'search', 'paid order', '--json', '--limit', '5'], io.io, { agent, }), ).resolves.toBe(0); expect(JSON.parse(io.stdout())).toEqual({ results: [ expect.objectContaining({ key: 'metrics/revenue', path: 'knowledge/global/metrics/revenue.md', matchReasons: ['lexical', 'token'], }), ], totalFound: 1, }); }); it('dispatches public connection subcommands through the existing connection implementation', async () => { const tempDir = await mkdtemp(join(tmpdir(), 'ktx-connection-dispatch-')); const connection = vi.fn(async () => 0); await expect( runKtxCli(['--project-dir', tempDir, 'connection', 'list'], makeIo().io, { connection }), ).resolves.toBe(0); const removeIo = makeIo(); await expect( runKtxCli(['--project-dir', tempDir, 'connection', 'remove', 'warehouse', '--force', '--no-input'], removeIo.io, { connection, }), ).resolves.toBe(0); const mapIo = makeIo(); await expect( runKtxCli(['--project-dir', tempDir, 'connection', 'map', 'prod-metabase', '--json'], mapIo.io, { connection, }), ).resolves.toBe(0); expect(connection).toHaveBeenNthCalledWith(1, { command: 'list', projectDir: tempDir }, expect.anything()); expect(connection).toHaveBeenNthCalledWith( 2, { command: 'remove', projectDir: tempDir, connectionId: 'warehouse', force: true, inputMode: 'disabled', }, expect.anything(), ); expect(connection).toHaveBeenNthCalledWith( 3, { command: 'map', projectDir: tempDir, sourceConnectionId: 'prod-metabase', json: true, }, expect.anything(), ); await rm(tempDir, { recursive: true, force: true }); }); it('prints help for connection metabase setup', async () => { const helpIo = makeIo(); await expect(runKtxCli(['connection', 'metabase', 'setup', '--help'], helpIo.io)).resolves.toBe(0); expect(helpIo.stdout()).toContain('Usage: ktx connection metabase setup'); for (const option of [ '--id ', '--url ', '--api-key ', '--username ', '--password ', '--mint-api-key', '--map ', '--sync ', '--sync-mode ', '--run-ingest', '--yes', '--no-input', ]) { expect(helpIo.stdout()).toContain(option); } expect(helpIo.stdout()).toContain('Guided equivalent of:'); for (const line of [ 'ktx connection mapping refresh --auto-accept', 'ktx connection mapping set databaseMappings =', 'ktx connection mapping set-sync-enabled --enabled true', 'ktx ingest ', ]) { expect(helpIo.stdout()).toContain(line); } expect(helpIo.stderr()).toBe(''); }); it('dispatches connection metabase setup through Commander', async () => { const connectionMetabaseSetup = vi.fn(async () => 0); const fakeMetabaseCredential = 'mb_example'; const setupIo = makeIo(); await expect( runKtxCli( [ 'connection', 'metabase', 'setup', '--project-dir', tempDir, '--id', 'metabase', '--url', 'http://metabase.example.test:3000', '--api-key', 'mb_example', '--map', '2=orbit', '--sync', '2', '--yes', '--no-input', ], setupIo.io, { connectionMetabaseSetup }, ), ).resolves.toBe(0); expect(connectionMetabaseSetup).toHaveBeenCalledWith( { command: 'setup', projectDir: tempDir, connectionId: 'metabase', url: 'http://metabase.example.test:3000', apiKey: fakeMetabaseCredential, mintApiKey: false, mappings: [{ metabaseDatabaseId: 2, targetConnectionId: 'orbit' }], syncEnabledDatabaseIds: [2], syncMode: 'ALL', runIngest: false, yes: true, inputMode: 'disabled', }, expect.anything(), ); expect(setupIo.stderr()).toBe(''); }); it('validates connection metabase setup option values before runner dispatch', async () => { const connectionMetabaseSetup = vi.fn(async () => 0); for (const argv of [ [ 'connection', 'metabase', 'setup', '--project-dir', tempDir, '--url', 'http://metabase.example.test:3000', '--api-key', 'mb_example', '--map', 'nope=orbit', ], [ 'connection', 'metabase', 'setup', '--project-dir', tempDir, '--url', 'http://metabase.example.test:3000', '--api-key', 'mb_example', '--map', '2=../orbit', ], [ 'connection', 'metabase', 'setup', '--project-dir', tempDir, '--url', 'http://metabase.example.test:3000', '--api-key', 'mb_example', '--sync', 'nope', ], [ 'connection', 'metabase', 'setup', '--project-dir', tempDir, '--url', 'http://metabase.example.test:3000', '--api-key', 'mb_example', '--sync-mode', 'BAD', ], [ 'connection', 'metabase', 'setup', '--project-dir', tempDir, '--url', 'http://metabase.example.test:3000', '--api-key', 'mb_example', '--mint-api-key', '--api-key', 'also_bad', ], ]) { const testIo = makeIo(); await expect(runKtxCli(argv, testIo.io, { connectionMetabaseSetup })).resolves.toBe(1); expect(testIo.stderr()).toMatch(/map|sync|sync-mode|conflict|cannot be used|invalid|integer|choices/i); } expect(connectionMetabaseSetup).not.toHaveBeenCalled(); }); it('rejects commands removed from the May 6 root surface', async () => { for (const argv of [ ['init'], ['connect', 'list'], ['scan', 'warehouse'], ['knowledge', 'list'], ['ask', 'What sources are connected?'], ]) { const testIo = makeIo(); await expect(runKtxCli(argv, testIo.io)).resolves.toBe(1); expect(testIo.stderr()).toMatch(/unknown command|error:/); } }); it('dispatches connection add options through Commander', async () => { const testIo = makeIo(); const connection = vi.fn(async () => 0); await expect( runKtxCli( [ 'connection', 'add', 'notion', 'notion-main', '--project-dir', tempDir, '--token-env', 'NOTION_AUTH_TOKEN', '--crawl-mode', 'selected_roots', '--root-page-id', 'page-1', '--root-database-id', 'database-1', '--max-pages', '80', ], testIo.io, { connection }, ), ).resolves.toBe(0); expect(connection).toHaveBeenCalledWith( { command: 'add', projectDir: tempDir, driver: 'notion', connectionId: 'notion-main', url: undefined, schemas: [], readonly: false, force: false, allowLiteralCredentials: false, notion: { authTokenRef: 'env:NOTION_AUTH_TOKEN', crawlMode: 'selected_roots', rootPageIds: ['page-1'], rootDatabaseIds: ['database-1'], rootDataSourceIds: [], maxPagesPerRun: 80, maxKnowledgeCreatesPerRun: undefined, maxKnowledgeUpdatesPerRun: undefined, }, }, testIo.io, ); expect(testIo.stderr()).toBe(''); }); it('prints generated connection notion pick help without invoking execution', async () => { const helpCases = [ ['connection', 'notion', '--help'], ['connection', 'notion', 'pick', '--help'], ['connection', 'notion', 'pick', 'notion-main', '--help'], ]; for (const argv of helpCases) { const testIo = makeIo(); const connectionNotion = vi.fn(async () => 0); await expect(runKtxCli(argv, testIo.io, { connectionNotion })).resolves.toBe(0); expect(testIo.stdout()).toContain('Usage: ktx connection notion'); expect(testIo.stdout()).toContain('pick'); expect(testIo.stderr()).toBe(''); expect(connectionNotion).not.toHaveBeenCalled(); } }); it('dispatches connection notion pick through Commander', async () => { const testIo = makeIo(); const connectionNotion = vi.fn(async () => 0); await expect( runKtxCli( [ '--project-dir', tempDir, 'connection', 'notion', 'pick', 'notion-main', '--no-input', '--root-page-id', '11111111222233334444555555555555', '--root-page-id', '11111111-2222-3333-4444-555555555555', ], testIo.io, { connectionNotion }, ), ).resolves.toBe(0); expect(connectionNotion).toHaveBeenCalledWith( { command: 'pick', projectDir: tempDir, connectionId: 'notion-main', mode: 'non-interactive', rootPageIds: ['11111111-2222-3333-4444-555555555555'], }, testIo.io, ); expect(testIo.stderr()).toBe(''); }); it('ignores connection notion pick root page flags in interactive mode', async () => { const testIo = makeIo(); const connectionNotion = vi.fn(async () => 0); await expect( runKtxCli(['connection', 'notion', 'pick', 'notion-main', '--root-page-id', 'not-a-uuid'], testIo.io, { connectionNotion, }), ).resolves.toBe(0); expect(connectionNotion).toHaveBeenCalledWith( { command: 'pick', projectDir: expect.any(String), connectionId: 'notion-main', mode: 'interactive', }, testIo.io, ); expect(testIo.stderr()).toBe(''); }); it('rejects connection notion pick no-input mode without root page ids', async () => { const testIo = makeIo(); const connectionNotion = vi.fn(async () => 0); await expect( runKtxCli(['connection', 'notion', 'pick', 'notion-main', '--no-input'], testIo.io, { connectionNotion }), ).resolves.toBe(1); expect(connectionNotion).not.toHaveBeenCalled(); expect(testIo.stderr()).toContain('connection notion pick --no-input requires at least one --root-page-id'); }); it('writes basic debug dispatch information when --debug is set', async () => { const testIo = makeIo(); const connection = vi.fn().mockResolvedValue(0); await expect( runKtxCli(['--project-dir', tempDir, '--debug', 'connection', 'list'], testIo.io, { connection }), ).resolves.toBe(0); expect(testIo.stderr()).toContain(`[debug] projectDir=${tempDir}`); expect(testIo.stderr()).toContain('[debug] dispatch=connection'); }); it('routes low-level scan through ktx dev with top-level project-dir', async () => { const testIo = makeIo(); const scan = vi.fn().mockResolvedValue(0); await expect(runKtxCli(['--project-dir', tempDir, 'dev', 'scan', 'warehouse'], testIo.io, { scan })).resolves.toBe( 0, ); expect(scan).toHaveBeenCalledWith( { command: 'run', projectDir: tempDir, connectionId: 'warehouse', mode: 'structural', detectRelationships: false, dryRun: false, databaseIntrospectionUrl: undefined, }, testIo.io, ); }); it('dispatches serve public command options through Commander', async () => { const serveIo = makeIo(); const serveStdio = vi.fn(async () => 0); await expect( runKtxCli( [ 'serve', '--mcp', 'stdio', '--project-dir', tempDir, '--semantic-compute-url', 'http://127.0.0.1:18080', '--execute-queries', '--memory-capture', '--memory-model', 'openai/gpt-5.2', ], serveIo.io, { serveStdio }, ), ).resolves.toBe(0); expect(serveStdio).toHaveBeenCalledWith({ mcp: 'stdio', projectDir: tempDir, userId: 'local', semanticCompute: true, semanticComputeUrl: 'http://127.0.0.1:18080', databaseIntrospectionUrl: undefined, executeQueries: true, memoryCapture: true, memoryModel: 'openai/gpt-5.2', }); expect(serveIo.stderr()).toBe(''); }); it('prints dev help for bare dev commands', async () => { const testIo = makeIo(); await expect(runKtxCli(['dev'], testIo.io)).resolves.toBe(0); expect(testIo.stdout()).toContain('Usage: ktx dev [options] [command]'); expect(testIo.stdout()).toContain('Low-level diagnostics'); expect(testIo.stdout()).toContain('scan'); expect(testIo.stdout()).toContain('ingest'); expect(testIo.stdout()).toContain('mapping'); expect(testIo.stdout()).not.toContain('model'); expect(testIo.stdout()).not.toContain('knowledge'); expect(testIo.stderr()).toBe(''); }); it('prints dev command help without invoking low-level execution', async () => { for (const [command, expected] of [ ['scan', ['Usage: ktx dev scan', '--dry-run', 'status', 'report']], ['ingest', ['Usage: ktx dev ingest', 'run', 'replay']], ['mapping', ['Usage: ktx dev mapping', 'sync-state', 'validate']], ] as const) { const testIo = makeIo(); const scan = vi.fn().mockResolvedValue(0); const sl = vi.fn().mockResolvedValue(0); await expect(runKtxCli(['dev', command, '--help'], testIo.io, { scan, sl })).resolves.toBe(0); for (const text of expected) { expect(testIo.stdout()).toContain(text); } expect(testIo.stderr()).toBe(''); expect(scan).not.toHaveBeenCalled(); expect(sl).not.toHaveBeenCalled(); } }); it('prints dev scan subcommand help without invoking scan execution', async () => { const testIo = makeIo(); const scan = vi.fn().mockResolvedValue(0); await expect(runKtxCli(['dev', 'scan', 'report', '--help'], testIo.io, { scan })).resolves.toBe(0); expect(testIo.stdout()).toContain('Usage: ktx dev scan report [options] '); expect(testIo.stderr()).toBe(''); expect(scan).not.toHaveBeenCalled(); }); it('rejects removed reserved dev subcommands', async () => { const testIo = makeIo(); await expect(runKtxCli(['dev', 'artifacts'], testIo.io)).resolves.toBe(1); expect(testIo.stderr()).toMatch(/unknown command|error:/); }); it('rejects mutually exclusive output modes before invoking runners', async () => { const ingest = vi.fn(async () => 0); const demo = vi.fn(async () => 0); for (const argv of [ ['dev', 'ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'fake', '--json', '--plain'], ['dev', 'ingest', 'status', 'run-1', '--json', '--viz'], ['setup', 'demo', '--json', '--plain'], ['setup', 'demo', 'replay', '--json', '--plain'], ]) { const testIo = makeIo(); await expect(runKtxCli(argv, testIo.io, { ingest, demo })).resolves.toBe(1); expect(testIo.stderr()).toMatch(/conflict|cannot be used/i); } expect(ingest).not.toHaveBeenCalled(); expect(demo).not.toHaveBeenCalled(); }); it('rejects mutually exclusive credential and scan mode options before invoking runners', async () => { const connection = vi.fn(async () => 0); const scan = vi.fn(async () => 0); const tokenIo = makeIo(); await expect( runKtxCli( [ 'connection', 'add', 'notion', 'notion-main', '--token-env', 'NOTION_TOKEN', '--token-file', '/tmp/notion-token', '--root-page-id', '11111111111111111111111111111111', ], tokenIo.io, { connection }, ), ).resolves.toBe(1); expect(tokenIo.stderr()).toMatch(/conflict|cannot be used/i); expect(connection).not.toHaveBeenCalled(); expect(scan).not.toHaveBeenCalled(); }); it('validates connection mapping set syntax before runner domain validation', async () => { const badFieldIo = makeIo(); await expect( runKtxCli(['connection', 'mapping', 'set', 'prod-metabase', 'invalidMappings', '1=warehouse'], badFieldIo.io), ).resolves.toBe(1); expect(badFieldIo.stderr()).toContain('databaseMappings or connectionMappings'); for (const assignment of ['missing-equals', '=warehouse', '1=']) { const testIo = makeIo(); await expect( runKtxCli(['connection', 'mapping', 'set', 'prod-metabase', 'databaseMappings', assignment], testIo.io), ).resolves.toBe(1); expect(testIo.stderr()).toContain('non-empty ='); } }); it('does not expose root init after setup owns project creation', async () => { const testIo = makeIo(); await expect(runKtxCli(['init'], testIo.io)).resolves.toBe(1); expect(testIo.stderr()).toContain("error: unknown command 'init'"); }); it('returns an error code for unknown commands', async () => { const testIo = makeIo(); await expect(runKtxCli(['unknown'], testIo.io)).resolves.toBe(1); expect(testIo.stderr()).toContain("error: unknown command 'unknown'"); }); });