diff --git a/AGENTS.md b/AGENTS.md index 49d8a97..c59e577 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md — @ultime/cli > 给在本仓库工作的 AI 与协作者:入口、可写 / 只读、红线与验收。 -> 适用版本:0.1.x +> 适用版本:0.2.x ## 先读 @@ -24,6 +24,7 @@ ## 红线 - **零领域依赖**:不 import 任何领域包;不内置具体命令。 +- **不发 bin、不做命令发现**:每个产品包自带入口;本包不聚合、不扫描其他包的 bin。 - **不做 IO 策略**:读盘写盘与配置路径由命令自己决定。 - **不引第三方解析库**:`parseArgs` 保持零依赖。 - **API 变化同步**:`runCli` / `ParsedArgs` / `CliContext` 变化时同步 README、`SKILLS.md` 与测试。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6764154..861ace2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.2.0 - 2026-11-12 + +### Added + +- `CliError` / `toErrorShape`:机器可读错误,带 `code` / `path` / `hint` / `details` / `exitCode`。 +- `--json` 作为保留布尔 flag:错误以 `{ "error": { … } }` 只写 stderr。 +- 未知命令给最近候选;单命令 help 缺省时按命令名生成。 + +### Changed + +- 退出码语义:0 成功 / 1 命令执行失败 / 2 用法或配置错误;无命令与未知命令从 1 改为 2。 +- 本包不发布 bin、不做命令发现;各产品包自带入口。 + ## 0.1.4 - 2026-11-10 - 构建改用 `vp pack`,产物同时生成 `dist/index.js` 与 `dist/index.d.ts`。 diff --git a/README.md b/README.md index 712ad21..491a5d9 100644 --- a/README.md +++ b/README.md @@ -40,17 +40,48 @@ process.exitCode = runCli(spec, process.argv.slice(2), processContext()) | 入口 | 提供 | |---|---| -| `@ultime/cli` | `parseArgs` / `runCli` / `processContext` / `CliContext` / `CommandSpec` / `CliSpec` | +| `@ultime/cli` | `parseArgs` / `runCli` / `processContext` / `CliError` / `toErrorShape` / `CliContext` / `CliSpec` / `CommandSpec` | -`parseArgs` 只认 `command`、`--flag`、`--key value` / `--key=value`;`-h/--help` 与 `-v/--version` 是保留 flag。 +`parseArgs` 只认 `command`、`--flag`、`--key value` / `--key=value`;`-h/--help`、`-v/--version` 与 `--json` 是保留 flag。 + +## 错误与退出码 + +命令用 `CliError` 抛机器可读错误;普通异常退化成 `code: 'ERROR'`: + +```ts +import { CliError } from '@ultime/cli' + +throw new CliError('缺少 --config', { + code: 'USAGE', + path: '--config', + hint: '补上 --config ', +}) +``` + +| 情况 | 退出码 | +|---|---| +| 版本 / 帮助 | 0 | +| 命令返回的码 | 原样透传 | +| 命令抛普通异常 | 1,`code: 'ERROR'` | +| `CliError` | `exitCode`,默认 2(用法或配置错误) | +| 无命令、未知命令 | 2 | + +- 未知命令给最近候选;没有候选时列出可用命令。 +- `--json` 时错误只写 stderr: + +```json +{ "error": { "code": "USAGE", "message": "缺少 --config", "path": "--config", "hint": "补上 --config " } } +``` + +- 人类文案与 JSON 同源,由 `toErrorShape` 统一转换。 AI 入口:[`llms.txt`](./llms.txt)(路由)与 [`SKILLS.md`](./SKILLS.md)(能力表)随包发布。 ## 边界 - **零领域依赖**:不 import 任何领域包,也不认识具体业务对象。 -- **不引第三方解析 / 着色库**:解析用零依赖实现,着色直接用 ANSI;Node 内置的 `util.parseArgs` / `util.styleText` 是后续替换首选,等出现嵌套子命令或补全需求再评估 `commander` 一类工具。 -- **不内置命令**:具体命令由调用方包注册,各自发布自己的 bin。 +- **不引第三方解析 / 着色库**:解析用零依赖实现,着色直接用 ANSI。Node 内置 `util.parseArgs` 表达不了自由 `--key value`,暂不切换。 +- **不内置命令、不发 bin**:具体命令由调用方包注册,各自发布自己的 bin;本包不做命令发现与聚合。 - **不做 IO 策略**:读盘写盘、配置路径由命令自己决定;本包只提供 `cwd` 与 `out/err`。 ## 目录 @@ -59,14 +90,16 @@ AI 入口:[`llms.txt`](./llms.txt)(路由)与 [`SKILLS.md`](./SKILLS.md) src/ args.ts 参数解析(纯函数) context.ts 命令上下文(cwd / out / err) + error.ts 机器可读错误与统一转换 run.ts 命令注册、help、未知命令与退出码 index.ts 公共出口 -tests/ 参数与运行时分发 +tests/ 参数、运行时分发与包内容边界 ``` ## 版本与兼容 -- 当前 `0.1.x`;0.x 阶段 API 仍可能调整。 +- 当前 `0.2.x`;0.x 阶段 API 仍可能调整。 +- 退出码语义:0 成功 / 1 命令执行失败 / 2 用法或配置错误;这是 0.2 起的新约定,升级时按本节目迁移。 - 破坏性变更写进 [CHANGELOG](./CHANGELOG.md),并在本节目说明迁移方式。 - 发布顺序:本包是基础设施,先于依赖它的包。 diff --git a/SKILLS.md b/SKILLS.md index 4dfbb7d..bd72d0b 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -1,23 +1,25 @@ # @ultime/cli · 可调用能力 > 定位:AI 可调用的通用命令设施。领域命令不在这里。 -> 适用版本:0.1.x -> 同步基线:0.1.x(手写;API 变化时同批更新) +> 适用版本:0.2.x +> 同步基线:0.2.x(手写;API 变化时同批更新) --- ## 入口 ```ts -import { parseArgs, runCli, processContext } from '@ultime/cli' -import type { ParsedArgs, CliContext, CliSpec, CommandSpec } from '@ultime/cli' +import { parseArgs, runCli, processContext, CliError } from '@ultime/cli' +import type { ParsedArgs, CliContext, CliSpec, CommandSpec, CliErrorOptions, CliErrorShape } from '@ultime/cli' ``` | 能力 | 输入 | 输出 | |---|---|---| | `parseArgs(argv)` | 字符串数组 | `{ command, flags, positional }` | -| `runCli(spec, argv, ctx?)` | `CliSpec` + 参数 | 退出码;版本 / 帮助 / 未知命令 / 异常由它处理 | +| `runCli(spec, argv, ctx?)` | `CliSpec` + 参数 | 退出码;版本 / 帮助 / 未知命令 / 错误由它处理 | | `processContext()` | — | `{ cwd, out, err }` | +| `CliError(message, options)` | 文案 + `code` / `path` / `hint` / `details` / `exitCode` | 可被 `runCli` 统一输出的错误 | +| `toErrorShape(error)` | 任意异常 | `{ code, message, path?, hint?, details? }` | ## `CliSpec` @@ -32,11 +34,15 @@ import type { ParsedArgs, CliContext, CliSpec, CommandSpec } from '@ultime/cli' ## 错误语义 -- 未知命令:`err` 写错误,`out` 写 usage,返回 1; -- 命令抛异常:`err` 写 message,返回 1; +- 未知命令:`err` 写错误与候选,`out` 写 usage,返回 2; +- 无命令:`out` 写 usage,返回 2; +- 普通异常:`err` 写 message,`code: 'ERROR'`,返回 1; +- `CliError`:带 `code` / `path` / `hint`,返回 `exitCode`(默认 2); +- `--json`:错误以 `{ "error": { … } }` 只写 stderr; - 版本 / 帮助:返回 0,不执行命令。 ## 反例 - 把 `--check` 当成 cli 内置 flag:它属于调用方的命令。 - 在 cli 里读具体项目配置:IO 与配置语义属于调用方命令。 +- 在 cli 里发现或聚合其他包的 bin:每个包自带入口,本包不发 bin。 diff --git a/llms.txt b/llms.txt index a6b6974..50ffa4d 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # @ultime/cli · AI 索引 -适用版本:0.1.x +适用版本:0.2.x ## 去哪找 @@ -9,8 +9,9 @@ ## 机器入口 -- 公共出口:`@ultime/cli` 的 `parseArgs` / `runCli` / `processContext` 与类型。 +- 公共出口:`@ultime/cli` 的 `parseArgs` / `runCli` / `processContext` / `CliError` / `toErrorShape` 与类型。 ## 边界 - 通用设施,不含任何领域命令;读盘写盘归调用方。 +- 不发 bin、不做命令发现;每个产品包发布自己的入口。 diff --git a/package.json b/package.json index eaf2a49..eb81bbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ultime/cli", - "version": "0.1.4", + "version": "0.2.0", "description": "Ultime · cli:通用命令行设施(参数 / 命令注册 / 上下文 / 退出码),零领域依赖", "keywords": [ "argument-parser", @@ -41,8 +41,8 @@ }, "scripts": { "build": "vp pack", - "check": "vp check && checks run && pnpm cycles", - "checks": "checks run", + "check": "vp check && ultime-checks run && pnpm cycles", + "checks": "ultime-checks run", "cycles": "vp lint --import-plugin --deny=import/no-cycle src", "test": "vp test", "size": "size-limit --json", @@ -53,7 +53,7 @@ "@arethetypeswrong/core": "^0.18.5", "@size-limit/preset-small-lib": "^14.3.0", "@types/node": "^22.20.5", - "@ultime/checks": "^0.2.0", + "@ultime/checks": "^0.3.0", "publint": "^0.3.25", "size-limit": "^14.3.0", "typescript": "^5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dc5f48..d052126 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,8 +176,8 @@ importers: specifier: ^22.20.5 version: 22.20.5 '@ultime/checks': - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.3.0 + version: 0.3.0 publint: specifier: ^0.3.25 version: 0.3.25 @@ -853,8 +853,8 @@ packages: '@types/node@22.20.5': resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} - '@ultime/checks@0.2.0': - resolution: {integrity: sha512-R3xPgd4AsHDrHUtCyzYNVgLg0ImdxpL5GSh5TpaY6x+Y+cX6tsbHQaX48fhPF5pHMOGAvgUjg008wxahEOjjNg==} + '@ultime/checks@0.3.0': + resolution: {integrity: sha512-0arXp70xHWA6iba9YaCctOku7hN8bMhNOH2EMHOS+z6rYdGb2tctGM9DUdAqJNH1JT49RFrXXKki7/l68SuVyA==} engines: {node: '>=22.18'} hasBin: true @@ -1951,7 +1951,7 @@ snapshots: dependencies: undici-types: 6.21.0 - '@ultime/checks@0.2.0': {} + '@ultime/checks@0.3.0': {} '@vitest/browser-preview@5.1.0(@voidzero-dev/vite-plus-core@1.5.1(@arethetypeswrong/core@0.18.5)(@types/node@22.20.5)(esbuild@0.28.2)(publint@0.3.25)(tsx@4.23.15)(typescript@5.9.3)(unplugin-unused@0.6.0))(vitest@5.1.0)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 05fcb44..487d59e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,4 +12,6 @@ minimumReleaseAgeExclude: - '@voidzero-dev/vite-plus-win32-arm64-msvc@1.5.1' - '@voidzero-dev/vite-plus-win32-x64-msvc@1.5.1' - vite-plus@1.5.1 - - '@ultime/checks@0.1.1 || 0.2.0' + - '@ultime/checks@0.1.1' + - '@ultime/checks@0.2.0' + - '@ultime/checks@0.3.0' diff --git a/rules/01-architecture.md b/rules/01-architecture.md index a08bde0..ea6a8b9 100644 --- a/rules/01-architecture.md +++ b/rules/01-architecture.md @@ -1,7 +1,7 @@ # 01 · 架构与依赖 > 定位:CLI 设施的职责、依赖方向与目录分工。 -> 适用版本:0.1.x +> 适用版本:0.2.x > 关系:规则索引见 [`README`](./README.md);运行协议见 [`02-runner`](./02-runner.md)。 --- @@ -10,9 +10,10 @@ | 做 | 不做 | |---|---| -| 参数解析、help、版本、未知命令与退出码 | 定义任何领域命令 | +| 参数解析、help、版本、未知命令、退出码与错误模型 | 定义任何领域命令 | | 命令注册与分发 | 读配置、写产物、判断配置合法性 | | 提供 `cwd` 与 `out/err` 上下文 | 决定日志格式、文件布局或 IO 策略 | +| 提供 `CliError` / `toErrorShape` 与 `--json` 错误输出 | 发布 bin、发现或聚合其他包的命令 | 依赖方向: @@ -20,8 +21,8 @@ 调用方(任何上层包) → @ultime/cli ``` -- 本包**不 import** 任何调用方包。 -- 调用方注册命令;本包只负责通用流程。 +- 本包**不 import** 任何调用方包,也不持有领域包清单。 +- 各产品包自带 bin;本包只负责通用流程。 ## 目录 @@ -29,7 +30,8 @@ |---|---| | `src/args.ts` | `command` / `--flag` / `--key value` 解析 | | `src/context.ts` | `CliContext` 与进程实现 | -| `src/run.ts` | `runCli`:版本 / 帮助 / 未知命令 / 异常转退出码 | +| `src/error.ts` | `CliError` / `toErrorShape` | +| `src/run.ts` | `runCli`:版本 / 帮助 / 未知命令 / 错误转退出码 | | `src/index.ts` | 公共出口 | ## 反例 diff --git a/rules/02-runner.md b/rules/02-runner.md index 33f7aa4..52207b8 100644 --- a/rules/02-runner.md +++ b/rules/02-runner.md @@ -1,7 +1,7 @@ # 02 · 参数、命令与退出码 > 定位:`runCli` 的稳定协议。 -> 适用版本:0.1.x +> 适用版本:0.2.x > 关系:架构见 [`01-architecture`](./01-architecture.md)。 --- @@ -14,7 +14,7 @@ - `--flag`:布尔; - `--key value` / `--key=value`:带值。 -保留 flag:`-h/--help`、`-v/--version`。其余 flag 原样交给命令解释。 +保留 flag:`-h/--help`、`-v/--version` 与 `--json`。`--json` 固定按布尔解析,只表达输出形态;其余 flag 原样交给命令解释。 ## 命令注册 @@ -29,14 +29,31 @@ runCli({ }, argv, ctx) ``` +`help` 缺省时生成 ` [options]`。 + +## 错误模型 + +命令抛 `CliError`,字段全部可选(`code` 必填): + +| 字段 | 用途 | +|---|---| +| `code` | 稳定错误码,例如 `USAGE` / `CONFIG`;上层可追加 | +| `path` | 出错位置,字段路径或参数名 | +| `hint` | 可执行的修复建议 | +| `details` | 附加诊断数据,原样进入 JSON | +| `exitCode` | 默认 2;业务失败可覆盖为 1 | + +普通异常没有字段信息,统一成 `{ code: 'ERROR', message }`。 + ## 退出码 | 情况 | 退出码 | |---|---| | 版本 / 帮助 | 0 | -| 无命令、未知命令 | 1 | | 命令返回的码 | 原样透传 | -| 命令抛异常 | 1,错误写入 `ctx.err` | +| 命令抛普通异常 | 1,`code: 'ERROR'` | +| `CliError` | `exitCode`,默认 2 | +| 无命令、未知命令 | 2 | -- 未知命令先写 `err`,再输出 usage 到 `out`。 +- 未知命令先写 `err`(带最近候选),再输出 usage 到 `out`;`--json` 时不写 stdout。 - 命令自己决定业务错误码;本包不定义领域错误。 diff --git a/rules/03-testing.md b/rules/03-testing.md index acce0a3..9bdf02b 100644 --- a/rules/03-testing.md +++ b/rules/03-testing.md @@ -1,7 +1,7 @@ # 03 · 测试与验收 > 定位:改 CLI 设施时跑什么、什么算通过。 -> 适用版本:0.1.x +> 适用版本:0.2.x > 关系:协议见 [`02-runner`](./02-runner.md);发布检查见 [`04-release`](./04-release.md)。 --- diff --git a/rules/04-release.md b/rules/04-release.md index 364ab6d..4268ced 100644 --- a/rules/04-release.md +++ b/rules/04-release.md @@ -1,7 +1,7 @@ # 04 · 版本与发布 > 定位:CLI 设施的编译 ESM 形态、发布顺序与检查。 -> 适用版本:0.1.x +> 适用版本:0.2.x > 关系:测试见 [`03-testing`](./03-testing.md)。 --- diff --git a/rules/README.md b/rules/README.md index 980d08a..1bec8cd 100644 --- a/rules/README.md +++ b/rules/README.md @@ -1,7 +1,7 @@ # cli · 开发规则 > 定位:本包开发规范的索引。只写判断、边界与反例;API 事实以 README 为准。 -> 适用版本:0.1.x +> 适用版本:0.2.x > owner:维护者(repo owner)· 复核:2026-11-11 · 到期降级为参考 > 关系:入口见 [`AGENTS.md`](../AGENTS.md);怎么用见 [`README.md`](../README.md)。 diff --git a/src/args.ts b/src/args.ts index a8f3e14..60b6fc9 100644 --- a/src/args.ts +++ b/src/args.ts @@ -3,6 +3,7 @@ * --------------------------------------------------------------------------- * 只认三种形态:`command`、`--flag`、`--key value` / `--key=value`; * 不引第三方解析库 —— CLI 必须零依赖可跑。 + * `--json` 是通用输出开关,固定按布尔解析,不吞后面的命令名。 */ export interface ParsedArgs { @@ -11,6 +12,9 @@ export interface ParsedArgs { positional: string[] } +/** 通用布尔 flag:只表达输出形态,不承载业务语义。 */ +const BOOLEAN_FLAGS = new Set(['json']) + export function parseArgs(argv: string[]): ParsedArgs { const flags: Record = {} const positional: string[] = [] @@ -33,6 +37,10 @@ export function parseArgs(argv: string[]): ParsedArgs { flags[body.slice(0, equal)] = body.slice(equal + 1) continue } + if (BOOLEAN_FLAGS.has(body)) { + flags[body] = true + continue + } const next = argv[i + 1] if (next && !next.startsWith('-')) { flags[body] = next diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 0000000..e35c31a --- /dev/null +++ b/src/error.ts @@ -0,0 +1,58 @@ +/** + * error.ts — 机器可读错误 + * --------------------------------------------------------------------------- + * 命令抛 `CliError`,`runCli` 统一转成人类文案或 `--json` 结构; + * 普通异常没有字段信息,退化成 `code: 'ERROR'`,退出码 1。 + */ + +export interface CliErrorOptions { + /** 稳定错误码,例如 `USAGE` / `CONFIG`;上层可追加自己的码。 */ + code: string + /** 出错位置:字段路径或参数名。 */ + path?: string + /** 可执行的修复建议。 */ + hint?: string + /** 附加诊断数据,原样进入 JSON 输出。 */ + details?: unknown + /** 退出码,默认 2(用法或配置错误)。 */ + exitCode?: number +} + +export class CliError extends Error { + readonly code: string + readonly path?: string + readonly hint?: string + readonly details?: unknown + readonly exitCode: number + + constructor(message: string, options: CliErrorOptions) { + super(message) + this.name = 'CliError' + this.code = options.code + this.path = options.path + this.hint = options.hint + this.details = options.details + this.exitCode = options.exitCode ?? 2 + } +} + +export interface CliErrorShape { + code: string + message: string + path?: string + hint?: string + details?: unknown +} + +export function toErrorShape(error: unknown): CliErrorShape { + if (error instanceof CliError) { + return { + code: error.code, + message: error.message, + ...(error.path === undefined ? {} : { path: error.path }), + ...(error.hint === undefined ? {} : { hint: error.hint }), + ...(error.details === undefined ? {} : { details: error.details }), + } + } + return { code: 'ERROR', message: error instanceof Error ? error.message : String(error) } +} diff --git a/src/index.ts b/src/index.ts index 5e5adad..04f66ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export { parseArgs, type ParsedArgs } from './args.ts' export { processContext, type CliContext } from './context.ts' +export { CliError, toErrorShape, type CliErrorOptions, type CliErrorShape } from './error.ts' export { runCli, type CommandSpec, type CliSpec } from './run.ts' diff --git a/src/run.ts b/src/run.ts index aebafd5..5daba0e 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,54 +1,112 @@ /** - * run.ts — 命令注册与退出码 + * run.ts — 命令注册、退出码与错误输出 * --------------------------------------------------------------------------- - * 通用设施:只负责版本 / 帮助 / 未知命令 / 异常转退出码; + * 通用设施:只负责版本 / 帮助 / 未知命令 / 错误转换; * 具体命令由调用方注册,cli 包不出现任何领域命令。 */ import { parseArgs, type ParsedArgs } from './args.ts' import { processContext, type CliContext } from './context.ts' +import { CliError, toErrorShape } from './error.ts' export interface CommandSpec { - /** 单条命令的 help;缺省时回落到全局 usage */ + /** 单条命令的 help;缺省时生成 ` [options]` */ help?: string run: (args: ParsedArgs, ctx: CliContext) => number } export interface CliSpec { - /** 用于错误与帮助文案,例如 `ultime` */ + /** 用于错误与帮助文案,例如 `my-cli` */ name: string version: string usage: string commands: Record } +/** 未知命令的候选阈值:太小漏提示,太大乱提示。 */ +const SUGGEST_DISTANCE = 2 + +function distance(left: string, right: string): number { + let previous = Array.from({ length: right.length + 1 }, (_, index) => index) + + for (let row = 1; row <= left.length; row += 1) { + const current = [row] + for (let col = 1; col <= right.length; col += 1) { + const cost = left[row - 1] === right[col - 1] ? 0 : 1 + current[col] = Math.min(previous[col]! + 1, current[col - 1]! + 1, previous[col - 1]! + cost) + } + previous = current + } + + return previous[right.length]! +} + +function nearest(input: string, candidates: string[]): string | undefined { + let best: { name: string; distance: number } | undefined + + for (const name of candidates) { + const value = distance(input, name) + if (value > SUGGEST_DISTANCE) continue + if (!best || value < best.distance) best = { name, distance: value } + } + + return best?.name +} + +function reportError(ctx: CliContext, error: unknown, json: boolean): void { + const shape = toErrorShape(error) + if (json) { + ctx.err(JSON.stringify({ error: shape })) + return + } + + ctx.err(`✗ ${shape.message}`) + if (shape.path) ctx.err(` 位置:${shape.path}`) + if (shape.hint) ctx.err(` 提示:${shape.hint}`) +} + +function exitCodeOf(error: unknown): number { + return error instanceof CliError ? error.exitCode : 1 +} + export function runCli(spec: CliSpec, argv: string[], ctx: CliContext = processContext()): number { const args = parseArgs(argv) + const json = args.flags.json === true if (args.flags.version) { ctx.out(spec.version) return 0 } if (args.flags.help) { - ctx.out(args.command && spec.commands[args.command]?.help ? spec.commands[args.command]!.help! : spec.usage) + const command = args.command ? spec.commands[args.command] : undefined + ctx.out(command ? (command.help ?? `${spec.name} ${args.command} [options]`) : spec.usage) return 0 } if (!args.command) { ctx.out(spec.usage) - return 1 + return 2 } const command = spec.commands[args.command] if (!command) { - ctx.err(`✗ 未知命令:${args.command}\n`) - ctx.out(spec.usage) - return 1 + const suggestion = nearest(args.command, Object.keys(spec.commands)) + reportError( + ctx, + new CliError(`未知命令:${args.command}`, { + code: 'USAGE', + path: args.command, + hint: suggestion ? `是否想用 ${suggestion}?` : `可用命令:${Object.keys(spec.commands).join(' / ')}`, + }), + json, + ) + if (!json) ctx.out(spec.usage) + return 2 } try { return command.run(args, ctx) } catch (error) { - ctx.err(`✗ ${error instanceof Error ? error.message : String(error)}`) - return 1 + reportError(ctx, error, json) + return exitCodeOf(error) } } diff --git a/tests/infra.test.ts b/tests/infra.test.ts index 1ea68b5..3d6ff8b 100644 --- a/tests/infra.test.ts +++ b/tests/infra.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test' -import { parseArgs, runCli, type CliContext, type CliSpec } from '../src/index.ts' +import { CliError, parseArgs, runCli, type CliContext, type CliSpec } from '../src/index.ts' function collector() { const out: string[] = [] @@ -29,6 +29,16 @@ const spec: CliSpec = { throw 'raw failure' }, }, + usage: { + run: () => { + throw new CliError('缺少 --config', { code: 'USAGE', path: '--config', hint: '补上 --config ' }) + }, + }, + config: { + run: () => { + throw new CliError('配置文件无法解析', { code: 'CONFIG', exitCode: 1, details: { file: 'a.json' } }) + }, + }, }, } @@ -55,6 +65,10 @@ describe('cli · 参数', () => { expect(parseArgs(['run', '--define=KEY=VALUE']).flags).toEqual({ define: 'KEY=VALUE' }) }) + it('--json 固定按布尔解析,不吞后面的命令名', () => { + expect(parseArgs(['--json', 'config'])).toEqual({ command: 'config', flags: { json: true }, positional: [] }) + }) + it('空参数返回空命令', () => { expect(parseArgs([])).toEqual({ command: '', flags: {}, positional: [] }) }) @@ -74,18 +88,33 @@ describe('cli · 运行时', () => { expect(out).toEqual(['demo echo [--text]']) }) - it('无命令返回 1 并输出 usage', () => { + it('单命令 help 缺省时按命令名生成', () => { + const { ctx, out } = collector() + expect(runCli(spec, ['status', '--help'], ctx)).toBe(0) + expect(out).toEqual(['demo status [options]']) + }) + + it('无命令返回 2 并输出 usage', () => { const { ctx, out } = collector() - expect(runCli(spec, [], ctx)).toBe(1) + expect(runCli(spec, [], ctx)).toBe(2) expect(out).toEqual(['demo ']) }) - it('未知命令与异常都转成退出码 1', () => { - const unknown = collector() - expect(runCli(spec, ['nope'], unknown.ctx)).toBe(1) - expect(unknown.err.join('\n')).toContain('未知命令') - expect(unknown.out.join('\n')).toContain('demo ') + it('未知命令返回 2,并给最近候选', () => { + const { ctx, err, out } = collector() + expect(runCli(spec, ['statsu'], ctx)).toBe(2) + expect(err.join('\n')).toContain('未知命令') + expect(err.join('\n')).toContain('是否想用 status') + expect(out.join('\n')).toContain('demo ') + }) + it('未知命令没有候选时列出可用命令', () => { + const { ctx, err } = collector() + expect(runCli(spec, ['zzzz'], ctx)).toBe(2) + expect(err.join('\n')).toContain('可用命令:ping / echo') + }) + + it('普通异常返回 1,不误报成用法错误', () => { const failed = collector() expect(runCli(spec, ['fail'], failed.ctx)).toBe(1) expect(failed.err.join('\n')).toContain('boom') @@ -95,6 +124,40 @@ describe('cli · 运行时', () => { expect(raw.err.join('\n')).toContain('raw failure') }) + it('CliError 带码、位置与提示,默认退出码 2', () => { + const { ctx, err } = collector() + expect(runCli(spec, ['usage'], ctx)).toBe(2) + expect(err).toEqual(['✗ 缺少 --config', ' 位置:--config', ' 提示:补上 --config ']) + }) + + it('CliError 可覆盖退出码', () => { + const { ctx } = collector() + expect(runCli(spec, ['config'], ctx)).toBe(1) + }) + + it('--json 输出机器可读错误,失败不写 stdout', () => { + const { ctx, err, out } = collector() + expect(runCli(spec, ['--json', 'config'], ctx)).toBe(1) + expect(out).toEqual([]) + expect(JSON.parse(err.join('\n'))).toEqual({ + error: { code: 'CONFIG', message: '配置文件无法解析', details: { file: 'a.json' } }, + }) + }) + + it('--json 下未知命令也只写 stderr', () => { + const { ctx, err, out } = collector() + expect(runCli(spec, ['--json', 'statsu'], ctx)).toBe(2) + expect(out).toEqual([]) + expect(JSON.parse(err.join('\n'))).toEqual({ + error: { + code: 'USAGE', + message: '未知命令:statsu', + path: 'statsu', + hint: '是否想用 status?', + }, + }) + }) + it('分发给注册的命令并保留退出码与上下文', () => { const { ctx, out } = collector() expect(runCli(spec, ['ping'], ctx)).toBe(0)