@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.
- package/README.md +31 -20
- package/install.mjs +25 -8
- package/lib/activation.mjs +31 -0
- package/lib/args.mjs +5 -2
- package/lib/bootstrap/manifest.mjs +21 -47
- package/lib/bootstrap/templates.mjs +7 -60
- package/lib/catalog.mjs +22 -241
- package/lib/clis.mjs +11 -118
- package/lib/commands.mjs +47 -0
- package/lib/engine.mjs +16 -11
- package/lib/i18n/catalog/en.mjs +58 -49
- package/lib/i18n/catalog/ko.mjs +58 -49
- package/lib/items/mcp.codebase-memory.mjs +6 -4
- package/lib/items/mcp.graft.mjs +6 -4
- package/lib/items/mcp.graphify.mjs +6 -4
- package/lib/items/mcp.headroom.mjs +6 -4
- package/lib/items/mcp.notion.mjs +7 -4
- package/lib/items/mcp.supabase.mjs +7 -4
- package/lib/items/mcp.vercel.mjs +7 -4
- package/lib/items/plugin.ai-memory.mjs +29 -0
- package/lib/items/plugin.cli-anything.mjs +18 -15
- package/lib/items/plugin.ecc.mjs +22 -20
- package/lib/items/plugin.hyperframes.mjs +17 -13
- package/lib/items/plugin.impeccable.mjs +19 -22
- package/lib/items/plugin.mattpocock-skills.mjs +23 -17
- package/lib/items/plugin.ponytail.mjs +24 -106
- package/lib/items/plugin.superpowers.mjs +20 -24
- package/lib/items/plugin.understand-anything.mjs +21 -19
- package/lib/items/skill.agent-browser.mjs +6 -6
- package/lib/items/skill.agent-handoff-manager.mjs +14 -0
- package/lib/items/skill.archify.mjs +5 -5
- package/lib/items/skill.caveman.mjs +5 -5
- package/lib/items/skill.diagram-design.mjs +5 -5
- package/lib/items/skill.eli5.mjs +6 -6
- package/lib/items/skill.find-skills.mjs +5 -5
- package/lib/items/skill.gsd.mjs +28 -89
- package/lib/items/skill.gstack.mjs +58 -65
- package/lib/items/skill.hallmark.mjs +5 -5
- package/lib/items/skill.html-plan.mjs +15 -0
- package/lib/items/skill.i-have-adhd.mjs +6 -6
- package/lib/items/skill.im-not-ai.mjs +5 -6
- package/lib/items/skill.impeccable.mjs +6 -6
- package/lib/items/skill.karpathy.mjs +5 -5
- package/lib/items/skill.mattpocock-skills.mjs +5 -6
- package/lib/items/skill.mcp-builder.mjs +5 -5
- package/lib/items/skill.officecli.mjs +5 -5
- package/lib/items/skill.prompt-master.mjs +5 -5
- package/lib/items/skill.remotion.mjs +5 -5
- package/lib/items/skill.strix.mjs +5 -6
- package/lib/items/skill.superpowers.mjs +6 -7
- package/lib/items/skill.taste.mjs +5 -5
- package/lib/items/skill.unslop.mjs +12 -0
- package/lib/probe.mjs +136 -0
- package/lib/recipe-helpers.mjs +281 -0
- package/lib/recipe.mjs +181 -0
- package/lib/skills-registry.mjs +93 -0
- package/lib/status.mjs +7 -3
- package/lib/tui/detail.mjs +5 -25
- package/lib/tui/rows.mjs +25 -14
- package/lib/tui/run.mjs +35 -8
- package/package.json +4 -3
- package/lib/claude-plugins.mjs +0 -64
- package/lib/global-plugin.mjs +0 -239
- package/lib/items/global.ponytail.mjs +0 -56
- package/lib/items/global.superpowers.mjs +0 -58
- package/lib/tomlfile.mjs +0 -67
package/lib/catalog.mjs
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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 대상이 명령 두 개로 쪼개졌다.
|
|
@@ -64,6 +60,9 @@ export function shellQuote(text, platform = process.platform) {
|
|
|
64
60
|
|
|
65
61
|
const execFileAsync = promisify(execFile)
|
|
66
62
|
|
|
63
|
+
// 셸이 따옴표 없이도 한 단어로 읽는 명령 이름(npx·claude·git 등).
|
|
64
|
+
const BARE_COMMAND = /^[A-Za-z0-9_.-]+$/
|
|
65
|
+
|
|
67
66
|
// 외부 명령 하나에 주는 시간. npx·git clone이 네트워크에서 멈추면 예전에는
|
|
68
67
|
// 영원히 기다렸다 — Ctrl+C도 항목 경계에서만 보므로 그 항목이 끝나야 멈췄다.
|
|
69
68
|
// 10분이면 gsd(3,500파일)·ECC 같은 큰 설치도 끝나고, 멈춘 명령은 결국 실패로
|
|
@@ -89,7 +88,11 @@ export function makeExec(dryRun, log = console.log, t = null) {
|
|
|
89
88
|
const shell = opts.shell ?? process.platform === 'win32'
|
|
90
89
|
// shell + 인자 배열을 함께 넘기면 Node가 DEP0190으로 경고한다(인자가 quote 없이 이어붙기 때문).
|
|
91
90
|
// 어차피 우리가 직접 quote하므로, 완성된 한 줄 명령을 넘기고 인자 배열은 비운다.
|
|
92
|
-
|
|
91
|
+
// 명령 이름은 메타문자가 없으면 감싸지 않는다 — cmd.exe는 따옴표로 감싼
|
|
92
|
+
// 확장자 없는 이름("npx")을 PATH에서 찾으면 그 배치 안의 %~dp0를 현재
|
|
93
|
+
// 디렉터리로 풀어, npx.cmd가 node_modules\npm을 못 찾고 죽는다.
|
|
94
|
+
const head = BARE_COMMAND.test(cmd) ? cmd : shellQuote(cmd)
|
|
95
|
+
const [file, fileArgs] = shell ? [[head, ...args.map((s) => shellQuote(s))].join(' '), []] : [cmd, args]
|
|
93
96
|
try {
|
|
94
97
|
// execFile은 stdio를 강제로 파이프로 고정하고 옵션으로 넘겨도 무시한다 —
|
|
95
98
|
// 그래서 stdout·stderr는 버퍼링이 공짜로 따라온다. 문제는 stdin이다:
|
|
@@ -124,225 +127,3 @@ export function makeExec(dryRun, log = console.log, t = null) {
|
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
129
|
}
|
|
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,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
+
// 지원하는 여섯 CLI. 설치는 각 CLI의 공식 명령이 맡으므로 여기에는
|
|
2
|
+
// 이름과 실행 파일만 둔다 — 설정 파일 경로와 쓰기 어댑터는 더는 없다.
|
|
45
3
|
export const CLIS = {
|
|
46
|
-
claude: {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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' },
|
|
9
|
+
copilot: { label: 'GitHub Copilot CLI', bin: 'copilot' },
|
|
10
|
+
// VS Code Copilot에는 설치 명령이 없다(`code --add-mcp`는 제거·목록이 없다).
|
|
11
|
+
// `code`는 "VS Code가 이 머신에 있다"는 신호로만 쓰고, 설정은 파일로 쓴다.
|
|
12
|
+
vscode: { label: 'VS Code Copilot', bin: 'code' },
|
|
118
13
|
}
|
|
119
14
|
|
|
120
15
|
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')
|
package/lib/commands.mjs
ADDED
|
@@ -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
|
-
|
|
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는
|
|
10
|
-
//
|
|
11
|
-
|
|
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
|