fix(cli): improve removed command guidance

This commit is contained in:
Andrey Avtomonov 2026-05-12 01:52:40 +02:00
parent 49a746af28
commit 9b0c15152d
6 changed files with 232 additions and 5 deletions

View file

@ -42,6 +42,20 @@ interface KtxGlobalOptionValues {
debug?: boolean;
}
const ROOT_COMMANDS = new Set([
'setup',
'connection',
'ingest',
'wiki',
'sl',
'runtime',
'serve',
'status',
'help',
'dev',
'agent',
]);
export interface CommandWithGlobalOptions {
opts: () => object;
optsWithGlobals?: () => object;
@ -158,6 +172,88 @@ function formatCliError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function shouldUseErrorStyle(io: KtxCliIo): boolean {
return io.stdout.isTTY === true && !process.env.NO_COLOR && process.env.TERM !== 'dumb' && !process.env.CI;
}
function ansi(text: string, open: string, close: string, enabled: boolean): string {
return enabled ? `\u001b[${open}m${text}\u001b[${close}m` : text;
}
function formatErrorLabel(enabled: boolean): string {
return ansi('error', '31', '39', enabled);
}
function formatCommandToken(command: string, enabled: boolean): string {
return enabled ? ansi(command, '1', '22', true) : `\`${command}\``;
}
function formatHint(text: string, enabled: boolean): string {
return ansi(text, '2', '22', enabled);
}
function findRootCommandToken(argv: string[]): string | null | undefined {
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg) {
continue;
}
if (arg === '--') {
return null;
}
if (arg === '--project-dir') {
const value = argv[i + 1];
if (!value || value.startsWith('-')) {
return undefined;
}
i += 1;
continue;
}
if (arg.startsWith('--project-dir=')) {
continue;
}
if (arg === '--debug' || arg === '--help' || arg === '-h' || arg === '--version' || arg === '-v') {
continue;
}
if (arg.startsWith('-')) {
return undefined;
}
return arg;
}
return null;
}
function writeRemovedInitCommandError(io: KtxCliIo): void {
const styled = shouldUseErrorStyle(io);
const command = (value: string) => formatCommandToken(value, styled);
io.stderr.write(`${formatErrorLabel(styled)}: ${command('ktx init')} is no longer a public command.\n\n`);
io.stderr.write('Create or resume a KTX project:\n');
io.stderr.write(` ${command('ktx setup')}\n`);
io.stderr.write(` ${command('ktx setup --new --project-dir <path>')}\n\n`);
io.stderr.write('Developer scaffolding:\n');
io.stderr.write(` ${command('ktx dev init [path] --name <project-name>')}\n\n`);
io.stderr.write(`${formatHint('Run `ktx --help` to see all commands.', styled)}\n`);
}
function writeUnknownRootCommandError(commandName: string, io: KtxCliIo): void {
const styled = shouldUseErrorStyle(io);
io.stderr.write(`${formatErrorLabel(styled)}: unknown command ${formatCommandToken(commandName, styled)}\n\n`);
io.stderr.write(`${formatHint('Run `ktx --help` to see available commands.', styled)}\n`);
}
function writeRootCommandPreflightError(argv: string[], io: KtxCliIo): boolean {
const commandName = findRootCommandToken(argv);
if (commandName === undefined || commandName === null || ROOT_COMMANDS.has(commandName)) {
return false;
}
if (commandName === 'init') {
writeRemovedInitCommandError(io);
return true;
}
writeUnknownRootCommandError(commandName, io);
return true;
}
async function runBareInteractiveCommand(
program: Command,
io: KtxCliIo,
@ -261,6 +357,10 @@ export async function runCommanderKtxCli(
return 0;
}
if (writeRootCommandPreflightError(argv, io)) {
return 1;
}
try {
await profileSpan('commander:parseAsync', () => program.parseAsync(argv, { from: 'user' }));
} catch (error) {

View file

@ -266,6 +266,27 @@ describe('runKtxDoctor', () => {
expect(testIo.stdout()).toContain('PASS Connections: 1 configured');
});
it('points project config failures at setup instead of removed init', async () => {
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{
runSetupChecks: async () => [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
],
runHistoricSqlDoctorChecks: async () => [],
},
),
).resolves.toBe(1);
expect(testIo.stdout()).toContain('FAIL Project config:');
expect(testIo.stdout()).toContain(`Fix: Run: ktx setup --new --project-dir ${tempDir}`);
expect(testIo.stdout()).not.toContain('ktx init');
});
it('includes Postgres historic-SQL readiness in project doctor output', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),

View file

@ -339,7 +339,7 @@ async function runProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): P
'project-config',
'Project config',
failureMessage(error),
`Run: ktx init ${projectDir} --name <project-name>`,
`Run: ktx setup --new --project-dir ${projectDir}`,
),
);
}

View file

@ -99,12 +99,33 @@ describe('memory-flow renderer exports', () => {
describe('runKtxCli', () => {
let tempDir: string;
let previousCi: string | undefined;
let previousNoColor: string | undefined;
let previousTerm: string | undefined;
beforeEach(async () => {
previousCi = process.env.CI;
previousNoColor = process.env.NO_COLOR;
previousTerm = process.env.TERM;
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-'));
});
afterEach(async () => {
if (previousCi === undefined) {
delete process.env.CI;
} else {
process.env.CI = previousCi;
}
if (previousNoColor === undefined) {
delete process.env.NO_COLOR;
} else {
process.env.NO_COLOR = previousNoColor;
}
if (previousTerm === undefined) {
delete process.env.TERM;
} else {
process.env.TERM = previousTerm;
}
await rm(tempDir, { recursive: true, force: true });
});
@ -2402,7 +2423,58 @@ describe('runKtxCli', () => {
await expect(runKtxCli(['init'], testIo.io)).resolves.toBe(1);
expect(testIo.stderr()).toContain("error: unknown command 'init'");
expect(testIo.stdout()).toBe('');
expect(testIo.stderr()).toContain('error: `ktx init` is no longer a public command.');
expect(testIo.stderr()).toContain('ktx setup');
expect(testIo.stderr()).toContain('ktx setup --new --project-dir <path>');
expect(testIo.stderr()).toContain('ktx dev init [path] --name <project-name>');
expect(testIo.stderr()).not.toContain('Did you mean');
expect(testIo.stderr()).not.toContain('Usage: ktx');
expect(testIo.stderr()).not.toContain('\u001b[');
});
it('recognizes removed init after global options', async () => {
const testIo = makeIo();
await expect(runKtxCli(['--project-dir', tempDir, 'init'], testIo.io)).resolves.toBe(1);
expect(testIo.stderr()).toContain('error: `ktx init` is no longer a public command.');
expect(testIo.stderr()).toContain('ktx setup --new --project-dir <path>');
expect(testIo.stderr()).not.toContain('Usage: ktx');
});
it('lets Commander handle malformed global options', async () => {
const testIo = makeIo();
await expect(runKtxCli(['--project-dir'], testIo.io)).resolves.toBe(1);
expect(testIo.stderr()).toContain("option '--project-dir <path>' argument missing");
expect(testIo.stderr()).not.toContain('no longer a public command');
});
it('uses restrained terminal styling for removed-command guidance in TTY output', async () => {
delete process.env.CI;
delete process.env.NO_COLOR;
process.env.TERM = 'xterm-256color';
const testIo = makeIo({ stdoutIsTty: true });
await expect(runKtxCli(['init'], testIo.io)).resolves.toBe(1);
expect(testIo.stderr()).toContain('\u001b[31merror\u001b[39m');
expect(testIo.stderr()).toContain('\u001b[1mktx setup\u001b[22m');
expect(testIo.stderr()).toContain('\u001b[2mRun `ktx --help` to see all commands.\u001b[22m');
});
it('honors NO_COLOR for removed-command guidance', async () => {
delete process.env.CI;
process.env.NO_COLOR = '1';
process.env.TERM = 'xterm-256color';
const testIo = makeIo({ stdoutIsTty: true });
await expect(runKtxCli(['init'], testIo.io)).resolves.toBe(1);
expect(testIo.stderr()).toContain('error: `ktx init` is no longer a public command.');
expect(testIo.stderr()).not.toContain('\u001b[');
});
it('returns an error code for unknown commands', async () => {
@ -2410,6 +2482,9 @@ describe('runKtxCli', () => {
await expect(runKtxCli(['unknown'], testIo.io)).resolves.toBe(1);
expect(testIo.stderr()).toContain("error: unknown command 'unknown'");
expect(testIo.stdout()).toBe('');
expect(testIo.stderr()).toContain('error: unknown command `unknown`');
expect(testIo.stderr()).toContain('Run `ktx --help` to see available commands.');
expect(testIo.stderr()).not.toContain('Usage: ktx');
});
});

View file

@ -120,6 +120,22 @@ export function buildLiveDatabaseIngestArgs(projectDir, databaseIntrospectionUrl
];
}
export function buildSetupNewProjectArgs(projectDir) {
return [
'exec',
'ktx',
'setup',
'--new',
'--project-dir',
projectDir,
'--skip-llm',
'--skip-embeddings',
'--skip-databases',
'--skip-sources',
'--no-input',
];
}
export function buildLiveDatabaseStatusArgs(projectDir, runId) {
return ['exec', 'ktx', 'ingest', 'status', '--project-dir', projectDir, runId];
}
@ -308,11 +324,11 @@ async function main() {
await prepareCleanInstall(layout, cleanInstallDir);
await mkdir(projectDir, { recursive: true });
const init = await run('pnpm', ['exec', 'ktx', 'init', projectDir, '--name', 'artifact-live-database'], {
const setup = await run('pnpm', buildSetupNewProjectArgs(projectDir), {
cwd: cleanInstallDir,
timeout: 30_000,
});
requireSuccess('ktx init', init);
requireSuccess('ktx setup --new', setup);
await writeFile(join(projectDir, 'ktx.yaml'), buildKtxYaml(postgresUrl), 'utf8');
const databaseIntrospectionUrl = await startDaemon(cleanInstallDir);

View file

@ -9,6 +9,7 @@ import {
buildPostgresUrl,
buildPostgresReadyArgs,
buildSeedSql,
buildSetupNewProjectArgs,
smokeContainerName,
} from './installed-live-database-smoke.mjs';
@ -99,6 +100,20 @@ describe('installed live-database artifact smoke helpers', () => {
});
it('builds installed CLI live-database ingest and status commands', () => {
assert.deepEqual(buildSetupNewProjectArgs('/tmp/project'), [
'exec',
'ktx',
'setup',
'--new',
'--project-dir',
'/tmp/project',
'--skip-llm',
'--skip-embeddings',
'--skip-databases',
'--skip-sources',
'--no-input',
]);
assert.deepEqual(buildLiveDatabaseIngestArgs('/tmp/project', 'http://127.0.0.1:8765'), [
'exec',
'ktx',