From 825f4ff71bef0c100255d093f1c900f3daae3f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E5=B0=92=E7=BA=A2?= <724132485@qq.com> Date: Fri, 25 Sep 2026 15:38:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20workflow=20=E7=89=A9=E5=8C=96?= =?UTF-8?q?=E5=9F=9F=20+=20bind=20=E5=AE=BF=E4=B8=BB=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E5=9F=9F=E2=80=94=E2=80=94CLI=20=E9=A9=B1=E5=8A=A8=20shadow=20?= =?UTF-8?q?=E7=94=9F=E6=80=81=E5=88=86=E5=8F=91(=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E6=97=A0=E5=85=B3,=E6=97=A0=20brief=20=E5=87=AD=E8=AF=81?= =?UTF-8?q?=E9=93=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 + cli.mjs | 7 +- lib/commands.mjs | 10 ++ lib/domains/bind.mjs | 103 +++++++++++ lib/domains/workflow.mjs | 167 ++++++++++++++++++ lib/i18n.mjs | 12 ++ .../20260925-feature-workflow-domain/brief.md | 104 +++++++++++ test/cli.test.mjs | 146 ++++++++++++++- 8 files changed, 553 insertions(+), 3 deletions(-) create mode 100644 lib/domains/bind.mjs create mode 100644 lib/domains/workflow.mjs create mode 100644 shadow-docs/changes/20260925-feature-workflow-domain/brief.md diff --git a/README.md b/README.md index dc9f752..2979668 100644 --- a/README.md +++ b/README.md @@ -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/.json` 把 skills 绑入宿主发现目录(复制 + sidecar 托管标记,非托管同名目录拒绝覆盖;`--host auto` 探测) | +| `bind status\|unbind` | 查看各宿主绑定状态 / 按 sidecar 解绑 | + +`workflow`/`bind` 是**无 brief 域**:`--plan-hash` 是 execute 的唯一凭证,且不要求 git 仓库(任意目录可用)。安装布局 `~/.local/share/shadow-dev-workflow/shadow-dev-workflow-/` + `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 可直接消费)。 diff --git a/cli.mjs b/cli.mjs index ee88d19..7c72ad9 100755 --- a/cli.mjs +++ b/cli.mjs @@ -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 } @@ -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) @@ -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) diff --git a/lib/commands.mjs b/lib/commands.mjs index 51149b1..0ec5c8f 100644 --- a/lib/commands.mjs +++ b/lib/commands.mjs @@ -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 --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 --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 --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 --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/.json 把 skills 绑入宿主目录(--host auto 探测)', 'plan binding skills into host dirs via adapters/.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 --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`),向后兼容既有断言; diff --git a/lib/domains/bind.mjs b/lib/domains/bind.mjs new file mode 100644 index 0000000..0230f46 --- /dev/null +++ b/lib/domains/bind.mjs @@ -0,0 +1,103 @@ +// bind 域:把 workflow 产物的 skills 按宿主适配器描述符绑入宿主 skills 发现目录(无 brief 域)。 +// 描述符数据化:adapters/.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 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}`) +} diff --git a/lib/domains/workflow.mjs b/lib/domains/workflow.mjs new file mode 100644 index 0000000..c7c7ae1 --- /dev/null +++ b/lib/domains/workflow.mjs @@ -0,0 +1,167 @@ +// workflow 域:shadow-dev-workflow 产物的物化/回滚/直通管理(无 brief 域,--plan-hash 是唯一凭证)。 +// 双轨模型与 CLI 自身安装器同构:release 物化轨(拉 tarball → 冒烟 → 版本化目录 → CURRENT/PREVIOUS 指针 → current 稳定入口) +// 与 link 直通轨(LINK 指向本机目录,代码即改即生效);解析序 LINK → CURRENT,两轨语义互斥不复用。 +import { execFileSync } from 'node:child_process' +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { err } from '../errors.mjs' +import { plan } from '../plan.mjs' +import { confirm } from '../input.mjs' + +export const REPO = 'stack-wuh/shadow-dev-workflow' + +export function prefix(o) { return o.prefix || process.env.SHADOW_WORKFLOW_PREFIX || join(homedir(), '.local', 'share', 'shadow-dev-workflow') } +const readPtr = (p, f) => { try { return readFileSync(join(p, f), 'utf8').trim() || null } catch { return null } } +export const linked = o => readPtr(prefix(o), 'LINK') +export const current = o => readPtr(prefix(o), 'CURRENT') +export const previous = o => readPtr(prefix(o), 'PREVIOUS') + +// 消费方(bind 等)解析产物根:LINK 优先,其次 CURRENT 版本目录;都缺失即未安装 +export function resolvedRoot(o) { + const p = prefix(o) + const l = linked(o) + if (l && existsSync(l)) return l + const c = current(o) + if (c && existsSync(join(p, `shadow-dev-workflow-${c}`))) return join(p, `shadow-dev-workflow-${c}`) + throw err('WORKFLOW_NOT_INSTALLED', 'WORKFLOW_NOT_INSTALLED: run `shadow-dev workflow plan` + `workflow execute` first', 1) +} + +const home = () => process.env.SHADOW_WORKFLOW_HOME || homedir() +const ms = () => Number(process.env.SHADOW_API_TIMEOUT_MS || 15000) + +// 公开仓的 release 拉取:token 可选(api.github.com 匿名可读),SHADOW_GITHUB_API_URL 供测试/代理覆盖 +async function ghJson(path) { + const base = process.env.SHADOW_GITHUB_API_URL || 'https://api.github.com' + const headers = { accept: 'application/vnd.github+json', 'user-agent': 'shadow-dev' } + const t = process.env.GITHUB_TOKEN || process.env.GH_TOKEN + if (t) headers.authorization = `Bearer ${t}` + let res + try { res = await fetch(new URL(path, base), { headers, signal: AbortSignal.timeout(ms()) }) } catch (e) { throw err('GITHUB_API_ERROR', `GITHUB_API_ERROR: ${e.name === 'TimeoutError' ? 'API_TIMEOUT' : e.message}`, 3) } + const j = await res.json().catch(() => ({})) + if (!res.ok) throw err(res.status === 404 ? 'RELEASE_NOT_FOUND' : 'GITHUB_API_ERROR', j.message || `HTTP ${res.status}`, 3) + return j +} + +async function download(url, out) { + let res + try { res = await fetch(url, { headers: { 'user-agent': 'shadow-dev' }, signal: AbortSignal.timeout(ms()) }) } catch (e) { throw err('DOWNLOAD_FAILED', `DOWNLOAD_FAILED: ${e.name === 'TimeoutError' ? 'API_TIMEOUT' : e.message}`, 3) } + if (!res.ok) throw err('DOWNLOAD_FAILED', `DOWNLOAD_FAILED: HTTP ${res.status}`, 3) + writeFileSync(out, Buffer.from(await res.arrayBuffer())) +} + +const verOf = d => { try { return JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')).version } catch { throw err('ARTIFACT_INVALID', 'ARTIFACT_INVALID: package.json unreadable', 3) } } + +// 产物契约(与 workflow 仓 pack.mjs 布局同源):marketplace.json/package.json/skills 三件必备 +export function requireArtifact(d) { + if (!existsSync(d) || !statSync(d).isDirectory()) throw err('ARTIFACT_INVALID', `ARTIFACT_INVALID: not a directory: ${d}`, 3) + for (const f of ['marketplace.json', 'package.json', 'skills']) if (!existsSync(join(d, f))) throw err('ARTIFACT_INVALID', `ARTIFACT_INVALID: missing ${f}`, 3) +} + +// 计划:三路同源解析目标版本——--from(目录/tarball)、--release(固定 tag)、latest +export async function planData(_r, o) { + const base = { action: 'install', prefix: prefix(o), current: current(o), previous: previous(o), linked: linked(o) } + if (o.from) { + const src = resolve(o.from) + if (statSync(src).isDirectory()) { requireArtifact(src); return { ...base, version: verOf(src), source: { dir: src } } } + const tmp = mkdtempSync(join(tmpdir(), 'wf-plan-')) + try { + execFileSync('tar', ['-xzf', src, '-C', tmp]) + const w = join(tmp, 'shadow-dev-workflow') + requireArtifact(w) + return { ...base, version: verOf(w), source: { tarball: resolve(src) } } + } finally { rmSync(tmp, { recursive: true, force: true }) } + } + const rel = await ghJson(o.release ? `/repos/${REPO}/releases/tags/${o.release}` : `/repos/${REPO}/releases/latest`) + const asset = (rel.assets || []).find(a => /^shadow-dev-workflow-v[0-9][0-9.]*\.tar\.gz$/.test(a.name)) + if (!asset) throw err('RELEASE_NOT_FOUND', 'RELEASE_NOT_FOUND: release carries no shadow-dev-workflow tarball asset', 3) + return { ...base, version: String(rel.tag_name).replace(/^v/, ''), source: { url: asset.browser_download_url, tag: rel.tag_name } } +} + +function prune(p, ver) { + const prev = readPtr(p, 'PREVIOUS') + for (const d of readdirSync(p)) { + if (!d.startsWith('shadow-dev-workflow-')) continue + if (d === `shadow-dev-workflow-${ver}` || (prev && d === `shadow-dev-workflow-${prev}`)) continue + rmSync(join(p, d), { recursive: true, force: true }) + } +} + +export async function executeDomain(o) { + confirm(o) + const e = plan('workflow', await planData(null, o)) + if (!o['plan-hash']) throw err('PLAN_HASH_REQUIRED', 'PLAN_HASH_REQUIRED: run `shadow-dev workflow plan` first or pass --plan-hash', 2) + if (o['plan-hash'] !== e.planHash) throw err('PLAN_HASH_INVALID') + const x = e.data + let src = x.source.dir + let tmp = null + if (!src) { + tmp = mkdtempSync(join(tmpdir(), 'wf-dl-')) + const tgz = join(tmp, 'artifact.tgz') + if (x.source.tarball) cpSync(x.source.tarball, tgz) + else await download(x.source.url, tgz) + execFileSync('tar', ['-xzf', tgz, '-C', tmp]) + src = join(tmp, 'shadow-dev-workflow') + } + requireArtifact(src) + const p = x.prefix, ver = verOf(src) + const dest = join(p, `shadow-dev-workflow-${ver}`) + rmSync(dest, { recursive: true, force: true }) + mkdirSync(p, { recursive: true }) + cpSync(src, dest, { recursive: true }) + if (tmp) rmSync(tmp, { recursive: true, force: true }) + // 冒烟已过、目录已物化,才动指针:失败路径绝不留半成品 + const cur = readPtr(p, 'CURRENT') + if (cur && cur !== ver) writeFileSync(join(p, 'PREVIOUS'), `${cur}\n`) + writeFileSync(join(p, 'CURRENT'), `${ver}\n`) + prune(p, ver) + return { action: 'install', version: ver, prefix: p, previous: readPtr(p, 'PREVIOUS') } +} + +export function rollbackDomain(o) { + confirm(o) + const p = prefix(o) + const cur = readPtr(p, 'CURRENT'), prev = readPtr(p, 'PREVIOUS') + if (!prev || !existsSync(join(p, `shadow-dev-workflow-${prev}`))) throw err('NO_PREVIOUS', 'NO_PREVIOUS: no previous version available for rollback', 1) + if (cur) writeFileSync(join(p, 'PREVIOUS'), `${cur}\n`) + writeFileSync(join(p, 'CURRENT'), `${prev}\n`) + return { action: 'rollback', current: prev, previous: cur } +} + +export function linkDomain(o) { + confirm(o) + if (!o.dir) throw err('DIR_REQUIRED', 'DIR_REQUIRED: pass --dir ', 1) + const target = resolve(o.dir) + requireArtifact(target) + const p = prefix(o) + mkdirSync(p, { recursive: true }) + // LINK 只删不写于 install 轨;语义互斥与 CLI 自身安装器同款(unlink 只删 LINK) + writeFileSync(join(p, 'LINK'), target) + return { action: 'link', linked: target, prefix: p } +} + +export function unlinkDomain(o) { + confirm(o) + const p = prefix(o) + if (!readPtr(p, 'LINK')) throw err('NOT_LINKED', 'NOT_LINKED: no LINK pointer present', 1) + rmSync(join(p, 'LINK')) + return { action: 'unlink', current: readPtr(p, 'CURRENT') } +} + +export function statusDomain(o) { + const p = prefix(o) + const l = readPtr(p, 'LINK') + const c = readPtr(p, 'CURRENT') + const resolved = l && existsSync(l) ? l : c && existsSync(join(p, `shadow-dev-workflow-${c}`)) ? join(p, `shadow-dev-workflow-${c}`) : null + return { prefix: p, current: c, previous: readPtr(p, 'PREVIOUS'), linked: l, resolved } +} + +export async function handle(a, o) { + if (a === 'plan') { const e = plan('workflow', await planData(null, o)); return e } + if (a === 'execute') return { ok: true, command: 'workflow.execute', data: await executeDomain(o) } + if (a === 'status') return { ok: true, command: 'workflow.status', data: statusDomain(o) } + if (a === 'rollback') return { ok: true, command: 'workflow.rollback', data: rollbackDomain(o) } + if (a === 'link') return { ok: true, command: 'workflow.link', data: linkDomain(o) } + if (a === 'unlink') return { ok: true, command: 'workflow.unlink', data: unlinkDomain(o) } + throw err('UNKNOWN_COMMAND', `unsupported command: workflow ${a}`) +} diff --git a/lib/i18n.mjs b/lib/i18n.mjs index 97fd301..e4cd5dc 100644 --- a/lib/i18n.mjs +++ b/lib/i18n.mjs @@ -60,6 +60,18 @@ export const HINTS = { GITHUB_API_ERROR: { zh: 'GitHub API 返回错误', en: 'the GitHub API returned an error' }, PR_CREATE_FAILED: { zh: '创建 PR 失败(多为分支未推送或凭据问题)', en: 'PR creation failed (branch not pushed or credential issue)' }, API_TIMEOUT: { zh: 'GitHub API 超时(SHADOW_API_TIMEOUT_MS 可调)', en: 'GitHub API timed out (tune SHADOW_API_TIMEOUT_MS)' }, + WORKFLOW_NOT_INSTALLED: { zh: 'workflow 产物未安装;先运行 workflow plan + workflow execute', en: 'workflow artifact not installed; run workflow plan + workflow execute first' }, + ARTIFACT_INVALID: { zh: '产物缺少 marketplace.json/package.json/skills 契约文件', en: 'artifact is missing its marketplace.json/package.json/skills contract' }, + RELEASE_NOT_FOUND: { zh: '找不到 release 或其 tarball 资产(--release 可固定 tag)', en: 'release or its tarball asset not found (pin one with --release)' }, + DOWNLOAD_FAILED: { zh: 'release 资产下载失败(网络或 URL 形态异常)', en: 'release asset download failed (network or malformed url)' }, + NO_PREVIOUS: { zh: '没有可回滚的上一版本', en: 'no previous version available for rollback' }, + NOT_LINKED: { zh: '当前无 LINK 指针;先运行 workflow link --dir ', en: 'no LINK pointer; run workflow link --dir first' }, + DIR_REQUIRED: { zh: '缺少 --dir(产物目录)', en: '--dir (artifact directory) is required' }, + HOST_NOT_FOUND: { zh: '没有匹配的宿主适配器,或其 skills 目录在本机不存在', en: 'no matching host adapter, or its skills dir is absent on this machine' }, + HOST_REQUIRED: { zh: '缺少 --host(unbind 需要显式宿主名)', en: '--host (explicit host name) is required for unbind' }, + UNMANAGED_TARGET: { zh: '同名目录非本工具托管,拒绝覆盖;手动移除后重试', en: 'target dirs are not managed by this tool; remove them manually first' }, + ADAPTERS_MISSING: { zh: '产物缺 adapters/(需要随产物分发描述符的 workflow 版本)', en: 'artifact lacks adapters/ (need a workflow release shipping descriptors)' }, + NOTHING_TO_UNBIND: { zh: '该宿主没有可解绑的托管 skills', en: 'no managed skills to unbind for this host' }, } export function resolveLang(o) { diff --git a/shadow-docs/changes/20260925-feature-workflow-domain/brief.md b/shadow-docs/changes/20260925-feature-workflow-domain/brief.md new file mode 100644 index 0000000..a2e1f20 --- /dev/null +++ b/shadow-docs/changes/20260925-feature-workflow-domain/brief.md @@ -0,0 +1,104 @@ +--- +{ + "schema": "shadow-dev/v1", + "name": "20260925-feature-workflow-domain", + "type": "feature", + "scope": "distribution", + "status": "reviewed", + "baseBranch": "main", + "branch": "feature/20260925-feature-workflow-domain", + "files": [ + "README.md", + "lib/args.mjs", + "lib/domains/bind.mjs", + "lib/domains/workflow.mjs", + "test/cli.test.mjs" + ], + "github": { + "repository": null, + "issue": null, + "issueUrl": null, + "pullRequest": null, + "pullRequestUrl": null + }, + "review": { + "conclusion": "passed", + "verifiedCommit": "77616b56f485c693c952ca114671381de59a87e0", + "verifiedAt": "2026-09-25T07:38:24.141Z" + }, + "workflow": { + "operation": null, + "checkpoint": null, + "planHash": "5e7cd62b9be6cf1cfdbe5e3f98d7005ed47a3752a9c450b83cc63780d38d6363", + "updatedAt": null, + "lastError": null, + "release": { + "files": [ + "README.md", + "cli.mjs", + "lib/commands.mjs", + "lib/domains/bind.mjs", + "lib/domains/workflow.mjs", + "lib/i18n.mjs", + "shadow-docs/changes/20260925-feature-workflow-domain/brief.md", + "test/cli.test.mjs" + ], + "message": "feat(cli): workflow 物化域 + bind 宿主绑定域——CLI 驱动 shadow 生态分发(宿主无关,无 brief 凭证链)", + "title": "20260925-feature-workflow-domain", + "body": "" + } + }, + "knowledge": { + "action": "更新", + "target": "shadow-docs/knowledge/install-distribution.md", + "reason": "scope 扩展至 lib/domains/workflow.mjs 与 bind.mjs;宿主绑定(描述符数据化、sidecar 托管标记、非托管 guard)为新执行约束;大小写不敏感文件系统上指针文件与入口软链命名不得冲突(CURRENT/current 教训)" + } +} +--- + +# workflow 域:生态物化与宿主绑定 + +## 动机 + +分发反转:CLI 从「被插件引导」变为「shadow 生态统一分发入口」。前置 brief `20260925-feature-pack-release`(shadow-dev-workflow 仓)提供带 adapters 的 release tarball;本 brief 在 CLI 侧新增物化与宿主绑定执行层。宿主无关:claude code 原生,zcode 兼容,codex/kimi 留描述符扩展位。 + +## 引用规范 + +- shadow-docs/knowledge/install-distribution.md + - 当前结论: 双轨(物化+link)、LINK/CURRENT 语义互斥、落盘前冒烟不过指针不动、非托管同名文件 guard 绝不静默覆盖、--json 单行输出为跨仓接缝契约 + - 适用 scope: lib/domains/workflow.mjs、lib/domains/bind.mjs 全部行为 +- shadow-docs/knowledge/cli-output-contract.md + - 当前结论: 单行 JSON 成功/失败结构、退出码 0-4、nextStep 提示面 + - 适用 scope: workflow/bind 新命令面的输出契约 + +## 决策 + +- **选型:** workflow 域完整复刻 CLI 自身双轨模型——release tarball 物化到 `~/.local/share/shadow-dev-workflow/shadow-dev-workflow-/` + CURRENT/PREVIOUS 指针 + `current` 稳定入口 + 落盘前冒烟(marketplace.json/package.json/skills/ 完整性)+ link 直通轨;bind 域读产物 `adapters/.json`,按描述符把 SKILL.md 复制入宿主发现目录并打 managed-by 标记,非托管同名文件 guard 拒绝,unbind 逆操作,`--host auto` 探测本机已有宿主 +- **对比方案:** 直写宿主注册表(否决——耦合内部格式);symlink 绑定(否决——跨宿主兼容未验证,复制+托管标记与托管 shim 同一已验证模式);以 ZCode marketplace 为接缝(否决——宿主无关要求) +- **理由:** 全部复用 install-distribution 卡已验证模式,测试与跨平台坑有先例;规则引用走 `$PREFIX/current` 稳定路径,注入宿主的文件面最小化 +- **非目标:** init 域(另立 brief)、codex/kimi 描述符(独立小 brief)、superpowers 式强制入口/子代理派发(独立 brief)、移除 SessionStart hook(保留为插件型宿主 fallback)、cliVersion 反向 pin(避免双向耦合) + +## 任务 + +### Phase 1 +- [x] workflow 域模块 — `lib/domains/workflow.mjs` — planData/execute:install|update|rollback|status|link|unlink(物化轨+link 轨+指针+冒烟) +- [x] 命令面注册 — `lib/args.mjs` — workflow 命令与 HELP 条目 + +### Phase 2 +- [x] bind 域模块 — `lib/domains/bind.mjs` — bind plan|execute --host auto|claude-code|zcode 与 unbind,描述符驱动+托管标记+guard +- [x] bind 命令面 — `lib/args.mjs` — bind/unbind 与 HELP + +### Phase 3 +- [x] 契约测试 — `test/cli.test.mjs` — workflow 布局/指针/回滚往返、bind 托管标记/guard/--json 契约用例 +- [x] 文档 — `README.md` — 命令表补 workflow/bind,分发章节改为生态入口 + +## 结果 + +- 实际耗时: — +- 验证: — + +## 知识评估 + +- **预期影响:** 更新 +- **候选卡片:** shadow-docs/knowledge/install-distribution.md +- **理由:** scope 扩展至 lib/domains/workflow.mjs 与 bind.mjs;宿主绑定(描述符驱动、托管标记、guard)作为新执行约束沉淀 diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 5fcbf3c..f2c8cfb 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { execFileSync, spawn, spawnSync } from 'node:child_process' -import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -41,8 +41,11 @@ const server = http.createServer((req, res) => { req.on('end', () => { appendFileSync(process.env.LOG_FILE, JSON.stringify({ method: req.method, url: req.url, body }) + '\\n') const rule = rules.find(item => item.method === req.method && req.url.startsWith(item.path)) || { status: 404, body: { message: 'not found' } } + if (rule.rawB64) return (res.writeHead(rule.status || 200, { 'content-type': 'application/octet-stream' }), res.end(Buffer.from(rule.rawB64, 'base64'))) + let out = rule.body + if (rule.template) out = JSON.parse(JSON.stringify(out).replaceAll('{{BASE}}', 'http://127.0.0.1:' + server.address().port)) res.writeHead(rule.status || 200, { 'content-type': 'application/json' }) - res.end(JSON.stringify(rule.body)) + res.end(JSON.stringify(out)) }) }) server.listen(0, '127.0.0.1', () => writeFileSync(process.env.PORT_FILE, String(server.address().port))) @@ -900,3 +903,142 @@ test('commit execute reuses persisted files and message without re-passing them' const message = execFileSync('git', ['log', '-1', '--format=%B'], { cwd: root }).toString() assert.match(message, /feat: two files/) }) + +// ---- workflow / bind 域:生态分发(无 brief 域,--plan-hash 是唯一凭证;任意目录可用)---- + +function buildArtifact(dir, version, { adapters = true, skills = ['shadow-dev-propose', 'shadow-dev-apply'] } = {}) { + mkdirSync(join(dir, 'skills'), { recursive: true }) + writeFileSync(join(dir, 'marketplace.json'), '{"name":"shadow-dev-workflow-local","plugins":[]}') + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'shadow-dev-workflow', version })) + for (const s of skills) { + mkdirSync(join(dir, 'skills', s), { recursive: true }) + writeFileSync(join(dir, 'skills', s, 'SKILL.md'), `---\nname: ${s}\n---\n\n# ${s}\n`) + } + if (adapters) { + mkdirSync(join(dir, 'adapters'), { recursive: true }) + writeFileSync(join(dir, 'adapters', 'claude-code.json'), JSON.stringify({ schema: 'shadow-dev-adapter/v1', host: 'claude-code', tier: 'native', skillsDir: '~/.claude/skills', skillLayout: '{skillsDir}/{skill}/SKILL.md', bind: { strategy: 'copy', marker: 'managed-by: shadow-dev-workflow' } })) + writeFileSync(join(dir, 'adapters', 'zcode.json'), JSON.stringify({ schema: 'shadow-dev-adapter/v1', host: 'zcode', tier: 'compatible', skillsDir: '~/.zcode/skills', skillLayout: '{skillsDir}/{skill}/SKILL.md', bind: { strategy: 'copy', marker: 'managed-by: shadow-dev-workflow' } })) + } + return dir +} + +const wfEnv = root => ({ SHADOW_WORKFLOW_PREFIX: join(root, 'wf-prefix'), SHADOW_WORKFLOW_HOME: join(root, 'home') }) + +test('help lists workflow and bind ecosystem commands', () => { + const help = JSON.parse(run(['--help']).stdout).data.help + assert.match(help, /workflow plan\|execute\|rollback\|status\|link\|unlink/) + assert.match(help, /bind plan\|execute\|status\|unbind/) +}) + +test('workflow plan is stable and execute materializes versioned layout with pointers', () => { + const root = mkdtempSync(join(tmpdir(), 'wf-')) + const artifact = buildArtifact(join(root, 'artifact'), '6.3.0') + const env = wfEnv(root) + const first = run(['workflow', 'plan', '--from', artifact, '--json'], root, env) + assert.equal(first.status, 0, first.stderr) + const hash = JSON.parse(first.stdout).planHash + assert.equal(hash, JSON.parse(run(['workflow', 'plan', '--from', artifact, '--json'], root, env).stdout).planHash, 'plan is stable') + assert.equal(JSON.parse(first.stdout).data.version, '6.3.0') + // 凭证链:execute 缺 --plan-hash exit 2,错误 hash exit 1 + assert.equal(run(['workflow', 'execute', '--from', artifact, '--confirm', '--json'], root, env).status, 2) + assert.equal(run(['workflow', 'execute', '--from', artifact, '--plan-hash', 'wrong', '--confirm', '--json'], root, env).status, 1) + const done = run(['workflow', 'execute', '--from', artifact, '--plan-hash', hash, '--confirm', '--json'], root, env) + assert.equal(done.status, 0, done.stderr) + assert.equal(JSON.parse(done.stdout).data.version, '6.3.0') + const prefixDir = join(root, 'wf-prefix') + assert.equal(readFileSync(join(prefixDir, 'CURRENT'), 'utf8').trim(), '6.3.0') + assert.equal(existsSync(join(prefixDir, 'shadow-dev-workflow-6.3.0', 'skills', 'shadow-dev-propose', 'SKILL.md')), true) + // 第二版:PREVIOUS 翻转 + current 稳定入口 + 旧版本保留供回滚 + const artifact2 = buildArtifact(join(root, 'artifact2'), '6.3.1') + const h2 = JSON.parse(run(['workflow', 'plan', '--from', artifact2, '--json'], root, env).stdout).planHash + assert.equal(run(['workflow', 'execute', '--from', artifact2, '--plan-hash', h2, '--confirm', '--json'], root, env).status, 0) + assert.equal(readFileSync(join(prefixDir, 'CURRENT'), 'utf8').trim(), '6.3.1') + assert.equal(readFileSync(join(prefixDir, 'PREVIOUS'), 'utf8').trim(), '6.3.0') + assert.equal(existsSync(join(prefixDir, 'shadow-dev-workflow-6.3.0')), true) + // 产物失去契约文件 → execute 重算时 ARTIFACT_INVALID(exit 3) + rmSync(join(artifact2, 'marketplace.json')) + assert.equal(run(['workflow', 'execute', '--from', artifact2, '--plan-hash', h2, '--confirm', '--json'], root, env).status, 3) + // rollback 往返 + status 如实反映(且 status 在任意目录可用) + const rb = run(['workflow', 'rollback', '--confirm', '--json'], root, env) + assert.equal(rb.status, 0, rb.stderr) + assert.equal(JSON.parse(rb.stdout).data.current, '6.3.0') + const st = JSON.parse(run(['workflow', 'status', '--json'], tmpdir(), env).stdout).data + assert.equal(st.current, '6.3.0') + assert.equal(st.previous, '6.3.1') +}) + +test('workflow link track wins over CURRENT and unlink restores it', () => { + const root = mkdtempSync(join(tmpdir(), 'wf-link-')) + const installed = buildArtifact(join(root, 'installed'), '6.3.0') + const dev = buildArtifact(join(root, 'dev-checkout'), '6.4.0') + const env = wfEnv(root) + const h = JSON.parse(run(['workflow', 'plan', '--from', installed, '--json'], root, env).stdout).planHash + assert.equal(run(['workflow', 'execute', '--from', installed, '--plan-hash', h, '--confirm', '--json'], root, env).status, 0) + assert.equal(run(['workflow', 'link', '--dir', dev, '--json'], root, env).status, 2, 'link mutates: needs --confirm') + const lk = run(['workflow', 'link', '--dir', dev, '--confirm', '--json'], root, env) + assert.equal(lk.status, 0, lk.stderr) + const st = JSON.parse(run(['workflow', 'status', '--json'], root, env).stdout).data + assert.equal(st.linked, dev) + assert.equal(st.resolved, dev, 'LINK wins over CURRENT at resolution') + assert.equal(run(['workflow', 'link', '--dir', join(root, 'nope'), '--confirm', '--json'], root, env).status, 3, 'target without artifact contract exits 3') + const un = run(['workflow', 'unlink', '--confirm', '--json'], root, env) + assert.equal(un.status, 0, un.stderr) + assert.equal(JSON.parse(run(['workflow', 'status', '--json'], root, env).stdout).data.linked, null) + assert.equal(JSON.parse(run(['workflow', 'status', '--json'], root, env).stdout).data.resolved, join(root, 'wf-prefix', 'shadow-dev-workflow-6.3.0')) +}) + +test('workflow release track resolves latest release and downloads the asset via the API', () => { + const root = mkdtempSync(join(tmpdir(), 'wf-rel-')) + const staging = join(root, 'staging') + buildArtifact(join(staging, 'shadow-dev-workflow'), '6.3.1') + // tar 必须经 bash -c:与安装器/打包用例同一解析路径(install-distribution 卡约束) + execFileSync('bash', ['-c', 'tar -czf "$1" -C "$2" shadow-dev-workflow', 'pack', join(root, 'asset.tgz'), staging]) + const api = apiStub([ + { method: 'GET', path: '/repos/stack-wuh/shadow-dev-workflow/releases/latest', template: true, body: { tag_name: 'v6.3.1', assets: [{ name: 'shadow-dev-workflow-v6.3.1.tar.gz', browser_download_url: '{{BASE}}/releases/download/v6.3.1/shadow-dev-workflow-v6.3.1.tar.gz' }] } }, + { method: 'GET', path: '/releases/download/v6.3.1/shadow-dev-workflow-v6.3.1.tar.gz', rawB64: readFileSync(join(root, 'asset.tgz')).toString('base64') }, + ]) + try { + const env = { ...wfEnv(root), SHADOW_GITHUB_API_URL: api.url } + const h = JSON.parse(run(['workflow', 'plan', '--json'], root, env).stdout).planHash + const done = run(['workflow', 'execute', '--plan-hash', h, '--confirm', '--json'], root, env) + assert.equal(done.status, 0, done.stderr) + assert.equal(JSON.parse(done.stdout).data.version, '6.3.1') + assert.equal(existsSync(join(root, 'wf-prefix', 'shadow-dev-workflow-6.3.1', 'marketplace.json')), true) + } finally { + api.close() + } +}) + +test('bind copies managed skills with a sidecar, blocks unmanaged targets, unbind removes', () => { + const root = mkdtempSync(join(tmpdir(), 'bind-')) + const artifact = buildArtifact(join(root, 'artifact'), '6.3.0') + const env = wfEnv(root) + const lk = run(['workflow', 'link', '--dir', artifact, '--confirm', '--json'], root, env) + assert.equal(lk.status, 0, lk.stderr) + const skillsDir = join(root, 'home', '.claude', 'skills') + mkdirSync(join(skillsDir, 'shadow-dev-apply'), { recursive: true }) + writeFileSync(join(skillsDir, 'shadow-dev-apply', 'SKILL.md'), 'foreign\n') + const plan1 = run(['bind', 'plan', '--host', 'claude-code', '--json'], root, env) + assert.equal(plan1.status, 0, plan1.stderr) + const p1 = JSON.parse(plan1.stdout) + assert.equal(p1.data.hosts[0].entries.find(t => t.skill === 'shadow-dev-apply').blocked, true, 'foreign dir is blocked') + assert.equal(run(['bind', 'execute', '--host', 'claude-code', '--plan-hash', p1.planHash, '--confirm', '--json'], root, env).status, 1, 'blocked execute exits 1') + rmSync(join(skillsDir, 'shadow-dev-apply'), { recursive: true, force: true }) + const p2 = JSON.parse(run(['bind', 'plan', '--host', 'claude-code', '--json'], root, env).stdout) + const ex = run(['bind', 'execute', '--host', 'claude-code', '--plan-hash', p2.planHash, '--confirm', '--json'], root, env) + assert.equal(ex.status, 0, ex.stderr) + assert.equal(readFileSync(join(skillsDir, 'shadow-dev-propose', 'SKILL.md'), 'utf8'), `---\nname: shadow-dev-propose\n---\n\n# shadow-dev-propose\n`) + const sidecar = JSON.parse(readFileSync(join(skillsDir, '.shadow-dev-workflow.json'), 'utf8')) + assert.equal(sidecar.marker, 'managed-by: shadow-dev-workflow') + assert.equal(Object.keys(sidecar.skills).length, 2) + // status 如实列出全部适配器(present 标记);auto 的存在性过滤只作用于 plan + const st = JSON.parse(run(['bind', 'status', '--json'], root, env).stdout).data + assert.equal(st.hosts.length, 2) + assert.deepEqual(st.hosts.find(h => h.host === 'claude-code').managed, ['shadow-dev-propose', 'shadow-dev-apply']) + assert.equal(st.hosts.find(h => h.host === 'zcode').present, false) + const ub = run(['bind', 'unbind', '--host', 'claude-code', '--confirm', '--json'], root, env) + assert.equal(ub.status, 0, ub.stderr) + assert.equal(existsSync(join(skillsDir, 'shadow-dev-propose')), false) + assert.equal(existsSync(join(skillsDir, '.shadow-dev-workflow.json')), false) + assert.equal(run(['bind', 'unbind', '--host', 'claude-code', '--confirm', '--json'], root, env).status, 1, 'second unbind has nothing to remove') +})