feat(佈署): 以 npm 裝出 tea-sdlc 指令並產生各平台轉接檔

單一入口 bin/tea-sdlc.js 認四個子指令。第一個位置參數是子指令,其餘 argv 原樣
交出去——既有的 flag 解析拒絕位置參數,所以子指令必須在那之前就被取走。

轉接檔裡沒有路徑,只有一句 tea-sdlc prompt --name <指令名>。正本在哪由 PATH 上
的 tea-sdlc 自己回推:fnm 把 Node 版號寫進全域安裝路徑,寫死路徑的話升一次
Node,七個平台的轉接檔會同時指向不存在的檔案,而且不會有任何錯誤訊息。

prompt 是全專案唯一輸出非 JSON 的路徑,理由只有一個:它的輸出要餵給模型讀。
失敗仍走 envelope——成功是內容,失敗才需要結構。

status 的 ok 不兼差表達環境好壞,健康與否放在 data.healthy:呼叫端要分得出
「status 掛了」與「status 成功查到你環境有問題」。

install 只寫進偵測得到的平台;缺 git/tea 只警告不中止,因為那兩個完全不影響
轉接檔產生,硬擋等於逼使用者為了裝 plugin 先去裝 tea。uninstall 只刪帶產生標記
的檔案,使用者自己寫的同名檔案一律留著並在輸出裡交代。裝哪些指令以 prompts/ 裡
實際存在的正本為準,不是寫死的六個名字——裝出指向不存在正本的轉接檔,使用者只會
看到 PROMPT_NOT_FOUND。

流程正本的 description 前綴在抄進轉接檔之前就檢查:有三個平台關不掉自動觸發,
全靠那句話把 description 窄到不會被誤判,不能等使用者發現誤觸才知道漏了。

議題 #26 #27 #28 #29 #17

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 06:59:37 +00:00
co-authored by Claude Opus 5
parent f736d11049
commit 17178f0c4d
16 changed files with 1746 additions and 39 deletions
+438
View File
@@ -0,0 +1,438 @@
/**
* 轉接檔的產生與移除。全專案唯一知道各平台目錄結構的地方。
*
* 轉接檔裡沒有路徑,只有一句「執行 tea-sdlc prompt --name <指令名>」。正本在哪由
* PATH 上的 tea-sdlc 自己回推,所以升級 Node、換版本管理器、改 npm prefix 都不會讓
* 七個平台的轉接檔同時指向不存在的檔案。
*
* 用法(由 bin/tea-sdlc.js 轉入):
* tea-sdlc install [--platform a,b] [--dry-run]
* tea-sdlc uninstall [--platform a,b] [--dry-run]
*/
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
readSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import {
ScriptError,
checkPluginLayout,
missingBinaries,
packageVersion,
parseFlags,
promptsDir,
} from './lib.js';
/**
* 七個平台。`detect` 是「這台機器裝了它沒有」的判準,`target` 是轉接檔的落點,
* 兩者都相對於 `base`——`home` 是家目錄,`cwd` 是目前的專案(Copilot 讀的是 repo 內的
* .github/,不是家目錄)。
*
* `kind` 決定轉接檔長什麼樣:`command` 的四個平台吃 commands/<名>.md,
* `skill` 的三個平台吃 skills/<名>/SKILL.md。
*
* 已接受的取捨:Antigravity、Copilot、Kiro 無法關閉自動觸發,只能靠窄化 description
* 降低誤觸;四個 command 平台則實際設上旗標。
*/
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: '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' },
];
/**
* 產生標記。它同時是三件事的依據:
* 1. 這個檔是不是我們產生的——移除時只刪帶標記的,使用者自己寫的同名檔一律留著
* 2. 是哪一版產生的——status 靠它判斷過時
* 3. 給讀到檔案的人一句「別手改這裡」
*/
const MARKER = 'tea-sdlc-adapter';
const MARKER_RE = new RegExp(`<!-- ${MARKER} v([^\\s:]+)`);
/**
* 兩種轉接檔形式的差別全部收在這裡:檔案擺哪、怎麼把已經擺好的找回來、
* frontmatter 要不要多一行、刪完要不要收殼。散在四個 if 裡的話,日後多一種形式
* 就得在四個地方各改一次,而漏掉哪一個不會有人發現。
*/
const KINDS = {
/** 一個指令一個檔:<目錄>/<指令名>.md */
command: {
path: (dir, name) => join(dir, `${name}.md`),
list: (dir) => readdirSync(dir).map((entry) => join(dir, entry)),
// 支援關閉自動觸發的平台就關掉
frontmatter: ['disable-model-invocation: true'],
cleanup: () => {},
},
/** 一個指令一層目錄:<目錄>/<指令名>/SKILL.md */
skill: {
path: (dir, name) => join(dir, name, 'SKILL.md'),
list: (dir) => readdirSync(dir).map((entry) => join(dir, entry, 'SKILL.md')),
// 這三個平台關不掉自動觸發,只能靠 description 已經窄到不會被誤判
frontmatter: [],
cleanup: (path) => rmEmptyDir(dirname(path)),
},
};
// ── 產生 ───────────────────────────────────────────────────────────
/**
* @param {string[]} argv bin 取走子指令之後剩下的參數
* @returns {object} 放進 data 的內容
*/
export function runInstall(argv) {
const flags = parseFlags(argv, { optional: ['platform'], booleans: ['dry-run'] });
// 只警告不中止:缺 tea 完全不影響轉接檔產生,硬擋等於逼使用者為了裝 plugin 先去裝 tea。
// 但完全不查也不行——安裝是一次性動作,使用者裝完就走,沒被提醒他會以為一切就緒。
const { missing, hint } = missingBinaries(['node', 'git', 'tea']);
const prompts = readPrompts();
const version = packageVersion();
const chosen = choose(flags.platform);
const platforms = chosen.map((platform) => {
const files = prompts.map((prompt) => ({
path: adapterPath(platform, prompt.name),
text: adapterText(platform, prompt, version),
}));
if (!flags['dry-run']) {
for (const file of files) {
mkdirSync(dirname(file.path), { recursive: true });
writeFileSync(file.path, file.text);
}
}
return { name: platform.name, kind: platform.kind, adapters: files.map((file) => file.path) };
});
return {
dryRun: flags['dry-run'] === true,
version,
commands: prompts.map((prompt) => prompt.name),
platforms,
missingBinaries: missing,
warning: hint === '' ? null : hint,
};
}
// ── 移除 ───────────────────────────────────────────────────────────
/**
* 只移除轉接檔,正本一動不動——正本在套件裡,這支腳本連碰都不碰它。
* 平台自己的 commands/、skills/ 目錄也留著:那不是我們建的東西。
*/
export function runUninstall(argv) {
const flags = parseFlags(argv, { optional: ['platform'], booleans: ['dry-run'] });
const requested = flags.platform === undefined ? null : parsePlatformFlag(flags.platform);
const removed = [];
const kept = [];
// 與 install 不同,這裡不要求平台偵測得到:平台被移掉之後留下的轉接檔更需要清,
// 而掃一個不存在的目錄本來就沒有東西可刪,不需要為此報錯。
for (const platform of PLATFORMS) {
if (requested && !requested.includes(platform.name)) continue;
for (const path of adaptersUnder(platform)) {
// 逐版累積的舊轉接檔也要清掉,所以掃的是目錄而不是這一版的指令清單
if (adapterVersion(path) === null) {
kept.push({ path, reason: `不是 tea-sdlc 產生的(沒有 ${MARKER} 標記)` });
continue;
}
removed.push(path);
if (flags['dry-run']) continue;
rmSync(path);
KINDS[platform.kind].cleanup(path);
}
}
return { dryRun: flags['dry-run'] === true, removed, kept };
}
// ── 現況 ───────────────────────────────────────────────────────────
/**
* 各平台的轉接檔現況,供 status 回報。平台目錄結構的知識只有這一份,
* status 不複製一份自己的。
* @returns {{name: string, kind: string, detected: boolean, adapters: number, versions: string[], stale: boolean}[]}
*/
export function platformReport() {
const current = packageVersion();
const expected = countPrompts();
return PLATFORMS.map((platform) => {
const versions = adaptersUnder(platform).map(adapterVersion).filter((v) => v !== null);
return {
name: platform.name,
kind: platform.kind,
dir: pathOf(platform, platform.target),
detected: existsSync(pathOf(platform, platform.detect)),
adapters: {
// 應該裝幾份 vs 實際裝了幾份:少了就是有指令沒佈署到
expected,
present: versions.length,
stale: versions.some((version) => version !== current),
},
};
});
}
// ── 平台挑選 ───────────────────────────────────────────────────────
/**
* 決定要動哪些平台。三條路:`--platform` 指名、TTY 上勾選、非互動時全裝。
* 指名了沒裝的平台要擋下來——那多半是打錯字,替它建目錄只會在機器上留下孤兒。
*/
function choose(flag) {
const detected = PLATFORMS.filter((platform) => existsSync(pathOf(platform, platform.detect)));
if (flag !== undefined) {
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 (detected.length === 0) {
throw new ScriptError(
'NO_PLATFORM_DETECTED',
`偵測不到任何 agent 平台(找過 ${PLATFORMS.map(detectLabel).join('、')});` +
'請先安裝其中至少一個,或用 --platform 指名',
);
}
// CI 與腳本一律不進互動提示:沒有 TTY 就照預設全裝
return process.stdin.isTTY ? select(detected) : detected;
}
/** `--platform a,b` 的值。認不得的名字要當場指出來,不要默默少裝一個。 */
function parsePlatformFlag(value) {
const names = String(value)
.split(',')
.map((name) => name.trim())
.filter((name) => name !== '');
const known = PLATFORMS.map((platform) => platform.name);
const unknown = names.filter((name) => !known.includes(name));
if (names.length === 0 || unknown.length > 0) {
throw new ScriptError(
'UNKNOWN_PLATFORM',
`不認得的平台 ${unknown.join('、') || '(空值)'};可用的是:${known.join('、')}`,
);
}
return names;
}
/**
* 在終端機上列出偵測到的平台讓人勾選,預設全勾。
* 問句走 stderr:stdout 是給呼叫端讀的單行 JSON,不能混進人看的字。
*/
function select(detected) {
process.stderr.write('要安裝到哪些平台?\n');
for (const line of checklist(detected)) process.stderr.write(`${line}\n`);
return selectPlatforms(detected, readLine('直接按 Enter 全裝,或輸入要裝的編號/名稱(逗號分隔):'));
}
/** 勾選清單的每一行。預設全勾,所以每一項都是 [x]。 */
export function checklist(detected) {
return detected.map((platform, i) => ` [x] ${i + 1}. ${platform.label}(${platform.name})`);
}
/**
* 把使用者打的那一行變成要安裝的平台。空字串是「全部」——預設全勾,直接按 Enter 就過。
* 與 I/O 分開是為了測得到:真的開一個 TTY 來測這段,測的會是 pty 而不是這個規則。
* @param {object[]} detected 剛才列出來的平台,順序即編號
* @param {string} answer 使用者打的那一行
*/
export function selectPlatforms(detected, answer) {
if (answer.trim() === '') return detected;
const picked = answer
.split(',')
.map((token) => token.trim())
.filter((token) => token !== '')
.map((token) => {
const byIndex = /^\d+$/.test(token) ? detected[Number(token) - 1] : undefined;
const platform = byIndex ?? detected.find((candidate) => candidate.name === token);
if (!platform) {
throw new ScriptError('UNKNOWN_PLATFORM', `勾選的 ${token} 不在剛才列出的平台裡`);
}
return platform;
});
return detected.filter((platform) => picked.includes(platform));
}
/**
* 從 stdin 讀一行。非同步讀在這裡沒有用武之地——問完就要等答案,後面什麼都不能做。
* EAGAIN 是 TTY 還沒有東西可讀,等一下再試,不要空轉燒 CPU。
*/
function readLine(question) {
process.stderr.write(question);
const buffer = Buffer.alloc(256);
let answer = '';
while (!answer.includes('\n')) {
let read;
try {
read = readSync(0, buffer, 0, buffer.length, null);
} catch (error) {
if (error.code !== 'EAGAIN') throw error;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
continue;
}
if (read === 0) break;
answer += buffer.toString('utf8', 0, read);
}
process.stderr.write('\n');
return answer.split('\n')[0].trim();
}
// ── 轉接檔 ─────────────────────────────────────────────────────────
/**
* 產生一份轉接檔。內容就是一句指向 tea-sdlc 的話:正本只有一份,改規則不會出現
* 各平台版本分歧,也不必為了改規則重跑安裝。
*/
function adapterText(platform, prompt, version) {
const front = [
'---',
`name: ${prompt.name}`,
`description: ${prompt.description}`,
...KINDS[platform.kind].frontmatter,
'---',
];
return [
...front,
'',
`<!-- ${MARKER} v${version}:由 tea-sdlc install 產生,請勿手動編輯。`,
' 改流程請改流程正本(不必重裝);指令數量變了才需要重跑 tea-sdlc install。 -->',
'',
`執行 \`tea-sdlc prompt --name ${prompt.name} --adapter-version ${version}\`,` +
'並完全遵照它印出的內容執行。',
'',
].join('\n');
}
/** 轉接檔的落點 */
function adapterPath(platform, name) {
return KINDS[platform.kind].path(pathOf(platform, platform.target), name);
}
/** 這個平台的目標目錄底下,所有長得像轉接檔的檔案(還沒判斷是不是我們產生的) */
function adaptersUnder(platform) {
const dir = pathOf(platform, platform.target);
if (!isDir(dir)) return [];
return KINDS[platform.kind].list(dir).filter(isFile).sort();
}
/**
* 這個檔是我們哪一版產生的;沒有標記就回 null。
* 「是不是我們的」與「是哪一版」是同一次讀檔的兩個答案,分成兩支函式會讓每份轉接檔被讀兩次。
* @returns {string|null}
*/
function adapterVersion(path) {
return readFileSync(path, 'utf8').match(MARKER_RE)?.[1] ?? null;
}
// ── 流程正本 ───────────────────────────────────────────────────────
/**
* 有哪些指令可以裝。以 prompts/ 裡實際存在的正本為準,不是寫死的六個名字——
* 裝出一個指向不存在正本的轉接檔,使用者只會看到 PROMPT_NOT_FOUND。
* @returns {{name: string, description: string}[]}
*/
function readPrompts() {
checkPluginLayout();
const prompts = readdirSync(promptsDir())
.filter((entry) => entry.endsWith('.md'))
.sort()
.map((entry) => {
const name = basename(entry, '.md');
const text = readFileSync(join(promptsDir(), entry), 'utf8');
const description = text.match(/^description:\s*(.+)$/m)?.[1]?.trim();
if (!description) {
throw new ScriptError(
'PROMPT_NO_DESCRIPTION',
`流程正本 ${entry} 沒有 description 那一行,轉接檔沒有東西可抄`,
);
}
// 前綴是這套指令「不自動觸發」的最後一道防線:三個平台關不掉自動觸發,
// 全靠這句把 description 窄到不會被誤判。抄過去之前就要擋,不能等使用者發現誤觸。
const prefix = `僅由 /${name} 指令叫用。`;
if (!description.startsWith(prefix)) {
throw new ScriptError(
'PROMPT_BAD_DESCRIPTION',
`流程正本 ${entry} 的 description 必須以「${prefix}」起頭,目前是:${description}`,
);
}
return { name, description };
});
if (prompts.length === 0) {
throw new ScriptError('NO_PROMPTS', `${promptsDir()} 裡沒有任何流程正本,沒有東西可以佈署`);
}
return prompts;
}
/**
* 應該裝幾份轉接檔。status 只要數量,不該為了數數就因為某份正本的 description
* 寫壞而整支失敗——回報現況的指令不該比被回報的東西更容易倒。
*/
function countPrompts() {
try {
return readdirSync(promptsDir()).filter((entry) => entry.endsWith('.md')).length;
} catch {
return 0;
}
}
// ── 路徑小工具 ─────────────────────────────────────────────────────
function pathOf(platform, segments) {
return join(platform.base === 'home' ? homedir() : process.cwd(), ...segments);
}
/** 偵測目錄的人類可讀寫法,例如 ~/.claude */
function detectLabel(platform) {
return `${platform.base === 'home' ? '~/' : './'}${platform.detect.join('/')}`;
}
function rmEmptyDir(dir) {
if (readdirSync(dir).length === 0) rmSync(dir, { recursive: true });
}
function isDir(path) {
return existsSync(path) && statSync(path).isDirectory();
}
function isFile(path) {
return existsSync(path) && statSync(path).isFile();
}