From 7fe26cf427bff1d2bc8714b3167ff3f02e3a7586 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 16:34:23 +0800 Subject: [PATCH 1/2] feat(cli): suppress stdout JSON on interactive TTY; --json/SHADOW_DEV_JSON opt in --- README.md | 4 +- cli.mjs | 7 ++-- lib/args.mjs | 2 +- lib/human.mjs | 2 + lib/i18n.mjs | 6 ++- lib/output.mjs | 4 ++ .../brief.md | 24 +++++------ shadow-docs/knowledge/cli-output-contract.md | 6 ++- shadow-docs/menu.md | 2 +- test/cli.test.mjs | 42 +++++++++++++++++++ 10 files changed, 76 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 9bae7b8..51b0830 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,11 @@ 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)。带流程后继的命令,成功结果的 `data.nextStep` 给出下一步建议命令(稳定英文模板,不随语言变化,agent 可直接消费)。 +**输出模型**:JSON 是机器契约面——单行格式,成功 `{"ok":true,"command":...,"data":...}`,失败 `{"ok":false,"error":{"code","message"}}`。其出现按环境路由:管道/重定向(agent、脚本)默认输出;**交互终端默认不输出 JSON,只看人用层**,任何环境想显式拿 JSON 用 `--json` 或 `SHADOW_DEV_JSON=1`。退出码不受 JSON 抑制影响。带流程后继的命令,成功结果的 `data.nextStep` 给出下一步建议命令(稳定英文模板,不随语言变化,agent 可直接消费)。 ## 人用输出层(stderr) -stdout 的 JSON 契约之外,CLI 在 stderr 渲染一层人类提示:进场横幅(命令+参数)、收场摘要(结果+耗时)、`nextStep` 引导、错误码的本地化解释与示例命令。stderr 内容不承载契约,可随时关闭。 +CLI 在 stderr 渲染一层人类提示:进场横幅(命令+参数)、收场摘要(结果+耗时,plan 命令含 `planHash`)、`nextStep` 引导、错误码的本地化解释与示例命令。stderr 内容不承载 JSON 契约,可随时关闭;但在交互终端抑制 stdout JSON 时它是唯一信息通道,`plan` 收场行的 `planHash` 即可直接取用。 - 语言解析:`--lang zh|en` > `SHADOW_DEV_LANG` > 系统 locale 自动探测 > 默认 `zh`。非法取值报 `INVALID_LANG`(退出码 2)。 - 关闭提示:`SHADOW_DEV_QUIET=1`(或 `true`)时 stderr 零输出,适合日志管道。 diff --git a/cli.mjs b/cli.mjs index 3a1c9cd..65f1ed0 100755 --- a/cli.mjs +++ b/cli.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { args } from './lib/args.mjs' -import { out, fail } from './lib/output.mjs' +import { out, fail, jsonEnabled } from './lib/output.mjs' import { plan } from './lib/plan.mjs' import { root } from './lib/git.mjs' import { brief, write } from './lib/brief.mjs' @@ -96,8 +96,9 @@ try { v = human.decorate(await handle(root(), p, o), o) human.done(L, v, Date.now() - t0) } - out(v) + if (jsonEnabled(o)) out(v) } catch (e) { human.error(L, e, p) - fail(e.code || e.message, e.message, e.status || 1) + if (jsonEnabled(o)) fail(e.code || e.message, e.message, e.status || 1) + else process.exitCode = e.status || 1 } diff --git a/lib/args.mjs b/lib/args.mjs index 01ba91b..8a5058c 100644 --- a/lib/args.mjs +++ b/lib/args.mjs @@ -6,7 +6,7 @@ export function args(a) { const p = [], o = {} for (let i = 0; i < a.length; i++) { if (!a[i].startsWith('--')) p.push(a[i]) - else { const k = a[i].slice(2); if (['confirm', 'json', 'full'].includes(k)) o[k] = true; else o[k] = a[++i] } + else { const k = a[i].slice(2); if (['confirm', 'json', 'full', 'help'].includes(k)) o[k] = true; else o[k] = a[++i] } } return { p, o } } diff --git a/lib/human.mjs b/lib/human.mjs index 3f72b32..8043846 100644 --- a/lib/human.mjs +++ b/lib/human.mjs @@ -9,6 +9,8 @@ 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 })) + // TTY 抑制 JSON 时,plan 凭证与下一步建议必须仍可从 stderr 恢复 + if (v.planHash) w(ui(L, 'planHash', { hash: v.planHash })) if (v.data?.nextStep) w(ui(L, 'next', { step: v.data.nextStep })) } diff --git a/lib/i18n.mjs b/lib/i18n.mjs index 2bf59df..459ea7e 100644 --- a/lib/i18n.mjs +++ b/lib/i18n.mjs @@ -5,20 +5,22 @@ const UI = { zh: { enter: '▶ [进场] {cmd}', done: '✅ [完成] {cmd} · {ms}ms', + planHash: ' planHash: {hash}', 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', + globals: ' 全局参数: --lang zh|en(或 SHADOW_DEV_LANG)· SHADOW_DEV_QUIET=1 关闭本提示层 · 管道默认输出 JSON 契约,交互终端用 --json 显式开启', }, en: { enter: '▶ [enter] {cmd}', done: '✅ [done] {cmd} · {ms}ms', + planHash: ' planHash: {hash}', 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', + globals: ' global: --lang zh|en (or SHADOW_DEV_LANG) · SHADOW_DEV_QUIET=1 silences this layer · stdout JSON is default in pipes, opt in with --json on a TTY', }, } diff --git a/lib/output.mjs b/lib/output.mjs index 47dc7f3..7654e7b 100644 --- a/lib/output.mjs +++ b/lib/output.mjs @@ -1,2 +1,6 @@ export function out(v, s = 0) { console.log(JSON.stringify(v)); process.exitCode = s } export function fail(c, m = c, s = 1) { out({ ok: false, error: { code: c, message: m } }, s) } + +// JSON 契约面路由:管道/重定向(agent、脚本)恒输出;交互 TTY 默认静默,仅 --json 或 SHADOW_DEV_JSON=1 显式开启。 +// 抑制时退出码照常设置,人用信息(含 planHash)由 stderr 层承载。 +export function jsonEnabled(o) { return !process.stdout.isTTY || !!o.json || process.env.SHADOW_DEV_JSON === '1' } diff --git a/shadow-docs/changes/20260917-feature-tty-human-default/brief.md b/shadow-docs/changes/20260917-feature-tty-human-default/brief.md index fd74ced..7540faf 100644 --- a/shadow-docs/changes/20260917-feature-tty-human-default/brief.md +++ b/shadow-docs/changes/20260917-feature-tty-human-default/brief.md @@ -4,9 +4,9 @@ "name": "20260917-feature-tty-human-default", "type": "feature", "scope": "cli.mjs,lib", - "status": "proposed", + "status": "branched", "baseBranch": "main", - "branch": null, + "branch": "feature/20260917-feature-tty-human-default", "files": [ "README.md", "cli.mjs", @@ -31,7 +31,7 @@ "workflow": { "operation": null, "checkpoint": "issue:9", - "planHash": "3b465a0a2be42f724fc9a72998f3445419f963e2347e03708ab1ce829f1a36b4", + "planHash": "74249a0f2a800fd21bea4037c19a176941a338e5657e613eea01052f8587beda", "updatedAt": null, "lastError": null, "issuePlan": { @@ -70,24 +70,24 @@ ### Phase 1 — 契约测试先行(TDD) -- [ ] `jsonEnabled` 纯函数单测:pipe 无 flag=true、TTY 无 flag=false、TTY+--json=true、env 强制=true —— `test/cli.test.mjs` `lib/output.mjs` -- [ ] 存量 47 项 subprocess 测试保持绿色(spawnSync 管道非 TTY 路径),新增断言:管道无 `--json` 仍出 JSON、有 `--json` 单行不 pretty —— `test/cli.test.mjs` +- [x] `jsonEnabled` 纯函数单测:pipe 无 flag=true、TTY 无 flag=false、TTY+--json=true、env 强制=true —— `test/cli.test.mjs` `lib/output.mjs` +- [x] 存量 47 项 subprocess 测试保持绿色(spawnSync 管道非 TTY 路径),新增断言:管道无 `--json` 仍出 JSON、有 `--json` 单行不 pretty —— `test/cli.test.mjs` ### Phase 2 — 实现 -- [ ] `lib/output.mjs`:新增 `jsonEnabled`/`emit`,`out`/`fail` 收拢 —— `lib/output.mjs` -- [ ] `cli.mjs` 出口改 `emit(v, o)`;`lib/human.mjs` 收场行透出 `planHash`(plan 信封存在时)与写结果 checkpoint —— `cli.mjs` `lib/human.mjs` -- [ ] `--json` 从"无操作兼容参数"升级为契约开关,README「--json」段落改写 —— `README.md` +- [x] `lib/output.mjs`:新增 `jsonEnabled`/`emit`,`out`/`fail` 收拢 —— `lib/output.mjs` +- [x] `cli.mjs` 出口改 `emit(v, o)`;`lib/human.mjs` 收场行透出 `planHash`(plan 信封存在时)与写结果 checkpoint —— `cli.mjs` `lib/human.mjs` +- [x] `--json` 从"无操作兼容参数"升级为契约开关,README「--json」段落改写 —— `README.md` ### Phase 3 — 回归与文档知识 -- [ ] README 输出模型段落更新(管道默认/TTY 默认/开关);全量回归 —— `README.md` `test/cli.test.mjs` -- [ ] 更新卡片 `cli-output-contract.md`(新规则 + source 追加)与 menu 关键词 —— `shadow-docs/knowledge/cli-output-contract.md` `shadow-docs/menu.md` +- [x] README 输出模型段落更新(管道默认/TTY 默认/开关);全量回归 —— `README.md` `test/cli.test.mjs` +- [x] 更新卡片 `cli-output-contract.md`(新规则 + source 追加)与 menu 关键词 —— `shadow-docs/knowledge/cli-output-contract.md` `shadow-docs/menu.md` ## 结果 -- 实际耗时: — -- 验证: — +- 实际耗时: 约 25 分钟 +- 验证: `node --test` 49/49(47 存量经管道路径零改动全绿 + 2 新增:jsonEnabled 纯函数矩阵、假 TTY 子进程行为含 planHash 透出与退出码);实现与 brief 微偏差一处:出口门控直接 `jsonEnabled(o)` 内联于 cli.mjs,未另抽 emit 抽象(行为等价,避免薄封装);顺带修复 `--help` 不在布尔参数表导致的 `--help --json` 值吞噬隐患。 ## 知识评估 diff --git a/shadow-docs/knowledge/cli-output-contract.md b/shadow-docs/knowledge/cli-output-contract.md index c47d2fe..a4d91f2 100644 --- a/shadow-docs/knowledge/cli-output-contract.md +++ b/shadow-docs/knowledge/cli-output-contract.md @@ -7,6 +7,7 @@ status: active source: - changes/20260917-feature-human-cli-ux/brief.md - changes/20260917-feature-help-compact-noise/brief.md + - changes/20260917-feature-tty-human-default/brief.md verified: 2026-09-17 --- @@ -14,11 +15,12 @@ verified: 2026-09-17 ## 当前结论 -本 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`、错误示例全部由它派生。 +本 CLI 的机器契约面是 **stdout 的单行 JSON**(`{ok, command, ...}` / `{ok:false, error:{code,message}}`),其**出现按环境路由**:非 TTY(管道/重定向)恒输出;交互 TTY 默认抑制,仅 `--json` 或 `SHADOW_DEV_JSON=1` 显式开启(判定纯函数 `output.jsonEnabled`)。JSON 的内容本身与语言、是否 TTY 无关。人类可读内容(进出场横幅、耗时、错误解释、help 人读版、TTY 下作为兜底的 `planHash` 行)只允许走 **stderr**,由 `lib/human.mjs` 渲染,可经 `SHADOW_DEV_QUIET` 整体关闭。退出码不受 JSON 抑制影响。命令目录 `lib/commands.mjs` 是两个通道的单一事实源:HELP 字符串、`help` JSON、`data.nextStep`、错误示例全部由它派生。 ## 执行约束 - 任何新增输出必须二选一:进 stdout JSON 契约(视为公开 API,需测试钉住),或进 stderr 人用层;**禁止**向 stdout 写非 JSON 内容。 +- 抑制 stdout 的分支必须仍然设置退出码;plan 的人用收场行必须透出 `planHash`(PTY 环境下的 agent 兜底)。`--json` 是跨环境逃生门,不得复用为其他语义。 - 错误 code 与 `data.nextStep` 模板永不本地化;语言链固定为 `--lang` > `SHADOW_DEV_LANG` > locale 探测 > 默认 zh,且只影响 stderr 文案。 - `nextStep` 为 additive 字段,写入发生在 planHash 持久化与计算之后,不得参与 hash 输入。 - help 的 `data.help` 恒为字符串(概览默认唯一字段,最小面 <1KB);结构化目录 `data.commands` 经 `--full` opt-in;`help <命令>` 组详情恒定返回该组 `commands`。 @@ -30,7 +32,7 @@ verified: 2026-09-17 ## 验证方式 -`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 应为空。 +`node --test test/cli.test.mjs` 全绿即契约成立(关键用例:`jsonEnabled routes the JSON surface by environment and explicit flags`、`TTY suppresses stdout JSON; --json and env restore it; planHash surfaces on stderr`、`human layer: banners and hints on stderr, stdout contract language-invariant`、`SHADOW_DEV_QUIET silences the human channel`)。手工复验:管道中 `shadow-dev repo inspect | jq .` 有 JSON;TTY 终端里 `shadow-dev help` 只见中文表、`shadow-dev --json help` 恢复 JSON;`SHADOW_DEV_QUIET=1 shadow-dev repo inspect` 的 stderr 应为空。 ## 关联知识 diff --git a/shadow-docs/menu.md b/shadow-docs/menu.md index 3c747c3..def0c75 100644 --- a/shadow-docs/menu.md +++ b/shadow-docs/menu.md @@ -7,5 +7,5 @@ | 技术域 | 关键词 | 应查阅 | |--------|--------|--------| | brief 读写 | brief frontmatter 行尾 CRLF autocrlf BRIEF_FRONTMATTER_REQUIRED planHash | knowledge/brief-frontmatter-crlf.md | -| CLI 输出面 | stdout stderr JSON 契约 语言 i18n 本地化 nextStep help 提示 QUIET --full 概览 摘要 减噪 | knowledge/cli-output-contract.md | +| CLI 输出面 | stdout stderr JSON 契约 语言 i18n 本地化 nextStep help 提示 QUIET --full 概览 摘要 减噪 TTY --json SHADOW_DEV_JSON | knowledge/cli-output-contract.md | | plan/execute 凭证 | planHash hash 漂移 norm changedFiles 脏工作区 porcelain trim 凭证链 | knowledge/plan-credential-chain.md | diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 34597b4..f06bef4 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -232,6 +232,48 @@ test('mutating results carry a stable untranslated nextStep in JSON', () => { assert.match(en.stderr, /next/i) }) +function runTty(args, cwd = process.cwd(), env = {}) { + const url = String(new URL('../cli.mjs', import.meta.url)) + const script = `process.stdout.isTTY = true; process.argv = [process.execPath, ${JSON.stringify(CLI)}, ${args.map(a => JSON.stringify(String(a))).join(', ')}]; await import(${JSON.stringify(url)})` + return spawnSync(process.execPath, ['--input-type=module', '-e', script], { cwd, encoding: 'utf8', env: { ...process.env, ...env } }) +} + +test('jsonEnabled routes the JSON surface by environment and explicit flags', async () => { + const { jsonEnabled } = await import('../lib/output.mjs') + const tty = process.stdout.isTTY, env = process.env.SHADOW_DEV_JSON + try { + process.stdout.isTTY = false + assert.equal(jsonEnabled({}), true, 'pipe default emits JSON') + assert.equal(jsonEnabled({ json: true }), true) + process.stdout.isTTY = true + assert.equal(jsonEnabled({}), false, 'TTY default suppresses JSON') + assert.equal(jsonEnabled({ json: true }), true, '--json forces JSON on TTY') + process.stdout.isTTY = undefined + process.env.SHADOW_DEV_JSON = '1' + assert.equal(jsonEnabled({}), true, 'env override forces JSON') + } finally { + process.stdout.isTTY = tty + if (env === undefined) delete process.env.SHADOW_DEV_JSON; else process.env.SHADOW_DEV_JSON = env + } +}) + +test('TTY suppresses stdout JSON; --json and env restore it; planHash surfaces on stderr', () => { + const plain = runTty(['--help']) + assert.equal(plain.status, 0, plain.stderr) + assert.equal(plain.stdout.trim(), '', 'interactive help must not print JSON') + assert.match(plain.stderr, /shadow-dev 命令一览/) + const forced = runTty(['--help', '--json']) + assert.equal(JSON.parse(forced.stdout).command, 'help') + const root = fixture() + assert.equal(JSON.parse(runTty(['repo', 'inspect'], root, { SHADOW_DEV_JSON: '1' }).stdout).command, 'repo.inspect') + const planned = runTty(['branch', 'plan', '--name', 'sample'], root) + assert.equal(planned.stdout.trim(), '') + assert.match(planned.stderr, /planHash: [0-9a-f]{64}/) + const bogus = runTty(['bogus'], root) + assert.equal(bogus.status, 1) + assert.equal(bogus.stdout.trim(), '', 'suppressed errors still exit nonzero without printing') +}) + test('help defaults to a compact summary; --full adds the structured catalog', () => { const overview = run(['help', '--lang', 'zh']) const data = JSON.parse(overview.stdout).data From f7ed9537576073dfffd40a9ef6c91c28d0937d5a 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 16:34:39 +0800 Subject: [PATCH 2/2] chore(shadow-docs): #9 review passed checkpoint --- .../20260917-feature-tty-human-default/brief.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/shadow-docs/changes/20260917-feature-tty-human-default/brief.md b/shadow-docs/changes/20260917-feature-tty-human-default/brief.md index 7540faf..a352bad 100644 --- a/shadow-docs/changes/20260917-feature-tty-human-default/brief.md +++ b/shadow-docs/changes/20260917-feature-tty-human-default/brief.md @@ -4,7 +4,7 @@ "name": "20260917-feature-tty-human-default", "type": "feature", "scope": "cli.mjs,lib", - "status": "branched", + "status": "reviewed", "baseBranch": "main", "branch": "feature/20260917-feature-tty-human-default", "files": [ @@ -24,14 +24,14 @@ "pullRequestUrl": null }, "review": { - "conclusion": "pending", - "verifiedCommit": null, - "verifiedAt": null + "conclusion": "passed", + "verifiedCommit": "7fe26cf427bff1d2bc8714b3167ff3f02e3a7586", + "verifiedAt": "2026-09-17T08:34:24.447Z" }, "workflow": { "operation": null, - "checkpoint": "issue:9", - "planHash": "74249a0f2a800fd21bea4037c19a176941a338e5657e613eea01052f8587beda", + "checkpoint": "7fe26cf427bff1d2bc8714b3167ff3f02e3a7586", + "planHash": "8b02b8b9a2e61b416b26fd0ee4b46dbbb3852fcd2282b42b33bd0f167ee8bf2c", "updatedAt": null, "lastError": null, "issuePlan": { @@ -41,6 +41,11 @@ "feature" ] } + }, + "knowledge": { + "action": "更新", + "target": "shadow-docs/knowledge/cli-output-contract.md", + "reason": "stdout 恒 JSON 条款修订为按环境路由(管道默认/TTY 显式),TTY 抑制时退出码与 planHash 透出为新增约束" } } ---