From cdcfd21e9532f6887746f3be4a8efd99790ad7fe Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 13 May 2026 00:23:58 +0200 Subject: [PATCH 1/8] refactor(cli): extract buildKtxProgram for reuse outside runCommanderKtxCli --- packages/cli/src/cli-program.test.ts | 54 ++++++++++++++++++++ packages/cli/src/cli-program.ts | 74 +++++++++++++++++----------- 2 files changed, 100 insertions(+), 28 deletions(-) create mode 100644 packages/cli/src/cli-program.test.ts diff --git a/packages/cli/src/cli-program.test.ts b/packages/cli/src/cli-program.test.ts new file mode 100644 index 00000000..79b0d23a --- /dev/null +++ b/packages/cli/src/cli-program.test.ts @@ -0,0 +1,54 @@ +import type { Command } from '@commander-js/extra-typings'; +import { describe, expect, it } from 'vitest'; +import { buildKtxProgram } from './cli-program.js'; +import type { KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; + +function stubIo(): KtxCliIo { + return { + stdout: { isTTY: false, columns: 80, write: () => {} }, + stderr: { write: () => {} }, + }; +} + +function stubPackageInfo(): KtxCliPackageInfo { + return { name: '@ktx/cli', version: '0.0.0-test', contextPackageName: '@ktx/context' }; +} + +describe('buildKtxProgram', () => { + it('returns a Command named "ktx" with all registered top-level subcommands', () => { + const program: Command = buildKtxProgram({ + io: stubIo(), + deps: {}, + packageInfo: stubPackageInfo(), + runInit: async () => 0, + }); + + expect(program.name()).toBe('ktx'); + const topLevel = program.commands.map((command) => command.name()).sort(); + for (const expected of ['setup', 'connection', 'ingest', 'sl', 'dev']) { + expect(topLevel).toContain(expected); + } + }); + + it('does not parse argv or invoke action handlers', () => { + let wrote = ''; + const io: KtxCliIo = { + stdout: { + isTTY: false, + columns: 80, + write: (chunk) => { + wrote += chunk; + }, + }, + stderr: { + write: (chunk) => { + wrote += chunk; + }, + }, + }; + + buildKtxProgram({ io, deps: {}, packageInfo: stubPackageInfo(), runInit: async () => 0 }); + + expect(wrote).toBe(''); + }); +}); diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index 408f3620..e2091bef 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -33,6 +33,14 @@ interface KtxCommanderProgramOptions { runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise; } +export interface BuildKtxProgramOptions { + io: KtxCliIo; + deps: KtxCliDeps; + packageInfo: KtxCliPackageInfo; + runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise; + setExitCode?: (code: number) => void; +} + type CommanderExitLike = { exitCode: number; code: string; message: string }; interface KtxGlobalOptionValues { @@ -288,6 +296,35 @@ async function runBareInteractiveCommand( return 0; } +export function buildKtxProgram(options: BuildKtxProgramOptions): Command { + const program = createBaseProgram(options.packageInfo, options.io); + program.hook('preAction', (_thisCommand, actionCommand) => { + writeProjectDir(options.io, actionCommand as CommandPathNode); + }); + + const context: KtxCliCommandContext = { + io: options.io, + deps: options.deps, + packageInfo: options.packageInfo, + setExitCode: options.setExitCode ?? (() => {}), + runInit: options.runInit, + writeDebug: (command: string, commandContext: CommandWithGlobalOptions) => { + writeDebug(options.io, commandContext, command); + }, + }; + + registerSetupCommands(program, context); + registerConnectionCommands(program, context); + registerPublicIngestCommands(program, context); + registerWikiCommands(program, context); + registerSlCommands(program, context); + registerStatusCommands(program, context); + registerAgentCommands(program, context); + registerDevCommands(program, context); + + return program; +} + export async function runCommanderKtxCli( argv: string[], io: KtxCliIo, @@ -297,11 +334,16 @@ export async function runCommanderKtxCli( ): Promise { profileMark('commander:entry'); let exitCode = 0; - const program = createBaseProgram(info, io); - program.hook('preAction', (_thisCommand, actionCommand) => { - writeProjectDir(io, actionCommand as CommandPathNode); + const program = buildKtxProgram({ + io, + deps, + packageInfo: info, + runInit: options.runInit, + setExitCode: (code: number) => { + exitCode = code; + }, }); - profileMark('commander:base-program'); + profileMark('commander:program-built'); const context: KtxCliCommandContext = { io, deps, @@ -315,30 +357,6 @@ export async function runCommanderKtxCli( }, }; - registerSetupCommands(program, context); - profileMark('commander:register-setup'); - - registerConnectionCommands(program, context); - profileMark('commander:register-connection'); - - registerPublicIngestCommands(program, context); - profileMark('commander:register-public-ingest'); - - registerWikiCommands(program, context); - profileMark('commander:register-wiki'); - - registerSlCommands(program, context); - profileMark('commander:register-sl'); - - registerStatusCommands(program, context); - profileMark('commander:register-status'); - - registerAgentCommands(program, context); - profileMark('commander:register-agent'); - - registerDevCommands(program, context); - profileMark('commander:register-dev'); - if (argv.length === 0) { if (io.stdout.isTTY === true) { try { From f205bec1f656367b32c98bf792cf6e34d55635d8 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 13 May 2026 00:24:51 +0200 Subject: [PATCH 2/8] feat(cli): add walkCommandTree and formatCommandTree helpers --- packages/cli/src/command-tree.test.ts | 81 +++++++++++++++++++++++++++ packages/cli/src/command-tree.ts | 35 ++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 packages/cli/src/command-tree.test.ts create mode 100644 packages/cli/src/command-tree.ts diff --git a/packages/cli/src/command-tree.test.ts b/packages/cli/src/command-tree.test.ts new file mode 100644 index 00000000..ee751224 --- /dev/null +++ b/packages/cli/src/command-tree.test.ts @@ -0,0 +1,81 @@ +import { Command } from '@commander-js/extra-typings'; +import { describe, expect, it } from 'vitest'; +import { formatCommandTree, walkCommandTree } from './command-tree.js'; + +describe('walkCommandTree', () => { + it('captures name, description, aliases, and nested children', () => { + const root = new Command('root').description('the root'); + const child = new Command('child').description('a child').alias('c').alias('ch'); + const grandchild = new Command('grand').description('a grandchild'); + child.addCommand(grandchild); + root.addCommand(child); + + const tree = walkCommandTree(root); + + expect(tree).toEqual({ + name: 'root', + description: 'the root', + aliases: [], + children: [ + { + name: 'child', + description: 'a child', + aliases: ['c', 'ch'], + children: [{ name: 'grand', description: 'a grandchild', aliases: [], children: [] }], + }, + ], + }); + }); + + it('returns an empty children array when there are no subcommands', () => { + const leaf = new Command('leaf').description('alone'); + expect(walkCommandTree(leaf)).toEqual({ + name: 'leaf', + description: 'alone', + aliases: [], + children: [], + }); + }); + + it('uses an empty string when description is unset', () => { + const command = new Command('bare'); + expect(walkCommandTree(command).description).toBe(''); + }); +}); + +describe('formatCommandTree', () => { + it('renders a single node with no children', () => { + const node = { name: 'solo', description: 'just me', aliases: [], children: [] }; + expect(formatCommandTree(node)).toBe('solo - just me\n'); + }); + + it('renders aliases in parentheses before the description', () => { + const node = { name: 'cmd', description: 'does things', aliases: ['c', 'co'], children: [] }; + expect(formatCommandTree(node)).toBe('cmd (c, co) - does things\n'); + }); + + it('omits the dash when description is empty', () => { + const node = { name: 'bare', description: '', aliases: [], children: [] }; + expect(formatCommandTree(node)).toBe('bare\n'); + }); + + it('indents children by two spaces per depth level and sorts siblings alphabetically', () => { + const tree = { + name: 'root', + description: 'top', + aliases: [], + children: [ + { name: 'beta', description: 'b', aliases: [], children: [] }, + { + name: 'alpha', + description: 'a', + aliases: ['al'], + children: [{ name: 'inner', description: 'i', aliases: [], children: [] }], + }, + ], + }; + expect(formatCommandTree(tree)).toBe( + 'root - top\n' + ' alpha (al) - a\n' + ' inner - i\n' + ' beta - b\n', + ); + }); +}); diff --git a/packages/cli/src/command-tree.ts b/packages/cli/src/command-tree.ts new file mode 100644 index 00000000..2cbe7d49 --- /dev/null +++ b/packages/cli/src/command-tree.ts @@ -0,0 +1,35 @@ +import type { Command } from '@commander-js/extra-typings'; + +export interface CommandTreeNode { + name: string; + description: string; + aliases: string[]; + children: CommandTreeNode[]; +} + +export function walkCommandTree(command: Command): CommandTreeNode { + return { + name: command.name(), + description: command.description(), + aliases: command.aliases(), + children: command.commands.map((child) => walkCommandTree(child as Command)), + }; +} + +export function formatCommandTree(node: CommandTreeNode): string { + const lines: string[] = []; + appendNode(node, 0, lines); + return `${lines.join('\n')}\n`; +} + +function appendNode(node: CommandTreeNode, depth: number, lines: string[]): void { + const indent = ' '.repeat(depth); + const aliasPart = node.aliases.length > 0 ? ` (${node.aliases.join(', ')})` : ''; + const descriptionPart = node.description.length > 0 ? ` - ${node.description}` : ''; + lines.push(`${indent}${node.name}${aliasPart}${descriptionPart}`); + + const sortedChildren = [...node.children].sort((a, b) => a.name.localeCompare(b.name)); + for (const child of sortedChildren) { + appendNode(child, depth + 1, lines); + } +} From fda3d4b7ec1533a2a844aaacc17fe43edafdb534 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 13 May 2026 00:25:35 +0200 Subject: [PATCH 3/8] feat(cli): add print-command-tree build-time script --- packages/cli/src/print-command-tree.test.ts | 23 ++++++++++++ packages/cli/src/print-command-tree.ts | 39 +++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 packages/cli/src/print-command-tree.test.ts create mode 100644 packages/cli/src/print-command-tree.ts diff --git a/packages/cli/src/print-command-tree.test.ts b/packages/cli/src/print-command-tree.test.ts new file mode 100644 index 00000000..003d467d --- /dev/null +++ b/packages/cli/src/print-command-tree.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { renderKtxCommandTree } from './print-command-tree.js'; + +describe('renderKtxCommandTree', () => { + it('renders an indented tree rooted at "ktx" with known top-level commands', () => { + const output = renderKtxCommandTree(); + + const lines = output.split('\n'); + expect(lines[0]).toMatch(/^ktx( |$|\s-)/); + + const topLevel = lines.filter((line) => /^ {2}\S/.test(line)).map((line) => line.trim().split(' ')[0]); + + for (const expected of ['setup', 'connection', 'ingest', 'sl', 'dev']) { + expect(topLevel).toContain(expected); + } + }); + + it('ends with a single trailing newline', () => { + const output = renderKtxCommandTree(); + expect(output.endsWith('\n')).toBe(true); + expect(output.endsWith('\n\n')).toBe(false); + }); +}); diff --git a/packages/cli/src/print-command-tree.ts b/packages/cli/src/print-command-tree.ts new file mode 100644 index 00000000..2ede889c --- /dev/null +++ b/packages/cli/src/print-command-tree.ts @@ -0,0 +1,39 @@ +import { fileURLToPath } from 'node:url'; +import { buildKtxProgram } from './cli-program.js'; +import type { KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; +import { formatCommandTree, walkCommandTree } from './command-tree.js'; + +function silentIo(): KtxCliIo { + return { + stdout: { isTTY: false, columns: 80, write: () => {} }, + stderr: { write: () => {} }, + }; +} + +function stubPackageInfo(): KtxCliPackageInfo { + return { name: '@ktx/cli', version: '0.0.0-docs', contextPackageName: '@ktx/context' }; +} + +export function renderKtxCommandTree(): string { + const program = buildKtxProgram({ + io: silentIo(), + deps: {}, + packageInfo: stubPackageInfo(), + runInit: async () => 0, + }); + return formatCommandTree(walkCommandTree(program)); +} + +export function main(stdout: { write(chunk: string): void }): void { + stdout.write(renderKtxCommandTree()); +} + +const invokedAsScript = + typeof process !== 'undefined' && + Array.isArray(process.argv) && + process.argv[1] !== undefined && + fileURLToPath(import.meta.url) === process.argv[1]; + +if (invokedAsScript) { + main(process.stdout); +} From d4ce275a3cebde8f2def0cf3b6fcb19b2929737e Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 13 May 2026 00:26:06 +0200 Subject: [PATCH 4/8] chore(cli): add docs:commands pnpm script --- packages/cli/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/package.json b/packages/cli/package.json index b073acf5..84a448f2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,6 +27,7 @@ "scripts": { "assets:demo": "node scripts/build-demo-assets.mjs", "build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node ../../scripts/prepare-cli-bin.mjs", + "docs:commands": "pnpm run build && node dist/print-command-tree.js", "smoke": "vitest run src/standalone-smoke.test.ts src/example-smoke.test.ts --testTimeout 30000", "test": "vitest run --exclude src/standalone-smoke.test.ts --exclude src/example-smoke.test.ts --exclude src/setup-databases.test.ts --exclude src/scan.test.ts --exclude src/commands/connection-metabase-setup.test.ts --exclude src/setup-models.test.ts --exclude src/setup-sources.test.ts --exclude src/setup.test.ts --exclude src/connection.test.ts --exclude src/setup-embeddings.test.ts --exclude src/ingest.test.ts --exclude src/commands/connection-mapping.test.ts --exclude src/ingest-viz.test.ts --exclude src/demo.test.ts --exclude src/setup-project.test.ts --exclude src/sl.test.ts --exclude src/local-scan-connectors.test.ts --exclude src/commands/connection-notion.test.ts", "test:slow": "vitest run src/setup-databases.test.ts src/scan.test.ts src/commands/connection-metabase-setup.test.ts src/setup-models.test.ts src/setup-sources.test.ts src/setup.test.ts src/connection.test.ts src/setup-embeddings.test.ts src/ingest.test.ts src/commands/connection-mapping.test.ts src/ingest-viz.test.ts src/demo.test.ts src/setup-project.test.ts src/sl.test.ts src/local-scan-connectors.test.ts src/commands/connection-notion.test.ts --testTimeout 30000", From e8a7018c55fd53fd183e6d0611affd6e6fbaf9fb Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 13 May 2026 00:27:39 +0200 Subject: [PATCH 5/8] fix(cli): match command tree description separator --- packages/cli/src/command-tree.test.ts | 6 +++--- packages/cli/src/command-tree.ts | 2 +- packages/cli/src/print-command-tree.test.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/command-tree.test.ts b/packages/cli/src/command-tree.test.ts index ee751224..3f4f486c 100644 --- a/packages/cli/src/command-tree.test.ts +++ b/packages/cli/src/command-tree.test.ts @@ -46,12 +46,12 @@ describe('walkCommandTree', () => { describe('formatCommandTree', () => { it('renders a single node with no children', () => { const node = { name: 'solo', description: 'just me', aliases: [], children: [] }; - expect(formatCommandTree(node)).toBe('solo - just me\n'); + expect(formatCommandTree(node)).toBe('solo — just me\n'); }); it('renders aliases in parentheses before the description', () => { const node = { name: 'cmd', description: 'does things', aliases: ['c', 'co'], children: [] }; - expect(formatCommandTree(node)).toBe('cmd (c, co) - does things\n'); + expect(formatCommandTree(node)).toBe('cmd (c, co) — does things\n'); }); it('omits the dash when description is empty', () => { @@ -75,7 +75,7 @@ describe('formatCommandTree', () => { ], }; expect(formatCommandTree(tree)).toBe( - 'root - top\n' + ' alpha (al) - a\n' + ' inner - i\n' + ' beta - b\n', + 'root — top\n' + ' alpha (al) — a\n' + ' inner — i\n' + ' beta — b\n', ); }); }); diff --git a/packages/cli/src/command-tree.ts b/packages/cli/src/command-tree.ts index 2cbe7d49..f70ce803 100644 --- a/packages/cli/src/command-tree.ts +++ b/packages/cli/src/command-tree.ts @@ -25,7 +25,7 @@ export function formatCommandTree(node: CommandTreeNode): string { function appendNode(node: CommandTreeNode, depth: number, lines: string[]): void { const indent = ' '.repeat(depth); const aliasPart = node.aliases.length > 0 ? ` (${node.aliases.join(', ')})` : ''; - const descriptionPart = node.description.length > 0 ? ` - ${node.description}` : ''; + const descriptionPart = node.description.length > 0 ? ` — ${node.description}` : ''; lines.push(`${indent}${node.name}${aliasPart}${descriptionPart}`); const sortedChildren = [...node.children].sort((a, b) => a.name.localeCompare(b.name)); diff --git a/packages/cli/src/print-command-tree.test.ts b/packages/cli/src/print-command-tree.test.ts index 003d467d..77679aa7 100644 --- a/packages/cli/src/print-command-tree.test.ts +++ b/packages/cli/src/print-command-tree.test.ts @@ -6,7 +6,7 @@ describe('renderKtxCommandTree', () => { const output = renderKtxCommandTree(); const lines = output.split('\n'); - expect(lines[0]).toMatch(/^ktx( |$|\s-)/); + expect(lines[0]).toMatch(/^ktx( |$|\s—)/); const topLevel = lines.filter((line) => /^ {2}\S/.test(line)).map((line) => line.trim().split(' ')[0]); From 39f0320c2bd071b989f452ceb2be861d09197aa2 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 13 May 2026 00:33:24 +0200 Subject: [PATCH 6/8] feat(cli): render command docs as tree --- packages/cli/src/command-tree.test.ts | 50 +++++++++++++++----- packages/cli/src/command-tree.ts | 51 +++++++++++++++------ packages/cli/src/print-command-tree.test.ts | 8 +++- 3 files changed, 81 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/command-tree.test.ts b/packages/cli/src/command-tree.test.ts index 3f4f486c..54dec54b 100644 --- a/packages/cli/src/command-tree.test.ts +++ b/packages/cli/src/command-tree.test.ts @@ -16,12 +16,14 @@ describe('walkCommandTree', () => { name: 'root', description: 'the root', aliases: [], + arguments: [], children: [ { name: 'child', description: 'a child', aliases: ['c', 'ch'], - children: [{ name: 'grand', description: 'a grandchild', aliases: [], children: [] }], + arguments: [], + children: [{ name: 'grand', description: 'a grandchild', aliases: [], arguments: [], children: [] }], }, ], }); @@ -33,6 +35,7 @@ describe('walkCommandTree', () => { name: 'leaf', description: 'alone', aliases: [], + arguments: [], children: [], }); }); @@ -41,41 +44,64 @@ describe('walkCommandTree', () => { const command = new Command('bare'); expect(walkCommandTree(command).description).toBe(''); }); + + it('captures required, optional, and variadic arguments', () => { + const command = new Command('scan') + .argument('', 'KTX connection id') + .argument('[schemas...]', 'Schemas'); + + expect(walkCommandTree(command).arguments).toEqual(['', '[schemas...]']); + }); }); describe('formatCommandTree', () => { it('renders a single node with no children', () => { - const node = { name: 'solo', description: 'just me', aliases: [], children: [] }; - expect(formatCommandTree(node)).toBe('solo — just me\n'); + const node = { name: 'solo', description: 'just me', aliases: [], arguments: [], children: [] }; + expect(formatCommandTree(node)).toMatch(/^solo\s+just me\n$/); }); it('renders aliases in parentheses before the description', () => { - const node = { name: 'cmd', description: 'does things', aliases: ['c', 'co'], children: [] }; - expect(formatCommandTree(node)).toBe('cmd (c, co) — does things\n'); + const node = { name: 'cmd', description: 'does things', aliases: ['c', 'co'], arguments: [], children: [] }; + expect(formatCommandTree(node)).toMatch(/^cmd \(c, co\)\s+does things\n$/); + }); + + it('renders command arguments after the command name', () => { + const node = { + name: 'test', + description: 'Test a configured connection', + aliases: [], + arguments: [''], + children: [], + }; + expect(formatCommandTree(node)).toMatch(/^test \s+Test a configured connection\n$/); }); it('omits the dash when description is empty', () => { - const node = { name: 'bare', description: '', aliases: [], children: [] }; + const node = { name: 'bare', description: '', aliases: [], arguments: [], children: [] }; expect(formatCommandTree(node)).toBe('bare\n'); }); - it('indents children by two spaces per depth level and sorts siblings alphabetically', () => { + it('renders tree connectors and preserves sibling registration order', () => { const tree = { name: 'root', description: 'top', aliases: [], + arguments: [], children: [ - { name: 'beta', description: 'b', aliases: [], children: [] }, + { name: 'beta', description: 'b', aliases: [], arguments: [], children: [] }, { name: 'alpha', description: 'a', aliases: ['al'], - children: [{ name: 'inner', description: 'i', aliases: [], children: [] }], + arguments: [''], + children: [{ name: 'inner', description: 'i', aliases: [], arguments: [], children: [] }], }, ], }; - expect(formatCommandTree(tree)).toBe( - 'root — top\n' + ' alpha (al) — a\n' + ' inner — i\n' + ' beta — b\n', - ); + const lines = formatCommandTree(tree).trimEnd().split('\n'); + expect(lines[0]).toMatch(/^root\s+top$/); + expect(lines[1]).toMatch(/^ ├── beta\s+b$/); + expect(lines[2]).toMatch(/^ └── alpha \(al\)\s+a$/); + expect(lines[3]).toMatch(/^ └── inner\s+i$/); }); }); diff --git a/packages/cli/src/command-tree.ts b/packages/cli/src/command-tree.ts index f70ce803..65e3f5b2 100644 --- a/packages/cli/src/command-tree.ts +++ b/packages/cli/src/command-tree.ts @@ -1,35 +1,58 @@ -import type { Command } from '@commander-js/extra-typings'; +import type { Argument, CommandUnknownOpts } from '@commander-js/extra-typings'; + +const DESCRIPTION_COLUMN = 42; export interface CommandTreeNode { name: string; description: string; aliases: string[]; + arguments: string[]; children: CommandTreeNode[]; } -export function walkCommandTree(command: Command): CommandTreeNode { +export function walkCommandTree(command: CommandUnknownOpts): CommandTreeNode { return { name: command.name(), description: command.description(), aliases: command.aliases(), - children: command.commands.map((child) => walkCommandTree(child as Command)), + arguments: command.registeredArguments.map(formatArgumentDeclaration), + children: command.commands.map((child) => walkCommandTree(child)), }; } export function formatCommandTree(node: CommandTreeNode): string { const lines: string[] = []; - appendNode(node, 0, lines); + appendNode(node, '', '', lines); return `${lines.join('\n')}\n`; } -function appendNode(node: CommandTreeNode, depth: number, lines: string[]): void { - const indent = ' '.repeat(depth); - const aliasPart = node.aliases.length > 0 ? ` (${node.aliases.join(', ')})` : ''; - const descriptionPart = node.description.length > 0 ? ` — ${node.description}` : ''; - lines.push(`${indent}${node.name}${aliasPart}${descriptionPart}`); - - const sortedChildren = [...node.children].sort((a, b) => a.name.localeCompare(b.name)); - for (const child of sortedChildren) { - appendNode(child, depth + 1, lines); - } +function formatArgumentDeclaration(argument: Argument): string { + const name = `${argument.name()}${argument.variadic ? '...' : ''}`; + return argument.required ? `<${name}>` : `[${name}]`; +} + +function appendNode(node: CommandTreeNode, prefix: string, connector: string, lines: string[]): void { + const label = formatLabel(node); + lines.push(formatLine(`${prefix}${connector}${label}`, node.description)); + + node.children.forEach((child, index) => { + const isLast = index === node.children.length - 1; + const childConnector = isLast ? '└── ' : '├── '; + const childPrefix = connector === '' ? ' ' : `${prefix}${isLast ? ' ' : '│ '}`; + appendNode(child, childPrefix, childConnector, lines); + }); +} + +function formatLabel(node: CommandTreeNode): string { + const argumentPart = node.arguments.length > 0 ? ` ${node.arguments.join(' ')}` : ''; + const aliasPart = node.aliases.length > 0 ? ` (${node.aliases.join(', ')})` : ''; + return `${node.name}${argumentPart}${aliasPart}`; +} + +function formatLine(label: string, description: string): string { + if (description.length === 0) { + return label; + } + const padding = label.length >= DESCRIPTION_COLUMN ? ' ' : ' '.repeat(DESCRIPTION_COLUMN - label.length); + return `${label}${padding}${description}`; } diff --git a/packages/cli/src/print-command-tree.test.ts b/packages/cli/src/print-command-tree.test.ts index 77679aa7..c50ee9a3 100644 --- a/packages/cli/src/print-command-tree.test.ts +++ b/packages/cli/src/print-command-tree.test.ts @@ -6,13 +6,17 @@ describe('renderKtxCommandTree', () => { const output = renderKtxCommandTree(); const lines = output.split('\n'); - expect(lines[0]).toMatch(/^ktx( |$|\s—)/); + expect(lines[0]).toMatch(/^ktx( |$)/); - const topLevel = lines.filter((line) => /^ {2}\S/.test(line)).map((line) => line.trim().split(' ')[0]); + const topLevel = lines + .filter((line) => /^ {2}[├└]── \S/.test(line)) + .map((line) => line.replace(/^ {2}[├└]── /, '').trim().split(' ')[0]); for (const expected of ['setup', 'connection', 'ingest', 'sl', 'dev']) { expect(topLevel).toContain(expected); } + + expect(output).toContain('│ ├── test '); }); it('ends with a single trailing newline', () => { From 5e057f97e304f782cdd0fa94f95b33d4b5b46c28 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 13 May 2026 00:35:07 +0200 Subject: [PATCH 7/8] fix(cli): preserve tree continuation guides --- packages/cli/src/command-tree.test.ts | 13 ++++++++++--- packages/cli/src/command-tree.ts | 3 ++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/command-tree.test.ts b/packages/cli/src/command-tree.test.ts index 54dec54b..85fa0e84 100644 --- a/packages/cli/src/command-tree.test.ts +++ b/packages/cli/src/command-tree.test.ts @@ -88,7 +88,13 @@ describe('formatCommandTree', () => { aliases: [], arguments: [], children: [ - { name: 'beta', description: 'b', aliases: [], arguments: [], children: [] }, + { + name: 'beta', + description: 'b', + aliases: [], + arguments: [], + children: [{ name: 'leaf', description: 'l', aliases: [], arguments: [], children: [] }], + }, { name: 'alpha', description: 'a', @@ -101,7 +107,8 @@ describe('formatCommandTree', () => { const lines = formatCommandTree(tree).trimEnd().split('\n'); expect(lines[0]).toMatch(/^root\s+top$/); expect(lines[1]).toMatch(/^ ├── beta\s+b$/); - expect(lines[2]).toMatch(/^ └── alpha \(al\)\s+a$/); - expect(lines[3]).toMatch(/^ └── inner\s+i$/); + expect(lines[2]).toMatch(/^ │ └── leaf\s+l$/); + expect(lines[3]).toMatch(/^ └── alpha \(al\)\s+a$/); + expect(lines[4]).toMatch(/^ └── inner\s+i$/); }); }); diff --git a/packages/cli/src/command-tree.ts b/packages/cli/src/command-tree.ts index 65e3f5b2..2eeb24e8 100644 --- a/packages/cli/src/command-tree.ts +++ b/packages/cli/src/command-tree.ts @@ -35,10 +35,11 @@ function appendNode(node: CommandTreeNode, prefix: string, connector: string, li const label = formatLabel(node); lines.push(formatLine(`${prefix}${connector}${label}`, node.description)); + const childPrefix = + connector === '' ? `${prefix} ` : `${prefix}${connector === '└── ' ? ' ' : '│ '}`; node.children.forEach((child, index) => { const isLast = index === node.children.length - 1; const childConnector = isLast ? '└── ' : '├── '; - const childPrefix = connector === '' ? ' ' : `${prefix}${isLast ? ' ' : '│ '}`; appendNode(child, childPrefix, childConnector, lines); }); } From 7b52ff9bd077b2c468f6c754f60e3a482fcc0c7b Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 13 May 2026 00:36:19 +0200 Subject: [PATCH 8/8] docs: add cli command tree implementation plan --- .../2026-05-13-cli-command-tree-script.md | 580 ++++++++++++++++++ 1 file changed, 580 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-13-cli-command-tree-script.md diff --git a/docs/superpowers/plans/2026-05-13-cli-command-tree-script.md b/docs/superpowers/plans/2026-05-13-cli-command-tree-script.md new file mode 100644 index 00000000..e336eb31 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-cli-command-tree-script.md @@ -0,0 +1,580 @@ +# CLI Command-Tree Script Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a build-time script that prints the full `ktx` CLI command tree (name, aliases, description per node) as an indented text tree, for docs and discovery — without adding a runtime `ktx` subcommand. + +**Architecture:** Commander.js exposes every registered command as a `Command` instance with `.commands`, `.name()`, `.aliases()`, `.description()` — we walk that tree. The current `runCommanderKtxCli` in `packages/cli/src/cli-program.ts` builds the program inline; we extract that assembly into a pure `buildKtxProgram(...)` helper that any caller can use to materialize the configured root `Command` without parsing argv. A new pure module `command-tree.ts` walks the `Command` into plain data and renders it as indented text. A new TypeScript entrypoint `print-command-tree.ts` compiles alongside `bin.ts` into `dist/print-command-tree.js`, instantiates the program with stub IO/deps, and writes the rendered tree to stdout. A pnpm script under `@ktx/cli` exposes it as `pnpm --filter @ktx/cli run docs:commands`. + +**Tech Stack:** TypeScript (NodeNext ESM), Node 22, Commander 14 via `@commander-js/extra-typings`, vitest 4. + +--- + +## File Map + +- **Modify:** `packages/cli/src/cli-program.ts` — extract `buildKtxProgram` from `runCommanderKtxCli`. +- **Create:** `packages/cli/src/cli-program.test.ts` — vitest tests for the new helper. +- **Create:** `packages/cli/src/command-tree.ts` — pure `walkCommandTree` + `formatCommandTree`. +- **Create:** `packages/cli/src/command-tree.test.ts` — vitest tests against ad-hoc Command trees. +- **Create:** `packages/cli/src/print-command-tree.ts` — script entrypoint; thin glue. +- **Create:** `packages/cli/src/print-command-tree.test.ts` — vitest test that calls the script's exported `main()` with a fake stdout and asserts the rendered tree includes known top-level commands. +- **Modify:** `packages/cli/package.json` — add `docs:commands` script and include the new entry in tsc build output (no change needed if `tsconfig` already globs `src/**/*.ts`, but verify). +- **Modify:** `packages/cli/README.md` (if it exists; otherwise skip) — document `pnpm run docs:commands`. + +Files that change together (cli-program + its test, command-tree + its test, print-command-tree + its test) live next to each other under `packages/cli/src/`, matching the existing convention (e.g. `bin.ts`, `cli-runtime.ts`, `runtime.ts` + `runtime.test.ts`). + +--- + +## Task 1: Extract `buildKtxProgram` from `runCommanderKtxCli` + +Refactor only — no behavior change. The current code in `cli-program.ts` interleaves program construction with `parseAsync` dispatch. Splitting them lets the new script reuse construction without invoking the CLI. + +**Files:** +- Modify: `packages/cli/src/cli-program.ts:197-275` (function `runCommanderKtxCli`) +- Create: `packages/cli/src/cli-program.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/src/cli-program.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; +import type { Command } from '@commander-js/extra-typings'; +import { buildKtxProgram } from './cli-program.js'; +import type { KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; + +function stubIo(): KtxCliIo { + return { + stdout: { isTTY: false, columns: 80, write: () => {} }, + stderr: { write: () => {} }, + }; +} + +function stubPackageInfo(): KtxCliPackageInfo { + return { name: '@ktx/cli', version: '0.0.0-test', contextPackageName: '@ktx/context' }; +} + +describe('buildKtxProgram', () => { + it('returns a Command named "ktx" with all registered top-level subcommands', () => { + const program: Command = buildKtxProgram({ + io: stubIo(), + deps: {}, + packageInfo: stubPackageInfo(), + runInit: async () => 0, + }); + + expect(program.name()).toBe('ktx'); + const topLevel = program.commands.map((c) => c.name()).sort(); + // Sanity check: at least these registrar surfaces must be present. + for (const expected of ['setup', 'serve', 'sl', 'dev']) { + expect(topLevel).toContain(expected); + } + }); + + it('does not parse argv or invoke action handlers', async () => { + // Build should be a pure call; no rejections, no side-effects to stdout. + let wrote = ''; + const io: KtxCliIo = { + stdout: { isTTY: false, columns: 80, write: (chunk) => { wrote += chunk; } }, + stderr: { write: (chunk) => { wrote += chunk; } }, + }; + buildKtxProgram({ io, deps: {}, packageInfo: stubPackageInfo(), runInit: async () => 0 }); + expect(wrote).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @ktx/cli exec vitest run src/cli-program.test.ts` + +Expected: FAIL — `buildKtxProgram is not exported from './cli-program.js'` (or similar TS/ESM error). + +- [ ] **Step 3: Extract `buildKtxProgram` from `runCommanderKtxCli`** + +Edit `packages/cli/src/cli-program.ts`. Add a new exported function above `runCommanderKtxCli`: + +```typescript +export interface BuildKtxProgramOptions { + io: KtxCliIo; + deps: KtxCliDeps; + packageInfo: KtxCliPackageInfo; + runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise; + setExitCode?: (code: number) => void; +} + +export function buildKtxProgram(options: BuildKtxProgramOptions): Command { + const program = createBaseProgram(options.packageInfo, options.io); + const context: KtxCliCommandContext = { + io: options.io, + deps: options.deps, + packageInfo: options.packageInfo, + setExitCode: options.setExitCode ?? (() => {}), + runInit: options.runInit, + writeDebug: (command, commandContext) => { + writeDebug(options.io, commandContext, command); + }, + }; + + registerSetupCommands(program, context); + registerConnectionCommands(program, context); + registerPublicIngestCommands(program, context); + registerWikiCommands(program, context); + registerSlCommands(program, context); + registerRuntimeCommands(program, context); + registerServeCommands(program, context); + registerStatusCommands(program, context); + registerAgentCommands(program, context); + registerDevCommands(program, context); + + return program; +} +``` + +Then rewrite the body of `runCommanderKtxCli` (lines 197-275) to delegate program assembly. Replace the block from `const program = createBaseProgram(info, io);` (line 206) through `registerDevCommands(program, context);` (line 248) with: + +```typescript + profileMark('commander:entry'); + let exitCode = 0; + const program = buildKtxProgram({ + io, + deps, + packageInfo: info, + runInit: options.runInit, + setExitCode: (code: number) => { + exitCode = code; + }, + }); + profileMark('commander:program-built'); + const context: KtxCliCommandContext = { + io, + deps, + packageInfo: info, + setExitCode: (code: number) => { + exitCode = code; + }, + runInit: options.runInit, + writeDebug: (command: string, commandContext: CommandWithGlobalOptions) => { + writeDebug(io, commandContext, command); + }, + }; +``` + +Keep the `context` re-declaration only if subsequent code (the `if (argv.length === 0)` branch that calls `runBareInteractiveCommand(program, io, context)`) still needs it. It does — `runBareInteractiveCommand` consumes `context`. Keep `context` exactly as it was after the deletion; do not change `runBareInteractiveCommand`'s signature or behavior. Drop the now-removed individual `register*` calls and their `profileMark` lines from `runCommanderKtxCli`. + +- [ ] **Step 4: Run the new test to verify it passes** + +Run: `pnpm --filter @ktx/cli exec vitest run src/cli-program.test.ts` + +Expected: PASS — both `it` blocks green. + +- [ ] **Step 5: Run the full CLI test suite to confirm no regression** + +Run: `pnpm --filter @ktx/cli run test 2>&1 | tee /tmp/ktx-cli-test-output.log` + +Expected: PASS overall. Inspect the log if any previously-passing test now fails — most likely a missing register call (compare to lines 221-249 of the pre-change file). + +- [ ] **Step 6: Type-check** + +Run: `pnpm --filter @ktx/cli run type-check` + +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/cli-program.ts packages/cli/src/cli-program.test.ts +git commit -m "refactor(cli): extract buildKtxProgram for reuse outside runCommanderKtxCli" +``` + +--- + +## Task 2: Pure tree walker `walkCommandTree` + +Take a Commander `Command` and produce plain data: `{ name, description, aliases, children }`. No formatting yet. Pure function — depends only on the public `Command` API. + +**Files:** +- Create: `packages/cli/src/command-tree.ts` +- Create: `packages/cli/src/command-tree.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/src/command-tree.test.ts`: + +```typescript +import { Command } from '@commander-js/extra-typings'; +import { describe, expect, it } from 'vitest'; +import { walkCommandTree } from './command-tree.js'; + +describe('walkCommandTree', () => { + it('captures name, description, aliases, and nested children', () => { + const root = new Command('root').description('the root'); + const child = new Command('child').description('a child').alias('c').alias('ch'); + const grandchild = new Command('grand').description('a grandchild'); + child.addCommand(grandchild); + root.addCommand(child); + + const tree = walkCommandTree(root); + + expect(tree).toEqual({ + name: 'root', + description: 'the root', + aliases: [], + children: [ + { + name: 'child', + description: 'a child', + aliases: ['c', 'ch'], + children: [ + { name: 'grand', description: 'a grandchild', aliases: [], children: [] }, + ], + }, + ], + }); + }); + + it('returns an empty children array when there are no subcommands', () => { + const leaf = new Command('leaf').description('alone'); + expect(walkCommandTree(leaf)).toEqual({ + name: 'leaf', + description: 'alone', + aliases: [], + children: [], + }); + }); + + it('uses an empty string when description is unset', () => { + const cmd = new Command('bare'); + expect(walkCommandTree(cmd).description).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @ktx/cli exec vitest run src/command-tree.test.ts` + +Expected: FAIL — `walkCommandTree` cannot be resolved. + +- [ ] **Step 3: Implement `walkCommandTree`** + +Create `packages/cli/src/command-tree.ts`: + +```typescript +import type { Command } from '@commander-js/extra-typings'; + +export interface CommandTreeNode { + name: string; + description: string; + aliases: string[]; + children: CommandTreeNode[]; +} + +export function walkCommandTree(command: Command): CommandTreeNode { + return { + name: command.name(), + description: command.description(), + aliases: command.aliases(), + children: command.commands.map((child) => walkCommandTree(child as Command)), + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @ktx/cli exec vitest run src/command-tree.test.ts` + +Expected: PASS (3 of 3). + +- [ ] **Step 5: Type-check** + +Run: `pnpm --filter @ktx/cli run type-check` + +Expected: no errors. + +--- + +## Task 3: Indented-text renderer `formatCommandTree` + +Render a `CommandTreeNode` as plain text. Each node on its own line: `[ (alias1, alias2)][ — description]`. Indent is two spaces per depth level. Children sorted alphabetically by name to keep output stable across changes that reorder registrar calls. + +**Files:** +- Modify: `packages/cli/src/command-tree.ts` +- Modify: `packages/cli/src/command-tree.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `packages/cli/src/command-tree.test.ts`: + +```typescript +import { formatCommandTree } from './command-tree.js'; + +describe('formatCommandTree', () => { + it('renders a single node with no children', () => { + const node = { name: 'solo', description: 'just me', aliases: [], children: [] }; + expect(formatCommandTree(node)).toBe('solo — just me\n'); + }); + + it('renders aliases in parentheses before the description', () => { + const node = { name: 'cmd', description: 'does things', aliases: ['c', 'co'], children: [] }; + expect(formatCommandTree(node)).toBe('cmd (c, co) — does things\n'); + }); + + it('omits the dash when description is empty', () => { + const node = { name: 'bare', description: '', aliases: [], children: [] }; + expect(formatCommandTree(node)).toBe('bare\n'); + }); + + it('indents children by two spaces per depth level and sorts siblings alphabetically', () => { + const tree = { + name: 'root', + description: 'top', + aliases: [], + children: [ + { name: 'beta', description: 'b', aliases: [], children: [] }, + { name: 'alpha', description: 'a', aliases: ['al'], children: [ + { name: 'inner', description: 'i', aliases: [], children: [] }, + ] }, + ], + }; + expect(formatCommandTree(tree)).toBe( + 'root — top\n' + + ' alpha (al) — a\n' + + ' inner — i\n' + + ' beta — b\n', + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @ktx/cli exec vitest run src/command-tree.test.ts` + +Expected: FAIL — `formatCommandTree` is not exported. + +- [ ] **Step 3: Implement `formatCommandTree`** + +Append to `packages/cli/src/command-tree.ts`: + +```typescript +export function formatCommandTree(node: CommandTreeNode): string { + const lines: string[] = []; + appendNode(node, 0, lines); + return `${lines.join('\n')}\n`; +} + +function appendNode(node: CommandTreeNode, depth: number, lines: string[]): void { + const indent = ' '.repeat(depth); + const aliasPart = node.aliases.length > 0 ? ` (${node.aliases.join(', ')})` : ''; + const descPart = node.description.length > 0 ? ` — ${node.description}` : ''; + lines.push(`${indent}${node.name}${aliasPart}${descPart}`); + + const sortedChildren = [...node.children].sort((a, b) => a.name.localeCompare(b.name)); + for (const child of sortedChildren) { + appendNode(child, depth + 1, lines); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @ktx/cli exec vitest run src/command-tree.test.ts` + +Expected: PASS (7 of 7 across walkCommandTree + formatCommandTree). + +- [ ] **Step 5: Type-check** + +Run: `pnpm --filter @ktx/cli run type-check` + +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/command-tree.ts packages/cli/src/command-tree.test.ts +git commit -m "feat(cli): add walkCommandTree and formatCommandTree helpers" +``` + +--- + +## Task 4: Script entrypoint `print-command-tree.ts` + +Thin glue: build the program with stub IO/deps, walk, format, write to a provided stdout. Export a `main(stdout)` function for unit testing; only auto-run when invoked as a script. + +**Files:** +- Create: `packages/cli/src/print-command-tree.ts` +- Create: `packages/cli/src/print-command-tree.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/src/print-command-tree.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; +import { renderKtxCommandTree } from './print-command-tree.js'; + +describe('renderKtxCommandTree', () => { + it('renders an indented tree rooted at "ktx" with known top-level commands', () => { + const output = renderKtxCommandTree(); + + const lines = output.split('\n'); + expect(lines[0]).toMatch(/^ktx( |$|\s—)/); + + // Top-level commands are indented exactly two spaces. + const topLevel = lines + .filter((line) => /^ {2}\S/.test(line)) + .map((line) => line.trim().split(' ')[0]); + + for (const expected of ['setup', 'serve', 'sl', 'dev']) { + expect(topLevel).toContain(expected); + } + }); + + it('ends with a single trailing newline', () => { + const output = renderKtxCommandTree(); + expect(output.endsWith('\n')).toBe(true); + expect(output.endsWith('\n\n')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @ktx/cli exec vitest run src/print-command-tree.test.ts` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the script** + +Create `packages/cli/src/print-command-tree.ts`: + +```typescript +import { fileURLToPath } from 'node:url'; +import { buildKtxProgram } from './cli-program.js'; +import type { KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; +import { formatCommandTree, walkCommandTree } from './command-tree.js'; + +function silentIo(): KtxCliIo { + return { + stdout: { isTTY: false, columns: 80, write: () => {} }, + stderr: { write: () => {} }, + }; +} + +function stubPackageInfo(): KtxCliPackageInfo { + return { name: '@ktx/cli', version: '0.0.0-docs', contextPackageName: '@ktx/context' }; +} + +export function renderKtxCommandTree(): string { + const program = buildKtxProgram({ + io: silentIo(), + deps: {}, + packageInfo: stubPackageInfo(), + runInit: async () => 0, + }); + return formatCommandTree(walkCommandTree(program)); +} + +export function main(stdout: { write(chunk: string): void }): void { + stdout.write(renderKtxCommandTree()); +} + +const invokedAsScript = + typeof process !== 'undefined' && + Array.isArray(process.argv) && + process.argv[1] !== undefined && + fileURLToPath(import.meta.url) === process.argv[1]; + +if (invokedAsScript) { + main(process.stdout); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @ktx/cli exec vitest run src/print-command-tree.test.ts` + +Expected: PASS — both assertions green. + +- [ ] **Step 5: Type-check** + +Run: `pnpm --filter @ktx/cli run type-check` + +Expected: no errors. + +- [ ] **Step 6: Build and run the script end-to-end** + +Run: +```bash +pnpm --filter @ktx/cli run build +node packages/cli/dist/print-command-tree.js | head -20 +``` + +Expected: first line begins with `ktx`, followed by indented top-level commands (`setup`, `serve`, `sl`, `dev`, etc.). No errors on stderr. + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/print-command-tree.ts packages/cli/src/print-command-tree.test.ts +git commit -m "feat(cli): add print-command-tree build-time script" +``` + +--- + +## Task 5: Wire pnpm script and document + +Expose the script through pnpm so contributors and CI don't need to remember the `node dist/…` path. + +**Files:** +- Modify: `packages/cli/package.json` (add `docs:commands` to `scripts`) + +- [ ] **Step 1: Inspect existing scripts block** + +Run: `node -e "const p=require('./packages/cli/package.json'); console.log(JSON.stringify(p.scripts, null, 2))"` + +Note the current keys (`build`, `smoke`, `test`, `test:slow`, `type-check`, `assets:demo`). Add a new entry that depends on `build`. + +- [ ] **Step 2: Add the `docs:commands` script** + +Edit `packages/cli/package.json`. In the `"scripts"` object, add (after `"build"`): + +```json +"docs:commands": "pnpm run build && node dist/print-command-tree.js", +``` + +Keep alphabetical-ish ordering consistent with the existing block; if other scripts use `&&` chains for build prerequisites, match the style. + +- [ ] **Step 3: Verify the script runs** + +Run: `pnpm --filter @ktx/cli run docs:commands | head -30` + +Expected: builds the CLI, then prints the tree (first line `ktx ...`, two-space-indented children below). + +- [ ] **Step 4: Verify nothing else broke** + +Run in parallel: +- `pnpm --filter @ktx/cli run type-check` +- `pnpm --filter @ktx/cli run test` + +Expected: both PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/package.json +git commit -m "chore(cli): add docs:commands pnpm script" +``` + +--- + +## Verification Summary + +After all tasks, confirm: + +- [ ] `pnpm --filter @ktx/cli run type-check` — clean +- [ ] `pnpm --filter @ktx/cli run test` — green, including new tests in `cli-program.test.ts`, `command-tree.test.ts`, `print-command-tree.test.ts` +- [ ] `pnpm --filter @ktx/cli run docs:commands` — prints `ktx` followed by indented subcommand tree +- [ ] `git status --short` — only the files listed in the File Map are modified or created; no incidental edits + +If any check fails, fix in place and re-run before declaring done.