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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute
| `reconcile plan\|execute` | 对齐 brief 状态与实际进度 |
| `archive plan\|execute` | 归档已合并变更并重建 INDEX |
| `index rebuild plan\|execute` | 重建变更索引 |
| `workflow plan\|execute` | 物化 shadow-dev-workflow 产物(release tarball → 版本化目录 → CURRENT/PREVIOUS 指针;`--release`/`--from`/latest 三路解析) |
| `workflow rollback\|status` | 产物版本回滚(离线对调指针)/ 查看安装状态 |
| `workflow link\|unlink` | link 直通轨:指向本机 checkout,改动即生效;`unlink` 移除 LINK 回落 CURRENT |
| `bind plan\|execute` | 按产物 `adapters/<host>.json` 把 skills 绑入宿主发现目录(复制 + sidecar 托管标记,非托管同名目录拒绝覆盖;`--host auto` 探测) |
| `bind status\|unbind` | 查看各宿主绑定状态 / 按 sidecar 解绑 |

`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`。

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

Expand Down
7 changes: 6 additions & 1 deletion cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import * as change from './lib/domains/change.mjs'
import * as task from './lib/domains/task.mjs'
import * as inspect from './lib/domains/inspect.mjs'
import * as index from './lib/domains/index.mjs'
import * as workflow from './lib/domains/workflow.mjs'
import * as bind from './lib/domains/bind.mjs'

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

Expand Down Expand Up @@ -75,6 +77,9 @@ async function handle(r, p, 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 (d === 'change' && a === 'list') return { ok: true, command: 'change.list', data: change.list(r, o) }
// 生态分发域:宿主无关、不要求 git 仓库,r 允许为 null
if (d === 'workflow') return await workflow.handle(a, o)
if (d === 'bind') return await bind.handle(a, o)
if (Object.hasOwn(DOMAINS, d)) {
const verb = a === 'rebuild' ? s : a, c = a === 'rebuild' ? `${d}.rebuild` : d
if (verb === 'plan') return await planDomain(c, DOMAINS[d], r, o)
Expand Down Expand Up @@ -102,7 +107,7 @@ try {
human.printHelp(L, v)
} else {
human.enter(L, p)
v = human.decorate(await handle(root(), p, o), o)
v = human.decorate(await handle(p[0] === 'workflow' || p[0] === 'bind' ? null : root(), p, o), o)
human.done(L, v, Date.now() - t0)
}
if (jsonEnabled(o)) out(v)
Expand Down
10 changes: 10 additions & 0 deletions lib/commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ export const COMMANDS = {
'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),
'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),
'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'),
'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'),
'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),
'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),
'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'),
'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),
'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'),
'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'),
'bind.status': c('bind status', '查看各宿主的绑定与托管状态', 'show bind and managed state per host', [f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev bind status', null),
'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),
}

// HELP 由目录派生,保持既有的分组行格式(`branch plan|execute`),向后兼容既有断言;
Expand Down
103 changes: 103 additions & 0 deletions lib/domains/bind.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// bind 域:把 workflow 产物的 skills 按宿主适配器描述符绑入宿主 skills 发现目录(无 brief 域)。
// 描述符数据化:adapters/<host>.json 随产物分发,新增宿主 = 新增描述符,本域零改动。
// 托管标记走 sidecar(.shadow-dev-workflow.json),不改 SKILL.md 字节;非托管同名目录 guard 拒绝,绝不静默覆盖。
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { err } from '../errors.mjs'
import { plan } from '../plan.mjs'
import { confirm } from '../input.mjs'
import { resolvedRoot } from './workflow.mjs'

const SIDECAR = '.shadow-dev-workflow.json'
const home = () => process.env.SHADOW_WORKFLOW_HOME || homedir()
// 描述符里 ~ 开头的 skillsDir 相对宿主 home 解析;SHADOW_WORKFLOW_HOME 供测试/隔离覆盖
const skillsDirOf = adapter => adapter.skillsDir.replace(/^~(?=\/|$)/, home())
const verOf = root => { try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version } catch { return null } }

export function loadAdapters(root) {
const dir = join(root, 'adapters')
if (!existsSync(dir)) throw err('ADAPTERS_MISSING', 'ADAPTERS_MISSING: artifact lacks adapters/ (needs a workflow release that ships descriptors)', 1)
return readdirSync(dir).filter(f => f.endsWith('.json')).map(f => JSON.parse(readFileSync(join(dir, f), 'utf8')))
}

const sidecarRead = dir => { try { return JSON.parse(readFileSync(join(dir, SIDECAR), 'utf8')) } catch { return null } }
const dirHash = d => createHash('sha256').update(readdirSync(d).sort().map(f => `${f}:${readFileSync(join(d, f), 'utf8')}`).join('\n')).digest('hex')

export function planData(_r, o) {
const root = resolvedRoot(o)
const adapters = loadAdapters(root)
let selected = o.host && o.host !== 'auto' ? adapters.filter(a => a.host === o.host) : adapters
if (o.host && o.host !== 'auto' && !selected.length) throw err('HOST_NOT_FOUND', `HOST_NOT_FOUND: no adapter for host "${o.host}"`, 1)
if (!o.host || o.host === 'auto') selected = selected.filter(a => existsSync(skillsDirOf(a)))
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)
const hosts = selected.map(a => {
const dir = skillsDirOf(a)
const sidecar = sidecarRead(dir)
const entries = readdirSync(join(root, 'skills')).filter(s => existsSync(join(root, 'skills', s))).sort().map(skill => {
const exists = existsSync(join(dir, skill))
const managed = !!(sidecar && sidecar.skills && sidecar.skills[skill])
return { skill, target: join(dir, skill), exists, managed, blocked: exists && !managed }
})
return { host: a.host, tier: a.tier || null, skillsDir: dir, sidecar: !!sidecar, entries }
})
return { action: 'bind', artifactRoot: root, version: verOf(root), hosts }
}

export function executeDomain(o) {
confirm(o)
const e = plan('bind', planData(null, o))
if (!o['plan-hash']) throw err('PLAN_HASH_REQUIRED', 'PLAN_HASH_REQUIRED: run `shadow-dev bind plan` first or pass --plan-hash', 2)
if (o['plan-hash'] !== e.planHash) throw err('PLAN_HASH_INVALID')
const x = e.data
const blocked = x.hosts.flatMap(h => h.entries.filter(t => t.blocked).map(t => `${h.host}:${t.skill}`))
if (blocked.length) throw err('UNMANAGED_TARGET', `UNMANAGED_TARGET: refusing to overwrite unmanaged skill dirs (${blocked.join(', ')}); remove or rename them first`, 1)
const bound = []
for (const h of x.hosts) {
mkdirSync(h.skillsDir, { recursive: true })
const skills = {}
for (const t of h.entries) {
rmSync(t.target, { recursive: true, force: true })
cpSync(join(x.artifactRoot, 'skills', t.skill), t.target, { recursive: true })
skills[t.skill] = { hash: dirHash(t.target) }
bound.push(`${h.host}:${t.skill}`)
}
writeFileSync(join(h.skillsDir, SIDECAR), JSON.stringify({ schema: 'shadow-dev-bind/v1', marker: 'managed-by: shadow-dev-workflow', version: x.version, skills }, null, 2))
}
return { action: 'bind', bound }
}

export function unbindDomain(o) {
confirm(o)
if (!o.host || o.host === 'auto') throw err('HOST_REQUIRED', 'HOST_REQUIRED: pass --host <name> for unbind', 1)
const root = resolvedRoot(o)
const adapter = loadAdapters(root).find(a => a.host === o.host)
if (!adapter) throw err('HOST_NOT_FOUND', `HOST_NOT_FOUND: no adapter for host "${o.host}"`, 1)
const dir = skillsDirOf(adapter)
const sidecar = sidecarRead(dir)
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)
const removed = Object.keys(sidecar.skills)
for (const skill of removed) rmSync(join(dir, skill), { recursive: true, force: true })
rmSync(join(dir, SIDECAR), { force: true })
return { action: 'unbind', host: o.host, removed }
}

export function statusDomain(o) {
let root = null
try { root = resolvedRoot(o) } catch { return { installed: false, hosts: [] } }
const hosts = loadAdapters(root).map(a => {
const dir = skillsDirOf(a)
const sidecar = sidecarRead(dir)
return { host: a.host, tier: a.tier || null, skillsDir: dir, present: existsSync(dir), managed: sidecar && sidecar.skills ? Object.keys(sidecar.skills) : [] }
})
return { installed: true, artifactRoot: root, version: verOf(root), hosts }
}

export async function handle(a, o) {
if (a === 'plan') return plan('bind', planData(null, o))
if (a === 'execute') return { ok: true, command: 'bind.execute', data: executeDomain(o) }
if (a === 'status') return { ok: true, command: 'bind.status', data: statusDomain(o) }
if (a === 'unbind') return { ok: true, command: 'bind.unbind', data: unbindDomain(o) }
throw err('UNKNOWN_COMMAND', `unsupported command: bind ${a}`)
}
Loading
Loading