@rch4com/agent-setup 1.28.0 → 2.0.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 (63) 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 +17 -47
  6. package/lib/bootstrap/templates.mjs +7 -60
  7. package/lib/catalog.mjs +14 -240
  8. package/lib/clis.mjs +7 -118
  9. package/lib/commands.mjs +47 -0
  10. package/lib/engine.mjs +16 -11
  11. package/lib/i18n/catalog/en.mjs +50 -48
  12. package/lib/i18n/catalog/ko.mjs +50 -48
  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 +16 -15
  22. package/lib/items/plugin.ecc.mjs +16 -20
  23. package/lib/items/plugin.hyperframes.mjs +15 -13
  24. package/lib/items/plugin.impeccable.mjs +17 -22
  25. package/lib/items/plugin.mattpocock-skills.mjs +19 -17
  26. package/lib/items/plugin.ponytail.mjs +21 -106
  27. package/lib/items/plugin.superpowers.mjs +17 -24
  28. package/lib/items/plugin.understand-anything.mjs +16 -18
  29. package/lib/items/skill.agent-browser.mjs +6 -6
  30. package/lib/items/skill.archify.mjs +5 -5
  31. package/lib/items/skill.caveman.mjs +5 -5
  32. package/lib/items/skill.diagram-design.mjs +5 -5
  33. package/lib/items/skill.eli5.mjs +6 -6
  34. package/lib/items/skill.find-skills.mjs +5 -5
  35. package/lib/items/skill.gsd.mjs +24 -89
  36. package/lib/items/skill.gstack.mjs +54 -65
  37. package/lib/items/skill.hallmark.mjs +5 -5
  38. package/lib/items/skill.i-have-adhd.mjs +6 -6
  39. package/lib/items/skill.im-not-ai.mjs +5 -6
  40. package/lib/items/skill.impeccable.mjs +6 -6
  41. package/lib/items/skill.karpathy.mjs +5 -5
  42. package/lib/items/skill.mattpocock-skills.mjs +5 -6
  43. package/lib/items/skill.mcp-builder.mjs +5 -5
  44. package/lib/items/skill.officecli.mjs +5 -5
  45. package/lib/items/skill.prompt-master.mjs +5 -5
  46. package/lib/items/skill.remotion.mjs +5 -5
  47. package/lib/items/skill.strix.mjs +5 -6
  48. package/lib/items/skill.superpowers.mjs +6 -7
  49. package/lib/items/skill.taste.mjs +5 -5
  50. package/lib/probe.mjs +111 -0
  51. package/lib/recipe-helpers.mjs +205 -0
  52. package/lib/recipe.mjs +181 -0
  53. package/lib/skills-registry.mjs +93 -0
  54. package/lib/status.mjs +7 -3
  55. package/lib/tui/detail.mjs +5 -25
  56. package/lib/tui/rows.mjs +25 -14
  57. package/lib/tui/run.mjs +35 -8
  58. package/package.json +3 -3
  59. package/lib/claude-plugins.mjs +0 -64
  60. package/lib/global-plugin.mjs +0 -239
  61. package/lib/items/global.ponytail.mjs +0 -56
  62. package/lib/items/global.superpowers.mjs +0 -58
  63. package/lib/tomlfile.mjs +0 -67
package/lib/catalog.mjs CHANGED
@@ -1,24 +1,28 @@
1
- import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
1
+ import { readdirSync } from 'node:fs'
2
2
  import { join, dirname } from 'node:path'
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url'
4
4
  import { execFile } from 'node:child_process'
5
5
  import { promisify } from 'node:util'
6
- import { CLIS, CLI_IDS, MCP_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
- // design-md가 아니라 잎 모듈에서 가져온다 — deps.test.mjs의 "의존성 없는
11
- // 트리"는 design-md를 통째로 빼고 --list를 돌린다(bootstrap.isolation.test.mjs).
12
- import { escapeRegExp, isSafeSegment } from './untrusted.mjs'
6
+ import { LocalizedError } from './i18n/index.mjs'
7
+ import { bindScope } from './recipe.mjs'
13
8
 
14
9
  const ITEMS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'items')
15
10
 
16
- export async function loadItems() {
11
+ // 레시피 항목은 범위마다 다른 모양으로 묶인다. 그 범위에 레시피가 없는
12
+ // 항목(예: gstack의 전역)은 목록에서 빠진다. 파일을 직접 쓰는 함수형 항목은
13
+ // 자기 scope와 같은 범위에서만 나온다.
14
+ export async function loadItems({ scope = 'project' } = {}) {
17
15
  const files = readdirSync(ITEMS_DIR).filter((f) => f.endsWith('.mjs')).sort()
18
16
  const items = []
19
17
  for (const f of files) {
20
18
  const mod = await import(pathToFileURL(join(ITEMS_DIR, f)).href)
21
- items.push(validate(mod.default, f))
19
+ if (mod.default?.kind === 'recipe') {
20
+ const item = bindScope(mod.default, scope)
21
+ if (item) items.push(item)
22
+ continue
23
+ }
24
+ const item = validate(mod.default, f)
25
+ if ((item.scope ?? 'project') === scope) items.push(item)
22
26
  }
23
27
  return items
24
28
  }
@@ -30,14 +34,6 @@ function validate(item, file) {
30
34
  return item
31
35
  }
32
36
 
33
- function assertReasons(id, supports, unsupported) {
34
- for (const cli of CLI_IDS) {
35
- if (!supports.includes(cli) && !unsupported[cli]) {
36
- throw new LocalizedError('error.itemReasonMissing', { id, cli })
37
- }
38
- }
39
- }
40
-
41
37
  // shell 모드에서 Node는 명령과 인자를 공백으로 이어붙일 뿐 quote하지 않으므로
42
38
  // 직접 감싼다. 공백이 있을 때만 감싸면 셸 메타문자가 그대로 노출된다 —
43
39
  // `D:\R&D\repo` 아래에서 gstack clone 대상이 명령 두 개로 쪼개졌다.
@@ -124,225 +120,3 @@ export function makeExec(dryRun, log = console.log, t = null) {
124
120
  }
125
121
  }
126
122
  }
127
-
128
- // supports 기본값은 프로젝트 MCP 파일이 있는 CLI(MCP_CLI_IDS)다. Antigravity는
129
- // 그 파일이 없어 빠지고, 사유가 기본으로 붙는다.
130
- //
131
- // stdio 서버도 copilot에 그대로 배선한다. Copilot CLI는 루트 .mcp.json(Claude
132
- // 파일)을 우선 읽고 거기엔 `type: "stdio"`가 쓰이는데, 한때 README는 그 형식이
133
- // 붙지 않는다고 적었다. 2026-09-05에 Copilot CLI 1.0.82의 app.js를 읽어 확인한
134
- // 바로는 로컬 서버 판정이 `type === undefined || type === "local" || type ===
135
- // "stdio"`이고 스키마도 `enum(["local", "stdio"])`라 stdio를 받는다 — 두 파일에
136
- // 각자 형식으로 써도 어느 쪽이 이기든 붙는다.
137
- export function defineMcp({ id, label, server, supports = [...MCP_CLI_IDS], unsupported: reasons = {}, note, group = null, verified = null }) {
138
- const unsupported = { ...reasons }
139
- for (const cli of CLI_IDS) {
140
- if (!supports.includes(cli) && !unsupported[cli] && !MCP_CLI_IDS.includes(cli)) unsupported[cli] = msg('item.unsupported.noProjectMcp')
141
- }
142
- assertReasons(id, supports, unsupported)
143
- const name = id.replace(/^mcp\./, '')
144
- return {
145
- id, category: 'mcp', label, scope: 'project', supports, unsupported, note, group, verified,
146
- async detect({ root }) {
147
- const present = supports.filter((cli) => CLIS[cli].has(root, name))
148
- if (present.length === 0) return { status: 'absent' }
149
- if (present.length === supports.length) return { status: 'installed' }
150
- return {
151
- status: 'partial',
152
- detail: msg('item.mcp.partial', {
153
- present: present.join(', '),
154
- missing: supports.filter((c) => !present.includes(c)).join(', '),
155
- }),
156
- }
157
- },
158
- async install({ root, dryRun, log = () => {}, t }) {
159
- for (const cli of supports) {
160
- if (!CLIS[cli].has(root, name)) {
161
- if (dryRun) log(t('log.mcp.add', { cli: CLIS[cli].label, name }))
162
- else CLIS[cli].add(root, name, server)
163
- }
164
- }
165
- },
166
- async uninstall({ root, dryRun, log = () => {}, t }) {
167
- for (const cli of supports) {
168
- if (CLIS[cli].has(root, name)) {
169
- if (dryRun) log(t('log.mcp.remove', { cli: CLIS[cli].label, name }))
170
- else CLIS[cli].remove(root, name)
171
- }
172
- }
173
- },
174
- }
175
- }
176
-
177
- // 배선은 Claude 한 곳이라도 미배선 "사유"는 CLI마다 다를 수 있다 — 상류가
178
- // 자체 경로로 지원하는 CLI에 일괄 "Claude 전용" 사유를 붙이면 거짓 정보가 된다.
179
- // 항목이 사유를 넘기면 그 CLI만 덮고, 나머지는 기본 사유로 채운다.
180
- export function definePlugin({ id, label, installId, detectIds, marketplace, note, group = null, exclusive = null, unsupported: reasons = {}, verified = null }) {
181
- const unsupported = Object.fromEntries(
182
- CLI_IDS.filter((c) => c !== 'claude').map((c) => [c, reasons[c] ?? msg('item.unsupported.claudePlugin')]),
183
- )
184
- return {
185
- id, category: 'plugin', label, scope: 'project', supports: ['claude'], unsupported, note, group, exclusive, verified,
186
- async detect({ root }) {
187
- return { status: isPluginEnabled(root, detectIds) ? 'installed' : 'absent' }
188
- },
189
- async install(ctx) {
190
- const { root, dryRun, exec } = ctx
191
- if (marketplace) await exec('claude', ['plugin', 'marketplace', 'add', marketplace.repo], { cwd: root })
192
- const r = await exec('claude', ['plugin', 'install', installId, '--scope', 'project'], { cwd: root })
193
- if (!r.ok) {
194
- if (!dryRun) enablePlugin(root, installId, marketplace)
195
- return { fallback: true, message: msg('item.plugin.deferred') }
196
- }
197
- },
198
- async uninstall(ctx) {
199
- const { root, dryRun, exec } = ctx
200
- const r = await exec('claude', ['plugin', 'uninstall', installId], { cwd: root })
201
- if (!r.ok && !dryRun) disablePlugin(root, detectIds)
202
- },
203
- }
204
- }
205
-
206
- // supports는 이 항목이 실제로 배선하는 CLI다. 기본값이 claude 하나인 것은
207
- // 대부분의 스킬 상류가 Claude 경로만 주기 때문이고, 상류가 런타임별 설치를
208
- // 주는 항목(skill.gsd)은 목록을 직접 넘긴다 — 넘긴 CLI는 미배선 사유 대상에서
209
- // 빠진다.
210
- export function defineSkill({ id, label, scope, detect, install, uninstall, note, group = null, supports = ['claude'], unsupported: reasons = {}, verified = null }) {
211
- const unsupported = Object.fromEntries(
212
- CLI_IDS.filter((c) => !supports.includes(c)).map((c) => [c, reasons[c] ?? msg('item.unsupported.claudeSkill')]),
213
- )
214
- return { id, category: 'skill', label, scope, supports: [...supports], unsupported, note, group, verified, detect, install, uninstall }
215
- }
216
-
217
- // 이 저장소가 공유 스킬 자리로 쓰는 디렉터리. vercel-labs/skills 레지스트리의
218
- // `--agent universal` 프로젝트 경로와 같은 값이다 — 그래서 레지스트리 설치가
219
- // 우리 배선 위에 그대로 얹힌다.
220
- const SHARED_SKILLS = '.agents/skills'
221
-
222
- function skillDirs(root) {
223
- const dir = repoPath(root, SHARED_SKILLS)
224
- if (!existsSync(dir)) return []
225
- return readdirSync(dir).filter((name) => {
226
- try {
227
- return statSync(join(dir, name)).isDirectory()
228
- } catch {
229
- return false
230
- }
231
- })
232
- }
233
-
234
- // 설치된 디렉터리 이름이 스킬 이름과 다를 수 있다(레지스트리는 SKILL.md의
235
- // frontmatter name으로 고르는데, 저장소의 폴더 이름은 다른 경우가 있다 —
236
- // taste-skill/이 design-taste-frontend를 담는 식이다). 이름이 같은 디렉터리를
237
- // 먼저 보고, 없으면 frontmatter를 읽어 찾는다. 디렉터리 이름만 보면 설치해
238
- // 놓고도 계속 '미설치'로 읽힌다.
239
- function findSkillDir(root, skill) {
240
- const base = repoPath(root, SHARED_SKILLS)
241
- if (existsSync(join(base, skill, 'SKILL.md'))) return skill
242
- for (const name of skillDirs(root)) {
243
- const file = join(base, name, 'SKILL.md')
244
- if (!existsSync(file)) continue
245
- const head = readFileSync(file, 'utf8').slice(0, 2000)
246
- if (new RegExp(`^name:\\s*['"]?${escapeRegExp(skill)}['"]?\\s*$`, 'm').test(head)) return name
247
- }
248
- return null
249
- }
250
-
251
- // 잠금 파일의 이름은 상류 SKILL.md frontmatter에서 온다 — 신뢰 경계 밖이다.
252
- // `npx skills remove <이름>`의 인자이자 `.agents/skills/<이름>` 경로가 되므로,
253
- // 플래그로 읽히는 선행 대시와 경로 구분자·상위 이동은 어느 쪽으로도 쓸 수 없다.
254
- function isSafeSkillName(name) {
255
- return isSafeSegment(name) && !name.startsWith('-')
256
- }
257
-
258
- // 레지스트리가 저장소 루트에 남기는 잠금 파일. 스킬 이름마다 어느 출처에서
259
- // 왔는지를 적어 두므로, 한 상류가 여러 스킬을 깔았을 때 "우리가 넣은 것"만
260
- // 골라 지울 수 있는 유일한 근거다.
261
- const SKILLS_LOCK = 'skills-lock.json'
262
-
263
- // 잠금 파일의 source는 `owner/repo`로 정규화돼 있고 항목은 URL을 넘긴다.
264
- function sourceKey(source) {
265
- return String(source).replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '').replace(/\/+$/, '')
266
- }
267
-
268
- function readLock(root) {
269
- const file = repoPath(root, SKILLS_LOCK)
270
- if (!existsSync(file)) return null
271
- try {
272
- return JSON.parse(readFileSync(file, 'utf8'))
273
- } catch {
274
- // 잠금 파일이 깨졌다고 제거를 막지는 않는다 — 디렉터리 스캔 폴백이 있다.
275
- return null
276
- }
277
- }
278
-
279
- function lockedSkills(root, source) {
280
- const want = sourceKey(source)
281
- return Object.entries(readLock(root)?.skills ?? {})
282
- .filter(([, v]) => sourceKey(v?.source ?? '') === want)
283
- .map(([name]) => name)
284
- }
285
-
286
- // 레지스트리의 remove는 디렉터리도, 잠금 파일의 항목도 남긴다(실측). 디렉터리는
287
- // 아래에서 우리가 지우는데 잠금 항목을 그대로 두면 파일이 "설치돼 있다"고
288
- // 거짓말한다 — 커밋해서 팀과 나누는 파일이라, 동료가 experimental_install로
289
- // 복원하면 지운 스킬이 되살아난다.
290
- function pruneLock(root, names) {
291
- const parsed = readLock(root)
292
- const skills = parsed?.skills
293
- if (!skills) return
294
- const gone = names.filter((n) => Object.hasOwn(skills, n))
295
- if (gone.length === 0) return
296
- for (const n of gone) delete skills[n]
297
- const file = repoPathStrict(root, SKILLS_LOCK)
298
- // 남은 스킬이 없으면 파일째 지운다. 이 파일은 레지스트리가 만든 산출물이라
299
- // 빈 껍데기를 남기면 우리가 만든 흔적이 저장소에 계속 남는다.
300
- if (Object.keys(skills).length === 0) rmSync(file, { force: true })
301
- else writeFileSync(file, `${JSON.stringify(parsed, null, 2)}\n`)
302
- }
303
-
304
- // vercel-labs/skills 레지스트리로 설치하는 스킬. `--agent universal`의 프로젝트
305
- // 경로가 .agents/skills라, 한 번 설치하면 10개 CLI가 함께 본다 — Claude·Kiro·
306
- // Grok은 부트스트랩이 만든 Junction으로, 나머지는 그 경로를 네이티브로 읽는다
307
- // (도구별 실측 근거는 bootstrap/manifest.mjs의 adapters 주석에 있다).
308
- // `--copy`로 실물을 남긴다: 커밋해서 팀과 나누는 자리라 링크는 클론 뒤 깨진다.
309
- //
310
- // skill에 `*`를 주면 그 상류의 스킬을 전부 넣는다. 그때 detect가 볼 대표
311
- // 스킬을 anchor로 지정해야 한다 — 디렉터리가 여럿이라 "무엇이 있으면 설치된
312
- // 것인가"를 이름 하나로 정해 두지 않으면 판정이 흔들린다.
313
- //
314
- // exclusive는 같은 상류를 플러그인으로도 넣을 수 있을 때 쓴다. 두 경로가 겹치면
315
- // 같은 스킬이 두 번 등록되므로 하나만 고르게 막는다.
316
- export function defineRegistrySkill({ id, label, source, skill, anchor = null, note, group = null, exclusive = null, verified = null }) {
317
- const probe = anchor ?? skill
318
- if (skill === '*' && !anchor) throw new LocalizedError('error.registrySkillAnchor', { id })
319
- return {
320
- id, category: 'skill', label, scope: 'project', supports: [...CLI_IDS], unsupported: {}, note, group, exclusive, verified,
321
- async detect({ root }) {
322
- return { status: findSkillDir(root, probe) ? 'installed' : 'absent' }
323
- },
324
- async install({ root, exec }) {
325
- const r = await exec('npx', ['-y', 'skills@latest', 'add', source, '--skill', skill, '--agent', 'universal', '--yes', '--copy'], { cwd: root })
326
- if (!r.ok) throw new LocalizedError('error.registrySkillInstall', { skill, output: r.output })
327
- },
328
- async uninstall({ root, dryRun, exec }) {
329
- // 지울 이름을 먼저 정한다. 여러 스킬을 깐 항목은 잠금 파일이 알려 주고,
330
- // 잠금 파일이 없거나 깨졌으면 대표 스킬 하나라도 치운다.
331
- const names = skill === '*' ? lockedSkills(root, source) : [skill]
332
- const targets = names.length > 0 ? names : [probe]
333
- for (const name of targets) {
334
- // 인자·경로로 쓸 수 없는 이름은 건너뛴다. 잠금 항목은 아래 pruneLock이
335
- // 함께 지운다 — 남기면 다음 제거가 같은 자리에서 또 멈춘다.
336
- if (!isSafeSkillName(name)) continue
337
- // 레지스트리의 remove는 --agent universal에서 "Done!"을 찍고도 디렉터리를
338
- // 남긴다(실측). 그래서 성공/실패와 무관하게 남은 디렉터리를 우리가 지운다 —
339
- // 남겨 두면 detect가 계속 installed로 읽어 제거가 안 된 채 성공으로 보인다.
340
- await exec('npx', ['-y', 'skills@latest', 'remove', name, '--agent', 'universal', '--yes'], { cwd: root })
341
- if (dryRun) continue
342
- const dir = findSkillDir(root, name)
343
- if (dir) rmSync(repoPathStrict(root, `${SHARED_SKILLS}/${dir}`), { recursive: true, force: true })
344
- }
345
- if (!dryRun) pruneLock(root, targets)
346
- },
347
- }
348
- }
package/lib/clis.mjs CHANGED
@@ -1,122 +1,11 @@
1
- import { repoPath, repoPathStrict } from './context.mjs'
2
- import { readJson, setKey, removeKey, getIn } from './jsonfile.mjs'
3
- import { hasSection, appendSection, removeSection } from './tomlfile.mjs'
4
-
5
- // 읽기(has)는 어휘적 경로면 충분하고, 실제로 만들거나 고치는 경로(add·remove)만
6
- // 링크를 통한 저장소 이탈까지 검사한다 — bootstrap의 apply.mjs, design-md의
7
- // designPaths와 같은 규칙이다. 저장소 안 `.codex`·`.claude`가 홈을 가리키는
8
- // Junction일 때 MCP 등록 한 번으로 글로벌 설정이 생성·수정되는 것을 막는다.
9
- function jsonAdapter(relFile, topKey, toEntry) {
10
- return {
11
- // 경로를 밖으로 낸다 — 상세 패널이 "이 항목이 어디에 쓰이나"를 보여 준다.
12
- // 어댑터가 실제로 쓰는 값과 같은 변수라 두 곳이 갈릴 수 없다.
13
- file: relFile,
14
- has(root, name) {
15
- const data = readJson(repoPath(root, relFile))
16
- return getIn(data, [topKey, name]) !== undefined
17
- },
18
- add(root, name, server) {
19
- setKey(repoPathStrict(root, relFile), [topKey, name], toEntry(server))
20
- },
21
- remove(root, name) {
22
- removeKey(repoPathStrict(root, relFile), [topKey, name])
23
- },
24
- }
25
- }
26
-
27
- function tomlLines(server) {
28
- if (server.kind === 'http') return [`url = ${JSON.stringify(server.url)}`]
29
- return [
30
- `command = ${JSON.stringify(server.command)}`,
31
- `args = [${server.args.map((a) => JSON.stringify(a)).join(', ')}]`,
32
- ]
33
- }
34
-
35
- // codex·grok은 TOML을 쓴다. jsonAdapter와 같은 이유로 경로를 한 번만 적는다.
36
- function tomlAdapter(file) {
37
- return {
38
- file,
39
- has: (root, name) => hasSection(repoPath(root, file), name),
40
- add: (root, name, s) => appendSection(repoPathStrict(root, file), name, tomlLines(s)),
41
- remove: (root, name) => removeSection(repoPathStrict(root, file), name),
42
- }
43
- }
44
-
1
+ // 2.0.0이 지원하는 네 CLI. 설치는 각 CLI의 공식 명령이 맡으므로 여기에는
2
+ // 이름과 실행 파일만 둔다 — 설정 파일 경로와 쓰기 어댑터는 더는 없다.
45
3
  export const CLIS = {
46
- claude: {
47
- label: 'Claude Code',
48
- ...jsonAdapter('.mcp.json', 'mcpServers', (s) =>
49
- s.kind === 'http'
50
- ? { type: 'http', url: s.url }
51
- : { type: 'stdio', command: s.command, args: s.args }),
52
- },
53
- codex: {
54
- label: 'Codex',
55
- ...tomlAdapter('.codex/config.toml'),
56
- },
57
- gemini: {
58
- label: 'Gemini CLI',
59
- ...jsonAdapter('.gemini/settings.json', 'mcpServers', (s) =>
60
- s.kind === 'http' ? { httpUrl: s.url } : { command: s.command, args: s.args }),
61
- },
62
- opencode: {
63
- label: 'OpenCode',
64
- ...jsonAdapter('opencode.jsonc', 'mcp', (s) =>
65
- s.kind === 'http'
66
- ? { type: 'remote', url: s.url, enabled: true }
67
- : { type: 'local', command: [s.command, ...s.args], enabled: true }),
68
- },
69
- kilo: {
70
- label: 'Kilo Code',
71
- ...jsonAdapter('.kilocode/mcp.json', 'mcpServers', (s) =>
72
- s.kind === 'http'
73
- ? { type: 'streamable-http', url: s.url, disabled: false }
74
- : { command: s.command, args: s.args, disabled: false }),
75
- },
76
- kiro: {
77
- label: 'Kiro',
78
- ...jsonAdapter('.kiro/settings/mcp.json', 'mcpServers', (s) =>
79
- s.kind === 'http'
80
- ? { url: s.url, disabled: false, autoApprove: [] }
81
- : { command: s.command, args: s.args, disabled: false, autoApprove: [] }),
82
- },
83
- kimi: {
84
- label: 'Kimi Code',
85
- ...jsonAdapter('.kimi-code/mcp.json', 'mcpServers', (s) =>
86
- s.kind === 'http' ? { url: s.url } : { command: s.command, args: s.args }),
87
- },
88
- grok: {
89
- label: 'Grok Build',
90
- ...tomlAdapter('.grok/config.toml'),
91
- },
92
- copilot: {
93
- label: 'GitHub Copilot CLI',
94
- // Copilot CLI는 루트 .mcp.json도 읽지만(같은 이름이면 그쪽이 우선),
95
- // 도구마다 자기 파일을 갖는 이 저장소의 패턴을 따라 .github/mcp.json에 쓴다.
96
- ...jsonAdapter('.github/mcp.json', 'mcpServers', (s) =>
97
- s.kind === 'http'
98
- ? { type: 'http', url: s.url }
99
- : { type: 'local', command: s.command, args: s.args }),
100
- },
101
- vscode: {
102
- label: 'VS Code Copilot',
103
- // VS Code는 최상위 키가 servers이고 로컬 서버 타입이 stdio다.
104
- ...jsonAdapter('.vscode/mcp.json', 'servers', (s) =>
105
- s.kind === 'http'
106
- ? { type: 'http', url: s.url }
107
- : { type: 'stdio', command: s.command, args: s.args }),
108
- },
109
- // Antigravity는 프로젝트 스코프 MCP 파일이 없다 — MCP는 홈 글로벌
110
- // (~/.gemini/config/mcp_config.json)에서만 설정한다(bootstrap/manifest.mjs
111
- // 주석·README). 그래서 어댑터(file·has·add·remove)가 없다. 그래도 목록에
112
- // 두는 이유는 부트스트랩이 지원하는 11개 도구와 화면의 CLI 수가 어긋나면
113
- // 안 되기 때문이다 — 공유 .agents/skills를 네이티브로 읽으므로 레지스트리
114
- // 스킬은 실제로 닿고, MCP 항목은 "왜 안 닿는지"를 사유로 밝힌다.
115
- antigravity: {
116
- label: 'Antigravity',
117
- },
4
+ claude: { label: 'Claude Code', bin: 'claude' },
5
+ codex: { label: 'Codex', bin: 'codex' },
6
+ opencode: { label: 'OpenCode', bin: 'opencode' },
7
+ // Antigravity CLI의 실행 파일 이름은 agy다.
8
+ antigravity: { label: 'Antigravity', bin: 'agy' },
118
9
  }
119
10
 
120
11
  export const CLI_IDS = Object.keys(CLIS)
121
- // 프로젝트 설정 파일에 MCP를 쓸 수 있는 CLI — 어댑터가 있는 것만.
122
- export const MCP_CLI_IDS = CLI_IDS.filter((id) => typeof CLIS[id].add === 'function')
@@ -0,0 +1,47 @@
1
+ // 출력 모드(--print). 실행 모드와 같은 item.steps()에서 명령을 얻는다 —
2
+ // 사용자가 복사해 직접 돌리는 명령과 설치기가 도는 명령이 같아야 한다.
3
+ import { CLIS, CLI_IDS } from './clis.mjs'
4
+ import { shellQuote } from './catalog.mjs'
5
+ import { SHARED } from './recipe.mjs'
6
+
7
+ const ORDER = [SHARED, ...CLI_IDS, 'files']
8
+ // 따옴표 없이도 셸이 한 인자로 읽는 문자만. 복사해 붙일 줄이 읽기 쉬워야 한다.
9
+ const PLAIN = /^[A-Za-z0-9_@+=:,./-]+$/
10
+
11
+ export function quoteArg(arg, platform = process.platform) {
12
+ return PLAIN.test(arg) ? arg : shellQuote(arg, platform)
13
+ }
14
+
15
+ export function planCommands(changes) {
16
+ const groups = new Map()
17
+ const push = (key, entry) => {
18
+ if (!groups.has(key)) groups.set(key, { key, entries: [] })
19
+ groups.get(key).entries.push(entry)
20
+ }
21
+ for (const { item, action, state } of changes) {
22
+ // 파일을 직접 쓰는 항목(.gitmessage.txt 등)은 명령이 없다.
23
+ if (typeof item.steps !== 'function') { push('files', { item, action, manual: true }); continue }
24
+ for (const step of item.steps(action, state)) push(step.cli, { item, action, step })
25
+ }
26
+ return ORDER.filter((k) => groups.has(k)).map((k) => groups.get(k))
27
+ }
28
+
29
+ export function formatCommands(groups, { t, scope, platform = process.platform }) {
30
+ if (groups.length === 0) return [t('print.none')]
31
+ const lines = [t('print.title'), '']
32
+ for (const g of groups) {
33
+ const title = g.key === SHARED ? t('print.shared') : g.key === 'files' ? t('print.files') : CLIS[g.key].label
34
+ lines.push(`# ${title} — ${t(`scope.${scope}`)}`)
35
+ for (const e of g.entries) {
36
+ if (e.manual) lines.push(`# ${t('print.fileItem', { label: e.item.label })}`)
37
+ else if (e.step.fn) lines.push(`# ${e.step.describe}`)
38
+ else {
39
+ // 작업 디렉터리가 필요한 단계 — 복사해 붙이는 사람이 위치를 알아야 한다.
40
+ if (e.step.cwd) lines.push(`# (run in ${e.step.cwd})`)
41
+ lines.push(e.step.argv.map((a) => quoteArg(a, platform)).join(' '))
42
+ }
43
+ }
44
+ lines.push('')
45
+ }
46
+ return lines
47
+ }
package/lib/engine.mjs CHANGED
@@ -1,14 +1,19 @@
1
1
  import { makeExec } from './catalog.mjs'
2
2
  import { createT, LocalizedError, msg } from './i18n/index.mjs'
3
+ import { createProbe } from './probe.mjs'
3
4
 
4
- export async function scan(root, items) {
5
+ // probe는 스캔 하나가 공유하는 감지 캐시다. 없으면 여기서 만든다 — design.md
6
+ // 항목처럼 probe를 안 쓰는 detect는 그냥 무시한다.
7
+ export async function scan(root, items, { probe = null, scope = 'project' } = {}) {
8
+ const shared = probe ?? createProbe({ root, scope, exec: makeExec(false) })
5
9
  const states = []
6
10
  for (const item of items) {
7
11
  try {
8
- const r = await item.detect({ root })
9
- // excluded는 전역 항목이 "이 머신에 없는 CLI"를 알리는 자리다 — 행 힌트가
10
- // supports를 나열할 때 그 CLI를 빼고 따로 표시한다.
11
- states.push({ item, status: r.status, detail: r.detail, excluded: r.excluded ?? [] })
12
+ const r = await item.detect({ root, probe: shared })
13
+ // excluded는 "이 머신에 없는 CLI"를 알리는 자리다 — 행 힌트가 supports를
14
+ // 나열할 때 그 CLI를 빼고 따로 표시한다. result는 감지 결과 전체로,
15
+ // 레시피 항목의 steps()가 어느 CLI에 이미 있는지(present)를 읽는다.
16
+ states.push({ item, status: r.status, detail: r.detail, excluded: r.excluded ?? [], result: r })
12
17
  } catch (err) {
13
18
  states.push({ item, status: 'absent', detail: msg('item.scanFailed', { message: err.message }) })
14
19
  }
@@ -33,11 +38,11 @@ export function assertExclusive(items, selectedIds) {
33
38
 
34
39
  export function planChanges(states, selectedIds) {
35
40
  const changes = []
36
- for (const { item, status } of states) {
41
+ for (const { item, status, result } of states) {
37
42
  const selected = selectedIds.has(item.id)
38
- if (selected && status === 'absent') changes.push({ item, action: 'install' })
39
- else if (selected && status === 'partial') changes.push({ item, action: 'complete' })
40
- else if (!selected && status !== 'absent') changes.push({ item, action: 'uninstall' })
43
+ if (selected && status === 'absent') changes.push({ item, action: 'install', state: result })
44
+ else if (selected && status === 'partial') changes.push({ item, action: 'complete', state: result })
45
+ else if (!selected && status !== 'absent') changes.push({ item, action: 'uninstall', state: result })
41
46
  }
42
47
  return changes
43
48
  }
@@ -74,7 +79,7 @@ export async function apply(root, changes, { dryRun = false, log = console.log,
74
79
  }
75
80
 
76
81
  for (let index = 0; index < total; index++) {
77
- const { item, action } = changes[index]
82
+ const { item, action, state } = changes[index]
78
83
 
79
84
  // 중단은 **항목 경계에서만** 본다. 외부 명령을 중간에 죽이면 반쯤 설치된
80
85
  // 상태가 남는다 — 그래서 AbortSignal이 아니라 술어(predicate)다.
@@ -98,7 +103,7 @@ export async function apply(root, changes, { dryRun = false, log = console.log,
98
103
  // log까지 넘긴다 — 파일을 직접 쓰는 항목(MCP·design.md)은 exec를 거치지
99
104
  // 않아 dry-run에서 아무것도 보고하지 못했다. 결과줄은 ✔ 설치라고 찍히는데
100
105
  // 무엇이 바뀔지는 알 수 없는 상태였다.
101
- const ctx = { root, dryRun, exec, log, t }
106
+ const ctx = { root, dryRun, exec, log, t, state }
102
107
  let result
103
108
  try {
104
109
  const fn = action === 'uninstall' ? item.uninstall : item.install