@rch4com/agent-setup 1.9.0 → 1.10.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 (49) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +199 -199
  3. package/install.mjs +163 -163
  4. package/lib/args.mjs +276 -276
  5. package/lib/bootstrap/adapter.mjs +96 -96
  6. package/lib/bootstrap/apply.mjs +380 -380
  7. package/lib/bootstrap/flow.mjs +140 -140
  8. package/lib/bootstrap/record.mjs +154 -154
  9. package/lib/bootstrap/templates.mjs +148 -148
  10. package/lib/bootstrap/text.mjs +19 -19
  11. package/lib/catalog.mjs +234 -234
  12. package/lib/claude-plugins.mjs +64 -64
  13. package/lib/clis.mjs +111 -111
  14. package/lib/context.mjs +66 -66
  15. package/lib/deps.mjs +17 -17
  16. package/lib/design-md/catalog.json +454 -454
  17. package/lib/design-md/catalog.mjs +266 -266
  18. package/lib/design-md/flow.mjs +192 -192
  19. package/lib/design-md/open.mjs +51 -51
  20. package/lib/design-md/providers/awesome-design-md.mjs +124 -124
  21. package/lib/design-md/providers/index.mjs +9 -9
  22. package/lib/design-md/scan.mjs +295 -295
  23. package/lib/engine.mjs +115 -115
  24. package/lib/gitignore.mjs +18 -18
  25. package/lib/i18n/catalog/en.mjs +426 -425
  26. package/lib/i18n/catalog/ko.mjs +411 -410
  27. package/lib/i18n/detect.mjs +39 -39
  28. package/lib/i18n/index.mjs +93 -93
  29. package/lib/items/mcp.codebase-memory.mjs +6 -6
  30. package/lib/items/mcp.notion.mjs +6 -6
  31. package/lib/items/mcp.supabase.mjs +6 -6
  32. package/lib/items/mcp.vercel.mjs +6 -6
  33. package/lib/items/plugin.mattpocock-skills.mjs +8 -8
  34. package/lib/items/plugin.ponytail.mjs +104 -104
  35. package/lib/items/skill.gsd.mjs +44 -44
  36. package/lib/items/skill.gstack.mjs +54 -54
  37. package/lib/items/skill.hallmark.mjs +10 -0
  38. package/lib/jsonfile.mjs +40 -40
  39. package/lib/status.mjs +93 -93
  40. package/lib/tomlfile.mjs +60 -60
  41. package/lib/tui/detail.mjs +127 -127
  42. package/lib/tui/progress.mjs +160 -160
  43. package/lib/tui/render.mjs +268 -268
  44. package/lib/tui/rows.mjs +317 -317
  45. package/lib/tui/run.mjs +396 -396
  46. package/lib/tui/state.mjs +249 -249
  47. package/lib/update.mjs +85 -85
  48. package/lib/width.mjs +115 -115
  49. package/package.json +36 -36
package/lib/catalog.mjs CHANGED
@@ -1,234 +1,234 @@
1
- import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
2
- import { join, dirname } from 'node:path'
3
- import { fileURLToPath, pathToFileURL } from 'node:url'
4
- import { execFile } from 'node:child_process'
5
- import { promisify } from 'node:util'
6
- import { CLIS, CLI_IDS } from './clis.mjs'
7
- import { isPluginEnabled, enablePlugin, disablePlugin } from './claude-plugins.mjs'
8
- import { repoPath, repoPathStrict } from './context.mjs'
9
- import { LocalizedError, msg } from './i18n/index.mjs'
10
-
11
- const ITEMS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'items')
12
-
13
- export async function loadItems() {
14
- const files = readdirSync(ITEMS_DIR).filter((f) => f.endsWith('.mjs')).sort()
15
- const items = []
16
- for (const f of files) {
17
- const mod = await import(pathToFileURL(join(ITEMS_DIR, f)).href)
18
- items.push(validate(mod.default, f))
19
- }
20
- return items
21
- }
22
-
23
- function validate(item, file) {
24
- for (const field of ['id', 'category', 'label', 'detect', 'install', 'uninstall']) {
25
- if (!item?.[field]) throw new LocalizedError('error.itemFieldMissing', { file, field })
26
- }
27
- return item
28
- }
29
-
30
- function assertReasons(id, supports, unsupported) {
31
- for (const cli of CLI_IDS) {
32
- if (!supports.includes(cli) && !unsupported[cli]) {
33
- throw new LocalizedError('error.itemReasonMissing', { id, cli })
34
- }
35
- }
36
- }
37
-
38
- // shell 모드에서 Node는 명령과 인자를 공백으로 이어붙일 뿐 quote하지 않으므로
39
- // 직접 감싼다. 공백이 있을 때만 감싸면 셸 메타문자가 그대로 노출된다 —
40
- // `D:\R&D\repo` 아래에서 gstack clone 대상이 명령 두 개로 쪼개졌다.
41
- // 조건 없이 항상 감싸고, 셸별로 실제로 안전한 인용 규칙을 쓴다.
42
- // - cmd.exe: 큰따옴표 안에서 & | ^ ( ) < >는 리터럴이다. 큰따옴표 자체는
43
- // 이 위치에서 일관되게 이스케이프할 방법이 없어(cmd는 \"를 해석하지 않는다)
44
- // 깨진 명령을 만드는 대신 거부한다 — Windows 경로에 올 수 없는 문자다.
45
- // 닫는 따옴표 앞의 백슬래시 연속은 자식의 argv 파서가 이스케이프로 읽으므로
46
- // 두 배로 늘린다(Windows 표준 규칙).
47
- // 남는 것은 %VAR% 확장뿐인데, 짝을 이룬 %가 실제 환경변수일 때만 치환되고
48
- // 그러면 경로가 달라져 명령이 실패한다 — 조용한 오작동이 아니라 보이는 실패다.
49
- // - POSIX sh: 작은따옴표 안은 $ ` \ 까지 전부 리터럴이다. 안의 작은따옴표만
50
- // '\'' 로 끊어 이어 붙인다. 큰따옴표로 감싸면 $(...)가 살아남는다.
51
- export function shellQuote(text, platform = process.platform) {
52
- const s = String(text)
53
- if (platform === 'win32') {
54
- if (s.includes('"')) {
55
- throw new LocalizedError('error.shellQuote', { value: s })
56
- }
57
- return `"${s.replace(/(\\*)$/, '$1$1')}"`
58
- }
59
- return `'${s.replace(/'/g, "'\\''")}'`
60
- }
61
-
62
- const execFileAsync = promisify(execFile)
63
-
64
- // 비동기다. 동기 실행은 이벤트 루프를 통째로 막아, npx가 도는 수십 초 동안
65
- // 진행 화면을 한 번도 다시 그릴 수 없었다. 반환 형태({ ok, output })는
66
- // 그대로라 호출부는 await만 더하면 된다.
67
- export function makeExec(dryRun, log = console.log) {
68
- return async (cmd, args, opts = {}) => {
69
- if (dryRun) {
70
- log(` [dry-run] ${cmd} ${args.join(' ')}`)
71
- return { ok: true, output: '' }
72
- }
73
- // Windows에서는 npx/claude가 .cmd 심이라 shell 경유가 필요하다.
74
- const shell = opts.shell ?? process.platform === 'win32'
75
- // shell + 인자 배열을 함께 넘기면 Node가 DEP0190으로 경고한다(인자가 quote 없이 이어붙기 때문).
76
- // 어차피 우리가 직접 quote하므로, 완성된 한 줄 명령을 넘기고 인자 배열은 비운다.
77
- const [file, fileArgs] = shell ? [[cmd, ...args].map((s) => shellQuote(s)).join(' '), []] : [cmd, args]
78
- try {
79
- // execFile은 stdio를 강제로 파이프로 고정하고 옵션으로 넘겨도 무시한다 —
80
- // 그래서 stdout·stderr는 버퍼링이 공짜로 따라온다. 문제는 stdin이다:
81
- // 파이프가 열린 채로 자식에게 넘어가므로, stdin을 읽는 명령(자격 증명·
82
- // 확인 프롬프트 등)은 EOF를 못 받아 영원히 멈춘다. 옛 execFileSync는
83
- // stdio: ['ignore', ...]로 자식의 stdin을 즉시 닫아 뒀다 — 그 동작을
84
- // 복원하려면 스폰 직후 우리가 직접 stdin을 닫아야 한다.
85
- const p = execFileAsync(file, fileArgs, {
86
- encoding: 'utf8',
87
- ...opts,
88
- shell,
89
- })
90
- // 옵션 조합에 따라 stdin이 없을 수 있다 — 없으면 조용히 넘어간다.
91
- p.child.stdin?.end()
92
- const { stdout } = await p
93
- return { ok: true, output: stdout }
94
- } catch (err) {
95
- // spawn 자체가 실패하면(ENOENT 등) err.stderr가 undefined가 아니라
96
- // 빈 문자열로 온다 — ??는 빈 문자열을 "값 있음"으로 보고 통과시켜
97
- // err.message(바이너리 이름이 담긴 진단 텍스트)를 삼켜 버린다. ||로
98
- // 빈 문자열도 폴백하게 한다.
99
- return { ok: false, output: String(err.stderr || err.message) }
100
- }
101
- }
102
- }
103
-
104
- export function defineMcp({ id, label, server, supports = [...CLI_IDS], unsupported = {}, note, group = null }) {
105
- assertReasons(id, supports, unsupported)
106
- const name = id.replace(/^mcp\./, '')
107
- return {
108
- id, category: 'mcp', label, scope: 'project', supports, unsupported, note, group,
109
- async detect({ root }) {
110
- const present = supports.filter((cli) => CLIS[cli].has(root, name))
111
- if (present.length === 0) return { status: 'absent' }
112
- if (present.length === supports.length) return { status: 'installed' }
113
- return {
114
- status: 'partial',
115
- detail: msg('item.mcp.partial', {
116
- present: present.join(', '),
117
- missing: supports.filter((c) => !present.includes(c)).join(', '),
118
- }),
119
- }
120
- },
121
- async install({ root, dryRun, log = () => {}, t }) {
122
- for (const cli of supports) {
123
- if (!CLIS[cli].has(root, name)) {
124
- if (dryRun) log(t('log.mcp.add', { cli: CLIS[cli].label, name }))
125
- else CLIS[cli].add(root, name, server)
126
- }
127
- }
128
- },
129
- async uninstall({ root, dryRun, log = () => {}, t }) {
130
- for (const cli of supports) {
131
- if (CLIS[cli].has(root, name)) {
132
- if (dryRun) log(t('log.mcp.remove', { cli: CLIS[cli].label, name }))
133
- else CLIS[cli].remove(root, name)
134
- }
135
- }
136
- },
137
- }
138
- }
139
-
140
- // 배선은 Claude 한 곳이라도 미배선 "사유"는 CLI마다 다를 수 있다 — 상류가
141
- // 자체 경로로 지원하는 CLI에 일괄 "Claude 전용" 사유를 붙이면 거짓 정보가 된다.
142
- // 항목이 사유를 넘기면 그 CLI만 덮고, 나머지는 기본 사유로 채운다.
143
- export function definePlugin({ id, label, installId, detectIds, marketplace, note, group = null, unsupported: reasons = {} }) {
144
- const unsupported = Object.fromEntries(
145
- CLI_IDS.filter((c) => c !== 'claude').map((c) => [c, reasons[c] ?? msg('item.unsupported.claudePlugin')]),
146
- )
147
- return {
148
- id, category: 'plugin', label, scope: 'project', supports: ['claude'], unsupported, note, group,
149
- async detect({ root }) {
150
- return { status: isPluginEnabled(root, detectIds) ? 'installed' : 'absent' }
151
- },
152
- async install(ctx) {
153
- const { root, dryRun, exec } = ctx
154
- if (marketplace) await exec('claude', ['plugin', 'marketplace', 'add', marketplace.repo], { cwd: root })
155
- const r = await exec('claude', ['plugin', 'install', installId, '--scope', 'project'], { cwd: root })
156
- if (!r.ok) {
157
- if (!dryRun) enablePlugin(root, installId, marketplace)
158
- return { fallback: true, message: msg('item.plugin.deferred') }
159
- }
160
- },
161
- async uninstall(ctx) {
162
- const { root, dryRun, exec } = ctx
163
- const r = await exec('claude', ['plugin', 'uninstall', installId], { cwd: root })
164
- if (!r.ok && !dryRun) disablePlugin(root, detectIds)
165
- },
166
- }
167
- }
168
-
169
- export function defineSkill({ id, label, scope, detect, install, uninstall, note, group = null, unsupported: reasons = {} }) {
170
- const unsupported = Object.fromEntries(
171
- CLI_IDS.filter((c) => c !== 'claude').map((c) => [c, reasons[c] ?? msg('item.unsupported.claudeSkill')]),
172
- )
173
- return { id, category: 'skill', label, scope, supports: ['claude'], unsupported, note, group, detect, install, uninstall }
174
- }
175
-
176
- // 이 저장소가 공유 스킬 자리로 쓰는 디렉터리. vercel-labs/skills 레지스트리의
177
- // `--agent universal` 프로젝트 경로와 같은 값이다 — 그래서 레지스트리 설치가
178
- // 우리 배선 위에 그대로 얹힌다.
179
- const SHARED_SKILLS = '.agents/skills'
180
-
181
- function skillDirs(root) {
182
- const dir = repoPath(root, SHARED_SKILLS)
183
- if (!existsSync(dir)) return []
184
- return readdirSync(dir).filter((name) => {
185
- try {
186
- return statSync(join(dir, name)).isDirectory()
187
- } catch {
188
- return false
189
- }
190
- })
191
- }
192
-
193
- // 설치된 디렉터리 이름이 스킬 이름과 다를 수 있다(레지스트리는 SKILL.md의
194
- // frontmatter name으로 고르는데, 저장소의 폴더 이름은 다른 경우가 있다 —
195
- // taste-skill/이 design-taste-frontend를 담는 식이다). 이름이 같은 디렉터리를
196
- // 먼저 보고, 없으면 frontmatter를 읽어 찾는다. 디렉터리 이름만 보면 설치해
197
- // 놓고도 계속 '미설치'로 읽힌다.
198
- function findSkillDir(root, skill) {
199
- const base = repoPath(root, SHARED_SKILLS)
200
- if (existsSync(join(base, skill, 'SKILL.md'))) return skill
201
- for (const name of skillDirs(root)) {
202
- const file = join(base, name, 'SKILL.md')
203
- if (!existsSync(file)) continue
204
- const head = readFileSync(file, 'utf8').slice(0, 2000)
205
- if (new RegExp(`^name:\\s*['"]?${skill}['"]?\\s*$`, 'm').test(head)) return name
206
- }
207
- return null
208
- }
209
-
210
- // vercel-labs/skills 레지스트리로 설치하는 스킬. `--agent universal`의 프로젝트
211
- // 경로가 .agents/skills라, 한 번 설치하면 10개 CLI가 함께 본다 — Claude·Kiro·
212
- // Grok은 부트스트랩이 만든 Junction으로, 나머지는 그 경로를 네이티브로 읽는다.
213
- // `--copy`로 실물을 남긴다: 커밋해서 팀과 나누는 자리라 링크는 클론 뒤 깨진다.
214
- export function defineRegistrySkill({ id, label, source, skill, note, group = null }) {
215
- return {
216
- id, category: 'skill', label, scope: 'project', supports: [...CLI_IDS], unsupported: {}, note, group,
217
- async detect({ root }) {
218
- return { status: findSkillDir(root, skill) ? 'installed' : 'absent' }
219
- },
220
- async install({ root, exec }) {
221
- const r = await exec('npx', ['-y', 'skills@latest', 'add', source, '--skill', skill, '--agent', 'universal', '--yes', '--copy'], { cwd: root })
222
- if (!r.ok) throw new LocalizedError('error.registrySkillInstall', { skill, output: r.output })
223
- },
224
- async uninstall({ root, dryRun, exec }) {
225
- // 레지스트리의 remove는 --agent universal에서 "Done!"을 찍고도 디렉터리를
226
- // 남긴다(실측). 그래서 성공/실패와 무관하게 남은 디렉터리를 우리가 지운다 —
227
- // 남겨 두면 detect가 계속 installed로 읽어 제거가 안 된 채 성공으로 보인다.
228
- await exec('npx', ['-y', 'skills@latest', 'remove', skill, '--agent', 'universal', '--yes'], { cwd: root })
229
- if (dryRun) return
230
- const dir = findSkillDir(root, skill)
231
- if (dir) rmSync(repoPathStrict(root, `${SHARED_SKILLS}/${dir}`), { recursive: true, force: true })
232
- },
233
- }
234
- }
1
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
2
+ import { join, dirname } from 'node:path'
3
+ import { fileURLToPath, pathToFileURL } from 'node:url'
4
+ import { execFile } from 'node:child_process'
5
+ import { promisify } from 'node:util'
6
+ import { CLIS, CLI_IDS } from './clis.mjs'
7
+ import { isPluginEnabled, enablePlugin, disablePlugin } from './claude-plugins.mjs'
8
+ import { repoPath, repoPathStrict } from './context.mjs'
9
+ import { LocalizedError, msg } from './i18n/index.mjs'
10
+
11
+ const ITEMS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'items')
12
+
13
+ export async function loadItems() {
14
+ const files = readdirSync(ITEMS_DIR).filter((f) => f.endsWith('.mjs')).sort()
15
+ const items = []
16
+ for (const f of files) {
17
+ const mod = await import(pathToFileURL(join(ITEMS_DIR, f)).href)
18
+ items.push(validate(mod.default, f))
19
+ }
20
+ return items
21
+ }
22
+
23
+ function validate(item, file) {
24
+ for (const field of ['id', 'category', 'label', 'detect', 'install', 'uninstall']) {
25
+ if (!item?.[field]) throw new LocalizedError('error.itemFieldMissing', { file, field })
26
+ }
27
+ return item
28
+ }
29
+
30
+ function assertReasons(id, supports, unsupported) {
31
+ for (const cli of CLI_IDS) {
32
+ if (!supports.includes(cli) && !unsupported[cli]) {
33
+ throw new LocalizedError('error.itemReasonMissing', { id, cli })
34
+ }
35
+ }
36
+ }
37
+
38
+ // shell 모드에서 Node는 명령과 인자를 공백으로 이어붙일 뿐 quote하지 않으므로
39
+ // 직접 감싼다. 공백이 있을 때만 감싸면 셸 메타문자가 그대로 노출된다 —
40
+ // `D:\R&D\repo` 아래에서 gstack clone 대상이 명령 두 개로 쪼개졌다.
41
+ // 조건 없이 항상 감싸고, 셸별로 실제로 안전한 인용 규칙을 쓴다.
42
+ // - cmd.exe: 큰따옴표 안에서 & | ^ ( ) < >는 리터럴이다. 큰따옴표 자체는
43
+ // 이 위치에서 일관되게 이스케이프할 방법이 없어(cmd는 \"를 해석하지 않는다)
44
+ // 깨진 명령을 만드는 대신 거부한다 — Windows 경로에 올 수 없는 문자다.
45
+ // 닫는 따옴표 앞의 백슬래시 연속은 자식의 argv 파서가 이스케이프로 읽으므로
46
+ // 두 배로 늘린다(Windows 표준 규칙).
47
+ // 남는 것은 %VAR% 확장뿐인데, 짝을 이룬 %가 실제 환경변수일 때만 치환되고
48
+ // 그러면 경로가 달라져 명령이 실패한다 — 조용한 오작동이 아니라 보이는 실패다.
49
+ // - POSIX sh: 작은따옴표 안은 $ ` \ 까지 전부 리터럴이다. 안의 작은따옴표만
50
+ // '\'' 로 끊어 이어 붙인다. 큰따옴표로 감싸면 $(...)가 살아남는다.
51
+ export function shellQuote(text, platform = process.platform) {
52
+ const s = String(text)
53
+ if (platform === 'win32') {
54
+ if (s.includes('"')) {
55
+ throw new LocalizedError('error.shellQuote', { value: s })
56
+ }
57
+ return `"${s.replace(/(\\*)$/, '$1$1')}"`
58
+ }
59
+ return `'${s.replace(/'/g, "'\\''")}'`
60
+ }
61
+
62
+ const execFileAsync = promisify(execFile)
63
+
64
+ // 비동기다. 동기 실행은 이벤트 루프를 통째로 막아, npx가 도는 수십 초 동안
65
+ // 진행 화면을 한 번도 다시 그릴 수 없었다. 반환 형태({ ok, output })는
66
+ // 그대로라 호출부는 await만 더하면 된다.
67
+ export function makeExec(dryRun, log = console.log) {
68
+ return async (cmd, args, opts = {}) => {
69
+ if (dryRun) {
70
+ log(` [dry-run] ${cmd} ${args.join(' ')}`)
71
+ return { ok: true, output: '' }
72
+ }
73
+ // Windows에서는 npx/claude가 .cmd 심이라 shell 경유가 필요하다.
74
+ const shell = opts.shell ?? process.platform === 'win32'
75
+ // shell + 인자 배열을 함께 넘기면 Node가 DEP0190으로 경고한다(인자가 quote 없이 이어붙기 때문).
76
+ // 어차피 우리가 직접 quote하므로, 완성된 한 줄 명령을 넘기고 인자 배열은 비운다.
77
+ const [file, fileArgs] = shell ? [[cmd, ...args].map((s) => shellQuote(s)).join(' '), []] : [cmd, args]
78
+ try {
79
+ // execFile은 stdio를 강제로 파이프로 고정하고 옵션으로 넘겨도 무시한다 —
80
+ // 그래서 stdout·stderr는 버퍼링이 공짜로 따라온다. 문제는 stdin이다:
81
+ // 파이프가 열린 채로 자식에게 넘어가므로, stdin을 읽는 명령(자격 증명·
82
+ // 확인 프롬프트 등)은 EOF를 못 받아 영원히 멈춘다. 옛 execFileSync는
83
+ // stdio: ['ignore', ...]로 자식의 stdin을 즉시 닫아 뒀다 — 그 동작을
84
+ // 복원하려면 스폰 직후 우리가 직접 stdin을 닫아야 한다.
85
+ const p = execFileAsync(file, fileArgs, {
86
+ encoding: 'utf8',
87
+ ...opts,
88
+ shell,
89
+ })
90
+ // 옵션 조합에 따라 stdin이 없을 수 있다 — 없으면 조용히 넘어간다.
91
+ p.child.stdin?.end()
92
+ const { stdout } = await p
93
+ return { ok: true, output: stdout }
94
+ } catch (err) {
95
+ // spawn 자체가 실패하면(ENOENT 등) err.stderr가 undefined가 아니라
96
+ // 빈 문자열로 온다 — ??는 빈 문자열을 "값 있음"으로 보고 통과시켜
97
+ // err.message(바이너리 이름이 담긴 진단 텍스트)를 삼켜 버린다. ||로
98
+ // 빈 문자열도 폴백하게 한다.
99
+ return { ok: false, output: String(err.stderr || err.message) }
100
+ }
101
+ }
102
+ }
103
+
104
+ export function defineMcp({ id, label, server, supports = [...CLI_IDS], unsupported = {}, note, group = null }) {
105
+ assertReasons(id, supports, unsupported)
106
+ const name = id.replace(/^mcp\./, '')
107
+ return {
108
+ id, category: 'mcp', label, scope: 'project', supports, unsupported, note, group,
109
+ async detect({ root }) {
110
+ const present = supports.filter((cli) => CLIS[cli].has(root, name))
111
+ if (present.length === 0) return { status: 'absent' }
112
+ if (present.length === supports.length) return { status: 'installed' }
113
+ return {
114
+ status: 'partial',
115
+ detail: msg('item.mcp.partial', {
116
+ present: present.join(', '),
117
+ missing: supports.filter((c) => !present.includes(c)).join(', '),
118
+ }),
119
+ }
120
+ },
121
+ async install({ root, dryRun, log = () => {}, t }) {
122
+ for (const cli of supports) {
123
+ if (!CLIS[cli].has(root, name)) {
124
+ if (dryRun) log(t('log.mcp.add', { cli: CLIS[cli].label, name }))
125
+ else CLIS[cli].add(root, name, server)
126
+ }
127
+ }
128
+ },
129
+ async uninstall({ root, dryRun, log = () => {}, t }) {
130
+ for (const cli of supports) {
131
+ if (CLIS[cli].has(root, name)) {
132
+ if (dryRun) log(t('log.mcp.remove', { cli: CLIS[cli].label, name }))
133
+ else CLIS[cli].remove(root, name)
134
+ }
135
+ }
136
+ },
137
+ }
138
+ }
139
+
140
+ // 배선은 Claude 한 곳이라도 미배선 "사유"는 CLI마다 다를 수 있다 — 상류가
141
+ // 자체 경로로 지원하는 CLI에 일괄 "Claude 전용" 사유를 붙이면 거짓 정보가 된다.
142
+ // 항목이 사유를 넘기면 그 CLI만 덮고, 나머지는 기본 사유로 채운다.
143
+ export function definePlugin({ id, label, installId, detectIds, marketplace, note, group = null, unsupported: reasons = {} }) {
144
+ const unsupported = Object.fromEntries(
145
+ CLI_IDS.filter((c) => c !== 'claude').map((c) => [c, reasons[c] ?? msg('item.unsupported.claudePlugin')]),
146
+ )
147
+ return {
148
+ id, category: 'plugin', label, scope: 'project', supports: ['claude'], unsupported, note, group,
149
+ async detect({ root }) {
150
+ return { status: isPluginEnabled(root, detectIds) ? 'installed' : 'absent' }
151
+ },
152
+ async install(ctx) {
153
+ const { root, dryRun, exec } = ctx
154
+ if (marketplace) await exec('claude', ['plugin', 'marketplace', 'add', marketplace.repo], { cwd: root })
155
+ const r = await exec('claude', ['plugin', 'install', installId, '--scope', 'project'], { cwd: root })
156
+ if (!r.ok) {
157
+ if (!dryRun) enablePlugin(root, installId, marketplace)
158
+ return { fallback: true, message: msg('item.plugin.deferred') }
159
+ }
160
+ },
161
+ async uninstall(ctx) {
162
+ const { root, dryRun, exec } = ctx
163
+ const r = await exec('claude', ['plugin', 'uninstall', installId], { cwd: root })
164
+ if (!r.ok && !dryRun) disablePlugin(root, detectIds)
165
+ },
166
+ }
167
+ }
168
+
169
+ export function defineSkill({ id, label, scope, detect, install, uninstall, note, group = null, unsupported: reasons = {} }) {
170
+ const unsupported = Object.fromEntries(
171
+ CLI_IDS.filter((c) => c !== 'claude').map((c) => [c, reasons[c] ?? msg('item.unsupported.claudeSkill')]),
172
+ )
173
+ return { id, category: 'skill', label, scope, supports: ['claude'], unsupported, note, group, detect, install, uninstall }
174
+ }
175
+
176
+ // 이 저장소가 공유 스킬 자리로 쓰는 디렉터리. vercel-labs/skills 레지스트리의
177
+ // `--agent universal` 프로젝트 경로와 같은 값이다 — 그래서 레지스트리 설치가
178
+ // 우리 배선 위에 그대로 얹힌다.
179
+ const SHARED_SKILLS = '.agents/skills'
180
+
181
+ function skillDirs(root) {
182
+ const dir = repoPath(root, SHARED_SKILLS)
183
+ if (!existsSync(dir)) return []
184
+ return readdirSync(dir).filter((name) => {
185
+ try {
186
+ return statSync(join(dir, name)).isDirectory()
187
+ } catch {
188
+ return false
189
+ }
190
+ })
191
+ }
192
+
193
+ // 설치된 디렉터리 이름이 스킬 이름과 다를 수 있다(레지스트리는 SKILL.md의
194
+ // frontmatter name으로 고르는데, 저장소의 폴더 이름은 다른 경우가 있다 —
195
+ // taste-skill/이 design-taste-frontend를 담는 식이다). 이름이 같은 디렉터리를
196
+ // 먼저 보고, 없으면 frontmatter를 읽어 찾는다. 디렉터리 이름만 보면 설치해
197
+ // 놓고도 계속 '미설치'로 읽힌다.
198
+ function findSkillDir(root, skill) {
199
+ const base = repoPath(root, SHARED_SKILLS)
200
+ if (existsSync(join(base, skill, 'SKILL.md'))) return skill
201
+ for (const name of skillDirs(root)) {
202
+ const file = join(base, name, 'SKILL.md')
203
+ if (!existsSync(file)) continue
204
+ const head = readFileSync(file, 'utf8').slice(0, 2000)
205
+ if (new RegExp(`^name:\\s*['"]?${skill}['"]?\\s*$`, 'm').test(head)) return name
206
+ }
207
+ return null
208
+ }
209
+
210
+ // vercel-labs/skills 레지스트리로 설치하는 스킬. `--agent universal`의 프로젝트
211
+ // 경로가 .agents/skills라, 한 번 설치하면 10개 CLI가 함께 본다 — Claude·Kiro·
212
+ // Grok은 부트스트랩이 만든 Junction으로, 나머지는 그 경로를 네이티브로 읽는다.
213
+ // `--copy`로 실물을 남긴다: 커밋해서 팀과 나누는 자리라 링크는 클론 뒤 깨진다.
214
+ export function defineRegistrySkill({ id, label, source, skill, note, group = null }) {
215
+ return {
216
+ id, category: 'skill', label, scope: 'project', supports: [...CLI_IDS], unsupported: {}, note, group,
217
+ async detect({ root }) {
218
+ return { status: findSkillDir(root, skill) ? 'installed' : 'absent' }
219
+ },
220
+ async install({ root, exec }) {
221
+ const r = await exec('npx', ['-y', 'skills@latest', 'add', source, '--skill', skill, '--agent', 'universal', '--yes', '--copy'], { cwd: root })
222
+ if (!r.ok) throw new LocalizedError('error.registrySkillInstall', { skill, output: r.output })
223
+ },
224
+ async uninstall({ root, dryRun, exec }) {
225
+ // 레지스트리의 remove는 --agent universal에서 "Done!"을 찍고도 디렉터리를
226
+ // 남긴다(실측). 그래서 성공/실패와 무관하게 남은 디렉터리를 우리가 지운다 —
227
+ // 남겨 두면 detect가 계속 installed로 읽어 제거가 안 된 채 성공으로 보인다.
228
+ await exec('npx', ['-y', 'skills@latest', 'remove', skill, '--agent', 'universal', '--yes'], { cwd: root })
229
+ if (dryRun) return
230
+ const dir = findSkillDir(root, skill)
231
+ if (dir) rmSync(repoPathStrict(root, `${SHARED_SKILLS}/${dir}`), { recursive: true, force: true })
232
+ },
233
+ }
234
+ }
@@ -1,64 +1,64 @@
1
- import { repoPath, repoPathStrict } from './context.mjs'
2
- import { readJson, setKey, removeKey } from './jsonfile.mjs'
3
-
4
- const SETTINGS = '.claude/settings.json'
5
-
6
- function readSettings(root) {
7
- return readJson(repoPath(root, SETTINGS)) ?? {}
8
- }
9
-
10
- // 쓰기 경로만 링크 이탈까지 검사한다 — 저장소 안 `.claude`가 홈을 가리키는
11
- // Junction이면 어휘적 검사는 통과하고 글로벌 settings.json이 수정된다.
12
- // clis.mjs의 add·remove, bootstrap의 apply.mjs와 같은 규칙.
13
- function settingsFile(root) {
14
- return repoPathStrict(root, SETTINGS)
15
- }
16
-
17
- function enabledList(settings) {
18
- const ep = settings.enabledPlugins
19
- if (Array.isArray(ep)) return ep
20
- if (ep && typeof ep === 'object') return Object.keys(ep).filter((k) => ep[k])
21
- return []
22
- }
23
-
24
- export function isPluginEnabled(root, ids) {
25
- const list = enabledList(readSettings(root))
26
- return ids.some((id) => list.includes(id))
27
- }
28
-
29
- export function enablePlugin(root, id, marketplace) {
30
- const file = settingsFile(root)
31
- const settings = readSettings(root)
32
- if (Array.isArray(settings.enabledPlugins)) {
33
- if (!settings.enabledPlugins.includes(id)) {
34
- setKey(file, ['enabledPlugins', settings.enabledPlugins.length], id)
35
- }
36
- } else {
37
- setKey(file, ['enabledPlugins', id], true)
38
- }
39
- if (marketplace) {
40
- setKey(file, ['extraKnownMarketplaces', marketplace.name], {
41
- source: { source: 'github', repo: marketplace.repo },
42
- })
43
- }
44
- }
45
-
46
- export function disablePlugin(root, ids) {
47
- const file = settingsFile(root)
48
- const settings = readSettings(root)
49
- if (Array.isArray(settings.enabledPlugins)) {
50
- const kept = settings.enabledPlugins.filter((e) => !ids.includes(e))
51
- setKey(file, ['enabledPlugins'], kept)
52
- } else if (settings.enabledPlugins) {
53
- for (const id of ids) removeKey(file, ['enabledPlugins', id])
54
- }
55
- // 제거한 플러그인이 쓰던 마켓플레이스만 정리 대상으로 삼는다 (무관한 사용자 항목 보존).
56
- const candidates = new Set(ids.map((id) => id.slice(id.indexOf('@') + 1)))
57
- const after = readSettings(root)
58
- const remaining = enabledList(after).map((e) => e.slice(e.indexOf('@') + 1))
59
- for (const mkt of candidates) {
60
- if (!remaining.includes(mkt) && after.extraKnownMarketplaces?.[mkt] !== undefined) {
61
- removeKey(file, ['extraKnownMarketplaces', mkt])
62
- }
63
- }
64
- }
1
+ import { repoPath, repoPathStrict } from './context.mjs'
2
+ import { readJson, setKey, removeKey } from './jsonfile.mjs'
3
+
4
+ const SETTINGS = '.claude/settings.json'
5
+
6
+ function readSettings(root) {
7
+ return readJson(repoPath(root, SETTINGS)) ?? {}
8
+ }
9
+
10
+ // 쓰기 경로만 링크 이탈까지 검사한다 — 저장소 안 `.claude`가 홈을 가리키는
11
+ // Junction이면 어휘적 검사는 통과하고 글로벌 settings.json이 수정된다.
12
+ // clis.mjs의 add·remove, bootstrap의 apply.mjs와 같은 규칙.
13
+ function settingsFile(root) {
14
+ return repoPathStrict(root, SETTINGS)
15
+ }
16
+
17
+ function enabledList(settings) {
18
+ const ep = settings.enabledPlugins
19
+ if (Array.isArray(ep)) return ep
20
+ if (ep && typeof ep === 'object') return Object.keys(ep).filter((k) => ep[k])
21
+ return []
22
+ }
23
+
24
+ export function isPluginEnabled(root, ids) {
25
+ const list = enabledList(readSettings(root))
26
+ return ids.some((id) => list.includes(id))
27
+ }
28
+
29
+ export function enablePlugin(root, id, marketplace) {
30
+ const file = settingsFile(root)
31
+ const settings = readSettings(root)
32
+ if (Array.isArray(settings.enabledPlugins)) {
33
+ if (!settings.enabledPlugins.includes(id)) {
34
+ setKey(file, ['enabledPlugins', settings.enabledPlugins.length], id)
35
+ }
36
+ } else {
37
+ setKey(file, ['enabledPlugins', id], true)
38
+ }
39
+ if (marketplace) {
40
+ setKey(file, ['extraKnownMarketplaces', marketplace.name], {
41
+ source: { source: 'github', repo: marketplace.repo },
42
+ })
43
+ }
44
+ }
45
+
46
+ export function disablePlugin(root, ids) {
47
+ const file = settingsFile(root)
48
+ const settings = readSettings(root)
49
+ if (Array.isArray(settings.enabledPlugins)) {
50
+ const kept = settings.enabledPlugins.filter((e) => !ids.includes(e))
51
+ setKey(file, ['enabledPlugins'], kept)
52
+ } else if (settings.enabledPlugins) {
53
+ for (const id of ids) removeKey(file, ['enabledPlugins', id])
54
+ }
55
+ // 제거한 플러그인이 쓰던 마켓플레이스만 정리 대상으로 삼는다 (무관한 사용자 항목 보존).
56
+ const candidates = new Set(ids.map((id) => id.slice(id.indexOf('@') + 1)))
57
+ const after = readSettings(root)
58
+ const remaining = enabledList(after).map((e) => e.slice(e.indexOf('@') + 1))
59
+ for (const mkt of candidates) {
60
+ if (!remaining.includes(mkt) && after.extraKnownMarketplaces?.[mkt] !== undefined) {
61
+ removeKey(file, ['extraKnownMarketplaces', mkt])
62
+ }
63
+ }
64
+ }