From 0ff14e1953d936cef6ac1ae9330e2fbb2259d268 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 12 May 2026 15:04:01 +0200 Subject: [PATCH] feat(cli): print resolved project dir --- packages/cli/src/cli-program.ts | 91 ++++++++++++++++++++++++++++ packages/cli/src/dev.test.ts | 4 +- packages/cli/src/index.test.ts | 42 +++++++++++-- packages/cli/src/project-dir.test.ts | 17 ++++-- 4 files changed, 143 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index fd7a50ef..330477bb 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -42,6 +42,13 @@ interface KtxGlobalOptionValues { debug?: boolean; } +type CommandPathNode = CommandWithGlobalOptions & { + name: () => string; + parent?: CommandPathNode | null; +}; + +const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']); + export interface CommandWithGlobalOptions { opts: () => object; optsWithGlobals?: () => object; @@ -115,6 +122,80 @@ function optionsWithGlobals(command: CommandWithGlobalOptions): KtxGlobalOptionV }; } +function commandOptions(command: CommandWithGlobalOptions): Record { + return (command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as Record; +} + +function commandPath(command: CommandPathNode): string[] { + const path: string[] = []; + let current: CommandPathNode | null | undefined = command; + + while (current) { + path.unshift(current.name()); + current = current.parent; + } + + return path; +} + +function isProjectAwareCommand(path: string[]): boolean { + if (path.includes('__complete')) { + return false; + } + + const rootCommand = path[1]; + if (rootCommand === 'dev') { + return path[2] !== undefined && path[2] !== 'completion'; + } + return rootCommand !== undefined && PROJECT_AWARE_ROOT_COMMANDS.has(rootCommand); +} + +function shouldSuppressProjectDirLine(path: string[], options: Record): boolean { + if (path.join(' ') === 'ktx dev init') { + return true; + } + + if (options.viz === true) { + return true; + } + + const commandPathKey = path.join(' '); + if (commandPathKey === 'ktx ingest watch') { + return options.json !== true; + } + if (commandPathKey === 'ktx dev ingest watch') { + return options.json !== true && options.plain !== true; + } + if (commandPathKey === 'ktx connection notion pick') { + return options.input !== false; + } + const demoIndex = path.indexOf('demo'); + if (demoIndex >= 0) { + const demoCommand = path[demoIndex + 1]; + return ( + options.json !== true && + options.plain !== true && + (demoCommand === undefined || demoCommand === 'replay' || demoCommand === 'ingest') + ); + } + + return false; +} + +function shouldPrintProjectDir(command: CommandPathNode): boolean { + const path = commandPath(command); + if (!isProjectAwareCommand(path)) { + return false; + } + + const options = commandOptions(command); + if (options.json === true || options.output === 'json' || options.format === 'json') { + return false; + } + + return !shouldSuppressProjectDirLine(path, options); +} + export function resolveCommandProjectDir(command: CommandWithGlobalOptions): string { return resolveKtxProjectDir({ explicitProjectDir: optionsWithGlobals(command).projectDir }); } @@ -154,6 +235,13 @@ function writeDebug(io: KtxCliIo, commandContext: CommandWithGlobalOptions, comm io.stderr.write(`[debug] dispatch=${command}\n`); } +function writeProjectDir(io: KtxCliIo, commandContext: CommandPathNode): void { + if (!shouldPrintProjectDir(commandContext)) { + return; + } + io.stderr.write(`Project: ${resolveCommandProjectDir(commandContext)}\n`); +} + function formatCliError(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -204,6 +292,9 @@ export async function runCommanderKtxCli( profileMark('commander:entry'); let exitCode = 0; const program = createBaseProgram(info, io); + program.hook('preAction', (_thisCommand, actionCommand) => { + writeProjectDir(io, actionCommand as CommandPathNode); + }); profileMark('commander:base-program'); const context: KtxCliCommandContext = { io, diff --git a/packages/cli/src/dev.test.ts b/packages/cli/src/dev.test.ts index 167513d5..dfa35395 100644 --- a/packages/cli/src/dev.test.ts +++ b/packages/cli/src/dev.test.ts @@ -239,7 +239,7 @@ describe('dev Commander tree', () => { }, scanIo.io, ); - expect(scanIo.stderr()).toBe(''); + expect(scanIo.stderr()).toBe('Project: /tmp/project\n'); }); it('dispatches dev scan --mode relationships through Commander', async () => { @@ -266,7 +266,7 @@ describe('dev Commander tree', () => { }, io.io, ); - expect(io.stderr()).toBe(''); + expect(io.stderr()).toBe('Project: /tmp/project\n'); }); it.each(['--enrich', '--detect-relationships'])('rejects removed scan shorthand option %s', async (option) => { diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index 87a0089f..855ff5fd 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -231,6 +231,38 @@ describe('runKtxCli', () => { ); }); + it('prints the resolved project directory for ordinary project commands', async () => { + const connection = vi.fn(async () => 0); + const testIo = makeIo(); + + 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(`Project: ${tempDir}\n`); + }); + + it('skips the project directory line for JSON and TUI output modes', async () => { + const publicIngest = vi.fn(async () => 0); + const ingest = vi.fn(async () => 0); + const jsonIo = makeIo(); + const vizIo = makeIo({ stdoutIsTty: true }); + + await expect(runKtxCli(['--project-dir', tempDir, 'ingest', '--all', '--json'], jsonIo.io, { publicIngest })) + .resolves.toBe(0); + await expect( + runKtxCli( + ['--project-dir', tempDir, 'dev', 'ingest', 'status', 'run-1', '--viz'], + vizIo.io, + { ingest }, + ), + ).resolves.toBe(0); + + expect(jsonIo.stderr()).toBe(''); + expect(vizIo.stderr()).toBe(''); + }); + it('documents runtime stop all in command help', async () => { const testIo = makeIo(); @@ -1035,7 +1067,7 @@ describe('runKtxCli', () => { }), nonInteractiveIo.io, ); - expect(nonInteractiveIo.stderr()).toBe(''); + expect(nonInteractiveIo.stderr()).toBe(`Project: ${tempDir}\n`); }); it('dispatches public connection through the existing connection implementation', async () => { @@ -1047,7 +1079,7 @@ describe('runKtxCli', () => { ); expect(connection).toHaveBeenCalledWith({ command: 'list', projectDir: tempDir }, testIo.io); - expect(testIo.stderr()).toBe(''); + expect(testIo.stderr()).toBe(`Project: ${tempDir}\n`); }); it('dispatches setup status and top-level status through the setup runner', async () => { @@ -1893,7 +1925,7 @@ describe('runKtxCli', () => { }, expect.anything(), ); - expect(setupIo.stderr()).toBe(''); + expect(setupIo.stderr()).toBe(`Project: ${tempDir}\n`); }); it('validates connection metabase setup option values before runner dispatch', async () => { @@ -2044,7 +2076,7 @@ describe('runKtxCli', () => { }, testIo.io, ); - expect(testIo.stderr()).toBe(''); + expect(testIo.stderr()).toBe(`Project: ${tempDir}\n`); }); it('prints generated connection notion pick help without invoking execution', async () => { @@ -2101,7 +2133,7 @@ describe('runKtxCli', () => { }, testIo.io, ); - expect(testIo.stderr()).toBe(''); + expect(testIo.stderr()).toBe(`Project: ${tempDir}\n`); }); it('ignores connection notion pick root page flags in interactive mode', async () => { diff --git a/packages/cli/src/project-dir.test.ts b/packages/cli/src/project-dir.test.ts index 02f6dfde..3fd8cc06 100644 --- a/packages/cli/src/project-dir.test.ts +++ b/packages/cli/src/project-dir.test.ts @@ -46,54 +46,63 @@ describe('project directory defaults', () => { spy: ReturnType; expected: Record; runnerType: 'cli' | 'serve'; + expectedStderr: string; }> = [ { argv: ['connection', 'list'], spy: connection, expected: { command: 'list', projectDir: '/tmp/ktx-env-project' }, runnerType: 'cli', + expectedStderr: 'Project: /tmp/ktx-env-project\n', }, { argv: ['setup', 'demo', 'scan', '--no-input'], spy: demo, expected: { command: 'scan', projectDir: '/tmp/ktx-env-project' }, runnerType: 'cli', + expectedStderr: 'Project: /tmp/ktx-env-project\n', }, { argv: ['dev', 'doctor', '--no-input'], spy: doctor, expected: { command: 'project', projectDir: '/tmp/ktx-env-project' }, runnerType: 'cli', + expectedStderr: 'Project: /tmp/ktx-env-project\n', }, { argv: ['ingest', 'status', 'run-1'], spy: publicIngest, expected: { command: 'status', projectDir: '/tmp/ktx-env-project', runId: 'run-1' }, runnerType: 'cli', + expectedStderr: 'Project: /tmp/ktx-env-project\n', }, { argv: ['setup', 'status'], spy: setup, expected: { command: 'status', projectDir: '/tmp/ktx-env-project' }, runnerType: 'cli', + expectedStderr: 'Project: /tmp/ktx-env-project\n', }, { argv: ['dev', 'scan', 'warehouse'], spy: scan, expected: { command: 'run', projectDir: '/tmp/ktx-env-project', connectionId: 'warehouse' }, runnerType: 'cli', + expectedStderr: 'Project: /tmp/ktx-env-project\n', }, { argv: ['serve', '--mcp', 'stdio'], spy: serveStdio, expected: { mcp: 'stdio', projectDir: '/tmp/ktx-env-project' }, runnerType: 'serve', + expectedStderr: '', }, { argv: ['agent', 'tools', '--json'], spy: agent, expected: { command: 'tools', projectDir: '/tmp/ktx-env-project' }, runnerType: 'cli', + expectedStderr: '', }, ]; @@ -105,7 +114,7 @@ describe('project directory defaults', () => { } else { expect(item.spy).toHaveBeenLastCalledWith(expect.objectContaining(item.expected), testIo.io); } - expect(testIo.stderr()).toBe(''); + expect(testIo.stderr()).toBe(item.expectedStderr); } }); @@ -134,8 +143,8 @@ describe('project directory defaults', () => { expect.objectContaining({ command: 'status', projectDir: '/tmp/ktx-explicit-project' }), ingestIo.io, ); - expect(scanIo.stderr()).toBe(''); - expect(ingestIo.stderr()).toBe(''); + expect(scanIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n'); + expect(ingestIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n'); }); it('uses nearest ancestor containing ktx.yaml when no explicit or environment project-dir exists', async () => { @@ -167,6 +176,6 @@ describe('project directory defaults', () => { expect.objectContaining({ command: 'run', projectDir: expectedProjectDir }), testIo.io, ); - expect(testIo.stderr()).toBe(''); + expect(testIo.stderr()).toBe(`Project: ${expectedProjectDir}\n`); }); });