@rch4com/agent-setup 1.28.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +31 -20
  2. package/install.mjs +25 -8
  3. package/lib/activation.mjs +31 -0
  4. package/lib/args.mjs +5 -2
  5. package/lib/bootstrap/manifest.mjs +21 -47
  6. package/lib/bootstrap/templates.mjs +7 -60
  7. package/lib/catalog.mjs +22 -241
  8. package/lib/clis.mjs +11 -118
  9. package/lib/commands.mjs +47 -0
  10. package/lib/engine.mjs +16 -11
  11. package/lib/i18n/catalog/en.mjs +58 -49
  12. package/lib/i18n/catalog/ko.mjs +58 -49
  13. package/lib/items/mcp.codebase-memory.mjs +6 -4
  14. package/lib/items/mcp.graft.mjs +6 -4
  15. package/lib/items/mcp.graphify.mjs +6 -4
  16. package/lib/items/mcp.headroom.mjs +6 -4
  17. package/lib/items/mcp.notion.mjs +7 -4
  18. package/lib/items/mcp.supabase.mjs +7 -4
  19. package/lib/items/mcp.vercel.mjs +7 -4
  20. package/lib/items/plugin.ai-memory.mjs +29 -0
  21. package/lib/items/plugin.cli-anything.mjs +18 -15
  22. package/lib/items/plugin.ecc.mjs +22 -20
  23. package/lib/items/plugin.hyperframes.mjs +17 -13
  24. package/lib/items/plugin.impeccable.mjs +19 -22
  25. package/lib/items/plugin.mattpocock-skills.mjs +23 -17
  26. package/lib/items/plugin.ponytail.mjs +24 -106
  27. package/lib/items/plugin.superpowers.mjs +20 -24
  28. package/lib/items/plugin.understand-anything.mjs +21 -19
  29. package/lib/items/skill.agent-browser.mjs +6 -6
  30. package/lib/items/skill.agent-handoff-manager.mjs +14 -0
  31. package/lib/items/skill.archify.mjs +5 -5
  32. package/lib/items/skill.caveman.mjs +5 -5
  33. package/lib/items/skill.diagram-design.mjs +5 -5
  34. package/lib/items/skill.eli5.mjs +6 -6
  35. package/lib/items/skill.find-skills.mjs +5 -5
  36. package/lib/items/skill.gsd.mjs +28 -89
  37. package/lib/items/skill.gstack.mjs +58 -65
  38. package/lib/items/skill.hallmark.mjs +5 -5
  39. package/lib/items/skill.html-plan.mjs +15 -0
  40. package/lib/items/skill.i-have-adhd.mjs +6 -6
  41. package/lib/items/skill.im-not-ai.mjs +5 -6
  42. package/lib/items/skill.impeccable.mjs +6 -6
  43. package/lib/items/skill.karpathy.mjs +5 -5
  44. package/lib/items/skill.mattpocock-skills.mjs +5 -6
  45. package/lib/items/skill.mcp-builder.mjs +5 -5
  46. package/lib/items/skill.officecli.mjs +5 -5
  47. package/lib/items/skill.prompt-master.mjs +5 -5
  48. package/lib/items/skill.remotion.mjs +5 -5
  49. package/lib/items/skill.strix.mjs +5 -6
  50. package/lib/items/skill.superpowers.mjs +6 -7
  51. package/lib/items/skill.taste.mjs +5 -5
  52. package/lib/items/skill.unslop.mjs +12 -0
  53. package/lib/probe.mjs +136 -0
  54. package/lib/recipe-helpers.mjs +281 -0
  55. package/lib/recipe.mjs +181 -0
  56. package/lib/skills-registry.mjs +93 -0
  57. package/lib/status.mjs +7 -3
  58. package/lib/tui/detail.mjs +5 -25
  59. package/lib/tui/rows.mjs +25 -14
  60. package/lib/tui/run.mjs +35 -8
  61. package/package.json +4 -3
  62. package/lib/claude-plugins.mjs +0 -64
  63. package/lib/global-plugin.mjs +0 -239
  64. package/lib/items/global.ponytail.mjs +0 -56
  65. package/lib/items/global.superpowers.mjs +0 -58
  66. package/lib/tomlfile.mjs +0 -67
package/lib/probe.mjs ADDED
@@ -0,0 +1,136 @@
1
+ // 감지 질의. 항목 detect는 여기 캐시만 조회한다 — CLI 목록 명령은 스캔
2
+ // 한 번에 질의마다 한 번만 돈다. 쓰기는 하지 않는다: 읽는 파일도 CLI가
3
+ // 소유한 설정이라, 고치는 일은 그 CLI의 명령이 맡는다.
4
+ import { existsSync, readdirSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { join, resolve } from 'node:path'
7
+ import { CLIS } from './clis.mjs'
8
+ import { readJson } from './jsonfile.mjs'
9
+ import { findSkillDir } from './skills-registry.mjs'
10
+
11
+ // 목록 명령 하나에 주는 시간. 설치 명령(10분)과 달리 목록은 금방 끝나야
12
+ // 정상이다 — 멈춘 CLI 하나가 화면 전체의 스캔을 붙잡지 않게 한다.
13
+ const PROBE_TIMEOUT_MS = 60 * 1000
14
+
15
+ // 구분자는 실행 중인 OS가 아니라 platform 인자를 따른다 — Windows의 PATH를
16
+ // 콜론으로 쪼개면 드라이브 문자(C:)에서 끊긴다.
17
+ export function onPath(bin, { env = process.env, platform = process.platform, exists = existsSync } = {}) {
18
+ const win = platform === 'win32'
19
+ const dirs = String(env.PATH ?? env.Path ?? '').split(win ? ';' : ':').filter(Boolean)
20
+ // Windows는 확장자 없는 파일을 실행하지 않는다 — PATHEXT 후보만 본다.
21
+ const exts = win ? String(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) : ['']
22
+ return dirs.some((dir) => exts.some((ext) => exists(join(dir, `${bin}${ext}`))))
23
+ }
24
+
25
+ // `agy mcp list`는 JSON을 주지 않는다. 머리줄(NAME …) 뒤 각 줄의 첫 열이 이름이다.
26
+ export function parseAgyMcpList(text) {
27
+ const lines = String(text).split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
28
+ const head = lines.findIndex((l) => /^NAME\s/.test(l))
29
+ return head === -1 ? [] : lines.slice(head + 1).map((l) => l.split(/\s+/)[0])
30
+ }
31
+
32
+ // OpenCode 설정 파일. 둘 다 없으면 OpenCode가 새로 만들 자리를 돌려준다.
33
+ export function opencodeConfigFile({ root, scope, home = homedir(), env = process.env }) {
34
+ const dir = scope === 'project' ? root : join(env.XDG_CONFIG_HOME || join(home, '.config'), 'opencode')
35
+ for (const name of ['opencode.jsonc', 'opencode.json']) {
36
+ if (existsSync(join(dir, name))) return join(dir, name)
37
+ }
38
+ return join(dir, scope === 'project' ? 'opencode.jsonc' : 'opencode.json')
39
+ }
40
+
41
+ export function readOpencodeConfig(opts) {
42
+ const file = opencodeConfigFile(opts)
43
+ return { file, data: readJson(file) ?? null }
44
+ }
45
+
46
+ // Copilot CLI의 사용자 MCP 설정. VS Code의 Global > Copilot도 같은 파일에 쓴다
47
+ // (code.visualstudio.com/docs/copilot/customization/mcp-servers) — 그래서 두 CLI의
48
+ // 전역 MCP는 한 저장소를 나눠 쓴다.
49
+ export function copilotMcpFile({ home = homedir(), env = process.env } = {}) {
50
+ return join(env.COPILOT_HOME || join(home, '.copilot'), 'mcp-config.json')
51
+ }
52
+
53
+ // VS Code 프로젝트 MCP. `.vscode/mcp.json`은 "호환용"으로 남았다는 표시가 붙었지만
54
+ // 플래그 없이 읽히는 유일한 프로젝트 경로다(`.mcp.json`은 실험 플래그가 필요하다).
55
+ export const VSCODE_MCP_FILE = '.vscode/mcp.json'
56
+
57
+ function samePath(a, b, platform) {
58
+ if (!a) return false
59
+ const norm = (p) => (platform === 'win32' ? resolve(p).toLowerCase() : resolve(p))
60
+ return norm(a) === norm(b)
61
+ }
62
+
63
+ export function createProbe({ root, scope, exec, env = process.env, home = homedir(), platform = process.platform }) {
64
+ const memo = new Map()
65
+ // 실패도 캐시한다 — 같은 스캔에서 항목마다 죽은 CLI를 다시 부르지 않는다.
66
+ const once = (key, fn) => {
67
+ if (!memo.has(key)) memo.set(key, Promise.resolve().then(fn))
68
+ return memo.get(key)
69
+ }
70
+ const run = (cli, args) => once(`${cli} ${args.join(' ')}`, async () => {
71
+ const r = await exec(CLIS[cli]?.bin ?? cli, args, { cwd: root, timeout: PROBE_TIMEOUT_MS })
72
+ if (!r.ok) throw new Error(String(r.output ?? '').trim() || `${cli} ${args.join(' ')} failed`)
73
+ return r.output
74
+ })
75
+ const json = async (cli, args) => JSON.parse(await run(cli, args))
76
+ const opencode = () => readOpencodeConfig({ root, scope, home, env }).data
77
+
78
+ return {
79
+ root, scope, home,
80
+ has: (cli) => onPath(CLIS[cli].bin, { env, platform }),
81
+ async claudePlugin(ids) {
82
+ const list = await json('claude', ['plugin', 'list', '--json'])
83
+ return list.some((p) => ids.includes(p.id) && p.scope === scope
84
+ && (scope !== 'project' || samePath(p.projectPath, root, platform)))
85
+ },
86
+ async codexPlugin(id) {
87
+ const data = await json('codex', ['plugin', 'list', '--json'])
88
+ return (data.installed ?? []).some((p) => p.pluginId === id && p.installed !== false)
89
+ },
90
+ async agyPlugin(name) {
91
+ return (await json('antigravity', ['plugin', 'list', '--json'])).some((p) => p.name === name)
92
+ },
93
+ // id는 `이름@마켓`이거나 마켓 없이 직접 설치한 `이름`이다. 내장 플러그인은
94
+ // 설치가 아니다 — 같은 이름이 와도 세지 않는다.
95
+ async copilotPlugin(id) {
96
+ const [name, market = ''] = id.split('@')
97
+ return (await json('copilot', ['plugin', 'list', '--json']))
98
+ .some((p) => p.name === name && (p.marketplace ?? '') === market && p.source === 'installed')
99
+ },
100
+ async opencodePlugin(module) {
101
+ const list = opencode()?.plugin
102
+ return Array.isArray(list) && list.includes(module)
103
+ },
104
+ async mcp(cli, name) {
105
+ if (cli === 'claude') {
106
+ // `claude mcp list`는 서버마다 연결을 검사해 느리고 네트워크를 탄다.
107
+ const file = scope === 'project' ? join(root, '.mcp.json') : join(home, '.claude.json')
108
+ return readJson(file)?.mcpServers?.[name] !== undefined
109
+ }
110
+ if (cli === 'codex') return (await json('codex', ['mcp', 'list', '--json'])).some((s) => s.name === name)
111
+ if (cli === 'antigravity') return parseAgyMcpList(await run('antigravity', ['mcp', 'list'])).includes(name)
112
+ if (cli === 'opencode') return opencode()?.mcp?.[name] !== undefined
113
+ // Copilot CLI는 프로젝트 MCP를 쓰는 명령이 없다 — 사용자 설정만 본다.
114
+ if (cli === 'copilot') return scope === 'user' && readJson(copilotMcpFile({ home, env }))?.mcpServers?.[name] !== undefined
115
+ if (cli === 'vscode') {
116
+ return scope === 'project'
117
+ ? readJson(join(root, VSCODE_MCP_FILE))?.servers?.[name] !== undefined
118
+ : readJson(copilotMcpFile({ home, env }))?.mcpServers?.[name] !== undefined
119
+ }
120
+ return false
121
+ },
122
+ async skill(name) {
123
+ if (scope === 'project') return findSkillDir(root, name) !== null
124
+ const list = JSON.parse(await run('npx', ['-y', 'skills@latest', 'list', '-g', '--json']))
125
+ return list.some((s) => s.name === name)
126
+ },
127
+ exists: (rel) => existsSync(join(root, rel)),
128
+ dirHasPrefix(rel, prefix) {
129
+ try {
130
+ return readdirSync(join(root, rel)).some((f) => f.startsWith(prefix))
131
+ } catch {
132
+ return false
133
+ }
134
+ },
135
+ }
136
+ }
@@ -0,0 +1,281 @@
1
+ // 항목 파일이 거의 다 쓰는 레시피 세 갈래. 명령 문법은 2026-10-24 이 머신의
2
+ // --help 실측(claude 2.1.304, codex-cli 0.163.0, agy)과 opencode.ai/docs/cli를
3
+ // 따른다.
4
+ import { existsSync, rmSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { repoPathStrict } from './context.mjs'
7
+ import { ensureGitignoreEntries } from './gitignore.mjs'
8
+ import { LocalizedError } from './i18n/index.mjs'
9
+ import { readJson, removeKey, setKey } from './jsonfile.mjs'
10
+ import { VSCODE_MCP_FILE, copilotMcpFile, readOpencodeConfig } from './probe.mjs'
11
+ import { SHARED } from './recipe.mjs'
12
+ import { SHARED_SKILLS, findSkillDir, isSafeSkillName, lockedSkills, pruneLock, sourceKey } from './skills-registry.mjs'
13
+
14
+ const SKILLS = ['npx', '-y', 'skills@latest']
15
+
16
+ // 전역 설치 에이전트. skills@1.7.4 docs/agents.md의 전역 경로 기준:
17
+ // claude-code → ~/.claude/skills, codex → ~/.agents/skills,
18
+ // antigravity-cli → ~/.gemini/antigravity-cli/skills(agy가 읽는 전역 경로,
19
+ // antigravity.google/docs/skills). OpenCode는 ~/.claude/skills와
20
+ // ~/.agents/skills를 둘 다 읽으므로(opencode.ai/docs/skills) 따로 넣지 않는다 —
21
+ // 넣으면 같은 스킬을 세 번 본다. Copilot CLI(`copilot skill --help`, 1.0.105)와
22
+ // VS Code Copilot(code.visualstudio.com/docs/copilot/customization/agent-skills,
23
+ // 2026-11-06)도 ~/.agents/skills를 읽으므로 codex가 깐 것을 그대로 본다.
24
+ export const SKILLS_AGENTS = ['claude-code', 'codex', 'antigravity-cli']
25
+
26
+ const OPENCODE_FILE = {
27
+ project: 'the project config ./opencode.jsonc (or ./opencode.json if only that one exists)',
28
+ user: 'the global config ~/.config/opencode/opencode.json (or opencode.jsonc there if only that one exists)',
29
+ }
30
+
31
+ // `opencode mcp add`는 대화형뿐이라 OpenCode 에이전트에게 편집을 맡긴다.
32
+ // 지시문에는 큰따옴표를 남기지 않는다 — Windows에서 명령 한 줄은 cmd.exe를
33
+ // 거치고, shellQuote는 그 안의 큰따옴표를 안전하게 이스케이프할 수 없어 거부한다.
34
+ export function opencodePrompt(scope, task) {
35
+ return `Edit ${OPENCODE_FILE[scope]}. ${task} Keep comments and every other key unchanged. Do not run any other command. Reply only DONE.`
36
+ .replace(/"/g, "'")
37
+ }
38
+
39
+ // JSON을 작은따옴표로 적고 "파일에는 표준 JSON으로"라고 덧붙인다.
40
+ const quotedJson = (value) => `${JSON.stringify(value)} (written with single quotes here; use standard JSON double quotes in the file)`
41
+
42
+ // LLM 실행이라 결과가 비결정적이다 — 파일을 다시 읽는 이 단계가 유일한 성공 근거다.
43
+ function verifyOpencode(scope, check, describe) {
44
+ return {
45
+ describe,
46
+ fn: ({ root }) => {
47
+ const { file, data } = readOpencodeConfig({ root, scope, home: homedir() })
48
+ if (!check(data)) throw new LocalizedError('error.opencodeVerify', { file })
49
+ },
50
+ }
51
+ }
52
+
53
+ const fileName = (scope) => (scope === 'project' ? './opencode.jsonc' : '~/.config/opencode/opencode.json')
54
+
55
+ // `codex mcp add --url`은 OAuth를 쓰는 서버면 설정을 쓴 뒤 로그인 콜백을 기다리다
56
+ // 약 5분 뒤 실패로 끝난다(2026-10-24 supabase 실측, codex-cli 0.163.0). 등록은 이미
57
+ // 끝났으므로 짧게 끊고, 목록 명령으로 등록됐는지 확인한다. 로그인은 사용자가
58
+ // `codex mcp login <이름>`으로 따로 한다(postInstall 안내).
59
+ const CODEX_LOGIN_WAIT_MS = 20 * 1000
60
+
61
+ function verifyCodexMcp(name) {
62
+ return {
63
+ describe: `check codex mcp list has ${name}`,
64
+ fn: async ({ root, exec }) => {
65
+ const r = await exec('codex', ['mcp', 'list', '--json'], { cwd: root })
66
+ let names = []
67
+ try { names = JSON.parse(r.output).map((s) => s.name) } catch { /* 목록을 못 읽으면 아래에서 실패로 보고한다 */ }
68
+ if (!r.ok || !names.includes(name)) throw new LocalizedError('error.codexMcpVerify', { name })
69
+ },
70
+ }
71
+ }
72
+
73
+ // VS Code Copilot에는 MCP 설치 명령이 없다. `code --add-mcp`는 JSON을 인자로 받는데
74
+ // 그 큰따옴표를 Windows 인용(shellQuote)이 거부하고, 제거·목록 명령도 없다.
75
+ // 그래서 파일을 고친다 — jsonfile.mjs는 사용자 주석과 들여쓰기를 지킨다.
76
+ // 프로젝트: .vscode/mcp.json의 servers (플래그 없이 읽히는 유일한 프로젝트 경로)
77
+ // 전역: ~/.copilot/mcp-config.json의 mcpServers — VS Code의 Global > Copilot이
78
+ // 쓰는 파일이고 `copilot mcp add`도 같은 파일에 쓴다.
79
+ function vscodeMcp(name, server) {
80
+ const http = server.kind === 'http'
81
+ const project = http ? { type: 'http', url: server.url } : { type: 'stdio', command: server.command, args: server.args }
82
+ // `copilot mcp add`가 쓰는 모양 그대로(2026-11-06 실측) — 어느 쪽이 먼저 써도 같은 파일이다.
83
+ const user = http ? { tools: ['*'], type: 'http', url: server.url } : { tools: ['*'], type: 'local', command: server.command, args: server.args }
84
+ return {
85
+ project: {
86
+ install: [{
87
+ describe: `add servers.${name} to ${VSCODE_MCP_FILE}`,
88
+ fn: ({ root }) => {
89
+ setKey(repoPathStrict(root, VSCODE_MCP_FILE), ['servers', name], project)
90
+ // `.vscode/*`를 무시하는 저장소(VisualStudio.gitignore)에서는 이 부정 항목이 없으면
91
+ // mcp.json이 조용히 커밋에서 빠진다. 무시 파일이 없으면 지킬 것이 없다.
92
+ if (existsSync(repoPathStrict(root, '.gitignore'))) ensureGitignoreEntries(root, [`!${VSCODE_MCP_FILE}`])
93
+ },
94
+ }],
95
+ uninstall: [{
96
+ describe: `remove servers.${name} from ${VSCODE_MCP_FILE}`,
97
+ fn: ({ root }) => removeKey(repoPathStrict(root, VSCODE_MCP_FILE), ['servers', name]),
98
+ }],
99
+ },
100
+ user: {
101
+ install: [{
102
+ describe: `add mcpServers.${name} to ~/.copilot/mcp-config.json (shared with Copilot CLI)`,
103
+ fn: () => {
104
+ const file = copilotMcpFile({ home: homedir() })
105
+ // Copilot CLI 단계가 같은 실행에서 먼저 썼을 수 있다 — 있으면 덮지 않는다.
106
+ if (readJson(file)?.mcpServers?.[name] !== undefined) return
107
+ setKey(file, ['mcpServers', name], user)
108
+ },
109
+ }],
110
+ uninstall: [{
111
+ describe: `remove mcpServers.${name} from ~/.copilot/mcp-config.json`,
112
+ fn: () => removeKey(copilotMcpFile({ home: homedir() }), ['mcpServers', name]),
113
+ }],
114
+ },
115
+ }
116
+ }
117
+
118
+ // copilot: `{ marketplace?, id, source? }`. marketplace가 있으면 먼저 등록하고(이미 있으면
119
+ // 참는다), source가 있으면 그것을 설치 인자로 쓴다(마켓 없이 `owner/repo:경로`로 직접
120
+ // 설치). id는 설치 인자이자 제거·감지에 쓰는 `이름@마켓`/`이름`이다.
121
+ export function marketplacePlugin({ claude, codex, opencode, antigravity, copilot }) {
122
+ const recipes = {}
123
+ const detect = {}
124
+ if (claude) {
125
+ const at = (scope) => ({
126
+ install: [
127
+ ...(claude.marketplace ? [{ argv: ['claude', 'plugin', 'marketplace', 'add', claude.marketplace, '--scope', scope], tolerateFail: true }] : []),
128
+ ['claude', 'plugin', 'install', claude.id, '--scope', scope],
129
+ ],
130
+ uninstall: [['claude', 'plugin', 'uninstall', claude.id, '--scope', scope]],
131
+ })
132
+ recipes.claude = { project: at('project'), user: at('user') }
133
+ detect.claude = (p) => p.claudePlugin(claude.detectIds ?? [claude.id])
134
+ }
135
+ if (codex) {
136
+ // Codex는 범위 플래그가 없다 — 활성 CODEX_HOME 하나에 깔린다(전역).
137
+ recipes.codex = {
138
+ user: {
139
+ install: [{ argv: ['codex', 'plugin', 'marketplace', 'add', codex.marketplace], tolerateFail: true }, ['codex', 'plugin', 'add', codex.id]],
140
+ uninstall: [['codex', 'plugin', 'remove', codex.id]],
141
+ },
142
+ }
143
+ detect.codex = (p) => p.codexPlugin(codex.id)
144
+ }
145
+ if (opencode) {
146
+ // 설치 명령은 있지만 제거 명령은 문서에 없다 — 제거는 run 지시문 + 검증이다.
147
+ const at = (scope) => ({
148
+ install: [['opencode', 'plugin', opencode.module, ...(scope === 'user' ? ['-g'] : [])]],
149
+ uninstall: [
150
+ ['opencode', 'run', opencodePrompt(scope, `Remove the string '${opencode.module}' from the top-level 'plugin' array; delete the array if it becomes empty.`)],
151
+ verifyOpencode(scope, (d) => !(Array.isArray(d?.plugin) && d.plugin.includes(opencode.module)), `check ${fileName(scope)} no longer lists plugin ${opencode.module}`),
152
+ ],
153
+ })
154
+ recipes.opencode = { project: at('project'), user: at('user') }
155
+ detect.opencode = (p) => p.opencodePlugin(opencode.module)
156
+ }
157
+ if (antigravity) {
158
+ // agy plugin install에도 범위 플래그가 없다 — ~/.gemini 아래 전역에 깔린다.
159
+ recipes.antigravity = {
160
+ user: {
161
+ install: [['agy', 'plugin', 'install', antigravity.source]],
162
+ uninstall: [['agy', 'plugin', 'uninstall', antigravity.name]],
163
+ },
164
+ }
165
+ detect.antigravity = (p) => p.agyPlugin(antigravity.name)
166
+ }
167
+ if (copilot) {
168
+ // `copilot plugin install`에도 범위 플래그가 없다 — ~/.copilot 아래 전역에 깔린다.
169
+ // 다른 copilot 세션이 떠 있으면 install·uninstall이 "os error 5"로 실패할 수 있다
170
+ // (2026-08-15 실측) — 세션을 닫고 다시 하면 된다.
171
+ recipes.copilot = {
172
+ user: {
173
+ install: [
174
+ ...(copilot.marketplace ? [{ argv: ['copilot', 'plugin', 'marketplace', 'add', copilot.marketplace], tolerateFail: true }] : []),
175
+ ['copilot', 'plugin', 'install', copilot.source ?? copilot.id],
176
+ ],
177
+ uninstall: [['copilot', 'plugin', 'uninstall', copilot.id]],
178
+ },
179
+ }
180
+ detect.copilot = (p) => p.copilotPlugin(copilot.id)
181
+ }
182
+ return { recipes, detect }
183
+ }
184
+
185
+ export function mcpServer({ name, server, clis = ['claude', 'codex', 'opencode', 'antigravity', 'copilot', 'vscode'] }) {
186
+ const http = server.kind === 'http'
187
+ // stdio 명령 앞의 `--`: 인자가 `-y`처럼 대시로 시작해도 CLI가 자기 플래그로 읽지 않는다.
188
+ const tail = http ? [] : ['--', server.command, ...server.args]
189
+ const entry = http
190
+ ? { type: 'remote', url: server.url, enabled: true }
191
+ : { type: 'local', command: [server.command, ...server.args], enabled: true }
192
+ const opencodeAt = (scope) => ({
193
+ install: [
194
+ ['opencode', 'run', opencodePrompt(scope, `Under the top-level 'mcp' object (create it, and the file with '$schema': 'https://opencode.ai/config.json', if missing) set the key '${name}' to this value: ${quotedJson(entry)}.`)],
195
+ verifyOpencode(scope, (d) => d?.mcp?.[name] !== undefined, `check ${fileName(scope)} has mcp.${name}`),
196
+ ],
197
+ uninstall: [
198
+ ['opencode', 'run', opencodePrompt(scope, `Delete the key '${name}' from the top-level 'mcp' object.`)],
199
+ verifyOpencode(scope, (d) => d?.mcp?.[name] === undefined, `check ${fileName(scope)} no longer has mcp.${name}`),
200
+ ],
201
+ })
202
+ const claudeAt = (scope) => ({
203
+ install: [http ? ['claude', 'mcp', 'add', '-s', scope, '-t', 'http', name, server.url] : ['claude', 'mcp', 'add', '-s', scope, name, ...tail]],
204
+ uninstall: [['claude', 'mcp', 'remove', '-s', scope, name]],
205
+ })
206
+ const all = {
207
+ claude: { project: claudeAt('project'), user: claudeAt('user') },
208
+ // Codex·Antigravity의 mcp add에는 범위 플래그가 없다 — 사용자 전역 설정에 쓴다.
209
+ codex: {
210
+ user: {
211
+ install: http
212
+ ? [{ argv: ['codex', 'mcp', 'add', name, '--url', server.url], tolerateFail: true, timeout: CODEX_LOGIN_WAIT_MS }, verifyCodexMcp(name)]
213
+ : [['codex', 'mcp', 'add', name, ...tail]],
214
+ uninstall: [['codex', 'mcp', 'remove', name]],
215
+ },
216
+ },
217
+ opencode: { project: opencodeAt('project'), user: opencodeAt('user') },
218
+ antigravity: { user: { install: [http ? ['agy', 'mcp', 'add', name, server.url] : ['agy', 'mcp', 'add', name, ...tail]], uninstall: [['agy', 'mcp', 'remove', name]] } },
219
+ // `copilot mcp add`는 사용자 설정(~/.copilot/mcp-config.json)에만 쓴다(2026-11-06 실측).
220
+ // 프로젝트 `.mcp.json`·`.github/mcp.json`은 읽기만 하고 쓰는 명령이 없다.
221
+ copilot: {
222
+ user: {
223
+ install: [http ? ['copilot', 'mcp', 'add', '--transport', 'http', name, server.url] : ['copilot', 'mcp', 'add', name, ...tail]],
224
+ uninstall: [['copilot', 'mcp', 'remove', name]],
225
+ },
226
+ },
227
+ vscode: vscodeMcp(name, server),
228
+ }
229
+ const recipes = Object.fromEntries(clis.map((c) => [c, all[c]]))
230
+ const detect = Object.fromEntries(clis.map((c) => [c, (p) => p.mcp(c, name)]))
231
+ return { recipes, detect }
232
+ }
233
+
234
+ // 레지스트리의 remove는 --agent universal에서 "Done!"을 찍고도 디렉터리와
235
+ // 잠금 항목을 남긴다(실측) — 남은 것을 우리가 치운다. 남겨 두면 detect가
236
+ // 계속 설치됨으로 읽고, 동료가 잠금 파일로 복원하면 지운 스킬이 되살아난다.
237
+ // 잠금 항목은 lockNames 전부를 지운다 — 인자·경로로 못 써 건너뛴 이름까지.
238
+ // 남기면 다음 제거가 같은 자리에서 또 멈춘다.
239
+ function cleanup(root, names, lockNames = names) {
240
+ for (const name of names) {
241
+ const dir = findSkillDir(root, name)
242
+ if (dir) rmSync(repoPathStrict(root, `${SHARED_SKILLS}/${dir}`), { recursive: true, force: true })
243
+ }
244
+ pruneLock(root, lockNames)
245
+ }
246
+
247
+ export function skillsAdd({ source, skill, anchor = null }) {
248
+ const wildcard = skill === '*'
249
+ // 스킬 전부(*)를 넣으면 detect가 볼 이름을 정해 둬야 한다 — 없으면 설치해도
250
+ // 계속 absent로 읽히는 항목이 조용히 카탈로그에 들어간다.
251
+ if (wildcard && !anchor) throw new LocalizedError('error.registrySkillAnchor', { id: sourceKey(source) })
252
+ const probeName = anchor ?? skill
253
+ const project = {
254
+ install: [[...SKILLS, 'add', source, '--skill', skill, '--agent', 'universal', '--yes', '--copy']],
255
+ uninstall: wildcard
256
+ ? [{
257
+ describe: `npx -y skills@latest remove <each skill from ${sourceKey(source)} in skills-lock.json> --agent universal --yes, then remove leftover ${SHARED_SKILLS} dirs`,
258
+ fn: async ({ root, exec }) => {
259
+ // 잠금 파일의 이름은 상류 frontmatter에서 온다 — 인자·경로로 못 쓰는 이름은 건너뛴다.
260
+ const locked = lockedSkills(root, source)
261
+ const all = locked.length > 0 ? locked : [probeName]
262
+ const names = all.filter(isSafeSkillName)
263
+ for (const name of names) await exec('npx', ['-y', 'skills@latest', 'remove', name, '--agent', 'universal', '--yes'], { cwd: root })
264
+ cleanup(root, names, all)
265
+ },
266
+ }]
267
+ : [
268
+ { argv: [...SKILLS, 'remove', skill, '--agent', 'universal', '--yes'], tolerateFail: true },
269
+ { describe: `remove leftover ${SHARED_SKILLS}/${skill} and its skills-lock.json entry`, fn: ({ root }) => cleanup(root, [skill]) },
270
+ ],
271
+ }
272
+ const byScope = { project }
273
+ // 한 상류의 스킬 전부(*)는 전역에서 무엇을 지울지 추적할 근거가 없다.
274
+ if (!wildcard) {
275
+ byScope.user = {
276
+ install: [[...SKILLS, 'add', source, '--skill', skill, '-g', '-a', ...SKILLS_AGENTS, '--yes', '--copy']],
277
+ uninstall: [[...SKILLS, 'remove', skill, '-g', '-a', ...SKILLS_AGENTS, '--yes']],
278
+ }
279
+ }
280
+ return { recipes: { [SHARED]: byScope }, detect: { [SHARED]: (p) => p.skill(probeName) } }
281
+ }
package/lib/recipe.mjs ADDED
@@ -0,0 +1,181 @@
1
+ // 레시피 = CLI·범위별 설치 명령 목록. 항목 파일은 데이터만 적고, 이 모듈이
2
+ // 그것을 엔진·TUI가 아는 항목 모양으로 바꾼다. 실행(install)과 출력(--print)이
3
+ // 같은 steps()에서 명령을 얻으므로 둘이 어긋날 수 없다.
4
+ import { join } from 'node:path'
5
+ import { CLIS, CLI_IDS as RECIPE_CLIS } from './clis.mjs'
6
+ import { LocalizedError, msg } from './i18n/index.mjs'
7
+
8
+ export const SCOPES = ['project', 'user']
9
+ // 한 단계 묶음이 네 CLI를 한꺼번에 덮는 자리(.agents/skills).
10
+ export const SHARED = 'shared'
11
+ // 실패 보고에 싣는 출력 줄 수. 설치 로그 전체는 화면을 덮는다.
12
+ const TAIL_LINES = 5
13
+
14
+ export function normalizeStep(step, cli) {
15
+ if (Array.isArray(step)) return { cli, argv: step, cwd: null, tolerateFail: false, undo: null, timeout: null }
16
+ if (step && Array.isArray(step.argv)) {
17
+ return { cli, argv: step.argv, cwd: step.cwd ?? null, tolerateFail: Boolean(step.tolerateFail), undo: step.undo ?? null, timeout: step.timeout ?? null }
18
+ }
19
+ if (step && typeof step.fn === 'function' && step.describe) return { cli, fn: step.fn, describe: String(step.describe) }
20
+ return null
21
+ }
22
+
23
+ function assertRecipe(def) {
24
+ const bad = (why) => { throw new LocalizedError('error.recipeInvalid', { id: def?.id ?? '?', why }) }
25
+ for (const field of ['id', 'category', 'label']) if (!def?.[field]) bad(`missing ${field}`)
26
+ const entries = Object.entries(def.recipes ?? {})
27
+ if (entries.length === 0) bad('no recipes')
28
+ if (def.recipes[SHARED] && entries.length > 1) bad('shared cannot be combined with per-CLI recipes')
29
+ for (const [cli, byScope] of entries) {
30
+ if (cli !== SHARED && !RECIPE_CLIS.includes(cli)) bad(`unknown CLI ${cli}`)
31
+ for (const [scope, recipe] of Object.entries(byScope)) {
32
+ if (!SCOPES.includes(scope)) bad(`unknown scope ${scope}`)
33
+ for (const action of ['install', 'uninstall']) {
34
+ if (!Array.isArray(recipe?.[action])) bad(`${cli}.${scope}.${action} is not a list`)
35
+ for (const raw of recipe[action]) {
36
+ const step = normalizeStep(raw, cli)
37
+ if (!step || (step.argv && (step.argv.length === 0 || !step.argv.every((a) => typeof a === 'string')))) {
38
+ bad(`${cli}.${scope}.${action} has a bad step`)
39
+ }
40
+ }
41
+ }
42
+ }
43
+ if (typeof def.detect?.[cli] !== 'function') bad(`detect.${cli} missing`)
44
+ }
45
+ }
46
+
47
+ export function defineRecipe(def) {
48
+ assertRecipe(def)
49
+ return { ...def, kind: 'recipe' }
50
+ }
51
+
52
+ const reasonOf = (r) => (typeof r === 'string' ? msg(r) : r)
53
+
54
+ export function bindScope(def, scope) {
55
+ const shared = def.recipes[SHARED]
56
+ if (shared && !shared[scope]) return null
57
+ const supports = shared ? [...RECIPE_CLIS] : RECIPE_CLIS.filter((c) => def.recipes[c]?.[scope])
58
+ if (supports.length === 0) return null
59
+
60
+ // 다른 범위엔 레시피가 있으면 "범위를 바꾸면 된다"가 사유다. 어느 범위에도
61
+ // 없을 때만 항목이 사유를 직접 대야 한다 — 빠뜨리면 로드 오류로 막는다.
62
+ const other = scope === 'project' ? 'user' : 'project'
63
+ const unsupported = {}
64
+ for (const cli of RECIPE_CLIS) {
65
+ if (supports.includes(cli)) continue
66
+ if (def.recipes[cli]?.[other]) unsupported[cli] = msg(`item.unsupported.${other}Only`)
67
+ else if (def.reasons?.[cli]) unsupported[cli] = reasonOf(def.reasons[cli])
68
+ else throw new LocalizedError('error.itemReasonMissing', { id: def.id, cli })
69
+ }
70
+
71
+ const recipeOf = (cli) => def.recipes[cli][scope]
72
+
73
+ return {
74
+ id: def.id, category: def.category, label: def.label,
75
+ group: def.group ?? null, exclusive: def.exclusive ?? null, note: def.note ?? null,
76
+ verified: def.verified ?? null, hooks: Boolean(def.hooks), postInstall: def.postInstall ?? null,
77
+ scope, supports, unsupported,
78
+
79
+ async detect({ probe }) {
80
+ const reach = supports.filter((c) => probe.has(c))
81
+ // 공유 스킬은 CLI가 아니라 디렉터리에 깔린다 — PATH와 무관하다.
82
+ const excluded = shared ? [] : supports.filter((c) => !reach.includes(c))
83
+ const targets = shared ? [SHARED] : reach
84
+ const present = []
85
+ const failed = []
86
+ for (const cli of targets) {
87
+ try {
88
+ if (await def.detect[cli](probe)) present.push(cli)
89
+ } catch {
90
+ // 목록 명령 실패(로그인 만료 등)를 "없음"으로 읽으면 깔린 것을 또 깐다.
91
+ failed.push(cli)
92
+ }
93
+ }
94
+ const live = targets.filter((c) => !failed.includes(c))
95
+ const status = present.length === 0 ? 'absent' : present.length === live.length ? 'installed' : 'partial'
96
+ let detail = null
97
+ if (failed.length > 0) detail = msg('item.probeFailed', { list: failed.join(', ') })
98
+ else if (status === 'partial') {
99
+ detail = msg('item.plugin.partial', { present: present.join(', '), missing: live.filter((c) => !present.includes(c)).join(', ') })
100
+ } else if (targets.length === 0) detail = msg('item.global.noCli', { list: excluded.join(', ') })
101
+ return { status, detail, excluded, present, failed, targets, reach }
102
+ },
103
+
104
+ steps(action, state = {}) {
105
+ const present = state.present ?? []
106
+ const failed = state.failed ?? []
107
+ const targets = state.targets ?? (shared ? [SHARED] : supports)
108
+ const removing = action === 'uninstall'
109
+ const clis = removing
110
+ ? targets.filter((c) => present.includes(c))
111
+ : targets.filter((c) => !present.includes(c) && !failed.includes(c))
112
+ return clis.flatMap((cli) => recipeOf(cli)[removing ? 'uninstall' : 'install'].map((s) => normalizeStep(s, cli)))
113
+ },
114
+
115
+ async install(ctx) {
116
+ const state = ctx.state ?? {}
117
+ const steps = this.steps('install', state)
118
+ // 실행할 명령이 없는데 ✔로 끝나면 --set을 쓰는 CI가 무동작을 성공으로 받는다.
119
+ // 감지 실패는 실패로, CLI가 이 머신에 없는 것은 사연 있는 성공으로 돌려준다.
120
+ if (steps.length === 0) {
121
+ const failed = state.failed ?? []
122
+ const excluded = state.excluded ?? []
123
+ if (failed.length > 0) throw new LocalizedError('item.probeFailed', { list: failed.join(', ') })
124
+ if (excluded.length > 0) return { message: msg('item.global.noCli', { list: excluded.join(', ') }) }
125
+ return undefined
126
+ }
127
+ await runSteps(steps, ctx)
128
+ return undefined
129
+ },
130
+
131
+ async uninstall(ctx) {
132
+ await runSteps(this.steps('uninstall', ctx.state), ctx)
133
+ },
134
+ }
135
+ }
136
+
137
+ function tail(output) {
138
+ return String(output ?? '').trim().split(/\r?\n/).slice(-TAIL_LINES).join('\n ')
139
+ }
140
+
141
+ const cliLabel = (cli) => CLIS[cli]?.label ?? cli
142
+
143
+ // 한 CLI의 단계가 실패하면 그 CLI의 남은 단계만 건너뛴다. 다른 CLI는 계속
144
+ // 간다 — 로그인이 풀린 Codex 하나 때문에 Claude 설치까지 막을 이유가 없다.
145
+ export async function runSteps(steps, ctx) {
146
+ const { root, exec, dryRun = false, log = () => {} } = ctx
147
+ const broken = new Set()
148
+ // 참아 준 실패(마켓플레이스 "이미 등록됨")는 뒤 단계가 실패할 때만 함께 보인다.
149
+ const soft = new Map()
150
+ const failures = []
151
+ for (const step of steps) {
152
+ if (broken.has(step.cli)) continue
153
+ if (step.fn) {
154
+ if (dryRun) { log(` [dry-run] ${step.describe}`); continue }
155
+ try {
156
+ await step.fn(ctx)
157
+ } catch (err) {
158
+ failures.push({ cli: step.cli, what: step.describe, output: err.message })
159
+ broken.add(step.cli)
160
+ }
161
+ continue
162
+ }
163
+ const [cmd, ...args] = step.argv
164
+ const opts = { cwd: step.cwd ? join(root, step.cwd) : root }
165
+ if (step.timeout) opts.timeout = step.timeout
166
+ const r = await exec(cmd, args, opts)
167
+ if (r.ok) continue
168
+ const failure = { cli: step.cli, what: step.argv.join(' '), output: tail(r.output) }
169
+ if (step.tolerateFail) { soft.set(step.cli, failure); continue }
170
+ if (step.undo && !dryRun) {
171
+ try { await step.undo(ctx) } catch { /* 되돌리기 실패가 원래 실패를 가리면 안 된다 */ }
172
+ }
173
+ if (soft.has(step.cli)) failures.push(soft.get(step.cli))
174
+ failures.push(failure)
175
+ broken.add(step.cli)
176
+ }
177
+ if (failures.length > 0) {
178
+ const details = failures.map((f) => ` ${cliLabel(f.cli)}: ${f.what}${f.output ? `\n ${f.output}` : ''}`).join('\n')
179
+ throw new LocalizedError('error.stepsFailed', { details })
180
+ }
181
+ }