/** * 轉接檔的產生與移除。全專案唯一知道各平台目錄結構的地方。 * * 轉接檔裡沒有路徑,只有一句「執行 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 { Failure, ScriptError, checkPluginLayout, missingBinaries, packageVersion, parseFlags, promptsDir, } from './lib.js'; import { invocation, verifyInstall, 診斷 } from './install-verify.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', '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' }, ]; /** * 產生標記。它同時是三件事的依據: * 1. 這個檔是不是我們產生的——移除時只刪帶標記的,使用者自己寫的同名檔一律留著 * 2. 是哪一版產生的——status 靠它判斷過時 * 3. 給讀到檔案的人一句「別手改這裡」 */ const MARKER = 'tea-sdlc-adapter'; const MARKER_RE = new RegExp(`', '', `執行 \`${invocation(prompt.name, version).line}\`,並完全遵照它印出的內容執行。`, '', ].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。 * * 連 text 一起帶出來,是因為驗證要拿它跟「PATH 上的 tea-sdlc 取回來的那一份」逐字比對。 * 那邊讀的是同一個檔案、同樣的 utf8,所以兩邊本來就該一字不差。 * @returns {{name: string, description: string, text: 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, text }; }); 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(); }