diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index fd7a50ef..b4392cf5 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -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 ')}\n\n`); + io.stderr.write('Developer scaffolding:\n'); + io.stderr.write(` ${command('ktx dev init [path] --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) { diff --git a/packages/cli/src/doctor.test.ts b/packages/cli/src/doctor.test.ts index d0ebdb95..2850052a 100644 --- a/packages/cli/src/doctor.test.ts +++ b/packages/cli/src/doctor.test.ts @@ -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'), diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts index 915c7cef..d9edfbd2 100644 --- a/packages/cli/src/doctor.ts +++ b/packages/cli/src/doctor.ts @@ -339,7 +339,7 @@ async function runProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): P 'project-config', 'Project config', failureMessage(error), - `Run: ktx init ${projectDir} --name `, + `Run: ktx setup --new --project-dir ${projectDir}`, ), ); } diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index 333c96e8..4dc73ddd 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -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 '); + expect(testIo.stderr()).toContain('ktx dev init [path] --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 '); + 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 ' 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'); }); }); diff --git a/scripts/installed-live-database-smoke.mjs b/scripts/installed-live-database-smoke.mjs index 7fe061c8..12738abc 100644 --- a/scripts/installed-live-database-smoke.mjs +++ b/scripts/installed-live-database-smoke.mjs @@ -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); diff --git a/scripts/installed-live-database-smoke.test.mjs b/scripts/installed-live-database-smoke.test.mjs index a3d6be9e..e9ae8513 100644 --- a/scripts/installed-live-database-smoke.test.mjs +++ b/scripts/installed-live-database-smoke.test.mjs @@ -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',