feat(cli): print resolved project dir

This commit is contained in:
Andrey Avtomonov 2026-05-12 15:04:01 +02:00
parent a0ea1609ac
commit 0ff14e1953
4 changed files with 143 additions and 11 deletions

View file

@ -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<string, unknown> {
return (command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as Record<string, unknown>;
}
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<string, unknown>): 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,

View file

@ -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) => {

View file

@ -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 () => {

View file

@ -46,54 +46,63 @@ describe('project directory defaults', () => {
spy: ReturnType<typeof vi.fn>;
expected: Record<string, unknown>;
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`);
});
});