Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 零输出,适合日志管道。
Expand Down
7 changes: 4 additions & 3 deletions cli.mjs
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion lib/args.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
2 changes: 2 additions & 0 deletions lib/human.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
}

Expand Down
6 changes: 4 additions & 2 deletions lib/i18n.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>)',
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',
},
}

Expand Down
4 changes: 4 additions & 0 deletions lib/output.mjs
Original file line number Diff line number Diff line change
@@ -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' }
37 changes: 21 additions & 16 deletions shadow-docs/changes/20260917-feature-tty-human-default/brief.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
"name": "20260917-feature-tty-human-default",
"type": "feature",
"scope": "cli.mjs,lib",
"status": "proposed",
"status": "reviewed",
"baseBranch": "main",
"branch": null,
"branch": "feature/20260917-feature-tty-human-default",
"files": [
"README.md",
"cli.mjs",
Expand All @@ -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": "3b465a0a2be42f724fc9a72998f3445419f963e2347e03708ab1ce829f1a36b4",
"checkpoint": "7fe26cf427bff1d2bc8714b3167ff3f02e3a7586",
"planHash": "8b02b8b9a2e61b416b26fd0ee4b46dbbb3852fcd2282b42b33bd0f167ee8bf2c",
"updatedAt": null,
"lastError": null,
"issuePlan": {
Expand All @@ -41,6 +41,11 @@
"feature"
]
}
},
"knowledge": {
"action": "更新",
"target": "shadow-docs/knowledge/cli-output-contract.md",
"reason": "stdout 恒 JSON 条款修订为按环境路由(管道默认/TTY 显式),TTY 抑制时退出码与 planHash 透出为新增约束"
}
}
---
Expand Down Expand Up @@ -70,24 +75,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` 值吞噬隐患。

## 知识评估

Expand Down
6 changes: 4 additions & 2 deletions shadow-docs/knowledge/cli-output-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@ 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
---

# 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`、错误示例全部由它派生。
本 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`。
Expand All @@ -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 应为空。

## 关联知识

Expand Down
2 changes: 1 addition & 1 deletion shadow-docs/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
42 changes: 42 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading