Skip to content

Commit 2eeef12

Browse files
authored
Merge pull request #10 from stack-wuh/feature/20260917-feature-tty-human-default
交互终端默认人用视图,JSON 经 --json 显式开启
2 parents a079864 + f7ed953 commit 2eeef12

10 files changed

Lines changed: 85 additions & 27 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute
2424
| `archive plan\|execute` | 归档已合并变更并重建 INDEX |
2525
| `index rebuild plan\|execute` | 重建变更索引 |
2626

27-
所有输出为单行 JSON:成功 `{"ok":true,"command":...,"data":...}`,失败 `{"ok":false,"error":{"code","message"}}`。`--json` 参数为历史兼容保留,接受即无操作(输出恒为 JSON)。带流程后继的命令,成功结果的 `data.nextStep` 给出下一步建议命令(稳定英文模板,不随语言变化,agent 可直接消费)。
27+
**输出模型**:JSON 是机器契约面——单行格式,成功 `{"ok":true,"command":...,"data":...}`,失败 `{"ok":false,"error":{"code","message"}}`。其出现按环境路由:管道/重定向(agent、脚本)默认输出;**交互终端默认不输出 JSON,只看人用层**,任何环境想显式拿 JSON 用 `--json` 或 `SHADOW_DEV_JSON=1`。退出码不受 JSON 抑制影响。带流程后继的命令,成功结果的 `data.nextStep` 给出下一步建议命令(稳定英文模板,不随语言变化,agent 可直接消费)。
2828

2929
## 人用输出层(stderr)
3030

31-
stdout 的 JSON 契约之外,CLI 在 stderr 渲染一层人类提示:进场横幅(命令+参数)、收场摘要(结果+耗时)、`nextStep` 引导、错误码的本地化解释与示例命令。stderr 内容不承载契约,可随时关闭。
31+
CLI 在 stderr 渲染一层人类提示:进场横幅(命令+参数)、收场摘要(结果+耗时,plan 命令含 `planHash`)、`nextStep` 引导、错误码的本地化解释与示例命令。stderr 内容不承载 JSON 契约,可随时关闭;但在交互终端抑制 stdout JSON 时它是唯一信息通道,`plan` 收场行的 `planHash` 即可直接取用。
3232

3333
- 语言解析:`--lang zh|en` > `SHADOW_DEV_LANG` > 系统 locale 自动探测 > 默认 `zh`。非法取值报 `INVALID_LANG`(退出码 2)。
3434
- 关闭提示:`SHADOW_DEV_QUIET=1`(或 `true`)时 stderr 零输出,适合日志管道。

‎cli.mjs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env node
22
import { args } from './lib/args.mjs'
3-
import { out, fail } from './lib/output.mjs'
3+
import { out, fail, jsonEnabled } from './lib/output.mjs'
44
import { plan } from './lib/plan.mjs'
55
import { root } from './lib/git.mjs'
66
import { brief, write } from './lib/brief.mjs'
@@ -96,8 +96,9 @@ try {
9696
v = human.decorate(await handle(root(), p, o), o)
9797
human.done(L, v, Date.now() - t0)
9898
}
99-
out(v)
99+
if (jsonEnabled(o)) out(v)
100100
} catch (e) {
101101
human.error(L, e, p)
102-
fail(e.code || e.message, e.message, e.status || 1)
102+
if (jsonEnabled(o)) fail(e.code || e.message, e.message, e.status || 1)
103+
else process.exitCode = e.status || 1
103104
}

‎lib/args.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export function args(a) {
66
const p = [], o = {}
77
for (let i = 0; i < a.length; i++) {
88
if (!a[i].startsWith('--')) p.push(a[i])
9-
else { const k = a[i].slice(2); if (['confirm', 'json', 'full'].includes(k)) o[k] = true; else o[k] = a[++i] }
9+
else { const k = a[i].slice(2); if (['confirm', 'json', 'full', 'help'].includes(k)) o[k] = true; else o[k] = a[++i] }
1010
}
1111
return { p, o }
1212
}

‎lib/human.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export function enter(L, p) { w(ui(L, 'enter', { cmd: `shadow-dev ${p.join(' ')}
99

1010
export function done(L, v, ms) {
1111
w(ui(L, 'done', { cmd: v.command, ms }))
12+
// TTY 抑制 JSON 时,plan 凭证与下一步建议必须仍可从 stderr 恢复
13+
if (v.planHash) w(ui(L, 'planHash', { hash: v.planHash }))
1214
if (v.data?.nextStep) w(ui(L, 'next', { step: v.data.nextStep }))
1315
}
1416

‎lib/i18n.mjs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,22 @@ const UI = {
55
zh: {
66
enter: '▶ [进场] {cmd}',
77
done: '✅ [完成] {cmd} · {ms}ms',
8+
planHash: ' planHash: {hash}',
89
next: '⤷ 下一步: {step}',
910
error: '✗ {code}: {hint}',
1011
example: ' 示例: {example}',
1112
helpHead: 'shadow-dev 命令一览(单命令详情: shadow-dev help <命令>)',
12-
globals: ' 全局参数: --lang zh|en(或 SHADOW_DEV_LANG)· SHADOW_DEV_QUIET=1 关闭本提示层 · stdout 契约恒为 JSON',
13+
globals: ' 全局参数: --lang zh|en(或 SHADOW_DEV_LANG)· SHADOW_DEV_QUIET=1 关闭本提示层 · 管道默认输出 JSON 契约,交互终端用 --json 显式开启',
1314
},
1415
en: {
1516
enter: '▶ [enter] {cmd}',
1617
done: '✅ [done] {cmd} · {ms}ms',
18+
planHash: ' planHash: {hash}',
1719
next: '⤷ next: {step}',
1820
error: '✗ {code}: {hint}',
1921
example: ' example: {example}',
2022
helpHead: 'shadow-dev commands (detail: shadow-dev help <command>)',
21-
globals: ' global: --lang zh|en (or SHADOW_DEV_LANG) · SHADOW_DEV_QUIET=1 silences this layer · stdout is always JSON',
23+
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',
2224
},
2325
}
2426

‎lib/output.mjs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
export function out(v, s = 0) { console.log(JSON.stringify(v)); process.exitCode = s }
22
export function fail(c, m = c, s = 1) { out({ ok: false, error: { code: c, message: m } }, s) }
3+
4+
// JSON 契约面路由:管道/重定向(agent、脚本)恒输出;交互 TTY 默认静默,仅 --json 或 SHADOW_DEV_JSON=1 显式开启。
5+
// 抑制时退出码照常设置,人用信息(含 planHash)由 stderr 层承载。
6+
export function jsonEnabled(o) { return !process.stdout.isTTY || !!o.json || process.env.SHADOW_DEV_JSON === '1' }

‎shadow-docs/changes/20260917-feature-tty-human-default/brief.md‎

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
"name": "20260917-feature-tty-human-default",
55
"type": "feature",
66
"scope": "cli.mjs,lib",
7-
"status": "proposed",
7+
"status": "reviewed",
88
"baseBranch": "main",
9-
"branch": null,
9+
"branch": "feature/20260917-feature-tty-human-default",
1010
"files": [
1111
"README.md",
1212
"cli.mjs",
@@ -24,14 +24,14 @@
2424
"pullRequestUrl": null
2525
},
2626
"review": {
27-
"conclusion": "pending",
28-
"verifiedCommit": null,
29-
"verifiedAt": null
27+
"conclusion": "passed",
28+
"verifiedCommit": "7fe26cf427bff1d2bc8714b3167ff3f02e3a7586",
29+
"verifiedAt": "2026-09-17T08:34:24.447Z"
3030
},
3131
"workflow": {
3232
"operation": null,
33-
"checkpoint": "issue:9",
34-
"planHash": "3b465a0a2be42f724fc9a72998f3445419f963e2347e03708ab1ce829f1a36b4",
33+
"checkpoint": "7fe26cf427bff1d2bc8714b3167ff3f02e3a7586",
34+
"planHash": "8b02b8b9a2e61b416b26fd0ee4b46dbbb3852fcd2282b42b33bd0f167ee8bf2c",
3535
"updatedAt": null,
3636
"lastError": null,
3737
"issuePlan": {
@@ -41,6 +41,11 @@
4141
"feature"
4242
]
4343
}
44+
},
45+
"knowledge": {
46+
"action": "更新",
47+
"target": "shadow-docs/knowledge/cli-output-contract.md",
48+
"reason": "stdout 恒 JSON 条款修订为按环境路由(管道默认/TTY 显式),TTY 抑制时退出码与 planHash 透出为新增约束"
4449
}
4550
}
4651
---
@@ -70,24 +75,24 @@
7075

7176
### Phase 1 — 契约测试先行(TDD)
7277

73-
- [ ] `jsonEnabled` 纯函数单测:pipe 无 flag=true、TTY 无 flag=false、TTY+--json=true、env 强制=true —— `test/cli.test.mjs` `lib/output.mjs`
74-
- [ ] 存量 47 项 subprocess 测试保持绿色(spawnSync 管道非 TTY 路径),新增断言:管道无 `--json` 仍出 JSON、有 `--json` 单行不 pretty —— `test/cli.test.mjs`
78+
- [x] `jsonEnabled` 纯函数单测:pipe 无 flag=true、TTY 无 flag=false、TTY+--json=true、env 强制=true —— `test/cli.test.mjs` `lib/output.mjs`
79+
- [x] 存量 47 项 subprocess 测试保持绿色(spawnSync 管道非 TTY 路径),新增断言:管道无 `--json` 仍出 JSON、有 `--json` 单行不 pretty —— `test/cli.test.mjs`
7580

7681
### Phase 2 — 实现
7782

78-
- [ ] `lib/output.mjs`:新增 `jsonEnabled`/`emit`,`out`/`fail` 收拢 —— `lib/output.mjs`
79-
- [ ] `cli.mjs` 出口改 `emit(v, o)`;`lib/human.mjs` 收场行透出 `planHash`(plan 信封存在时)与写结果 checkpoint —— `cli.mjs` `lib/human.mjs`
80-
- [ ] `--json` 从"无操作兼容参数"升级为契约开关,README「--json」段落改写 —— `README.md`
83+
- [x] `lib/output.mjs`:新增 `jsonEnabled`/`emit`,`out`/`fail` 收拢 —— `lib/output.mjs`
84+
- [x] `cli.mjs` 出口改 `emit(v, o)`;`lib/human.mjs` 收场行透出 `planHash`(plan 信封存在时)与写结果 checkpoint —— `cli.mjs` `lib/human.mjs`
85+
- [x] `--json` 从"无操作兼容参数"升级为契约开关,README「--json」段落改写 —— `README.md`
8186

8287
### Phase 3 — 回归与文档知识
8388

84-
- [ ] README 输出模型段落更新(管道默认/TTY 默认/开关);全量回归 —— `README.md` `test/cli.test.mjs`
85-
- [ ] 更新卡片 `cli-output-contract.md`(新规则 + source 追加)与 menu 关键词 —— `shadow-docs/knowledge/cli-output-contract.md` `shadow-docs/menu.md`
89+
- [x] README 输出模型段落更新(管道默认/TTY 默认/开关);全量回归 —— `README.md` `test/cli.test.mjs`
90+
- [x] 更新卡片 `cli-output-contract.md`(新规则 + source 追加)与 menu 关键词 —— `shadow-docs/knowledge/cli-output-contract.md` `shadow-docs/menu.md`
8691

8792
## 结果
8893

89-
- 实际耗时: —
90-
- 验证: —
94+
- 实际耗时: 约 25 分钟
95+
- 验证: `node --test` 49/49(47 存量经管道路径零改动全绿 + 2 新增:jsonEnabled 纯函数矩阵、假 TTY 子进程行为含 planHash 透出与退出码);实现与 brief 微偏差一处:出口门控直接 `jsonEnabled(o)` 内联于 cli.mjs,未另抽 emit 抽象(行为等价,避免薄封装);顺带修复 `--help` 不在布尔参数表导致的 `--help --json` 值吞噬隐患。
9196

9297
## 知识评估
9398

‎shadow-docs/knowledge/cli-output-contract.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,20 @@ status: active
77
source:
88
- changes/20260917-feature-human-cli-ux/brief.md
99
- changes/20260917-feature-help-compact-noise/brief.md
10+
- changes/20260917-feature-tty-human-default/brief.md
1011
verified: 2026-09-17
1112
---
1213

1314
# CLI 双通道输出契约
1415

1516
## 当前结论
1617

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`、错误示例全部由它派生。
18+
本 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`、错误示例全部由它派生。
1819

1920
## 执行约束
2021

2122
- 任何新增输出必须二选一:进 stdout JSON 契约(视为公开 API,需测试钉住),或进 stderr 人用层;**禁止**向 stdout 写非 JSON 内容。
23+
- 抑制 stdout 的分支必须仍然设置退出码;plan 的人用收场行必须透出 `planHash`(PTY 环境下的 agent 兜底)。`--json` 是跨环境逃生门,不得复用为其他语义。
2224
- 错误 code 与 `data.nextStep` 模板永不本地化;语言链固定为 `--lang` > `SHADOW_DEV_LANG` > locale 探测 > 默认 zh,且只影响 stderr 文案。
2325
- `nextStep` 为 additive 字段,写入发生在 planHash 持久化与计算之后,不得参与 hash 输入。
2426
- help 的 `data.help` 恒为字符串(概览默认唯一字段,最小面 <1KB);结构化目录 `data.commands` 经 `--full` opt-in;`help <命令>` 组详情恒定返回该组 `commands`。
@@ -30,7 +32,7 @@ verified: 2026-09-17
3032

3133
## 验证方式
3234

33-
`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 应为空。
35+
`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 应为空。
3436

3537
## 关联知识
3638

‎shadow-docs/menu.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@
77
| 技术域 | 关键词 | 应查阅 |
88
|--------|--------|--------|
99
| brief 读写 | brief frontmatter 行尾 CRLF autocrlf BRIEF_FRONTMATTER_REQUIRED planHash | knowledge/brief-frontmatter-crlf.md |
10-
| CLI 输出面 | stdout stderr JSON 契约 语言 i18n 本地化 nextStep help 提示 QUIET --full 概览 摘要 减噪 | knowledge/cli-output-contract.md |
10+
| CLI 输出面 | stdout stderr JSON 契约 语言 i18n 本地化 nextStep help 提示 QUIET --full 概览 摘要 减噪 TTY --json SHADOW_DEV_JSON | knowledge/cli-output-contract.md |
1111
| plan/execute 凭证 | planHash hash 漂移 norm changedFiles 脏工作区 porcelain trim 凭证链 | knowledge/plan-credential-chain.md |

‎test/cli.test.mjs‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,48 @@ test('mutating results carry a stable untranslated nextStep in JSON', () => {
232232
assert.match(en.stderr, /next/i)
233233
})
234234

235+
function runTty(args, cwd = process.cwd(), env = {}) {
236+
const url = String(new URL('../cli.mjs', import.meta.url))
237+
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)})`
238+
return spawnSync(process.execPath, ['--input-type=module', '-e', script], { cwd, encoding: 'utf8', env: { ...process.env, ...env } })
239+
}
240+
241+
test('jsonEnabled routes the JSON surface by environment and explicit flags', async () => {
242+
const { jsonEnabled } = await import('../lib/output.mjs')
243+
const tty = process.stdout.isTTY, env = process.env.SHADOW_DEV_JSON
244+
try {
245+
process.stdout.isTTY = false
246+
assert.equal(jsonEnabled({}), true, 'pipe default emits JSON')
247+
assert.equal(jsonEnabled({ json: true }), true)
248+
process.stdout.isTTY = true
249+
assert.equal(jsonEnabled({}), false, 'TTY default suppresses JSON')
250+
assert.equal(jsonEnabled({ json: true }), true, '--json forces JSON on TTY')
251+
process.stdout.isTTY = undefined
252+
process.env.SHADOW_DEV_JSON = '1'
253+
assert.equal(jsonEnabled({}), true, 'env override forces JSON')
254+
} finally {
255+
process.stdout.isTTY = tty
256+
if (env === undefined) delete process.env.SHADOW_DEV_JSON; else process.env.SHADOW_DEV_JSON = env
257+
}
258+
})
259+
260+
test('TTY suppresses stdout JSON; --json and env restore it; planHash surfaces on stderr', () => {
261+
const plain = runTty(['--help'])
262+
assert.equal(plain.status, 0, plain.stderr)
263+
assert.equal(plain.stdout.trim(), '', 'interactive help must not print JSON')
264+
assert.match(plain.stderr, /shadow-dev 命令一览/)
265+
const forced = runTty(['--help', '--json'])
266+
assert.equal(JSON.parse(forced.stdout).command, 'help')
267+
const root = fixture()
268+
assert.equal(JSON.parse(runTty(['repo', 'inspect'], root, { SHADOW_DEV_JSON: '1' }).stdout).command, 'repo.inspect')
269+
const planned = runTty(['branch', 'plan', '--name', 'sample'], root)
270+
assert.equal(planned.stdout.trim(), '')
271+
assert.match(planned.stderr, /planHash: [0-9a-f]{64}/)
272+
const bogus = runTty(['bogus'], root)
273+
assert.equal(bogus.status, 1)
274+
assert.equal(bogus.stdout.trim(), '', 'suppressed errors still exit nonzero without printing')
275+
})
276+
235277
test('help defaults to a compact summary; --full adds the structured catalog', () => {
236278
const overview = run(['help', '--lang', 'zh'])
237279
const data = JSON.parse(overview.stdout).data

0 commit comments

Comments
 (0)