Merge pull request #39 from Kaelio/cli-print-project-dir

feat(cli): print resolved project dir
This commit is contained in:
Andrey Avtomonov 2026-05-12 16:51:15 +02:00 committed by GitHub
commit 866d33e71a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 196 additions and 40 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`);
});
});

View file

@ -146,6 +146,10 @@ function parseJsonOutput<T>(stdout: string): T {
return JSON.parse(stdout) as T;
}
function expectProjectStderr(result: CliResult, projectDir: string): void {
expect(result).toMatchObject({ code: 0, stderr: `Project: ${projectDir}\n` });
}
async function runSetupNewProject(projectDir: string): Promise<CliResult> {
return await runBuiltCli([
'setup',
@ -178,7 +182,7 @@ describe('standalone built ktx CLI smoke', () => {
const sourceDir = join(tempDir, 'source');
const init = await runSetupNewProject(projectDir);
expect(init).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(init, projectDir);
expect(init.stdout).toContain(`Project: ${projectDir}`);
await writeWarehouseConfig(projectDir);
@ -204,14 +208,15 @@ describe('standalone built ktx CLI smoke', () => {
});
it('runs the default pre-seeded demo without credentials', async () => {
const projectDir = join(tempDir, 'demo-project');
const result = await runBuiltCli(
['setup', 'demo', '--project-dir', join(tempDir, 'demo-project'), '--plain', '--no-input'],
['setup', 'demo', '--project-dir', projectDir, '--plain', '--no-input'],
{
env: { ...process.env, ANTHROPIC_API_KEY: '' },
},
);
expect(result).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(result, projectDir);
expect(result.stdout).toContain('Mode: seeded');
expect(result.stdout).toContain('Source: packaged demo project');
expect(result.stdout).toContain('LLM calls: none');
@ -231,7 +236,7 @@ describe('standalone built ktx CLI smoke', () => {
const seeded = await runBuiltCli(['setup', 'demo', '--project-dir', projectDir, '--plain', '--no-input'], {
env: { ...process.env, ANTHROPIC_API_KEY: '' },
});
expect(seeded).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(seeded, projectDir);
expect(seeded.stdout).toContain('Mode: seeded');
const wikiSearch = await runBuiltCli([
@ -319,7 +324,7 @@ describe('standalone built ktx CLI smoke', () => {
expect(seeded.stdout).toContain('Knowledge pages:');
const inspect = await runBuiltCli(['setup', 'demo', 'inspect', '--project-dir', projectDir, '--no-input']);
expect(inspect).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(inspect, projectDir);
expect(inspect.stdout).toContain('Mode: seeded');
expect(inspect.stdout).toContain('Status: ready');
expect(inspect.stdout).toContain('Warehouse: 8 tables, 11,234 rows');
@ -348,7 +353,7 @@ describe('standalone built ktx CLI smoke', () => {
env: { ...process.env, ANTHROPIC_API_KEY: '' },
},
);
expect(seeded).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(seeded, projectDir);
expect(seeded.stdout).toContain('Mode: seeded');
const client = new Client({ name: 'ktx-seeded-demo-smoke-client', version: '0.0.0' });
@ -413,7 +418,7 @@ describe('standalone built ktx CLI smoke', () => {
expect(result.stdout).toContain('KTX setup doctor');
expect(result.stdout).toContain('Node 22+');
expect(result.stdout).toContain('Workspace-local CLI');
expect(result.stderr).toBe('');
expect(result.stderr).toBe(`Project: ${process.cwd()}\n`);
expect([0, 1]).toContain(result.code);
});
@ -433,7 +438,7 @@ describe('standalone built ktx CLI smoke', () => {
const projectDir = join(tempDir, 'reset-demo-project');
const init = await runBuiltCli(['setup', 'demo', 'init', '--project-dir', projectDir, '--no-input']);
expect(init).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(init, projectDir);
const withoutForce = await runBuiltCli(['setup', 'demo', 'reset', '--project-dir', projectDir, '--no-input']);
expect(withoutForce.code).toBe(1);
@ -450,7 +455,7 @@ describe('standalone built ktx CLI smoke', () => {
'--force',
'--no-input',
]);
expect(withForce).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(withForce, projectDir);
expect(withForce.stdout).toContain(`Demo project reset: ${projectDir}`);
});
@ -458,7 +463,7 @@ describe('standalone built ktx CLI smoke', () => {
const projectDir = join(tempDir, 'corrupt-demo-project');
const init = await runBuiltCli(['setup', 'demo', 'init', '--project-dir', projectDir, '--no-input']);
expect(init).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(init, projectDir);
await rm(join(projectDir, 'demo.db'), { force: true });
const replay = await runBuiltCli(['setup', 'demo', '--mode', 'replay', '--project-dir', projectDir, '--no-input']);
@ -471,14 +476,14 @@ describe('standalone built ktx CLI smoke', () => {
const projectDir = join(tempDir, 'doctor-demo-project');
const init = await runBuiltCli(['setup', 'demo', 'init', '--project-dir', projectDir, '--no-input']);
expect(init).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(init, projectDir);
const result = await runBuiltCli(['setup', 'demo', 'doctor', '--project-dir', projectDir, '--no-input']);
expect(result.stdout).toContain('KTX demo doctor');
expect(result.stdout).toContain('Demo dataset');
expect(result.stdout).toContain('Demo replay');
expect(result.stdout).toContain('Demo LLM provider');
expect(result.stderr).toBe('');
expect(result.stderr).toBe(`Project: ${projectDir}\n`);
expect([0, 1]).toContain(result.code);
});
@ -504,20 +509,20 @@ describe('standalone built ktx CLI smoke', () => {
it('runs structural and enriched scans through the built binary with manifest artifacts', async () => {
const projectDir = join(tempDir, 'scan-project');
const init = await runSetupNewProject(projectDir);
expect(init).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(init, projectDir);
const dbPath = join(projectDir, 'warehouse.db');
createSqliteWarehouse(dbPath);
await writeSqliteScanConfig(projectDir, dbPath);
const connectionTest = await runBuiltCli(['connection', 'test', 'warehouse', '--project-dir', projectDir]);
expect(connectionTest).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(connectionTest, projectDir);
expect(connectionTest.stdout).toContain('Connection test passed: warehouse');
expect(connectionTest.stdout).toContain('Driver: sqlite');
expect(connectionTest.stdout).toContain('Tables: 2');
const structural = await runBuiltCli(['dev', 'scan', 'warehouse', '--project-dir', projectDir]);
expect(structural).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(structural, projectDir);
expect(structural.stdout).toContain('Status: done');
expect(structural.stdout).toContain('Mode: structural');
const structuralRunId = getRunId(structural.stdout);
@ -560,7 +565,7 @@ describe('standalone built ktx CLI smoke', () => {
'--mode',
'enriched',
]);
expect(providerlessEnriched).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(providerlessEnriched, projectDir);
expect(providerlessEnriched.stdout).toContain('Mode: enriched');
expect(providerlessEnriched.stdout).toContain('Relationships');
expect(providerlessEnriched.stdout).toContain('Accepted: 1');
@ -614,7 +619,7 @@ describe('standalone built ktx CLI smoke', () => {
await writeSqliteScanConfig(projectDir, dbPath, true);
const enriched = await runBuiltCli(['dev', 'scan', 'warehouse', '--project-dir', projectDir, '--mode', 'enriched']);
expect(enriched).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(enriched, projectDir);
expect(enriched.stdout).toContain('Mode: enriched');
const enrichedRunId = getRunId(enriched.stdout);
@ -706,7 +711,7 @@ describe('standalone built ktx CLI smoke', () => {
it('adds a redacted Notion connection through the built binary', async () => {
const projectDir = join(tempDir, 'notion-project');
const init = await runSetupNewProject(projectDir);
expect(init).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(init, projectDir);
const add = await runBuiltCli([
'connection',
@ -723,7 +728,7 @@ describe('standalone built ktx CLI smoke', () => {
'5',
]);
expect(add).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(add, projectDir);
expect(add.stdout).toContain('Connection: notion-main');
expect(add.stdout).toContain('Driver: notion');
@ -746,7 +751,7 @@ describe('standalone built ktx CLI smoke', () => {
const projectDir = join(tempDir, 'project');
const init = await runSetupNewProject(projectDir);
expect(init).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(init, projectDir);
await writeWarehouseConfig(projectDir);
const client = new Client({ name: 'ktx-smoke-client', version: '0.0.0' });
@ -812,7 +817,7 @@ describe('standalone built ktx CLI smoke', () => {
it('serves scan execution and artifact inspection tools over stdio from the built binary', async () => {
const projectDir = join(tempDir, 'scan-mcp-project');
const init = await runSetupNewProject(projectDir);
expect(init).toMatchObject({ code: 0, stderr: '' });
expectProjectStderr(init, projectDir);
const dbPath = join(projectDir, 'warehouse.db');
createSqliteWarehouse(dbPath);

View file

@ -520,6 +520,15 @@ function requireSuccess(label, result) {
assert.equal(result.stderr, '', label + ' wrote unexpected stderr');
}
function requireProjectStderr(label, result, projectDir) {
assert.equal(
result.code,
0,
label + ' failed with code ' + result.code + '\\nstdout:\\n' + result.stdout + '\\nstderr:\\n' + result.stderr,
);
assert.equal(result.stderr, 'Project: ' + projectDir + '\\n', label + ' wrote unexpected stderr');
}
function requireSuccessWithStderr(label, result, stderrPattern) {
assert.equal(
result.code,
@ -631,7 +640,7 @@ try {
'--skip-sources',
'--skip-agents',
]);
requireSuccess('ktx setup', init);
requireProjectStderr('ktx setup', init, projectDir);
requireOutput('ktx setup', init, /Project: /);
const emptyProjectDir = join(root, 'empty-project');
@ -650,7 +659,7 @@ try {
'--skip-sources',
'--skip-agents',
]);
requireSuccess('ktx setup empty project', emptyInit);
requireProjectStderr('ktx setup empty project', emptyInit, emptyProjectDir);
const emptySearch = await run('pnpm', [
'exec',
'ktx',
@ -905,7 +914,7 @@ try {
'--project-dir',
projectDir,
]);
requireSuccess('ktx scan structural', structuralScan);
requireProjectStderr('ktx scan structural', structuralScan, projectDir);
requireOutput('ktx scan structural', structuralScan, /Status: done/);
requireOutput('ktx scan structural', structuralScan, /Mode: structural/);
requireOutput('ktx scan structural', structuralScan, /Needs attention\\s+None/);
@ -916,7 +925,7 @@ try {
projectDir,
structuralScanRunId,
]);
requireSuccess('ktx scan status', scanStatus);
requireProjectStderr('ktx scan status', scanStatus, projectDir);
requireOutput('ktx scan status', scanStatus, new RegExp('Run: ' + structuralScanRunId));
requireOutput('ktx scan status', scanStatus, /Status: done/);
requireOutput('ktx scan status', scanStatus, /Mode: structural/);
@ -943,7 +952,7 @@ try {
'--mode',
'enriched',
]);
requireSuccess('ktx scan enriched', enrichedScan);
requireProjectStderr('ktx scan enriched', enrichedScan, projectDir);
requireOutput('ktx scan enriched', enrichedScan, /Status: done/);
requireOutput('ktx scan enriched', enrichedScan, /Mode: enriched/);
const enrichedScanRunId = getRunId(enrichedScan.stdout);
@ -1037,6 +1046,15 @@ function requireStdout(label, result, pattern) {
assert.match(result.stdout, pattern, label + ' stdout did not match ' + pattern);
}
function requireProjectStderr(label, result, projectDir) {
assert.equal(
result.code,
0,
label + ' failed with code ' + result.code + '\\nstdout:\\n' + result.stdout + '\\nstderr:\\n' + result.stderr,
);
assert.equal(result.stderr, 'Project: ' + projectDir + '\\n', label + ' wrote unexpected stderr');
}
const root = await mkdtemp(join(tmpdir(), 'ktx-packed-demo-smoke-'));
try {
const projectDir = join(root, 'demo-project');
@ -1059,7 +1077,7 @@ try {
requireStdout('ktx setup demo seeded', seeded, /ktx serve --mcp stdio/);
assert.doesNotMatch(seeded.stdout, new RegExp(['--mode', 'deterministic'].join(' ')));
assert.doesNotMatch(seeded.stdout, /KTX memory flow/);
assert.equal(seeded.stderr, '', 'ktx setup demo seeded wrote unexpected stderr');
requireProjectStderr('ktx setup demo seeded', seeded, projectDir);
const demoWikiSearch = await run('pnpm', [
'exec',
@ -1108,7 +1126,7 @@ try {
assert.ok([0, 1].includes(doctor.code), 'ktx dev doctor setup exit code must be 0 or 1');
requireStdout('ktx dev doctor setup', doctor, /KTX setup doctor/);
requireStdout('ktx dev doctor setup', doctor, /Node 22\\+/);
assert.equal(doctor.stderr, '', 'ktx dev doctor setup wrote unexpected stderr');
assert.equal(doctor.stderr, 'Project: ' + process.cwd() + '\\n', 'ktx dev doctor setup wrote unexpected stderr');
} finally {
await rm(root, { recursive: true, force: true });
}

View file

@ -521,7 +521,8 @@ describe('verification snippets', () => {
assert.doesNotMatch(source, new RegExp(["'demo'", "'--mode'", "'deterministic'"].join(', ')));
assert.match(source, /'dev', 'doctor', 'setup', '--no-input'/);
assert.match(source, /'--plain'/);
assert.match(source, /ktx setup demo seeded wrote unexpected stderr/);
assert.match(source, /function requireProjectStderr/);
assert.match(source, /requireProjectStderr\('ktx setup demo seeded', seeded, projectDir\)/);
assert.match(source, /Object\.keys\(packageJson\.dependencies\)/);
assert.match(source, /'@kaelio\/ktx'/);
});