From f05550891f580cfe3b51488feb98b822987ef734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Thu, 17 Sep 2026 11:21:43 +0800 Subject: [PATCH 1/2] feat(cli): stderr human layer with zh/en i18n, structured help, nextStep guidance --- README.md | 15 +++- cli.mjs | 59 ++++++++++---- lib/args.mjs | 4 +- lib/commands.mjs | 52 ++++++++++++ lib/human.mjs | 60 ++++++++++++++ lib/i18n.mjs | 80 +++++++++++++++++++ lib/input.mjs | 10 +-- .../20260917-feature-human-cli-ux/brief.md | 26 +++--- test/cli.test.mjs | 58 ++++++++++++++ 9 files changed, 326 insertions(+), 38 deletions(-) create mode 100644 lib/commands.mjs create mode 100644 lib/human.mjs create mode 100644 lib/i18n.mjs diff --git a/README.md b/README.md index e0a22ba..42853ae 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,16 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute | `archive plan\|execute` | 归档已合并变更并重建 INDEX | | `index rebuild plan\|execute` | 重建变更索引 | -所有输出为单行 JSON:成功 `{"ok":true,"command":...,"data":...}`,失败 `{"ok":false,"error":{"code","message"}}`。`--json` 参数为历史兼容保留,接受即无操作(输出恒为 JSON)。 +所有输出为单行 JSON:成功 `{"ok":true,"command":...,"data":...}`,失败 `{"ok":false,"error":{"code","message"}}`。`--json` 参数为历史兼容保留,接受即无操作(输出恒为 JSON)。带流程后继的命令,成功结果的 `data.nextStep` 给出下一步建议命令(稳定英文模板,不随语言变化,agent 可直接消费)。 + +## 人用输出层(stderr) + +stdout 的 JSON 契约之外,CLI 在 stderr 渲染一层人类提示:进场横幅(命令+参数)、收场摘要(结果+耗时)、`nextStep` 引导、错误码的本地化解释与示例命令。stderr 内容不承载契约,可随时关闭。 + +- 语言解析:`--lang zh|en` > `SHADOW_DEV_LANG` > 系统 locale 自动探测 > 默认 `zh`。非法取值报 `INVALID_LANG`(退出码 2)。 +- 关闭提示:`SHADOW_DEV_QUIET=1`(或 `true`)时 stderr 零输出,适合日志管道。 +- 语言只影响 stderr 文案;错误 code、JSON 结构、`nextStep` 模板均不本地化。 +- `shadow-dev help` 输出全部命令的结构化目录(`data.help` 保留原字符串 + `data.commands` 明细);`shadow-dev help <命令>` 查看单命令的参数、必填项与示例。 ## 平台兼容 @@ -45,6 +54,8 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute - `GITHUB_TOKEN` / `GH_TOKEN`:GitHub API 必需(issue/publish/release/archive)。 - `SHADOW_GITHUB_API_URL`:覆盖 API base URL(测试/代理),默认 `https://api.github.com`。 - `SHADOW_API_TIMEOUT_MS`:API 超时,默认 15000。 +- `SHADOW_DEV_LANG`:`zh|en`,stderr 人用层语言(被 `--lang` 覆盖)。 +- `SHADOW_DEV_QUIET`:非空且非 `0` 时关闭 stderr 人用层。 ## 安装与分发 @@ -53,7 +64,7 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute ## 开发 ```bash -npm test # node --test,38 个契约测试覆盖全部命令域 +npm test # node --test,45 个契约测试覆盖全部命令域与 stderr 人用层 ``` 行为契约:命令、JSON 输出结构、错误码、planHash 机制保持稳定;`test/cli.test.mjs` 是唯一契约规格。 diff --git a/cli.mjs b/cli.mjs index 220ea4a..4bd2c4c 100755 --- a/cli.mjs +++ b/cli.mjs @@ -1,11 +1,14 @@ #!/usr/bin/env node -import { args, HELP } from './lib/args.mjs' +import { args } from './lib/args.mjs' import { out, fail } from './lib/output.mjs' import { plan } from './lib/plan.mjs' import { root } from './lib/git.mjs' import { brief, write } from './lib/brief.mjs' import { confirm, name } from './lib/input.mjs' import { err } from './lib/errors.mjs' +import { resolveLang } from './lib/i18n.mjs' +import { HELP, COMMANDS } from './lib/commands.mjs' +import * as human from './lib/human.mjs' import * as branch from './lib/domains/branch.mjs' import * as sync from './lib/domains/sync.mjs' import * as review from './lib/domains/review.mjs' @@ -34,8 +37,8 @@ async function executeDomain(c, mod, r, o) { } const b = brief(r, name(o)) if (b.data.workflow.planHash !== e.planHash) { - const code = b.data.workflow.planHash ? 'PLAN_HASH_INVALID' : 'PLAN_HASH_REQUIRED' - throw err(code, code, b.data.workflow.planHash ? 1 : 2) + const stale = !!b.data.workflow.planHash + throw err(stale ? 'PLAN_HASH_INVALID' : 'PLAN_HASH_REQUIRED', stale ? 'PLAN_HASH_INVALID' : 'PLAN_HASH_REQUIRED', stale ? 1 : 2) } return mod.execute(r, o, e.data, b) } @@ -50,28 +53,50 @@ async function planDomain(c, mod, r, o) { return e } +function helpEnvelope(p) { + const g = p[1] + if (!g) return { ok: true, command: 'help', data: { help: HELP, commands: COMMANDS } } + const commands = Object.fromEntries(Object.entries(COMMANDS).filter(([k]) => k === g || k.startsWith(g + '.'))) + if (!Object.keys(commands).length) throw err('UNKNOWN_COMMAND', `unsupported command: help ${g}`) + return { ok: true, command: `help.${g}`, data: { help: Object.values(commands).map(e => e.usage).join('\n'), commands } } +} + async function handle(r, p, o) { const [d, a, s] = p - if (!d || d === 'help') return out({ ok: true, command: 'help', data: HELP }) - if (d === 'repo' && a === 'inspect') return out({ ok: true, command: 'repo.inspect', data: inspect.repoState(r) }) - if (d === 'pr' && a === 'inspect') return out({ ok: true, command: 'pr.inspect', data: await inspect.pullRequest(r, o) }) - if (d === 'conflict' && a === 'inspect') return out({ ok: true, command: 'conflict.inspect', data: inspect.conflict(r, o) }) - if (d === 'task' && a === 'list') return out({ ok: true, command: 'task.list', data: task.list(r, o) }) - if (d === 'task' && a === 'set') return out({ ok: true, command: 'task.set', data: task.set(r, o) }) - if (d === 'change' && a === 'create') return out({ ok: true, command: 'change.create', data: change.create(r, o) }) - if (d === 'change' && a === 'approve') return out({ ok: true, command: 'change.approve', data: change.approve(r, o) }) + if (d === 'repo' && a === 'inspect') return { ok: true, command: 'repo.inspect', data: inspect.repoState(r) } + if (d === 'pr' && a === 'inspect') return { ok: true, command: 'pr.inspect', data: await inspect.pullRequest(r, o) } + if (d === 'conflict' && a === 'inspect') return { ok: true, command: 'conflict.inspect', data: inspect.conflict(r, o) } + if (d === 'task' && a === 'list') return { ok: true, command: 'task.list', data: task.list(r, o) } + if (d === 'task' && a === 'set') return { ok: true, command: 'task.set', data: task.set(r, o) } + if (d === 'change' && a === 'create') return { ok: true, command: 'change.create', data: change.create(r, o) } + if (d === 'change' && a === 'approve') return { ok: true, command: 'change.approve', data: change.approve(r, o) } if (Object.hasOwn(DOMAINS, d)) { const verb = a === 'rebuild' ? s : a, c = a === 'rebuild' ? `${d}.rebuild` : d - if (verb === 'plan') return out(await planDomain(c, DOMAINS[d], r, o)) - if (verb === 'execute') return out({ ok: true, command: `${c}.execute`, data: await executeDomain(c, DOMAINS[d], r, o) }) + if (verb === 'plan') return await planDomain(c, DOMAINS[d], r, o) + if (verb === 'execute') return { ok: true, command: `${c}.execute`, data: await executeDomain(c, DOMAINS[d], r, o) } } - return fail('UNKNOWN_COMMAND', `unsupported command: ${p.join(' ')}`) + throw err('UNKNOWN_COMMAND', `unsupported command: ${p.join(' ')}`) } +// stdout 恒为单行 JSON 契约;进出场横幅、错误解释、help 人读版只写 stderr(见 lib/human.mjs) +let p = [], o = {}, L = 'zh' try { - const { p, o } = args(process.argv.slice(2)) - if (p.includes('--help') || !p.length) out({ ok: true, command: 'help', data: HELP }) - else await handle(root(), p, o) + const parsed = args(process.argv.slice(2)) + p = parsed.p; o = parsed.o + L = resolveLang(o) + const helpMode = !p.length || p.includes('--help') || p[0] === 'help' + const t0 = Date.now() + let v + if (helpMode) { + v = helpEnvelope(p) + human.printHelp(L, v) + } else { + human.enter(L, p) + v = human.decorate(await handle(root(), p, o), o) + human.done(L, v, Date.now() - t0) + } + out(v) } catch (e) { + human.error(L, e, p) fail(e.code || e.message, e.message, e.status || 1) } diff --git a/lib/args.mjs b/lib/args.mjs index 26860c6..c6b8edc 100644 --- a/lib/args.mjs +++ b/lib/args.mjs @@ -1,4 +1,6 @@ -export const HELP = 'repo inspect\nchange create|approve\nissue plan|execute\nbranch plan|execute\nsync plan|execute\nconflict inspect\ntask list|set\nreview plan|execute\ncommit plan|execute\npublish plan|execute\nrelease plan|execute\npr inspect\nreconcile plan|execute\narchive plan|execute\nindex rebuild plan|execute' +import { HELP } from './commands.mjs' + +export { HELP } export function args(a) { const p = [], o = {} diff --git a/lib/commands.mjs b/lib/commands.mjs new file mode 100644 index 0000000..d842f89 --- /dev/null +++ b/lib/commands.mjs @@ -0,0 +1,52 @@ +// 命令目录:单一事实源。HELP 字符串、help JSON、stderr 人用提示与 nextStep 全部由此派生。 +// summary/args.desc 提供 zh/en 双版(机器流不本地化,人用层按语言取用);next 为稳定英文命令模板,{x} 由执行结果填充。 +const f = (flag, required, zh, en) => ({ flag, required, desc: { zh, en } }) +const c = (usage, zh, en, args, example, next = null) => ({ usage, summary: { zh, en }, args, example, next }) +const N = f('--name', true, '变更名,如 20260917-feature-x', 'change name, e.g. 20260917-feature-x') +const CF = f('--confirm', true, '写操作显式确认', 'explicit confirmation for mutating commands') +const PH = f('--plan-hash', false, '缺省时读取 brief 中持久化的 planHash', 'defaults to the planHash persisted in the brief') +const T = f('--title', false, '标题,缺省为变更名', 'title, defaults to change name') +const B = f('--body', false, '正文', 'body text') +const FI = f('--files', false, '逗号分隔文件列表(反斜杠自动归一为正斜杠)', 'comma-separated file list (backslashes normalized)') +const M = f('--message', false, '提交信息', 'commit message') + +export const COMMANDS = { + 'repo.inspect': c('repo inspect', '查看仓库状态(分支、HEAD、脏文件)', 'show repository state (branch, HEAD, dirty files)', [], 'shadow-dev repo inspect'), + 'change.create': c('change create', '创建变更 brief', 'create a change brief', [N, f('--type', false, 'feature|fix|build|chore|docs|refactor|style|test,默认 feat', 'one of feature|fix|build|chore|docs|refactor|style|test, default feat'), f('--scope', false, '影响范围', 'scope'), f('--base-branch', false, '基线分支,默认 main', 'base branch, default main'), FI, f('--body-file', false, 'brief 正文来源文件', 'file supplying the brief body'), f('--repository', false, 'GitHub owner/repo', 'GitHub owner/repo'), CF], 'shadow-dev change create --name --type feature --confirm', 'change approve --name {name} --confirm'), + 'change.approve': c('change approve', '批准 brief(draft → proposed)', 'approve the brief (draft → proposed)', [N, CF], 'shadow-dev change approve --name --confirm', 'branch plan --name {name}'), + 'issue.plan': c('issue plan', '预览创建 GitHub issue', 'plan a GitHub issue', [N, T, B, f('--labels', false, '逗号分隔标签', 'comma-separated labels')], 'shadow-dev issue plan --name --title "标题" --labels feature', 'issue execute --name {name} --title "{title}" --body "{body}" --labels {labels} --confirm'), + 'issue.execute': c('issue execute', '创建 GitHub issue', 'create the GitHub issue', [N, T, B, f('--labels', false, '逗号分隔标签', 'comma-separated labels'), PH, CF], 'shadow-dev issue execute --name --confirm', null), + 'branch.plan': c('branch plan', '预览建功能分支', 'plan creating the feature branch', [N], 'shadow-dev branch plan --name ', 'branch execute --name {name} --confirm'), + 'branch.execute': c('branch execute', '从基线分支创建并切换', 'create and switch to the feature branch', [N, PH, CF], 'shadow-dev branch execute --name --confirm', null), + 'sync.plan': c('sync plan', '预览 fast-forward 同步上游', 'plan a fast-forward sync with upstream', [N], 'shadow-dev sync plan --name ', 'sync execute --name {name} --confirm'), + 'sync.execute': c('sync execute', 'fetch 后仅 fast-forward 合并上游', 'fetch then ff-only merge upstream', [N, PH, CF], 'shadow-dev sync execute --name --confirm', null), + 'conflict.inspect': c('conflict inspect', '检查与其他 active brief 的文件重叠', 'check file overlaps with other active briefs', [N], 'shadow-dev conflict inspect --name ', null), + 'task.list': c('task list', '列出 brief 任务清单', 'list the brief task checklist', [N], 'shadow-dev task list --name ', null), + 'task.set': c('task set', '勾选/取消任务', 'tick or untick a task', [N, f('--task', true, '任务 id,如 task-3', 'task id, e.g. task-3'), f('--state', true, 'todo|done', 'todo|done'), CF], 'shadow-dev task set --name --task task-1 --state done --confirm', null), + 'review.plan': c('review plan', '预览审查记录', 'plan the review record', [N], 'shadow-dev review plan --name ', 'review execute --name {name} --conclusion passed --confirm'), + 'review.execute': c('review execute', '写入审查结论与知识评估(任务未全部勾选会被拒绝)', 'persist review conclusion and knowledge action (blocked until all tasks are done)', [N, f('--conclusion', false, 'passed|blocked,默认 passed', 'passed|blocked, default passed'), f('--knowledge', false, '新增|更新|废弃|无需变更', 'knowledge action: 新增|更新|废弃|无需变更'), f('--target', false, '知识卡片路径', 'knowledge card path'), f('--reason', false, '知识动作理由', 'knowledge action reason'), PH, CF], 'shadow-dev review execute --name --conclusion passed --confirm', null), + 'commit.plan': c('commit plan', '预览按显式文件列表提交', 'plan an explicit-file-list commit', [N, f('--files', true, '逗号分隔文件列表', 'comma-separated file list'), f('--message', true, '提交信息', 'commit message')], 'shadow-dev commit plan --name --files a.mjs,b.mjs --message "fix: x"', 'commit execute --name {name} --files {files} --message "{message}" --confirm'), + 'commit.execute': c('commit execute', '提交并写 checkpoint(参数须与 plan 完全一致)', 'commit and write checkpoint (args must match the plan exactly)', [N, FI, M, PH, CF], 'shadow-dev commit execute --name --files a.mjs --message "fix: x" --confirm', null), + 'publish.plan': c('publish plan', '预览推送分支并创建/复用 PR', 'plan pushing the branch and creating/reusing the PR', [N, T, B], 'shadow-dev publish plan --name --title "标题"', 'publish execute --name {name} --title "{title}" --body "{body}" --confirm'), + 'publish.execute': c('publish execute', '推送分支并创建/复用 PR(参数须与 plan 完全一致)', 'push and create/reuse the PR (args must match the plan exactly)', [N, T, B, PH, CF], 'shadow-dev publish execute --name --confirm', 'archive plan --name {name}'), + 'release.plan': c('release plan', '预览提交+推送+PR 复合发布', 'plan the commit+push+PR composite release', [N, FI, M, T, B], 'shadow-dev release plan --name --files a.mjs --message "feat: x" --title "标题"', 'release execute --name {name} --confirm'), + 'release.execute': c('release execute', '执行复合发布(缺省参数回退 brief workflow.release)', 'run the composite release (params default to the stored workflow.release plan)', [N, FI, M, T, B, PH, CF], 'shadow-dev release execute --name --confirm', 'archive plan --name {name}'), + 'pr.inspect': c('pr inspect', '查看 brief 关联 PR', 'inspect the PR linked to the brief', [N], 'shadow-dev pr inspect --name ', null), + 'reconcile.plan': c('reconcile plan', '预览 brief 状态与实际进度对齐', 'plan reconciling brief state with actual progress', [N], 'shadow-dev reconcile plan --name ', 'reconcile execute --name {name} --confirm'), + 'reconcile.execute': c('reconcile execute', '回写对齐后的状态', 'persist the reconciled state', [N, PH, CF], 'shadow-dev reconcile execute --name --confirm', null), + 'archive.plan': c('archive plan', '预览归档(要求 review passed 且 PR merged)', 'plan archiving (requires review passed and PR merged)', [N], 'shadow-dev archive plan --name ', 'archive execute --name {name} --confirm'), + 'archive.execute': c('archive execute', '移入 archive 并重建 INDEX', 'move into archive and rebuild INDEX', [N, PH, CF], 'shadow-dev archive execute --name --confirm', null), + 'index.rebuild.plan': c('index rebuild plan', '预览变更索引重建', 'plan rebuilding the change index', [], 'shadow-dev index rebuild plan', 'index rebuild execute --plan-hash {planHash} --confirm'), + 'index.rebuild.execute': c('index rebuild execute', '重建 INDEX.md(无 brief 域,--plan-hash 为唯一凭证)', 'rebuild INDEX.md (briefless command: --plan-hash is the only credential)', [f('--plan-hash', true, 'index rebuild plan 的输出', 'hash from index rebuild plan'), CF], 'shadow-dev index rebuild execute --plan-hash --confirm', null), +} + +// HELP 由目录派生,保持既有的分组行格式(`branch plan|execute`),向后兼容既有断言 +export const HELP = (() => { + const rows = [], index = new Map() + for (const key of Object.keys(COMMANDS)) { + const parts = key.split('.'), base = parts.slice(0, -1).join(' '), action = parts.at(-1) + if (!index.has(base)) { index.set(base, rows.length); rows.push([base, []]) } + rows[index.get(base)][1].push(action) + } + return rows.map(([base, actions]) => `${base} ${actions.join('|')}`).join('\n') +})() diff --git a/lib/human.mjs b/lib/human.mjs new file mode 100644 index 0000000..83bf8c1 --- /dev/null +++ b/lib/human.mjs @@ -0,0 +1,60 @@ +import { COMMANDS } from './commands.mjs' +import { ui, hint } from './i18n.mjs' + +// 人用输出层:只写 stderr;SHADOW_DEV_QUIET 关闭。stdout JSON 契约与本模块完全隔离。 +const quiet = () => { const q = process.env.SHADOW_DEV_QUIET; return !!q && q !== '0' } +const w = s => { if (!quiet()) process.stderr.write(s + '\n') } + +export function enter(L, p) { w(ui(L, 'enter', { cmd: `shadow-dev ${p.join(' ')}` })) } + +export function done(L, v, ms) { + w(ui(L, 'done', { cmd: v.command, ms })) + if (v.data?.nextStep) w(ui(L, 'next', { step: v.data.nextStep })) +} + +export function error(L, e, p) { + const code = e.code || e.message + w(ui(L, 'error', { code, hint: hint(L, code) })) + const spec = lookup(p) + if (spec) w(ui(L, 'example', { example: spec.example })) +} + +export function printHelp(L, v) { + const commands = v.data.commands + if (v.command === 'help') { + w(ui(L, 'helpHead')) + w(ui(L, 'globals')) + for (const key of Object.keys(commands)) { + const e = commands[key] + w(` ${e.usage.padEnd(26)} ${e.summary[L] ?? e.summary.zh}`) + } + return + } + for (const e of Object.values(commands)) { + w(` ${e.usage} — ${e.summary[L] ?? e.summary.zh}`) + for (const a of e.args) w(` ${a.flag}${a.required ? ' *' : ' '} ${a.desc[L] ?? a.desc.zh}`) + w(ui(L, 'example', { example: e.example })) + } +} + +// nextStep:按命令目录把英文稳定模板实例化为建议命令;数组参数以逗号连接 +export function decorate(v, o = {}) { + if (!v.ok || !v.data || typeof v.data !== 'object') return v + const next = COMMANDS[v.command]?.next + if (!next) return v + const src = { name: o.name, ...v.data, planHash: v.planHash } + v.data.nextStep = next.replace(/\{(\w+)\}/g, (m, k) => { + const x = src[k] + if (x === undefined || x === null) return '' + return Array.isArray(x) ? x.join(',') : String(x) + }) + return v +} + +function lookup(p) { + for (let n = p.length; n > 0; n--) { + const e = COMMANDS[p.slice(0, n).join('.')] + if (e) return e + } + return null +} diff --git a/lib/i18n.mjs b/lib/i18n.mjs new file mode 100644 index 0000000..2bf59df --- /dev/null +++ b/lib/i18n.mjs @@ -0,0 +1,80 @@ +import { err } from './errors.mjs' + +// 语言解析链:--lang > SHADOW_DEV_LANG > 系统 locale > 默认 zh。错误 code 永不本地化,只本地化提示文案。 +const UI = { + zh: { + enter: '▶ [进场] {cmd}', + done: '✅ [完成] {cmd} · {ms}ms', + next: '⤷ 下一步: {step}', + error: '✗ {code}: {hint}', + example: ' 示例: {example}', + helpHead: 'shadow-dev 命令一览(单命令详情: shadow-dev help <命令>)', + globals: ' 全局参数: --lang zh|en(或 SHADOW_DEV_LANG)· SHADOW_DEV_QUIET=1 关闭本提示层 · stdout 契约恒为 JSON', + }, + en: { + enter: '▶ [enter] {cmd}', + done: '✅ [done] {cmd} · {ms}ms', + next: '⤷ next: {step}', + error: '✗ {code}: {hint}', + example: ' example: {example}', + helpHead: 'shadow-dev commands (detail: shadow-dev help )', + globals: ' global: --lang zh|en (or SHADOW_DEV_LANG) · SHADOW_DEV_QUIET=1 silences this layer · stdout is always JSON', + }, +} + +// 每个稳定 code 一句人话解释(zh/en),缺失时回退 code 本身 +export const HINTS = { + UNKNOWN_COMMAND: { zh: '未知命令;运行 shadow-dev help 查看命令目录', en: 'unknown command; run shadow-dev help for the command list' }, + INVALID_LANG: { zh: '--lang 只支持 zh 或 en', en: '--lang accepts zh or en only' }, + NOT_GIT_REPOSITORY: { zh: '当前目录不在 git 仓库内', en: 'not inside a git repository' }, + NAME_REQUIRED: { zh: '缺少必填参数 --name <变更名>', en: 'missing required flag --name ' }, + CHANGE_EXISTS: { zh: '同名变更已存在(shadow-docs/changes/)', en: 'a change with this name already exists under shadow-docs/changes' }, + BRIEF_NOT_FOUND: { zh: 'brief 不存在;先运行 shadow-dev change create --name <变更名>', en: 'brief not found; run shadow-dev change create --name first' }, + BRIEF_FRONTMATTER_REQUIRED: { zh: 'brief 缺少 --- 包裹的 frontmatter', en: 'brief is missing its --- frontmatter block' }, + BODY_FILE_NOT_FOUND: { zh: '--body-file 指向的文件不存在', en: 'the --body-file path does not exist' }, + BODY_FILE_EMPTY: { zh: '--body-file 内容为空', en: 'the --body-file content is empty' }, + CONFIRMATION_REQUIRED: { zh: '写操作必须显式携带 --confirm', en: 'mutating commands require --confirm' }, + PLAN_HASH_REQUIRED: { zh: '先运行同命令的 plan(planHash 会持久化进 brief),或显式传 --plan-hash', en: 'run the plan command first (its hash persists in the brief), or pass --plan-hash' }, + PLAN_HASH_INVALID: { zh: 'plan 与 execute 之间输入已变化;重新运行 plan(commit/publish/release 等参数须与 plan 完全一致)', en: 'inputs changed between plan and execute; run plan again (commit/publish/release args must match exactly)' }, + DIRTY_WORKTREE: { zh: '工作区存在业务改动;先 commit 或还原后重试', en: 'worktree has non-shadow-docs changes; commit or restore them first' }, + SYNC_NOT_FAST_FORWARD: { zh: '当前分支不是上游祖先,禁止自动合并分叉', en: 'HEAD is not an ancestor of upstream; diverged history needs manual handling' }, + GIT_FETCH_FAILED: { zh: 'git fetch 失败或超时(检查网络与凭据)', en: 'git fetch failed or timed out (check network and credentials)' }, + GIT_PUSH_FAILED: { zh: 'git push 失败或超时(检查网络与凭据)', en: 'git push failed or timed out (check network and credentials)' }, + COMMIT_INPUT_REQUIRED: { zh: '缺少 --files 或 --message(只按显式文件列表提交)', en: '--files and --message are required (explicit file lists only)' }, + UNSUPPORTED_OPERATION: { zh: '不支持 . / -A / 绝对路径等隐式或越界提交,逐个列出文件', en: 'implicit or out-of-tree paths (. , -A, absolute) are rejected; list files explicitly' }, + TASKS_NOT_COMPLETE: { zh: '任务清单未全部勾选;用 shadow-dev task set 完成后重试', en: 'task checklist incomplete; finish it with shadow-dev task set' }, + TASK_NOT_FOUND: { zh: '--task 序号超出任务清单范围', en: '--task index is beyond the task list' }, + INVALID_TASK: { zh: '参数应为 --task task- 且 --state todo|done', en: 'expected --task task- with --state todo|done' }, + INVALID_CONCLUSION: { zh: '--conclusion 只支持 passed 或 blocked', en: '--conclusion accepts passed or blocked' }, + INVALID_KNOWLEDGE: { zh: '--knowledge 只支持 新增|更新|废弃|无需变更', en: '--knowledge accepts 新增|更新|废弃|无需变更' }, + REVIEW_NOT_PASSED: { zh: 'review 未通过或 HEAD 已变化;重新运行 review', en: 'review not passed for this HEAD; run review again' }, + PR_NOT_MERGED: { zh: '关联 PR 尚未合并;先在 GitHub 合并再归档', en: 'the linked PR is not merged yet; merge it on GitHub first' }, + PULL_REQUEST_REQUIRED: { zh: 'brief 未关联 PR;先 publish', en: 'brief has no linked PR; run publish first' }, + ISSUE_TITLE_REQUIRED: { zh: '缺少 --title(或在 plan 中持久化)', en: '--title is missing (or persisted from issue plan)' }, + GITHUB_TOKEN_REQUIRED: { zh: '未设置 GITHUB_TOKEN/GH_TOKEN 环境变量', en: 'GITHUB_TOKEN or GH_TOKEN environment variable is required' }, + GITHUB_REPOSITORY_REQUIRED: { zh: '无法确定 GitHub 仓库;传 --repository 或设置 brief.github.repository', en: 'cannot resolve the GitHub repository; pass --repository or set brief.github.repository' }, + GITHUB_API_ERROR: { zh: 'GitHub API 返回错误', en: 'the GitHub API returned an error' }, + PR_CREATE_FAILED: { zh: '创建 PR 失败(多为分支未推送或凭据问题)', en: 'PR creation failed (branch not pushed or credential issue)' }, + API_TIMEOUT: { zh: 'GitHub API 超时(SHADOW_API_TIMEOUT_MS 可调)', en: 'GitHub API timed out (tune SHADOW_API_TIMEOUT_MS)' }, +} + +export function resolveLang(o) { + if (o.lang !== undefined && o.lang !== 'zh' && o.lang !== 'en') throw err('INVALID_LANG', `INVALID_LANG: expected zh|en, got "${o.lang}"`, 2) + if (o.lang) return o.lang + const env = process.env.SHADOW_DEV_LANG + if (env === 'zh' || env === 'en') return env + const loc = String(process.env.LANG || process.env.LC_ALL || process.env.LC_MESSAGES || (globalThis.Intl?.DateTimeFormat?.().resolvedOptions?.().locale) || '').toLowerCase() + if (loc.startsWith('en')) return 'en' + return 'zh' +} + +export function ui(L, key, p = {}) { + let s = UI[L]?.[key] ?? UI.zh[key] ?? key + for (const [k, v] of Object.entries(p)) s = s.replaceAll(`{${k}}`, String(v ?? '')) + return s +} + +export function hint(L, code) { + const h = HINTS[code] + return h ? h[L] : code +} diff --git a/lib/input.mjs b/lib/input.mjs index 5c8c2a4..83605cb 100644 --- a/lib/input.mjs +++ b/lib/input.mjs @@ -2,12 +2,12 @@ import { existsSync, readFileSync } from 'node:fs' import { err } from './errors.mjs' export function confirm(o, ph = false) { - if (!o.confirm) throw err('CONFIRMATION_REQUIRED', 'CONFIRMATION_REQUIRED', 2) - if (ph && !o['plan-hash']) throw err('PLAN_HASH_REQUIRED', 'PLAN_HASH_REQUIRED', 2) + if (!o.confirm) throw err('CONFIRMATION_REQUIRED', 'CONFIRMATION_REQUIRED: mutating commands require an explicit --confirm', 2) + if (ph && !o['plan-hash']) throw err('PLAN_HASH_REQUIRED', 'PLAN_HASH_REQUIRED: run the plan command first or pass --plan-hash', 2) } export function name(o) { - if (!o.name) throw err('NAME_REQUIRED') + if (!o.name) throw err('NAME_REQUIRED', 'NAME_REQUIRED: pass --name , e.g. --name 20260917-feature-x') return o.name } @@ -19,8 +19,8 @@ export function fileList(v) { export function readBody(o, n) { if (!o['body-file']) return `\n# ${n}\n\n## 任务\n\n` const p = o['body-file'] - if (!existsSync(p)) throw err('BODY_FILE_NOT_FOUND') + if (!existsSync(p)) throw err('BODY_FILE_NOT_FOUND', `BODY_FILE_NOT_FOUND: file not readable: ${p}`) const c = readFileSync(p, 'utf8').trim() - if (!c) throw err('BODY_FILE_EMPTY') + if (!c) throw err('BODY_FILE_EMPTY', 'BODY_FILE_EMPTY: --body-file content must not be blank') return `\n${c}\n` } diff --git a/shadow-docs/changes/20260917-feature-human-cli-ux/brief.md b/shadow-docs/changes/20260917-feature-human-cli-ux/brief.md index a593b7d..8273c88 100644 --- a/shadow-docs/changes/20260917-feature-human-cli-ux/brief.md +++ b/shadow-docs/changes/20260917-feature-human-cli-ux/brief.md @@ -4,9 +4,9 @@ "name": "20260917-feature-human-cli-ux", "type": "feature", "scope": "cli.mjs,lib", - "status": "proposed", + "status": "branched", "baseBranch": "main", - "branch": null, + "branch": "feature/20260917-feature-human-cli-ux", "files": [ "README.md", "cli.mjs", @@ -33,7 +33,7 @@ "workflow": { "operation": null, "checkpoint": "issue:2", - "planHash": "8d398b8b084c221c2d9d6c858a7e13a2daf4130dbef0f6ff6161c34ce2f31770", + "planHash": "b250a3afca93dcad9c17e89b1aaaabd87f752dffa441a95a5cd9e3f4dd47c519", "updatedAt": null, "lastError": null, "issuePlan": { @@ -72,25 +72,25 @@ CLI 当前是纯机器契约界面:无进出场反馈(44 秒的 release 全 ### Phase 1 — 事实源与语言基础(依赖 refactor Phase 2 完成) -- [ ] 命令目录 `lib/commands.mjs`:13 个命令组的 usage/参数(名称/必填/说明)/示例/nextStep 结构化定义,HELP 字符串由目录派生 —— `lib/commands.mjs` `lib/args.mjs` -- [ ] i18n 词典 `lib/i18n.mjs`:zh/en 消息集 + 语言解析链(--lang > SHADOW_DEV_LANG > locale > zh),非法 --lang 报 usage 提示 —— `lib/i18n.mjs` `lib/args.mjs` +- [x] 命令目录 `lib/commands.mjs`:13 个命令组的 usage/参数(名称/必填/说明)/示例/nextStep 结构化定义,HELP 字符串由目录派生 —— `lib/commands.mjs` `lib/args.mjs` +- [x] i18n 词典 `lib/i18n.mjs`:zh/en 消息集 + 语言解析链(--lang > SHADOW_DEV_LANG > locale > zh),非法 --lang 报 usage 提示 —— `lib/i18n.mjs` `lib/args.mjs` ### Phase 2 — 人用层渲染与接线 -- [ ] `lib/human.mjs`:stderr 渲染器——进场横幅(命令+关键参数)、收场(✅ 结果摘要+耗时)、错误(code 本地化解释+该命令 usage 示例,参数表从命令目录派生)、`SHADOW_DEV_QUIET` 抑制 —— `lib/human.mjs` -- [ ] `cli.mjs` 接线:handle 出口统一挂进出场/错误渲染;成功结果按目录追加 `data.nextStep`(含参数化建议,如 approve 后提示 `branch plan --name `) —— `cli.mjs` `lib/output.mjs` -- [ ] help 升级:`help` 返回保留旧字符串字段并新增结构化 commands;`help ` 单命令详情(stdout JSON、stderr 人读版) —— `cli.mjs` `lib/commands.mjs` -- [ ] 漏参提示:全部验证错误(NAME_REQUIRED/CONFIRMATION_REQUIRED/PLAN_HASH_*/BRIEF_NOT_FOUND 等)的人用输出附带期望参数与示例 —— `lib/input.mjs` `lib/human.mjs` +- [x] `lib/human.mjs`:stderr 渲染器——进场横幅(命令+关键参数)、收场(✅ 结果摘要+耗时)、错误(code 本地化解释+该命令 usage 示例,参数表从命令目录派生)、`SHADOW_DEV_QUIET` 抑制 —— `lib/human.mjs` +- [x] `cli.mjs` 接线:handle 出口统一挂进出场/错误渲染;成功结果按目录追加 `data.nextStep`(含参数化建议,如 approve 后提示 `branch plan --name `) —— `cli.mjs` `lib/output.mjs` +- [x] help 升级:`help` 返回保留旧字符串字段并新增结构化 commands;`help ` 单命令详情(stdout JSON、stderr 人读版) —— `cli.mjs` `lib/commands.mjs` +- [x] 漏参提示:全部验证错误(NAME_REQUIRED/CONFIRMATION_REQUIRED/PLAN_HASH_*/BRIEF_NOT_FOUND 等)的人用输出附带期望参数与示例 —— `lib/input.mjs` `lib/human.mjs` ### Phase 3 — 回归与文档 -- [ ] 契约测试:同命令在 `--lang zh`/`--lang en`/无 lang 下 stdout JSON 逐字节一致(nextStep 为稳定 key 非译文);stderr 含对应语言提示;`SHADOW_DEV_QUIET=1` 时 stderr 无输出;help 子命令断言 —— `test/cli.test.mjs` -- [ ] README:语言切换配置、stderr 人用层与 nextStep 说明、help 示例(与 refactor 变更的退出码表合流成稿) —— `README.md` +- [x] 契约测试:同命令在 `--lang zh`/`--lang en`/无 lang 下 stdout JSON 逐字节一致(nextStep 为稳定 key 非译文);stderr 含对应语言提示;`SHADOW_DEV_QUIET=1` 时 stderr 无输出;help 子命令断言 —— `test/cli.test.mjs` +- [x] README:语言切换配置、stderr 人用层与 nextStep 说明、help 示例(与 refactor 变更的退出码表合流成稿) —— `README.md` ## 结果 -- 实际耗时: — -- 验证: — +- 实际耗时: 约 35 分钟 +- 验证: `node --test` 45/45 通过(38 存量契约 + 7 新增人用层契约:语言无关 stdout 逐字节断言、stderr 提示、nextStep 稳定 key、结构化 help、QUIET 抑制、INVALID_LANG);全模块 `node --check` 通过;期间发现并修复消息富化误伤 CONFIRMATION_REQUIRED/PLAN_HASH_REQUIRED 退出码 2 的回归(新契约测试当场捕获)。 ## 知识评估 diff --git a/test/cli.test.mjs b/test/cli.test.mjs index eb973ea..9630d61 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -187,6 +187,64 @@ test('file lists normalize Windows backslash separators', () => { assert.deepEqual(JSON.parse(conflict.stdout).data.overlaps, [{ change: 'backslash', files: ['src/example.js'] }]) }) +test('human layer: banners and hints on stderr, stdout contract language-invariant', () => { + const root = fixture() + const zh = run(['task', 'list', '--name', 'sample', '--lang', 'zh'], root) + const en = run(['task', 'list', '--name', 'sample', '--lang', 'en'], root) + assert.equal(zh.status, 0, zh.stderr) + assert.equal(zh.stdout, en.stdout, 'stdout JSON must be byte-identical across languages') + assert.match(zh.stderr, /进场|完成/) + assert.match(en.stderr, /enter|done/i) +}) + +test('validation errors carry localized usage hints on stderr', () => { + const result = run(['task', 'set', '--state', 'done', '--confirm'], fixture()) + assert.equal(result.status, 1) + assert.equal(JSON.parse(result.stdout).error.code, 'NAME_REQUIRED') + assert.match(result.stderr, /--name/) +}) + +test('mutating results carry a stable untranslated nextStep in JSON', () => { + const root = fixture() + const zh = run(['change', 'approve', '--name', 'sample', '--confirm', '--lang', 'zh'], root) + const en = run(['change', 'approve', '--name', 'sample', '--confirm', '--lang', 'en'], root) + assert.equal(zh.status, 0, zh.stderr) + assert.equal(JSON.parse(zh.stdout).data.nextStep, 'branch plan --name sample') + assert.equal(JSON.parse(zh.stdout).data.nextStep, JSON.parse(en.stdout).data.nextStep) + assert.match(en.stderr, /next/i) +}) + +test('help is structured: legacy string kept, per-command detail, language-invariant stdout', () => { + const overview = run(['help', '--lang', 'zh']) + const data = JSON.parse(overview.stdout).data + assert.match(data.help, /repo inspect/) + assert.equal(data.commands['branch.execute'].usage, 'branch execute') + assert.deepEqual(Object.keys(data.commands['change.create'].summary).sort(), ['en', 'zh']) + const detail = run(['help', 'branch']) + assert.equal(JSON.parse(detail.stdout).command, 'help.branch') + assert.equal(overview.stdout, run(['help', '--lang', 'en']).stdout) +}) + +test('SHADOW_DEV_QUIET silences the human channel', () => { + const result = run(['repo', 'inspect', '--lang', 'en'], fixture(), { SHADOW_DEV_QUIET: '1' }) + assert.equal(result.status, 0, result.stderr) + assert.equal(result.stderr, '') +}) + +test('unknown commands keep stable code with localized stderr', () => { + const zh = run(['bogus', '--lang', 'zh'], fixture()) + const en = run(['bogus', '--lang', 'en'], fixture()) + assert.equal(JSON.parse(zh.stdout).error.code, 'UNKNOWN_COMMAND') + assert.equal(zh.stdout, en.stdout) + assert.match(zh.stderr, /help/) +}) + +test('invalid --lang is rejected with a usage hint', () => { + const result = run(['help', '--lang', 'fr']) + assert.equal(result.status, 2) + assert.equal(JSON.parse(result.stdout).error.code, 'INVALID_LANG') +}) + test('changed files resolve renamed porcelain entries to the new path', () => { const root = fixture() execFileSync('git', ['mv', 'README.md', 'DOCS.md'], { cwd: root }) From f3ce3ca2c89374f7dd012e136da6f8ffe6df22ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Thu, 17 Sep 2026 14:41:42 +0800 Subject: [PATCH 2/2] docs(knowledge): add cli output contract card and menu route --- .../20260917-feature-human-cli-ux/brief.md | 17 +++++---- shadow-docs/knowledge/cli-output-contract.md | 36 +++++++++++++++++++ shadow-docs/menu.md | 1 + 3 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 shadow-docs/knowledge/cli-output-contract.md diff --git a/shadow-docs/changes/20260917-feature-human-cli-ux/brief.md b/shadow-docs/changes/20260917-feature-human-cli-ux/brief.md index 8273c88..89065aa 100644 --- a/shadow-docs/changes/20260917-feature-human-cli-ux/brief.md +++ b/shadow-docs/changes/20260917-feature-human-cli-ux/brief.md @@ -4,7 +4,7 @@ "name": "20260917-feature-human-cli-ux", "type": "feature", "scope": "cli.mjs,lib", - "status": "branched", + "status": "reviewed", "baseBranch": "main", "branch": "feature/20260917-feature-human-cli-ux", "files": [ @@ -26,14 +26,14 @@ "pullRequestUrl": null }, "review": { - "conclusion": "pending", - "verifiedCommit": null, - "verifiedAt": null + "conclusion": "passed", + "verifiedCommit": "f05550891f580cfe3b51488feb98b822987ef734", + "verifiedAt": "2026-09-17T03:38:05.285Z" }, "workflow": { "operation": null, - "checkpoint": "issue:2", - "planHash": "b250a3afca93dcad9c17e89b1aaaabd87f752dffa441a95a5cd9e3f4dd47c519", + "checkpoint": "f05550891f580cfe3b51488feb98b822987ef734", + "planHash": "6c934f68c66e6f8801dfb50b52404697e6a5c83eb5927e6a934b5047ae6a19ad", "updatedAt": null, "lastError": null, "issuePlan": { @@ -43,6 +43,11 @@ "feature" ] } + }, + "knowledge": { + "action": "新增", + "target": "shadow-docs/knowledge/cli-output-contract.md", + "reason": "stdout 纯 JSON / 人用输出走 stderr / code 不本地化——输出面根契约,所有触碰输出面的变更必须遵守" } } --- diff --git a/shadow-docs/knowledge/cli-output-contract.md b/shadow-docs/knowledge/cli-output-contract.md new file mode 100644 index 0000000..f3ae95a --- /dev/null +++ b/shadow-docs/knowledge/cli-output-contract.md @@ -0,0 +1,36 @@ +--- +title: CLI 双通道输出契约 +domain: cli-infrastructure +keywords: [stdout, stderr, JSON 契约, 语言, i18n, nextStep, help, 人用提示] +scope: [cli.mjs, lib/output.mjs, lib/human.mjs, lib/i18n.mjs, lib/commands.mjs] +status: active +source: + - changes/20260917-feature-human-cli-ux/brief.md +verified: 2026-09-17 +--- + +# CLI 双通道输出契约 + +## 当前结论 + +本 CLI 有且只有一个机器契约面:**stdout 恒为单行 JSON**(`{ok, command, ...}` / `{ok:false, error:{code,message}}`),与运行环境、语言设置、是否 TTY 完全无关。人类可读内容(进出场横幅、耗时、错误解释、help 人读版)只允许走 **stderr**,由 `lib/human.mjs` 渲染,可经 `SHADOW_DEV_QUIET` 整体关闭。命令目录 `lib/commands.mjs` 是两个通道的单一事实源:HELP 字符串、`help` JSON、`data.nextStep`、错误示例全部由它派生。 + +## 执行约束 + +- 任何新增输出必须二选一:进 stdout JSON 契约(视为公开 API,需测试钉住),或进 stderr 人用层;**禁止**向 stdout 写非 JSON 内容。 +- 错误 code 与 `data.nextStep` 模板永不本地化;语言链固定为 `--lang` > `SHADOW_DEV_LANG` > locale 探测 > 默认 zh,且只影响 stderr 文案。 +- `nextStep` 为 additive 字段,写入发生在 planHash 持久化与计算之后,不得参与 hash 输入。 +- help 的 `data.help` 保留原字符串字段向后兼容,结构化明细放 `data.commands`。 +- 语言不变性由契约测试保护(同命令 zh/en stdout 逐字节一致),触碰输出面的变更必须保持其绿色。 + +## 适用边界 + +适用于 shadow-dev 全部子命令的 stdout/stderr 行为。不适用于技能(SKILL.md)自身向用户输出的进场/离场文案——那是编排层,不读本 CLI 的 stderr。 + +## 验证方式 + +`node --test test/cli.test.mjs` 全绿即契约成立(关键用例:`human layer: banners and hints on stderr, stdout contract language-invariant`、`SHADOW_DEV_QUIET silences the human channel`)。手工复验:`shadow-dev help --lang zh` 与 `--lang en` 的 stdout 应 diff 为空、stderr 应不同;`SHADOW_DEV_QUIET=1 shadow-dev repo inspect` 的 stderr 应为空。 + +## 关联知识 + +- [brief.md frontmatter 行尾契约](brief-frontmatter-crlf.md) diff --git a/shadow-docs/menu.md b/shadow-docs/menu.md index 578d611..8ecb837 100644 --- a/shadow-docs/menu.md +++ b/shadow-docs/menu.md @@ -7,3 +7,4 @@ | 技术域 | 关键词 | 应查阅 | |--------|--------|--------| | brief 读写 | brief frontmatter 行尾 CRLF autocrlf BRIEF_FRONTMATTER_REQUIRED planHash | knowledge/brief-frontmatter-crlf.md | +| CLI 输出面 | stdout stderr JSON 契约 语言 i18n 本地化 nextStep help 提示 QUIET | knowledge/cli-output-contract.md |