單一入口 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>
439 lines
17 KiB
JavaScript
439 lines
17 KiB
JavaScript
/**
|
||
* 轉接檔的產生與移除。全專案唯一知道各平台目錄結構的地方。
|
||
*
|
||
* 轉接檔裡沒有路徑,只有一句「執行 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();
|
||
}
|