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
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <命令>` 查看单命令的参数、必填项与示例。

## 平台兼容

Expand All @@ -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 人用层。

## 安装与分发

Expand All @@ -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` 是唯一契约规格。
59 changes: 42 additions & 17 deletions cli.mjs
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
4 changes: 3 additions & 1 deletion lib/args.mjs
Original file line number Diff line number Diff line change
@@ -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 = {}
Expand Down
52 changes: 52 additions & 0 deletions lib/commands.mjs
Original file line number Diff line number Diff line change
@@ -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 <n> --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 <n> --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 <n> --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 <n> --confirm', null),
'branch.plan': c('branch plan', '预览建功能分支', 'plan creating the feature branch', [N], 'shadow-dev branch plan --name <n>', '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 <n> --confirm', null),
'sync.plan': c('sync plan', '预览 fast-forward 同步上游', 'plan a fast-forward sync with upstream', [N], 'shadow-dev sync plan --name <n>', '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 <n> --confirm', null),
'conflict.inspect': c('conflict inspect', '检查与其他 active brief 的文件重叠', 'check file overlaps with other active briefs', [N], 'shadow-dev conflict inspect --name <n>', null),
'task.list': c('task list', '列出 brief 任务清单', 'list the brief task checklist', [N], 'shadow-dev task list --name <n>', 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 <n> --task task-1 --state done --confirm', null),
'review.plan': c('review plan', '预览审查记录', 'plan the review record', [N], 'shadow-dev review plan --name <n>', '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 <n> --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 <n> --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 <n> --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 <n> --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 <n> --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 <n> --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 <n> --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 <n>', null),
'reconcile.plan': c('reconcile plan', '预览 brief 状态与实际进度对齐', 'plan reconciling brief state with actual progress', [N], 'shadow-dev reconcile plan --name <n>', 'reconcile execute --name {name} --confirm'),
'reconcile.execute': c('reconcile execute', '回写对齐后的状态', 'persist the reconciled state', [N, PH, CF], 'shadow-dev reconcile execute --name <n> --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 <n>', '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 <n> --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 <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')
})()
60 changes: 60 additions & 0 deletions lib/human.mjs
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading