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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute

| 命令 | 说明 |
|------|------|
| `version` | CLI 版本(任意目录可用,不要求 git 仓库) |
| `repo inspect` | 查看仓库状态(分支、HEAD、脏文件) |
| `change create\|approve\|list` | 创建/批准变更 brief;`list` 默认只列活动变更,`--all` 合并归档、`--archived` 只列归档(条目带 `archived` 布尔) |
| `issue plan\|execute` | 创建 GitHub issue(正文由 brief 确定性渲染,见「Issue 正文结构契约」) |
Expand Down
8 changes: 7 additions & 1 deletion cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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 { VERSION } from './lib/version.mjs'
import * as human from './lib/human.mjs'
import * as branch from './lib/domains/branch.mjs'
import * as sync from './lib/domains/sync.mjs'
Expand Down Expand Up @@ -89,9 +90,14 @@ try {
p = parsed.p; o = parsed.o
L = resolveLang(o)
const helpMode = !p.length || p.includes('--help') || p[0] === 'help'
// 元命令 version 与 help 同类:不要求 git 仓库,在 root() 之前拦截;--version 先于 help 判定
const versionMode = p[0] === 'version' && p.length === 1 || ('version' in o && !p.length)
const t0 = Date.now()
let v
if (helpMode) {
if (versionMode) {
v = { ok: true, command: 'version', data: { version: VERSION } }
human.printVersion(L, VERSION)
} else if (helpMode) {
v = helpEnvelope(p, o)
human.printHelp(L, v)
} else {
Expand Down
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', 'help', 'all', 'archived'].includes(k)) o[k] = true; else o[k] = a[++i] }
else { const k = a[i].slice(2); if (['confirm', 'json', 'full', 'help', 'all', 'archived', 'version'].includes(k)) o[k] = true; else o[k] = a[++i] }
}
return { p, o }
}
10 changes: 7 additions & 3 deletions lib/commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const FI = f('--files', false, '逗号分隔文件列表(反斜杠自动归一
const M = f('--message', false, '提交信息', 'commit message')

export const COMMANDS = {
'version': c('version', 'CLI 版本(任意目录可用,不要求 git 仓库)', 'CLI version (any directory, no git repository required)', [], 'shadow-dev version'),
'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 <change-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 <change-name> --confirm', 'branch plan --name {name}'),
Expand Down Expand Up @@ -41,13 +42,16 @@ export const COMMANDS = {
'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`),向后兼容既有断言
// HELP 由目录派生,保持既有的分组行格式(`branch plan|execute`),向后兼容既有断言;
// 单段 key(如 version)是平铺元命令,整行即命令名
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)
const parts = key.split('.')
if (parts.length === 1) { rows.push([key, null]); continue }
const 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')
return rows.map(([base, actions]) => (actions === null ? base : `${base} ${actions.join('|')}`)).join('\n')
})()
2 changes: 2 additions & 0 deletions lib/human.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export function error(L, e, p, o = {}) {
w(ui(L, 'example', { example: spec.example }))
}

export function printVersion(L, v) { w(ui(L, 'version', { v })) }

export function printHelp(L, v) {
if (v.command === 'help') {
w(ui(L, 'helpHead'))
Expand Down
2 changes: 2 additions & 0 deletions lib/i18n.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const UI = {
next: '⤷ 下一步: {step}',
error: '✗ {code}: {hint}',
example: ' 示例: {example}',
version: 'ℹ shadow-dev-cli 版本 {v}',
helpHead: 'shadow-dev 命令一览(单命令详情: shadow-dev help <命令>)',
globals: ' 全局参数: --lang zh|en(或 SHADOW_DEV_LANG)· SHADOW_DEV_QUIET=1 关闭本提示层 · 管道默认输出 JSON 契约,交互终端用 --json 显式开启',
},
Expand All @@ -19,6 +20,7 @@ const UI = {
next: '⤷ next: {step}',
error: '✗ {code}: {hint}',
example: ' example: {example}',
version: 'ℹ shadow-dev-cli version {v}',
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 JSON is default in pipes, opt in with --json on a TTY',
},
Expand Down
5 changes: 5 additions & 0 deletions lib/version.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { readFileSync } from 'node:fs'

// 版本号唯一事实源 = 同源 package.json(与 issue-render 的 cliVersion 同一读法):
// 运行时读取,零编译期注入,release 布局(cli.mjs/lib/package.json 同目录物化)天然成立
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "shadow-dev-cli",
"version": "1.2.0",
"version": "1.3.0",
"description": "Deterministic scaffolding CLI for the Shadow dev workflow: brief lifecycle, plan/execute with plan hashes, git and GitHub operations.",
"type": "module",
"engines": {
Expand Down
98 changes: 98 additions & 0 deletions shadow-docs/changes/20260918-feature-cli-version/brief.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
{
"schema": "shadow-dev/v1",
"name": "20260918-feature-cli-version",
"type": "feature",
"scope": "cli",
"status": "published",
"baseBranch": "main",
"branch": "feature/20260918-feature-cli-version",
"files": [
"README.md",
"cli.mjs",
"lib/args.mjs",
"lib/commands.mjs",
"lib/human.mjs",
"lib/i18n.mjs",
"lib/version.mjs",
"package.json",
"test/cli.test.mjs"
],
"github": {
"repository": "stack-wuh/shadow-dev-cli",
"issue": 27,
"issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/27",
"pullRequest": 28,
"pullRequestUrl": "https://github.com/stack-wuh/shadow-dev-cli/pull/28"
},
"review": {
"conclusion": "passed",
"verifiedCommit": "c32f32ad373ae449b29d55d10e4bf655c6379bac",
"verifiedAt": "2026-09-18T03:40:27.336Z"
},
"workflow": {
"operation": null,
"checkpoint": "pr:28",
"planHash": "e91178e0aff22b5c13aa226a7ef74bdf6e1e1797baef91d512d5c44bdb7c645c",
"updatedAt": null,
"lastError": null,
"issuePlan": {
"title": "[feature] CLI version 命令——运行时版本自检",
"titleRaw": null,
"supplement": "",
"body": "## 动机\n插件侧 bootstrap 以 package.json cliVersion + CURRENT 指针锁定安装版本,但「当前跑的是哪个 CLI」在 CLI 本身无自证手段:agent 排障、双仓开发(link 轨)都只能翻指针文件确认。version 与 help 同属不依赖仓库的元命令,应补齐。\n\n## 引用规范\n- cli-output-contract:新增输出必须二选一(stdout JSON 契约 / stderr 人用层);COMMANDS 目录是 HELP、help JSON、人用提示的单一事实源。\n- install-distribution:LINK → CURRENT 双轨指针解析——version 只做展示,不参与指针逻辑。\n\n## 决策\n- COMMANDS 新增平铺目录条目 `version`(usage `version`,任意目录可用;HELP 派生加单段 key 守卫,保持既有 `domain.action` 分组行格式不变)。\n- 入口在 helpMode 之前拦截 versionMode:`shadow-dev version` 与 `--version` 皆命中,不要求 git 仓库,输出 `{\"ok\":true,\"command\":\"version\",\"data\":{\"version\":...}}`。\n- 版本号运行时读同源 package.json(复用 issue-render 的 `readFileSync(new URL('../package.json', import.meta.url))` 形态),零漂移,无编译期注入。\n- 人用层向 stderr 输出版本行(语言随 --lang,stdout JSON 与语言无关)。`--version` 在 args 白名单登记为无值布尔。\n- 版本 bump 1.2.0→1.3.0 随本变更提交;合入后 tag v1.3.0、pack.mjs 产物上 GitHub Release。\n\n## 任务\n- [ ] lib/version.mjs + COMMANDS 登记与 HELP 派生守卫 — lib/version.mjs,lib/commands.mjs — 目录单源\n- [ ] 入口 versionMode 拦截与参数/输出层接入 — cli.mjs,lib/args.mjs,lib/i18n.mjs,lib/human.mjs — 任意目录可用\n- [ ] 契约测试:JSON 形态、flag 别名、仓库内外、HELP 行与语言不变性 — test/cli.test.mjs — 钉死契约\n- [ ] README 命令表与 package.json 版本 1.3.0 — README.md,package.json — 文档随代码\n\n完整 brief:shadow-docs/changes/20260918-feature-cli-version/brief.md\n\n<!-- shadow-dev:issue-metadata {\"name\":\"20260918-feature-cli-version\",\"type\":\"feature\",\"scope\":\"cli\",\"status\":\"proposed\",\"branch\":null,\"baseBranch\":\"main\",\"briefPath\":\"shadow-docs/changes/20260918-feature-cli-version/brief.md\",\"cliVersion\":\"1.2.0\",\"prUrl\":null,\"issueNumber\":null} -->\n",
"labels": [
"feature"
]
},
"release": {
"files": [
"README.md",
"cli.mjs",
"lib/args.mjs",
"lib/commands.mjs",
"lib/human.mjs",
"lib/i18n.mjs",
"lib/version.mjs",
"package.json",
"shadow-docs/changes/20260918-feature-cli-version/brief.md",
"test/cli.test.mjs"
],
"message": "feat(cli): version command for runtime self-check - catalog entry first HELP row, entry intercept before root, package.json is the version source (v1.3.0)",
"title": "feat(cli): version command (v1.3.0)",
"body": ""
}
},
"knowledge": {
"action": "无需变更",
"target": null,
"reason": "版本事实由 README/help/COMMANDS 目录承载,不构成独立执行约束"
}
}
---

# CLI version 命令——运行时版本自检

## 动机

插件侧 bootstrap 以 package.json cliVersion + CURRENT 指针锁定安装版本,但「当前跑的是哪个 CLI」在 CLI 本身无自证手段:agent 排障、双仓开发(link 轨)都只能翻指针文件确认。version 与 help 同属不依赖仓库的元命令,应补齐。

## 引用规范

- cli-output-contract:新增输出必须二选一(stdout JSON 契约 / stderr 人用层);COMMANDS 目录是 HELP、help JSON、人用提示的单一事实源。
- install-distribution:LINK → CURRENT 双轨指针解析——version 只做展示,不参与指针逻辑。

## 决策

- COMMANDS 新增平铺目录条目 `version`(usage `version`,任意目录可用;HELP 派生加单段 key 守卫,保持既有 `domain.action` 分组行格式不变)。
- 入口在 helpMode 之前拦截 versionMode:`shadow-dev version` 与 `--version` 皆命中,不要求 git 仓库,输出 `{"ok":true,"command":"version","data":{"version":...}}`。
- 版本号运行时读同源 package.json(复用 issue-render 的 `readFileSync(new URL('../package.json', import.meta.url))` 形态),零漂移,无编译期注入。
- 人用层向 stderr 输出版本行(语言随 --lang,stdout JSON 与语言无关)。`--version` 在 args 白名单登记为无值布尔。
- 版本 bump 1.2.0→1.3.0 随本变更提交;合入后 tag v1.3.0、pack.mjs 产物上 GitHub Release。

## 任务

- [x] lib/version.mjs + COMMANDS 登记与 HELP 派生守卫 — lib/version.mjs,lib/commands.mjs — 目录单源
- [x] 入口 versionMode 拦截与参数/输出层接入 — cli.mjs,lib/args.mjs,lib/i18n.mjs,lib/human.mjs — 任意目录可用
- [x] 契约测试:JSON 形态、flag 别名、仓库内外、HELP 行与语言不变性 — test/cli.test.mjs — 钉死契约
- [x] README 命令表与 package.json 版本 1.3.0 — README.md,package.json — 文档随代码
25 changes: 25 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,31 @@ test('help lists deterministic workflow commands', () => {
assert.match(result.stdout, /archive plan\|execute/)
})

test('version returns the package version without a git repository', () => {
const expected = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
const outside = run(['version', '--json'], tmpdir())
assert.equal(outside.status, 0, outside.stderr)
const o = JSON.parse(outside.stdout)
assert.equal(o.ok, true)
assert.equal(o.command, 'version')
assert.equal(o.data.version, expected)
// 仓库内同样可用
const root = fixture()
assert.equal(JSON.parse(run(['version', '--json'], root).stdout).data.version, expected)
// --version 别名与 help 目录行
assert.equal(JSON.parse(run(['--version', '--json'], tmpdir()).stdout).data.version, expected)
assert.match(JSON.parse(run(['--help']).stdout).data.help, /^version\n/)
assert.match(JSON.parse(run(['help', 'version', '--json']).stdout).data.commands.version.usage, /^version$/)
})

test('version stdout is language-invariant while human line localizes', () => {
const zh = run(['version', '--json', '--lang', 'zh'], tmpdir())
const en = run(['version', '--json', '--lang', 'en'], tmpdir())
assert.equal(JSON.stringify(JSON.parse(zh.stdout)), JSON.stringify(JSON.parse(en.stdout)))
assert.match(zh.stderr, /版本/)
assert.match(en.stderr, /version/)
})

test('unknown commands return a stable JSON error', () => {
const result = run(['unknown', '--json'])
assert.equal(result.status, 1)
Expand Down
Loading