From fb65eeb5765c6bb2067c1ea345b0dc8bb42d4005 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Apr 2026 17:32:07 +0800 Subject: [PATCH] fix(cli): improve parse error output --- cli/src/commands/help.ts | 12 +- cli/src/index.ts | 155 +++++++++++++++++++- cli/test/integration/error-output.test.ts | 84 +++++++++++ cli/test/integration/help-command.test.ts | 7 + cli/test/integration/search-command.test.ts | 15 ++ 5 files changed, 261 insertions(+), 12 deletions(-) create mode 100644 cli/test/integration/error-output.test.ts diff --git a/cli/src/commands/help.ts b/cli/src/commands/help.ts index c1e91015..8fad2055 100644 --- a/cli/src/commands/help.ts +++ b/cli/src/commands/help.ts @@ -1,6 +1,6 @@ import { printResult } from '../shared/output' -const commands = { +export const commands = { help: { summary: 'Show available commands', usage: 'skillhub help [command] [--json]', @@ -28,8 +28,8 @@ const commands = { }, search: { summary: 'Search published skills', - usage: 'skillhub search [--limit ] [--registry ] [--json]', - examples: ['skillhub search pdf', 'skillhub search "" --limit 20'] + usage: 'skillhub search [query] [--limit ] [--registry ] [--json]', + examples: ['skillhub search', 'skillhub search pdf'] }, install: { summary: 'Install a skill locally', @@ -63,6 +63,10 @@ const commands = { } } as const +export function formatCommandList(): string { + return Object.entries(commands).map(([name, detail]) => `${name.padEnd(10)} ${detail.summary}`).join('\n') +} + export async function helpCommand(args: string[]): Promise { const json = args.includes('--json') const topic = args.find(arg => !arg.startsWith('--')) @@ -85,5 +89,5 @@ export async function helpCommand(args: string[]): Promise { ...detail.examples.map(example => ` ${example}`) ].join('\n') } - return Object.entries(commands).map(([name, detail]) => `${name.padEnd(10)} ${detail.summary}`).join('\n') + return formatCommandList() } diff --git a/cli/src/index.ts b/cli/src/index.ts index ba5d8d1a..c7fce1de 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { cac } from 'cac' import { doctorCommand } from './commands/doctor' -import { helpCommand } from './commands/help' +import { commands, formatCommandList, helpCommand } from './commands/help' import { installCommand, type InstallCommandOptions } from './commands/install' import { listCommand, type ListCommandOptions } from './commands/list' import { loginCommand } from './commands/login' @@ -36,11 +36,140 @@ async function runCommand(action: () => Promise, json = false): Promise< } } +const KNOWN_COMMANDS = Object.keys(commands) + +function levenshteinDistance(left: string, right: string): number { + const rows = left.length + 1 + const cols = right.length + 1 + const matrix = Array.from({ length: rows }, () => Array(cols).fill(0)) + + for (let row = 0; row < rows; row += 1) matrix[row]![0] = row + for (let col = 0; col < cols; col += 1) matrix[0]![col] = col + + for (let row = 1; row < rows; row += 1) { + for (let col = 1; col < cols; col += 1) { + const cost = left[row - 1] === right[col - 1] ? 0 : 1 + matrix[row]![col] = Math.min( + matrix[row - 1]![col]! + 1, + matrix[row]![col - 1]! + 1, + matrix[row - 1]![col - 1]! + cost + ) + } + } + + return matrix[left.length]![right.length]! +} + +function findCommandSuggestions(input: string): string[] { + return KNOWN_COMMANDS + .map(command => ({ + command, + score: command.startsWith(input) + ? 0 + : command.includes(input) + ? 1 + : levenshteinDistance(input, command) + })) + .filter(({ command, score }) => + command.startsWith(input) || + (input.length > 2 && command.includes(input)) || + score <= Math.max(2, Math.floor(command.length / 3)) + ) + .sort((left, right) => left.score - right.score || left.command.localeCompare(right.command)) + .map(({ command }) => command) + .slice(0, 3) +} + +function renderCommandDirectory(): string { + return ['Available commands:', formatCommandList()].join('\n') +} + +function exitWithOutput(output: string, exitCode: number): never { + process.stderr.write(`${output}\n`) + process.exit(exitCode) +} + +function exitWithCliError(error: CliError, json: boolean, humanOutput?: string): never { + return exitWithOutput(json ? renderError(error, true) : (humanOutput ?? renderError(error, false)), error.exitCode) +} + +function exitUnknownCommand(command: string, json: boolean): never { + const suggestions = findCommandSuggestions(command) + const lines = [`unknown command "${command}" for "skillhub"`, ''] + + if (suggestions.length > 0) { + lines.push(`Did you mean ${suggestions.length === 1 ? 'this' : 'one of these'}?`) + lines.push(...suggestions.map(suggestion => ` ${suggestion}`)) + lines.push('') + } + + lines.push('Usage: skillhub [flags]', '') + lines.push(renderCommandDirectory(), '') + lines.push('Run "skillhub help" for more information.') + return exitWithCliError(new CliError(`unknown command "${command}" for "skillhub"`, 5), json, lines.join('\n')) +} + +function exitUnknownFlag(flag: string, json: boolean): never { + return exitWithCliError(new CliError(`unknown flag: ${flag}`, 5), json, [ + `unknown flag: ${flag}`, + '', + 'Usage: skillhub [flags]', + '', + renderCommandDirectory(), + '', + 'Run "skillhub help" for more information.' + ].join('\n')) +} + +function handleCliParseError(error: unknown, json: boolean): never { + if (!(error instanceof Error)) { + return exitWithCliError(new CliError('unexpected failure', 1), json, 'Unexpected error') + } + + if (error.name === 'CACError') { + const message = error.message + + if (/unknown option/i.test(message)) { + const match = message.match(/unknown option ["`]?([^"`]+)["`]?/i) + return exitUnknownFlag(match?.[1] ?? 'unknown', json) + } + + if (message.includes('missing required args')) { + const match = message.match(/command `([^`]+)`/) + const cmdName = match?.[1] ?? 'command' + const firstWord = cmdName.split(' ')[0] ?? 'command' + + return exitWithCliError(new CliError('missing required argument', 5), json, [ + 'Error: missing required argument', + '', + `Usage: skillhub ${cmdName}`, + '', + `Run "skillhub help ${firstWord}" for more information.` + ].join('\n')) + } + + const cleanMessage = message.replace(/`/g, '"') + return exitWithCliError(new CliError(cleanMessage, 5), json) + } + + return exitWithCliError(new CliError('unexpected failure', 1), json, `Unexpected error: ${error.message}`) +} + +function isJsonRequested(argv: string[]): boolean { + return argv.includes('--json') +} + +function readUnknownCommand(argv: string[]): string | undefined { + const firstArg = argv[0] + if (!firstArg || firstArg.startsWith('-') || KNOWN_COMMANDS.includes(firstArg)) { + return undefined + } + return firstArg +} + cli .command('', 'Show help') - .action(() => { - return runCommand(() => helpCommand([])) - }) + .action(() => runCommand(() => helpCommand([]))) cli .command('help [command]', 'Show help') @@ -91,12 +220,12 @@ cli }) cli - .command('search ', 'Search published skills') + .command('search [query]', 'Search published skills') .option('--registry ', 'Registry URL') .option('--limit ', 'Max results', { default: 20 }) .option('--json', 'Output JSON') - .action((query: string, options: { registry?: string; limit?: number; json?: boolean }) => { - return runCommand(() => searchCommand(query, options), Boolean(options.json)) + .action((query: string | undefined, options: { registry?: string; limit?: number; json?: boolean }) => { + return runCommand(() => searchCommand(query ?? '', options), Boolean(options.json)) }) cli @@ -158,5 +287,15 @@ cli cli.help() if (import.meta.main) { - cli.parse(process.argv) + const args = process.argv.slice(2) + const json = isJsonRequested(args) + const unknownCommand = readUnknownCommand(args) + if (unknownCommand) { + exitUnknownCommand(unknownCommand, json) + } + try { + cli.parse(process.argv) + } catch (error) { + handleCliParseError(error, json) + } } diff --git a/cli/test/integration/error-output.test.ts b/cli/test/integration/error-output.test.ts new file mode 100644 index 00000000..246b3070 --- /dev/null +++ b/cli/test/integration/error-output.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test' +import { runCli } from '../helpers/run-cli' + +describe('cli error output', () => { + test('prints gh-style help for unknown commands', async () => { + const result = await runCli(['foo']) + + expect(result.exitCode).toBe(5) + expect(result.stderr).toContain('unknown command "foo" for "skillhub"') + expect(result.stderr).toContain('Usage: skillhub [flags]') + expect(result.stderr).toContain('Available commands:') + expect(result.stderr).toContain('help Show available commands') + expect(result.stderr).toContain('publish Publish a local skill package') + }) + + test('prefers unknown command output when the command is followed by a bad flag', async () => { + const result = await runCli(['foo', '--bad']) + + expect(result.exitCode).toBe(5) + expect(result.stderr).toContain('unknown command "foo" for "skillhub"') + expect(result.stderr).not.toContain('unknown flag: --bad') + }) + + test('prints unknown command as json when --json is requested', async () => { + const result = await runCli(['foo', '--json']) + + expect(result.exitCode).toBe(5) + expect(JSON.parse(result.stderr)).toEqual({ + ok: false, + message: 'unknown command "foo" for "skillhub"', + exitCode: 5 + }) + }) + + test('suggests close matches for mistyped commands', async () => { + const result = await runCli(['serch']) + + expect(result.exitCode).toBe(5) + expect(result.stderr).toContain('unknown command "serch" for "skillhub"') + expect(result.stderr).toContain('Did you mean this?') + expect(result.stderr).toContain(' search') + }) + + test('prints unknown flag message and command directory', async () => { + const result = await runCli(['version', '--badflag']) + + expect(result.exitCode).toBe(5) + expect(result.stderr).toContain('unknown flag: --badflag') + expect(result.stderr).toContain('Usage: skillhub [flags]') + expect(result.stderr).toContain('Available commands:') + expect(result.stderr).toContain('version Show installed CLI version') + }) + + test('prints unknown flag as json when --json is requested', async () => { + const result = await runCli(['version', '--badflag', '--json']) + + expect(result.exitCode).toBe(5) + expect(JSON.parse(result.stderr)).toEqual({ + ok: false, + message: 'unknown flag: --badflag', + exitCode: 5 + }) + }) + + test('prints missing argument usage for install', async () => { + const result = await runCli(['install']) + + expect(result.exitCode).toBe(5) + expect(result.stderr).toContain('Error: missing required argument') + expect(result.stderr).toContain('Usage: skillhub install ') + expect(result.stderr).toContain('Run "skillhub help install" for more information.') + }) + + test('prints parse errors as json when --json is requested', async () => { + const result = await runCli(['install', '--json']) + + expect(result.exitCode).toBe(5) + expect(JSON.parse(result.stderr)).toEqual({ + ok: false, + message: 'missing required argument', + exitCode: 5 + }) + }) +}) diff --git a/cli/test/integration/help-command.test.ts b/cli/test/integration/help-command.test.ts index bce1e45f..58782179 100644 --- a/cli/test/integration/help-command.test.ts +++ b/cli/test/integration/help-command.test.ts @@ -8,4 +8,11 @@ describe('help command', () => { expect(result.stdout).toContain('Usage: skillhub install ') expect(result.stdout).toContain('--agent ') }) + + test('prints search help with optional query', async () => { + const result = await runCli(['help', 'search']) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Usage: skillhub search [query]') + expect(result.stdout).toContain('skillhub search') + }) }) diff --git a/cli/test/integration/search-command.test.ts b/cli/test/integration/search-command.test.ts index e31b8f44..ef2fd1d6 100644 --- a/cli/test/integration/search-command.test.ts +++ b/cli/test/integration/search-command.test.ts @@ -35,4 +35,19 @@ describe('search command', () => { expect(json.items).toHaveLength(1) expect(json.items[0].slug).toBe('pdf-parser') }) + + test('search without query returns full result set', async () => { + registry = await startFakeRegistry({ + searchItems: [ + { namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }, + { namespace: 'global', slug: 'doc-parser', latestVersion: '2.0.0', summary: 'Parse docs' } + ] + }) + + const result = await runCli(['search', '--registry', registry.url]) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('global/pdf-parser') + expect(result.stdout).toContain('global/doc-parser') + }) })