From 5a259a95af4aec067badb1051a29e9f72d99febd Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 18 Sep 2026 16:42:09 +0800 Subject: [PATCH] feat(install): validate agent CLI runtime registries --- README.md | 29 +++++--- bin/tea-sdlc.js | 5 +- scripts/install-verify.js | 22 ++++-- scripts/install.js | 43 +++++++++++- scripts/runtime-verify.js | 134 ++++++++++++++++++++++++++++++++++++ scripts/verify.js | 20 ++++++ scripts/version.js | 23 +++++++ test/bin-entry.test.js | 3 +- test/install-verify.test.js | 13 +++- test/install.test.js | 4 +- test/runtime-verify.test.js | 77 +++++++++++++++++++++ 11 files changed, 350 insertions(+), 23 deletions(-) create mode 100644 scripts/runtime-verify.js create mode 100644 scripts/verify.js create mode 100644 scripts/version.js create mode 100644 test/runtime-verify.test.js diff --git a/README.md b/README.md index 8f36bd1..d1b5e5a 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ tea-sdlc/ ├── scripts/ # 所有 Gitea / git 副作用的唯一出口(Node,零相依) ├── templates/ # 議題、PR、報表、總覽網頁的輸出模板 ├── references/ # 規則正本:實作規範、註解格式、可行性檢查清單 -├── bin/tea-sdlc.js # 單一指令入口:install / uninstall / prompt / status +├── bin/tea-sdlc.js # 單一指令入口:install / uninstall / prompt / status / verify / sdlc-version ├── skills/ # 各助理原生 plugin 機制讀取的 skills(目前為空) ├── .claude-plugin/ # Claude Code 的 plugin / marketplace manifest ├── .codex-plugin/ # Codex 的 plugin manifest @@ -94,7 +94,7 @@ tea-sdlc install --dry-run | `claude`(Claude Code) | `~/.claude/` | `~/.claude/commands/sdlc-*.md` | command | | `codex`(Codex) | `~/.codex/` | `~/.codex/prompts/sdlc-*.md` | command | | `opencode`(OpenCode) | `~/.config/opencode/` | `~/.config/opencode/command/sdlc-*.md` | command | -| `oh-my-pi` | `~/.omp/` | `~/.omp/commands/sdlc-*.md` | command | +| `oh-my-pi` | `~/.omp/` | `~/.omp/agent/commands/sdlc-*.md` | command | | `antigravity`(Antigravity) | `~/.gemini/` | `~/.gemini/skills/sdlc-*/SKILL.md` | skill | | `kiro`(Kiro) | `~/.kiro/` | `~/.kiro/skills/sdlc-*/SKILL.md` | skill | | `copilot`(GitHub Copilot) | 專案裡的 `.github/` | `.github/skills/sdlc-*/SKILL.md` | skill | @@ -104,10 +104,13 @@ tea-sdlc install --dry-run ### 安裝完成等於驗過能用 -寫完轉接檔之後,`install` 會把那條叫用鏈真的走一遍:**轉接檔 → PATH 上的 `tea-sdlc` → 流程正本**。 -它實際到 PATH 上把 `tea-sdlc` 找出來執行一次、取回一份正本逐字比對,再逐一比對每個平台的轉接檔 -存在且內容含正確的叫用行,結果放在輸出的 `verify` 裡,逐平台 pass/fail。**任一環不通,`install` -就回 `ok:false`。** 這段完全不碰網路,也與 Gitea 登入、時間追蹤無關。 +寫完轉接檔之後,`install` 會把三層叫用鏈驗一遍: +**轉接檔 → PATH 上的 `tea-sdlc` → AI Agent CLI 的 runtime registry**。 +它會確認 PATH 上的 `tea-sdlc` 能取回正本,並針對 Claude、Codex、oh-my-pi 與 OpenCode +檢查其本機 command registry 是否包含全部六個流程。沒有安全、非互動 registry probe 的平台 +會明確回報 `not-supported`,不冒充驗證成功。結果放在輸出的 `verify.platforms[].runtime`: +`status` 為 `pass`、`fail` 或 `not-supported`,並列出 `resolved`、`commands`、`missing`、 +`probe` 與 `remediation`。任何 `fail` 都讓 `install` 回 `ok:false`。 最脆弱的是中間那一環:套件裝在某個 Node 版本底下,換個版本就找不到了,而轉接檔本身看起來完全正常—— 沒有這道驗證,使用者要到第一次打 `/sdlc-plan` 才發現。 @@ -119,9 +122,15 @@ tea-sdlc install --dry-run `--dry-run` 不寫入任何轉接檔,也就沒有東西可驗,`verify` 會標成 `skipped` 並說明原因。 -還沒裝 `git` 或 [`tea`](https://gitea.com/gitea/tea) 也可以先裝:轉接檔的產生不需要它們, -安裝會把缺的東西列在輸出的 `missingBinaries` 與 `warning` 裡,但不會替你安裝,也不會因此中止。 -真正需要它們的是流程指令本身,跑之前補上即可。 +安裝後可單獨重跑不寫檔的完整驗證: + +```bash +tea-sdlc verify +tea-sdlc verify --platform oh-my-pi +``` + +`tea-sdlc sdlc-version` 是無副作用的健康檢查,回報目前 `tea-sdlc` 的版本、PATH 實際 +解析到的 executable,以及可用流程;它不執行 AI Agent,也不連網。 --- @@ -135,6 +144,8 @@ tea-sdlc install --dry-run | --- | --- | | 更新到最新版 | `npm i -g git+https://gitea.jsc.idv.tw/plugins/tea-sdlc.git` | | 指令數量變了之後重新佈署 | `tea-sdlc install` | +| 重新驗證已部署的 AI Agent CLI | `tea-sdlc verify` | +| 查版本、PATH executable 與流程 | `tea-sdlc sdlc-version` | | 查目前版本、正本位置與環境 | `tea-sdlc status` | | 只移除轉接檔(正本不動) | `tea-sdlc uninstall` | | 連套件一起移除 | `tea-sdlc uninstall` → `npm rm -g tea-sdlc` | diff --git a/bin/tea-sdlc.js b/bin/tea-sdlc.js index 4278f01..df6fce7 100644 --- a/bin/tea-sdlc.js +++ b/bin/tea-sdlc.js @@ -15,15 +15,18 @@ */ import { ScriptError, main } from '../scripts/lib.js'; import { runInstall, runUninstall } from '../scripts/install.js'; +import { runVersion } from '../scripts/version.js'; +import { runVerify } from '../scripts/verify.js'; import { runPrompt } from '../scripts/prompt.js'; import { runStatus } from '../scripts/status.js'; -/** 子指令名 → 實作。名字就是使用者打的字,也是轉接檔裡寫的字。 */ const SUBCOMMANDS = { install: runInstall, uninstall: runUninstall, prompt: runPrompt, status: runStatus, + 'sdlc-version': runVersion, + verify: runVerify, }; main(async () => { diff --git a/scripts/install-verify.js b/scripts/install-verify.js index e1804c6..9a978ae 100644 --- a/scripts/install-verify.js +++ b/scripts/install-verify.js @@ -3,8 +3,10 @@ * * install 寫完轉接檔之後,這裡把那條叫用鏈真的走一遍: * - * 轉接檔 → PATH 上的 tea-sdlc → 流程正本 + * 轉接檔 → PATH 上的 tea-sdlc → AI Agent CLI runtime registry * + * PATH 上的 tea-sdlc 仍會取回流程正本;runtime verifier 再依平台的安全 probe + * 確認六個流程都出現在正確的本機 registry。沒有安全 probe 的平台回報 not-supported。 * 最脆弱的是中間那一環。套件裝在某個 Node 版本底下,換個版本管理器或改 npm prefix * 就找不到了,而轉接檔本身看起來完全正常——使用者要到第一次打 /sdlc-plan 才發現, * 那時他已經離開安裝的心智狀態很久了。所以這裡不是「檢查檔案在不在」,而是真的到 @@ -19,6 +21,7 @@ * 「有點舊但能用」變成什麼都沒有。而且最可能的病灶是「PATH 上找不到 tea-sdlc」, * 那不是轉接檔的問題,刪掉它一點幫助也沒有——所以報告把叫用鏈與轉接檔分開講。 */ +import { verifyRuntimePlatforms } from './runtime-verify.js'; import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { onPath } from './lib.js'; @@ -50,7 +53,8 @@ export function invocation(name, version) { */ export function verifyInstall({ version, prompt, platforms }) { const chain = verifyChain(prompt, version); - const reports = platforms.map((platform) => verifyPlatform(platform, version)); + const runtime = verifyRuntimePlatforms(platforms); + const reports = platforms.map((platform, index) => verifyPlatform(platform, version, runtime[index])); return { ok: chain.ok && reports.every((report) => report.ok), @@ -169,12 +173,20 @@ export function 診斷(verify) { * 讀回磁碟上的內容而不是相信剛才寫出去的字串:這一步要驗的正是「寫出去之後檔案 * 真的長那樣」,拿記憶體裡的原稿來比等於自己驗自己。 */ -function verifyPlatform(platform, version) { - const failures = platform.adapters +function verifyPlatform(platform, version, runtime) { + const adapterFailures = platform.adapters .map((adapter) => checkAdapter(adapter, version)) .filter((failure) => failure !== null); + const failures = [...adapterFailures, ...runtime.failures]; - return { name: platform.name, ok: failures.length === 0, checked: platform.adapters.length, failures }; + return { + name: platform.name, + ok: failures.length === 0, + status: runtime.status, + checked: platform.adapters.length, + failures, + runtime, + }; } /** diff --git a/scripts/install.js b/scripts/install.js index 95b0dc4..9720b91 100644 --- a/scripts/install.js +++ b/scripts/install.js @@ -47,7 +47,7 @@ const PLATFORMS = [ { name: 'claude', label: 'Claude Code', base: 'home', detect: ['.claude'], target: ['.claude', 'commands'], kind: 'command' }, { name: 'codex', label: 'Codex', base: 'home', detect: ['.codex'], target: ['.codex', 'prompts'], kind: 'command' }, { name: 'opencode', label: 'OpenCode', base: 'home', detect: ['.config', 'opencode'], target: ['.config', 'opencode', 'command'], kind: 'command' }, - { name: 'oh-my-pi', label: 'oh-my-pi', base: 'home', detect: ['.omp'], target: ['.omp', 'commands'], kind: 'command' }, + { name: 'oh-my-pi', label: 'oh-my-pi', base: 'home', detect: ['.omp'], target: ['.omp', 'agent', 'commands'], kind: 'command' }, { name: 'antigravity', label: 'Antigravity', base: 'home', detect: ['.gemini'], target: ['.gemini', 'skills'], kind: 'skill' }, { name: 'kiro', label: 'Kiro', base: 'home', detect: ['.kiro'], target: ['.kiro', 'skills'], kind: 'skill' }, { name: 'copilot', label: 'GitHub Copilot', base: 'cwd', detect: ['.github'], target: ['.github', 'skills'], kind: 'skill' }, @@ -220,7 +220,48 @@ export function platformReport() { }); } +/** + * 供 verify 使用的唯讀目標。它只看目前偵測到的平台,不進互動選擇,也不寫檔。 + * @param {string|undefined} flag --platform 的值 + * @returns {{name: string, adapters: {name: string, path: string}[]}[]} + */ +export function verificationTargets(flag) { + const detected = PLATFORMS.filter((platform) => existsSync(pathOf(platform, platform.detect))); + const chosen = flag === undefined + ? detected + : (() => { + const names = parsePlatformFlag(flag); + const missing = names.filter((name) => !detected.some((platform) => platform.name === name)); + if (missing.length > 0) { + throw new ScriptError( + 'PLATFORM_NOT_DETECTED', + `這台機器上偵測不到 ${missing.join('、')};請先把該平台裝起來再重跑`, + ); + } + return PLATFORMS.filter((platform) => names.includes(platform.name)); + })(); + if (chosen.length === 0) { + throw new ScriptError( + 'NO_PLATFORM_DETECTED', + `偵測不到任何 agent 平台(找過 ${PLATFORMS.map(detectLabel).join('、')});請先安裝其中至少一個`, + ); + } + + const names = readPrompts().map((prompt) => prompt.name); + return chosen.map((platform) => ({ + name: platform.name, + adapters: names.map((name) => ({ name, path: adapterPath(platform, name) })), + })); +} + + + +/** @returns {{name: string, text: string}} */ +export function verificationPrompt() { + const prompt = readPrompts()[0]; + return { name: prompt.name, text: prompt.text }; +} // ── 平台挑選 ─────────────────────────────────────────────────────── /** diff --git a/scripts/runtime-verify.js b/scripts/runtime-verify.js new file mode 100644 index 0000000..fde3a3c --- /dev/null +++ b/scripts/runtime-verify.js @@ -0,0 +1,134 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { onPath } from './lib.js'; + +/** Runtime probes are deliberately local, non-interactive, and network-free. */ +const SPECS = { + 'oh-my-pi': { binary: 'omp', supported: true, root: (home) => join(home, '.omp', 'agent', 'commands') }, + claude: { binary: 'claude', supported: true, root: (home) => join(home, '.claude', 'commands') }, + codex: { binary: 'codex', supported: true, root: (home) => join(home, '.codex', 'prompts') }, + opencode: { binary: 'opencode', supported: true, root: (home) => join(home, '.config', 'opencode', 'command') }, + antigravity: { binary: 'gemini', supported: false }, + kiro: { binary: 'kiro', supported: false }, + copilot: { binary: 'gh', supported: false }, +}; + +const PROBE_VERSION = 1; + +/** + * @param {{name: string, adapters: {name: string, path: string}[]}[]} platforms + * @returns {object[]} + */ +export function verifyRuntimePlatforms(platforms) { + return platforms.map((platform) => verifyRuntimePlatform(platform)); +} + +/** + * @param {{name: string, adapters: {name: string, path: string}[]}} platform + * @returns {object} + */ +function verifyRuntimePlatform(platform) { + const spec = SPECS[platform.name]; + if (!spec || !spec.supported) { + return { + name: platform.name, + status: 'not-supported', + probe: { command: spec?.binary ?? null, args: [], network: false, interactive: false, version: PROBE_VERSION }, + resolved: spec ? onPath(spec.binary) : null, + commands: [], + missing: [], + failures: [], + remediation: '此 AI Agent CLI 沒有可供 tea-sdlc 安全呼叫的 command registry probe;請在該 CLI 內手動確認指令。', + }; + } + + const resolved = onPath(spec.binary); + const probe = { + command: 'filesystem-registry', + args: [spec.binary], + network: false, + interactive: false, + version: PROBE_VERSION, + }; + if (resolved === null) { + return unsupportedRuntime( + platform, + probe, + null, + `找不到 ${spec.binary},沒有可安全執行的 runtime probe`, + `請安裝 ${spec.binary} 並把它加入 PATH 後再重跑 tea-sdlc verify`, + ); + } + + + const root = dirname(platform.adapters[0]?.path ?? spec.root(process.env.HOME || homedir())); + const expected = platform.adapters.map(({ name }) => name); + const available = new Set( + existsSync(root) + ? readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + .map((entry) => basename(entry.name, '.md')) + : [], + ); + const commands = expected.filter((name) => available.has(name)); + const missing = expected.filter((name) => !available.has(name)); + if (missing.length > 0) { + return failedRuntime( + platform, + { ...probe, registry: root }, + resolved, + `runtime registry 找不到:${missing.join('、')}`, + `確認 ${root} 是 ${spec.binary} 的 command registry,然後重跑 tea-sdlc install`, + commands, + missing, + ); + } + + return { + name: platform.name, + status: 'pass', + probe: { ...probe, registry: root }, + resolved, + commands, + missing, + failures: [], + remediation: null, + }; +} + +function unsupportedRuntime(platform, probe, resolved, message, remediation) { + return { + name: platform.name, + status: 'not-supported', + probe, + resolved, + commands: [], + missing: platform.adapters.map(({ name }) => name), + failures: [], + remediation: `${message};${remediation}`, + }; +} + +function failedRuntime(platform, probe, resolved, message, remediation, commands = [], missing = platform.adapters.map(({ name }) => name)) { + const failure = { + path: probe.registry ?? null, + 病灶: message, + 修復: remediation, + }; + return { + name: platform.name, + status: 'fail', + probe, + resolved, + commands, + missing, + failures: [failure], + remediation, + }; +} + +/** @returns {number} */ +export function runtimeProbeVersion() { + return PROBE_VERSION; +} diff --git a/scripts/verify.js b/scripts/verify.js new file mode 100644 index 0000000..ae9caf0 --- /dev/null +++ b/scripts/verify.js @@ -0,0 +1,20 @@ +import { Failure, packageVersion, parseFlags } from './lib.js'; +import { verificationPrompt, verificationTargets } from './install.js'; +import { 診斷, verifyInstall } from './install-verify.js'; + +/** + * 不寫檔的完整安裝/runtime 驗證;install 寫完轉接檔後也走同一條鏈。 + * @param {string[]} argv + * @returns {object|Failure} + */ +export function runVerify(argv) { + const flags = parseFlags(argv, { optional: ['platform'] }); + const verify = verifyInstall({ + version: packageVersion(), + prompt: verificationPrompt(), + platforms: verificationTargets(flags.platform), + }); + return verify.ok ? verify : new Failure('RUNTIME_VERIFY_FAILED', 診斷(verify), verify); +} + + diff --git a/scripts/version.js b/scripts/version.js new file mode 100644 index 0000000..9854210 --- /dev/null +++ b/scripts/version.js @@ -0,0 +1,23 @@ +import { readdirSync } from 'node:fs'; +import { ScriptError, onPath, packageVersion, promptsDir } from './lib.js'; + +/** + * 回報這一份 tea-sdlc 的版本、實際 executable 與可用流程;不連網、不改檔。 + * @param {string[]} argv + * @returns {object} + */ +export function runVersion(argv) { + if (argv.length > 0) { + throw new ScriptError('UNKNOWN_FLAG', 'sdlc-version 不接受參數'); + } + + return { + name: 'tea-sdlc', + version: packageVersion(), + executable: onPath('tea-sdlc'), + commands: readdirSync(promptsDir()) + .filter((entry) => entry.endsWith('.md')) + .map((entry) => entry.slice(0, -3)) + .sort(), + }; +} diff --git a/test/bin-entry.test.js b/test/bin-entry.test.js index 9dccd64..54fb8fd 100644 --- a/test/bin-entry.test.js +++ b/test/bin-entry.test.js @@ -9,8 +9,7 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { repoRoot, manifest, runBin } from './helpers/run-script.js'; -/** 入口必須認得的四個名字,缺一個就有轉接檔或文件會指向不存在的子指令 */ -const SUBCOMMANDS = ['install', 'uninstall', 'prompt', 'status']; +const SUBCOMMANDS = ['install', 'uninstall', 'prompt', 'status', 'sdlc-version', 'verify']; // ── 子指令解析 ───────────────────────────────────────────────────── diff --git a/test/install-verify.test.js b/test/install-verify.test.js index 0913d28..d9dd70d 100644 --- a/test/install-verify.test.js +++ b/test/install-verify.test.js @@ -82,9 +82,16 @@ test('轉接檔寫完就驗一次真實的叫用鏈,逐平台回報 pass', asy assert.equal(code, 0); assert.equal(json.ok, true); assert.equal(json.data.verify.ok, true); - // 逐平台 pass/fail,而不是只有一個總結 - assert.deepEqual(byName(json.data.verify).claude, { name: 'claude', ok: true, checked: 2, failures: [] }); - assert.deepEqual(byName(json.data.verify).kiro, { name: 'kiro', ok: true, checked: 2, failures: [] }); + const claude = byName(json.data.verify).claude; + assert.equal(claude.name, 'claude'); + assert.equal(claude.ok, true); + assert.equal(claude.status, 'pass'); + assert.deepEqual(claude.runtime.commands, ['sdlc-feat', 'sdlc-plan']); + const kiro = byName(json.data.verify).kiro; + assert.equal(kiro.name, 'kiro'); + assert.equal(kiro.ok, true); + assert.equal(kiro.status, 'not-supported'); + assert.deepEqual(kiro.failures, []); }); test('驗證是真的把 PATH 上的 tea-sdlc 找出來執行,不是查有沒有這個檔', async (t) => { diff --git a/test/install.test.js b/test/install.test.js index 7c28325..9d9079f 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -170,7 +170,7 @@ test('支援 command 的四個平台產生 command 轉接檔,另外三個產 assert.deepEqual(filesUnder(join(home, '.claude')), ['commands/sdlc-plan.md']); assert.deepEqual(filesUnder(join(home, '.codex')), ['prompts/sdlc-plan.md']); assert.deepEqual(filesUnder(join(home, '.config', 'opencode')), ['command/sdlc-plan.md']); - assert.deepEqual(filesUnder(join(home, '.omp')), ['commands/sdlc-plan.md']); + assert.deepEqual(filesUnder(join(home, '.omp')), ['agent/commands/sdlc-plan.md']); assert.deepEqual(filesUnder(join(home, '.gemini')), ['skills/sdlc-plan/SKILL.md']); assert.deepEqual(filesUnder(join(home, '.kiro')), ['skills/sdlc-plan/SKILL.md']); assert.deepEqual(filesUnder(join(home, 'work', '.github')), ['skills/sdlc-plan/SKILL.md']); @@ -213,7 +213,7 @@ test('支援關閉自動觸發的 command 平台設上旗標;三個 skill 平 join(home, '.claude', 'commands', 'sdlc-plan.md'), join(home, '.codex', 'prompts', 'sdlc-plan.md'), join(home, '.config', 'opencode', 'command', 'sdlc-plan.md'), - join(home, '.omp', 'commands', 'sdlc-plan.md'), + join(home, '.omp', 'agent', 'commands', 'sdlc-plan.md'), ]; const skills = [ join(home, '.gemini', 'skills', 'sdlc-plan', 'SKILL.md'), diff --git a/test/runtime-verify.test.js b/test/runtime-verify.test.js new file mode 100644 index 0000000..1ad7652 --- /dev/null +++ b/test/runtime-verify.test.js @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js'; +import { tmpRoot } from './helpers/run-script.js'; + +const PROMPTS = { + 'sdlc-analyze': fakePrompt('sdlc-analyze'), + 'sdlc-plan': fakePrompt('sdlc-plan'), +}; + +function homeWith(t, platform) { + mkdirSync(tmpRoot, { recursive: true }); + const home = join(tmpRoot, `runtime-home-${platform}`); + rmSync(home, { recursive: true, force: true }); + mkdirSync(join(home, 'work'), { recursive: true }); + const roots = { + 'oh-my-pi': ['.omp'], + claude: ['.claude'], + codex: ['.codex'], + kiro: ['.kiro'], + }; + mkdirSync(join(home, ...roots[platform]), { recursive: true }); + t.after(() => rmSync(home, { recursive: true, force: true })); + return home; +} + +const runInHome = (plugin, home) => (args) => + plugin.run(args, { env: { HOME: home }, cwd: join(home, 'work') }); + +test('sdlc-version 回報版本、實際 tea-sdlc executable 與六個流程', async (t) => { + const plugin = makeFakePlugin(t, { prompts: PROMPTS }); + const { json } = await plugin.run(['sdlc-version']); + + assert.equal(json.ok, true); + assert.equal(json.data.name, 'tea-sdlc'); + assert.equal(json.data.version, '0.0.1'); + assert.equal(json.data.executable, join(plugin.shim, 'tea-sdlc')); + assert.deepEqual(json.data.commands, ['sdlc-analyze', 'sdlc-plan']); +}); + +test('OMP 使用 ~/.omp/agent/commands 作為 runtime registry,六個 command 全部找到才 pass', async (t) => { + const plugin = makeFakePlugin(t, { prompts: PROMPTS }); + const home = homeWith(t, 'oh-my-pi'); + const run = runInHome(plugin, home); + const installed = await run(['install']); + + assert.equal(installed.code, 0); + assert.ok(existsSync(join(home, '.omp', 'agent', 'commands', 'sdlc-analyze.md'))); + assert.equal(installed.json.data.verify.platforms[0].runtime.status, 'pass'); + assert.deepEqual(installed.json.data.verify.platforms[0].runtime.commands, ['sdlc-analyze', 'sdlc-plan']); +}); + +test('runtime registry 缺少 command 時 verify exit 1 並列出 missing', async (t) => { + const plugin = makeFakePlugin(t, { prompts: PROMPTS }); + const home = homeWith(t, 'claude'); + const run = runInHome(plugin, home); + await run(['install']); + rmSync(join(home, '.claude', 'commands', 'sdlc-plan.md')); + + const result = await run(['verify', '--platform', 'claude']); + assert.equal(result.code, 1); + assert.equal(result.json.error.code, 'RUNTIME_VERIFY_FAILED'); + assert.deepEqual(result.json.data.platforms[0].runtime.missing, ['sdlc-plan']); + assert.equal(result.json.data.platforms[0].runtime.status, 'fail'); +}); + +test('沒有安全 registry probe 的平台回報 not-supported 而不阻止 install', async (t) => { + const plugin = makeFakePlugin(t, { prompts: PROMPTS }); + const home = homeWith(t, 'kiro'); + const result = await runInHome(plugin, home)(['install']); + + assert.equal(result.code, 0); + assert.equal(result.json.data.verify.platforms[0].runtime.status, 'not-supported'); + assert.deepEqual(result.json.data.verify.platforms[0].runtime.failures, []); +});