Skip to content

Commit 825f4ff

Browse files
committed
feat(cli): workflow 物化域 + bind 宿主绑定域——CLI 驱动 shadow 生态分发(宿主无关,无 brief 凭证链)
1 parent 77616b5 commit 825f4ff

8 files changed

Lines changed: 553 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute
2424
| `reconcile plan\|execute` | 对齐 brief 状态与实际进度 |
2525
| `archive plan\|execute` | 归档已合并变更并重建 INDEX |
2626
| `index rebuild plan\|execute` | 重建变更索引 |
27+
| `workflow plan\|execute` | 物化 shadow-dev-workflow 产物(release tarball → 版本化目录 → CURRENT/PREVIOUS 指针;`--release`/`--from`/latest 三路解析) |
28+
| `workflow rollback\|status` | 产物版本回滚(离线对调指针)/ 查看安装状态 |
29+
| `workflow link\|unlink` | link 直通轨:指向本机 checkout,改动即生效;`unlink` 移除 LINK 回落 CURRENT |
30+
| `bind plan\|execute` | 按产物 `adapters/<host>.json` 把 skills 绑入宿主发现目录(复制 + sidecar 托管标记,非托管同名目录拒绝覆盖;`--host auto` 探测) |
31+
| `bind status\|unbind` | 查看各宿主绑定状态 / 按 sidecar 解绑 |
32+
33+
`workflow`/`bind` 是**无 brief 域**:`--plan-hash` 是 execute 的唯一凭证,且不要求 git 仓库(任意目录可用)。安装布局 `~/.local/share/shadow-dev-workflow/shadow-dev-workflow-<ver>/` + `CURRENT`/`PREVIOUS` 指针 + `LINK` 直通指针(解析序 LINK → CURRENT),与 CLI 自身安装器同构;产物契约(`marketplace.json`/`package.json`/`skills`)见 [shadow-dev-workflow](https://github.com/stack-wuh/shadow-dev-workflow) 的 `scripts/pack.mjs`。
2734

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

‎cli.mjs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import * as change from './lib/domains/change.mjs'
2323
import * as task from './lib/domains/task.mjs'
2424
import * as inspect from './lib/domains/inspect.mjs'
2525
import * as index from './lib/domains/index.mjs'
26+
import * as workflow from './lib/domains/workflow.mjs'
27+
import * as bind from './lib/domains/bind.mjs'
2628

2729
const DOMAINS = { branch, sync, review, commit, publish, release, reconcile, archive, issue, index }
2830

@@ -75,6 +77,9 @@ async function handle(r, p, o) {
7577
if (d === 'change' && a === 'create') return { ok: true, command: 'change.create', data: change.create(r, o) }
7678
if (d === 'change' && a === 'approve') return { ok: true, command: 'change.approve', data: change.approve(r, o) }
7779
if (d === 'change' && a === 'list') return { ok: true, command: 'change.list', data: change.list(r, o) }
80+
// 生态分发域:宿主无关、不要求 git 仓库,r 允许为 null
81+
if (d === 'workflow') return await workflow.handle(a, o)
82+
if (d === 'bind') return await bind.handle(a, o)
7883
if (Object.hasOwn(DOMAINS, d)) {
7984
const verb = a === 'rebuild' ? s : a, c = a === 'rebuild' ? `${d}.rebuild` : d
8085
if (verb === 'plan') return await planDomain(c, DOMAINS[d], r, o)
@@ -102,7 +107,7 @@ try {
102107
human.printHelp(L, v)
103108
} else {
104109
human.enter(L, p)
105-
v = human.decorate(await handle(root(), p, o), o)
110+
v = human.decorate(await handle(p[0] === 'workflow' || p[0] === 'bind' ? null : root(), p, o), o)
106111
human.done(L, v, Date.now() - t0)
107112
}
108113
if (jsonEnabled(o)) out(v)

‎lib/commands.mjs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ export const COMMANDS = {
4040
'archive.execute': c('archive execute', '移入 archive 并重建 INDEX', 'move into archive and rebuild INDEX', [N, PH, CF], 'shadow-dev archive execute --name <change-name> --confirm', null),
4141
'index.rebuild.plan': c('index rebuild plan', '预览变更索引重建', 'plan rebuilding the change index', [], 'shadow-dev index rebuild plan', 'index rebuild execute --plan-hash {planHash} --confirm'),
4242
'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),
43+
'workflow.plan': c('workflow plan', '预览 workflow 产物安装(--release/--from/latest 三路解析)', 'plan the workflow artifact install (--release/--from/latest)', [f('--release', false, '固定 release tag,缺省取 latest', 'pin a release tag; defaults to latest'), f('--from', false, '本地目录或 tarball(离线安装)', 'local dir or tarball (offline install)'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev workflow plan', 'workflow execute --plan-hash {planHash} --confirm'),
44+
'workflow.execute': c('workflow execute', '物化产物并切指针(先冒烟后落盘,留 PREVIOUS 供回滚)', 'materialize the artifact and flip pointers (smoke first, PREVIOUS kept for rollback)', [f('--plan-hash', true, 'workflow plan 的输出', 'hash from workflow plan'), f('--release', false, '固定 release tag', 'pin a release tag'), f('--from', false, '本地目录或 tarball', 'local dir or tarball'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev workflow execute --plan-hash <hash> --confirm', 'bind plan --host auto'),
45+
'workflow.rollback': c('workflow rollback', '回滚到上一版(离线,PREVIOUS/CURRENT 对调)', 'roll back to the previous version (offline pointer swap)', [CF, f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev workflow rollback --confirm', null),
46+
'workflow.status': c('workflow status', '查看 current/previous/linked 与解析出的产物根', 'show current/previous/linked and the resolved artifact root', [f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev workflow status', null),
47+
'workflow.link': c('workflow link', 'link 直通轨:指向本机 checkout,改动即生效', 'link track: point at a local checkout, edits take effect immediately', [f('--dir', true, '产物目录(含 marketplace.json/package.json/skills)', 'artifact dir with marketplace.json/package.json/skills'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev workflow link --dir <path> --confirm', 'bind plan --host auto'),
48+
'workflow.unlink': c('workflow unlink', '移除 LINK,回落 CURRENT 版本', 'remove the LINK pointer and fall back to CURRENT', [f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev workflow unlink --confirm', null),
49+
'bind.plan': c('bind plan', '预览按 adapters/<host>.json 把 skills 绑入宿主目录(--host auto 探测)', 'plan binding skills into host dirs via adapters/<host>.json (--host auto detects)', [f('--host', false, 'auto|claude-code|zcode…,缺省 auto', 'auto|claude-code|zcode…; defaults to auto'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev bind plan --host auto', 'bind execute --plan-hash {planHash} --confirm'),
50+
'bind.execute': c('bind execute', '执行绑定(复制 + sidecar 托管标记,非托管同名目录拒绝覆盖)', 'run the binding (copy + sidecar marker; unmanaged targets are refused)', [f('--plan-hash', true, 'bind plan 的输出', 'hash from bind plan'), f('--host', false, 'auto|claude-code|zcode…', 'auto|claude-code|zcode…'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev bind execute --plan-hash <hash> --confirm', 'bind status'),
51+
'bind.status': c('bind status', '查看各宿主的绑定与托管状态', 'show bind and managed state per host', [f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev bind status', null),
52+
'bind.unbind': c('bind unbind', '按 sidecar 移除托管 skills 与标记', 'remove managed skills and the sidecar per the record', [f('--host', true, '宿主名,如 claude-code', 'host name, e.g. claude-code'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev bind unbind --host claude-code --confirm', null),
4353
}
4454

4555
// HELP 由目录派生,保持既有的分组行格式(`branch plan|execute`),向后兼容既有断言;

‎lib/domains/bind.mjs‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// bind 域:把 workflow 产物的 skills 按宿主适配器描述符绑入宿主 skills 发现目录(无 brief 域)。
2+
// 描述符数据化:adapters/<host>.json 随产物分发,新增宿主 = 新增描述符,本域零改动。
3+
// 托管标记走 sidecar(.shadow-dev-workflow.json),不改 SKILL.md 字节;非托管同名目录 guard 拒绝,绝不静默覆盖。
4+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
5+
import { createHash } from 'node:crypto'
6+
import { homedir } from 'node:os'
7+
import { join } from 'node:path'
8+
import { err } from '../errors.mjs'
9+
import { plan } from '../plan.mjs'
10+
import { confirm } from '../input.mjs'
11+
import { resolvedRoot } from './workflow.mjs'
12+
13+
const SIDECAR = '.shadow-dev-workflow.json'
14+
const home = () => process.env.SHADOW_WORKFLOW_HOME || homedir()
15+
// 描述符里 ~ 开头的 skillsDir 相对宿主 home 解析;SHADOW_WORKFLOW_HOME 供测试/隔离覆盖
16+
const skillsDirOf = adapter => adapter.skillsDir.replace(/^~(?=\/|$)/, home())
17+
const verOf = root => { try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version } catch { return null } }
18+
19+
export function loadAdapters(root) {
20+
const dir = join(root, 'adapters')
21+
if (!existsSync(dir)) throw err('ADAPTERS_MISSING', 'ADAPTERS_MISSING: artifact lacks adapters/ (needs a workflow release that ships descriptors)', 1)
22+
return readdirSync(dir).filter(f => f.endsWith('.json')).map(f => JSON.parse(readFileSync(join(dir, f), 'utf8')))
23+
}
24+
25+
const sidecarRead = dir => { try { return JSON.parse(readFileSync(join(dir, SIDECAR), 'utf8')) } catch { return null } }
26+
const dirHash = d => createHash('sha256').update(readdirSync(d).sort().map(f => `${f}:${readFileSync(join(d, f), 'utf8')}`).join('\n')).digest('hex')
27+
28+
export function planData(_r, o) {
29+
const root = resolvedRoot(o)
30+
const adapters = loadAdapters(root)
31+
let selected = o.host && o.host !== 'auto' ? adapters.filter(a => a.host === o.host) : adapters
32+
if (o.host && o.host !== 'auto' && !selected.length) throw err('HOST_NOT_FOUND', `HOST_NOT_FOUND: no adapter for host "${o.host}"`, 1)
33+
if (!o.host || o.host === 'auto') selected = selected.filter(a => existsSync(skillsDirOf(a)))
34+
if (!selected.length) throw err('HOST_NOT_FOUND', 'HOST_NOT_FOUND: no supported host detected on this machine (adapters exist, but no skillsDir present)', 1)
35+
const hosts = selected.map(a => {
36+
const dir = skillsDirOf(a)
37+
const sidecar = sidecarRead(dir)
38+
const entries = readdirSync(join(root, 'skills')).filter(s => existsSync(join(root, 'skills', s))).sort().map(skill => {
39+
const exists = existsSync(join(dir, skill))
40+
const managed = !!(sidecar && sidecar.skills && sidecar.skills[skill])
41+
return { skill, target: join(dir, skill), exists, managed, blocked: exists && !managed }
42+
})
43+
return { host: a.host, tier: a.tier || null, skillsDir: dir, sidecar: !!sidecar, entries }
44+
})
45+
return { action: 'bind', artifactRoot: root, version: verOf(root), hosts }
46+
}
47+
48+
export function executeDomain(o) {
49+
confirm(o)
50+
const e = plan('bind', planData(null, o))
51+
if (!o['plan-hash']) throw err('PLAN_HASH_REQUIRED', 'PLAN_HASH_REQUIRED: run `shadow-dev bind plan` first or pass --plan-hash', 2)
52+
if (o['plan-hash'] !== e.planHash) throw err('PLAN_HASH_INVALID')
53+
const x = e.data
54+
const blocked = x.hosts.flatMap(h => h.entries.filter(t => t.blocked).map(t => `${h.host}:${t.skill}`))
55+
if (blocked.length) throw err('UNMANAGED_TARGET', `UNMANAGED_TARGET: refusing to overwrite unmanaged skill dirs (${blocked.join(', ')}); remove or rename them first`, 1)
56+
const bound = []
57+
for (const h of x.hosts) {
58+
mkdirSync(h.skillsDir, { recursive: true })
59+
const skills = {}
60+
for (const t of h.entries) {
61+
rmSync(t.target, { recursive: true, force: true })
62+
cpSync(join(x.artifactRoot, 'skills', t.skill), t.target, { recursive: true })
63+
skills[t.skill] = { hash: dirHash(t.target) }
64+
bound.push(`${h.host}:${t.skill}`)
65+
}
66+
writeFileSync(join(h.skillsDir, SIDECAR), JSON.stringify({ schema: 'shadow-dev-bind/v1', marker: 'managed-by: shadow-dev-workflow', version: x.version, skills }, null, 2))
67+
}
68+
return { action: 'bind', bound }
69+
}
70+
71+
export function unbindDomain(o) {
72+
confirm(o)
73+
if (!o.host || o.host === 'auto') throw err('HOST_REQUIRED', 'HOST_REQUIRED: pass --host <name> for unbind', 1)
74+
const root = resolvedRoot(o)
75+
const adapter = loadAdapters(root).find(a => a.host === o.host)
76+
if (!adapter) throw err('HOST_NOT_FOUND', `HOST_NOT_FOUND: no adapter for host "${o.host}"`, 1)
77+
const dir = skillsDirOf(adapter)
78+
const sidecar = sidecarRead(dir)
79+
if (!sidecar || !sidecar.skills || !Object.keys(sidecar.skills).length) throw err('NOTHING_TO_UNBIND', `NOTHING_TO_UNBIND: no shadow-dev-workflow managed skills under ${dir}`, 1)
80+
const removed = Object.keys(sidecar.skills)
81+
for (const skill of removed) rmSync(join(dir, skill), { recursive: true, force: true })
82+
rmSync(join(dir, SIDECAR), { force: true })
83+
return { action: 'unbind', host: o.host, removed }
84+
}
85+
86+
export function statusDomain(o) {
87+
let root = null
88+
try { root = resolvedRoot(o) } catch { return { installed: false, hosts: [] } }
89+
const hosts = loadAdapters(root).map(a => {
90+
const dir = skillsDirOf(a)
91+
const sidecar = sidecarRead(dir)
92+
return { host: a.host, tier: a.tier || null, skillsDir: dir, present: existsSync(dir), managed: sidecar && sidecar.skills ? Object.keys(sidecar.skills) : [] }
93+
})
94+
return { installed: true, artifactRoot: root, version: verOf(root), hosts }
95+
}
96+
97+
export async function handle(a, o) {
98+
if (a === 'plan') return plan('bind', planData(null, o))
99+
if (a === 'execute') return { ok: true, command: 'bind.execute', data: executeDomain(o) }
100+
if (a === 'status') return { ok: true, command: 'bind.status', data: statusDomain(o) }
101+
if (a === 'unbind') return { ok: true, command: 'bind.unbind', data: unbindDomain(o) }
102+
throw err('UNKNOWN_COMMAND', `unsupported command: bind ${a}`)
103+
}

0 commit comments

Comments
 (0)