@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
@@ -6,7 +6,6 @@
6
6
  // 렌더 층의 의존성이 된다. 그래서 사유 그룹핑이 rows가 아니라 여기 있고,
7
7
  // rows.mjs가 반대로 여기서 가져다 쓴다.
8
8
  import { toText, createT } from '../i18n/index.mjs'
9
- import { CLIS } from '../clis.mjs'
10
9
  import { categoryLabel } from '../design-md/flow.mjs'
11
10
  import { cut, labelWidth, pad, width, wrap } from '../width.mjs'
12
11
 
@@ -68,36 +67,17 @@ function wrapNames(names, limit) {
68
67
  function wiredLines(item, w, lead, t) {
69
68
  const supports = item.supports ?? []
70
69
  if (supports.length === 0) return []
71
- // 경로는 MCP에서만 붙인다. 그때만 clis.mjs 어댑터가 경로의 유일한 진실이다 —
72
- // plugin·skill은 설치 자리가 항목마다 달라 어댑터가 알지 못한다.
73
- const showFile = item.category === 'mcp'
74
- // 경로가 없으면 세로로 늘어놓을 이유가 없다 — CLI 열한 개를 한 줄에 하나씩
75
- // 놓으면 80×24에서 `배선 ✔ claude` 뒤가 `…외 9줄`로 잘려 정작 "어디에
76
- // 되나"를 펼쳐야만 읽을 수 있었다. 이름을 ·로 이어 접는다(미배선과 같은 꼴).
77
- if (!showFile) {
78
- return wrapNames(supports, Math.max(1, w - lead - 2)).map((line, i) =>
79
- cut(`${i === 0 ? pad(t('detail.wired'), lead) : ' '.repeat(lead)}${i === 0 ? '✔ ' : ' '}${line}`, w))
80
- }
81
- // MCP는 "CLI 경로" 쌍을 한 줄에 들어가는 만큼 채운다. 한 줄에 하나씩 놓으면
82
- // 열 개가 열 줄이라 80×24 패널에서 절반이 `…외 N줄`로 접혔다. 폭이 좁으면
83
- // 한 줄에 하나로 자연히 돌아간다.
84
- const pairs = supports.map((cli) => (CLIS[cli]?.file ? `${cli} ${CLIS[cli].file}` : cli))
85
- const colW = Math.max(...pairs.map((p) => width(p))) + 3
86
- const room = Math.max(1, w - lead - 2)
87
- const perLine = Math.max(1, Math.floor(room / colW))
88
- const rows = []
89
- for (let i = 0; i < pairs.length; i += perLine) {
90
- const chunk = pairs.slice(i, i + perLine)
91
- rows.push(chunk.map((p, j) => (j < chunk.length - 1 ? pad(p, colW) : p)).join(''))
92
- }
93
- return rows.map((line, i) => cut(`${i === 0 ? pad(t('detail.wired'), lead) : ' '.repeat(lead)}${i === 0 ? '✔ ' : ' '}${line}`, w))
70
+ // 설치는 각 CLI의 명령이 맡아 이 설치기가 쓰는 설정 파일이 없다 — 이름만
71
+ // ·로 이어 접는다(미배선과 같은 꼴).
72
+ return wrapNames(supports, Math.max(1, w - lead - 2)).map((line, i) =>
73
+ cut(`${i === 0 ? pad(t('detail.wired'), lead) : ' '.repeat(lead)}${i === 0 ? '✔ ' : ' '}${line}`, w))
94
74
  }
95
75
 
96
76
  function unwiredLines(item, w, lead, t) {
97
77
  const out = []
98
78
  unsupportedGroups(item, t).forEach((group, i) => {
99
79
  const head = i === 0 ? pad(t('detail.unwired'), lead) : ' '.repeat(lead)
100
- // 미배선 CLI 이름도 같은 규칙으로 접는다 — 11개를 한 줄에 두면 뒤가 잘린다.
80
+ // 미배선 CLI 이름도 같은 규칙으로 접는다 — 여럿을 한 줄에 두면 좁은 화면에서 뒤가 잘린다.
101
81
  wrapNames(group.clis, Math.max(1, w - lead - 2)).forEach((line, j) => {
102
82
  out.push(cut(`${j === 0 ? head : ' '.repeat(lead)}${j === 0 ? '✖ ' : ' '}${line}`, w))
103
83
  })
package/lib/tui/rows.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  import { existsSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
  import { scan } from '../engine.mjs'
6
- import { loadItems } from '../catalog.mjs'
6
+ import { loadItems, makeExec } from '../catalog.mjs'
7
+ import { createProbe } from '../probe.mjs'
7
8
  import { CLI_IDS } from '../clis.mjs'
8
9
  import { runBootstrap } from '../bootstrap/flow.mjs'
9
10
  import { MANIFEST } from '../bootstrap/manifest.mjs'
@@ -30,7 +31,7 @@ export const SECTION_ORDER = [ACTION_SECTION, 'plugin', 'mcp', 'skill', 'config'
30
31
  // __commit은 CONFIG 탭에만 나오지만 규칙은 같다 — 헤더가 "무엇을 고르는
31
32
  // 자리인가"를 말한다. 커밋 템플릿은 대상 파일이 하나뿐이라 헤더에 그 파일
32
33
  // 이름과 "하나만"이라는 규칙까지 실어, 라디오 표시와 함께 읽히게 한다.
33
- export const GROUP_ORDER = ['__scope-project', '__scope-user', '__token', '__context', '__style', '__flow', '__commit', '__service']
34
+ export const GROUP_ORDER = ['__token', '__context', '__style', '__flow', '__commit', '__service']
34
35
 
35
36
  function groupRank(group) {
36
37
  const i = GROUP_ORDER.indexOf(group)
@@ -106,7 +107,7 @@ export function designHint(state, multiProvider = false, t) {
106
107
  return parts.filter(Boolean).join(' · ')
107
108
  }
108
109
 
109
- // 이름을 나열할 수 있는 최대 CLI 수. 넷까지는 이름(codex·gemini·opencode·copilot)이
110
+ // 이름을 나열할 수 있는 최대 CLI 수. 넷까지는 이름(claude·codex·opencode·antigravity)이
110
111
  // 80칸 힌트 자리에 들고, 그보다 많으면 "몇 개나 되나"가 더 유용하다.
111
112
  const NAME_LIMIT = 4
112
113
 
@@ -152,7 +153,7 @@ export function designShortHint(state, multiProvider = false, t = createT('en'))
152
153
 
153
154
  // group = 탭 **안쪽**의 소분류 헤더. design.md만 76개라 카테고리로 갈라야 읽힌다.
154
155
  // 나머지 탭은 항목이 적어 그룹 없이 평평하게 둔다.
155
- function itemRow({ id, section, label, hint, fullHint = hint, statusDetail = null, status, previewTarget = null, item, extra = '', group = null, t = createT('en') }) {
156
+ function itemRow({ id, section, label, hint, fullHint = hint, statusDetail = null, status, previewTarget = null, item, result = null, extra = '', group = null, t = createT('en') }) {
156
157
  return {
157
158
  kind: 'item',
158
159
  id,
@@ -171,6 +172,7 @@ function itemRow({ id, section, label, hint, fullHint = hint, statusDetail = nul
171
172
  status,
172
173
  previewTarget,
173
174
  item,
175
+ result,
174
176
  searchText: `${label} ${fullHint} ${sectionTerms(t, section)} ${extra}`.toLowerCase(),
175
177
  }
176
178
  }
@@ -202,7 +204,7 @@ function bootstrapHint(root, t) {
202
204
  return t('action.bootstrap.hint', { present, total: MANIFEST.files.length })
203
205
  }
204
206
 
205
- export function buildActions(root, { designItems = [], t = createT('en') } = {}) {
207
+ export function buildActions(root, { designItems = [], scope = 'project', t = createT('en') } = {}) {
206
208
  return [
207
209
  // 맨 위 상주 행. 실행은 run.mjs가 특수 처리한다 — 화면을 벗어나지 않고
208
210
  // 그 자리에서 t를 갈아끼워야 하므로 다른 액션과 흐름이 다르다(run은 null).
@@ -212,6 +214,14 @@ export function buildActions(root, { designItems = [], t = createT('en') } = {})
212
214
  hint: t('action.language.hint', { current: t(`locale.${t.locale}`) }),
213
215
  t,
214
216
  }),
217
+ // 언어 행과 같은 방식 — run.mjs가 그 자리에서 범위를 바꾸고 다시 스캔한다.
218
+ // 목록 포커스의 글자 키는 검색칸으로 올라가므로 단축키 대신 행으로 둔다.
219
+ actionRow({
220
+ id: 'action.scope',
221
+ label: t('action.scope.label'),
222
+ hint: t('action.scope.hint', { current: t(`scope.${scope}`) }),
223
+ t,
224
+ }),
215
225
  actionRow({
216
226
  id: 'action.bootstrap',
217
227
  label: t('action.bootstrap.label'),
@@ -270,18 +280,16 @@ export function buildRows({ actions = [], agentStates = [], designStates = [], m
270
280
  itemRow({
271
281
  id: s.item.id,
272
282
  section: s.item.category,
273
- // plugin 탭은 성격이 아니라 설치 범위로 가른다 — 같은 상류의 두 판
274
- // (저장소/전역)이 무엇을 건드리는지가 이 탭에서 고르는 기준이다.
275
- // 표시 계층의 오버라이드다: 항목의 group 필드는 성격의 진실로 남는다.
276
- group: s.item.category === 'plugin'
277
- ? (s.item.scope === 'user' ? '__scope-user' : '__scope-project')
278
- : s.item.group ?? null,
283
+ // 범위는 실행당 하나(작업 탭의 범위 행)라 plugin 탭도 성격으로 가른다.
284
+ group: s.item.group ?? null,
279
285
  label: scopedLabel(s.item, t),
280
286
  hint: agentShortHint(s.item, s, t),
281
287
  fullHint: agentHint(s.item, s, t),
282
288
  statusDetail: toText(t, s.detail) ?? null,
283
289
  status: s.status,
284
290
  item: s.item,
291
+ // 감지 결과 — 변경 계획이 어느 CLI에 이미 있는지(present)를 여기서 읽는다.
292
+ result: s.result ?? null,
285
293
  extra: s.item.id,
286
294
  t,
287
295
  }),
@@ -324,7 +332,10 @@ export function buildRows({ actions = [], agentStates = [], designStates = [], m
324
332
  export async function collectRows(root, opts = {}) {
325
333
  const { fetchImpl = netFetch, designDirs = [], env = process.env, catalogFile = CATALOG_PATH, log = () => {}, t = createT('en') } = opts
326
334
 
327
- const agentItems = await loadItems()
335
+ const scope = opts.scope ?? 'project'
336
+ const agentItems = await loadItems({ scope })
337
+ // 스캔마다 새 감지 캐시 — 적용 뒤 재스캔이 적용 전 목록을 읽으면 안 된다.
338
+ const probe = createProbe({ root, scope, exec: makeExec(false) })
328
339
  const catalog = loadCatalog(catalogFile)
329
340
  const sources = opts.sources ?? discoverSources({
330
341
  ...(opts.bundleDir ? { bundleDir: opts.bundleDir } : {}),
@@ -334,12 +345,12 @@ export async function collectRows(root, opts = {}) {
334
345
  })
335
346
  const designItems = buildItems(catalog, { fetchImpl, sources })
336
347
 
337
- const [agentStates, designStates] = await Promise.all([scan(root, agentItems), scan(root, designItems)])
348
+ const [agentStates, designStates] = await Promise.all([scan(root, agentItems, { probe }), scan(root, designItems)])
338
349
  const multiProvider = new Set(designItems.map((i) => i.providerId)).size > 1
339
350
 
340
351
  return {
341
352
  rows: buildRows({
342
- actions: buildActions(root, { designItems, t }),
353
+ actions: buildActions(root, { designItems, scope, t }),
343
354
  agentStates,
344
355
  designStates,
345
356
  multiProvider,
package/lib/tui/run.mjs CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  import { render, renderReview, renderHelp, bodyHeight, footerHeight } from './render.mjs'
16
16
  import { createProgress, applyEvent, progressLines } from './progress.mjs'
17
17
  import { scopedLabel } from '../labels.mjs'
18
+ import { planCommands, formatCommands } from '../commands.mjs'
19
+ import { activationLines } from '../activation.mjs'
18
20
 
19
21
  const ESC = String.fromCharCode(27)
20
22
  const HIDE_CURSOR = `${ESC}[?25l`
@@ -53,7 +55,7 @@ function keyReader(stdin) {
53
55
  }
54
56
 
55
57
  function itemStates(rows) {
56
- return rows.filter((r) => r.kind === 'item').map((r) => ({ item: r.item, status: r.status }))
58
+ return rows.filter((r) => r.kind === 'item').map((r) => ({ item: r.item, status: r.status, result: r.result }))
57
59
  }
58
60
 
59
61
  // row.section은 이제 소문자 id다(rows.mjs Task 9) — t 없이 그대로 찍으면
@@ -92,8 +94,10 @@ export async function runTui(root, opts = {}) {
92
94
  const opener = opts.opener ?? makeOpener(dryRun, log)
93
95
  // let으로 둔다 — 언어 전환 행(cycleLanguage)이 실행 중에 갈아끼운다.
94
96
  let { t = createT('en'), localeForced = false } = opts
97
+ // 범위 행(action.scope)이 실행 중에 갈아끼운다.
98
+ let scope = opts.scope ?? 'project'
95
99
 
96
- let collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, log: () => {}, t })
100
+ let collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, scope, log: () => {}, t })
97
101
 
98
102
  // raw 모드를 켤 수 없으면(CI·파이프) 목록만 내고 끝낸다.
99
103
  if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
@@ -106,7 +110,7 @@ export async function runTui(root, opts = {}) {
106
110
  // 타이핑하면 검색칸으로 올라간다.
107
111
  let state = createState(collected.rows, { selectedIds: installedIds(collected.rows), focus: 'list' })
108
112
  let status = ''
109
- // 순환 대상: 전체(null) + CLI 10개. state.mjs는 아무것도 import 하지 않으므로
113
+ // 순환 대상: 전체(null) + CLI 넷. state.mjs는 아무것도 import 하지 않으므로
110
114
  // 목록을 여기서 만들어 넘긴다.
111
115
  const CLI_OPTIONS = [null, ...CLI_IDS]
112
116
  let detailExpanded = false
@@ -165,7 +169,7 @@ export async function runTui(root, opts = {}) {
165
169
  // 실제 설치 상태로 되돌려야 한다 — 둘을 한 함수로 묶으면 언어를 바꿀 때
166
170
  // 사용자가 고르던 항목이 조용히 날아간다.
167
171
  const rebuild = async (keepSelection) => {
168
- collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, t, log: () => {} })
172
+ collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, scope, t, log: () => {} })
169
173
  const ids = keepSelection ? [...state.selected] : installedIds(collected.rows)
170
174
  state = replaceRows(state, collected.rows, ids)
171
175
  }
@@ -227,6 +231,7 @@ export async function runTui(root, opts = {}) {
227
231
  if (key.ctrl && (key.name === 'c' || key.name === 'q')) return 'quit'
228
232
  if (key.name === 'escape') return 'cancel'
229
233
  if (key.name === 'return' || key.name === 'enter') return 'apply'
234
+ if (key.str === 'p') return 'print'
230
235
  }
231
236
  } finally {
232
237
  redraw = paint
@@ -256,7 +261,6 @@ export async function runTui(root, opts = {}) {
256
261
 
257
262
  try {
258
263
  const results = await apply(root, changes, {
259
- dryRun,
260
264
  log: () => {}, // 로그는 진행 화면이 대신한다
261
265
  t,
262
266
  shouldStop: checkAbort,
@@ -385,6 +389,14 @@ export async function runTui(root, opts = {}) {
385
389
  const row = currentRow(state)
386
390
  // 언어 행은 화면을 벗어나지 않는다 — 그 자리에서 t를 갈아끼우고 다시 그린다.
387
391
  if (row?.id === 'action.language') { status = await cycleLanguage(); continue }
392
+ if (row?.id === 'action.scope') {
393
+ scope = scope === 'project' ? 'user' : 'project'
394
+ // 다른 범위의 설치 상태로 선택을 다시 맞춘다 — 저장소에서 고르던 체크가
395
+ // 전역 목록에 남으면 엉뚱한 항목이 설치된다.
396
+ await rebuild(false)
397
+ status = t('tui.scope.switched', { current: t(`scope.${scope}`) })
398
+ continue
399
+ }
388
400
  if (row?.kind === 'action') {
389
401
  await suspend(async () => {
390
402
  // 작업 하나의 예외가 화면 전체를 끌고 내려가면 안 된다 — 깨진
@@ -409,13 +421,24 @@ export async function runTui(root, opts = {}) {
409
421
  if (verdict === 'quit') break
410
422
  if (verdict === 'cancel') { status = t('tui.submitCancelled'); continue }
411
423
 
424
+ // --print로 띄웠거나 검토 화면에서 p를 눌렀다 — 실행 대신 같은 steps()의
425
+ // 명령을 화면 밖에 편다. 복사해 직접 돌릴 수 있어야 한다.
426
+ if (verdict === 'print' || dryRun) {
427
+ await suspend(async () => {
428
+ log('')
429
+ for (const line of formatCommands(planCommands(changes), { t, scope })) log(line)
430
+ for (const line of activationLines(changes, t)) log(line)
431
+ await pause()
432
+ })
433
+ continue
434
+ }
435
+
412
436
  const results = await runApply(changes)
413
437
 
414
438
  // 실패가 있으면 자세한 사연을 화면 밖에서 보여 준다 — 실패 메시지는
415
439
  // 길고, 화면 안에서 잘리면 진단이 불가능해진다. 성공해도 message가
416
- // 붙은 결과(definePlugin·ponytail이 claude CLI 부재로 config 직접
417
- // 기록으로 대체할 때 등)는 똑같이 사연이 있다 — ok만 보고 걸러내면
418
- // 그 사연이 조용히 사라진다. 건너뜀은 실패가 아니다 — 진행 화면에
440
+ // 붙은 결과는 똑같이 사연이 있다 — ok만 보고 걸러내면 그 사연이
441
+ // 조용히 사라진다. 건너뜀은 실패가 아니다 — 진행 화면에
419
442
  // 이미 나타났고, 다시 여기 나열하면 사용자가 스스로 취소한 일을
420
443
  // 실패처럼 읽게 된다.
421
444
  const notable = results.filter((r) => !r.skipped && (!r.ok || r.message))
@@ -427,6 +450,10 @@ export async function runTui(root, opts = {}) {
427
450
  log(` ${r.ok ? '✔' : '✖'} ${t(`change.${r.action}`)} ${scopedLabel(r.item, t)}${message ? ` — ${message}` : ''}`)
428
451
  }
429
452
  }
453
+ // 성공한 설치만 안내한다 — 실패한 항목에 "새 세션을 시작하라"고 하면 거짓이다.
454
+ const done = new Set(results.filter((r) => r.ok).map((r) => r.item.id))
455
+ const notes = activationLines(changes.filter((c) => done.has(c.item.id)), t)
456
+ if (notes.length > 0) log(`\n${notes.join('\n')}`)
430
457
  log(`\n${t('apply.seeGitDiff')}`)
431
458
  await pause()
432
459
  })
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@rch4com/agent-setup",
3
- "version": "1.28.0",
3
+ "version": "2.0.0",
4
4
  "description": "Repository-scoped bootstrap and optional-item installer for using multiple coding-agent CLIs in one repository | 여러 코딩 에이전트 CLI를 한 저장소에서 함께 쓰기 위한 저장소 범위 부트스트랩과 선택 항목 설치기",
5
5
  "keywords": [
6
6
  "agents-md",
7
7
  "claude-code",
8
8
  "codex",
9
- "gemini-cli",
10
- "copilot",
9
+ "opencode",
10
+ "antigravity",
11
11
  "mcp",
12
12
  "agent-skills"
13
13
  ],
@@ -1,64 +0,0 @@
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,239 +0,0 @@
1
- // 사용자 전역(scope: 'user') 플러그인 항목의 공통 기구. 상류가 하니스별 전역
2
- // 설치만 지원하는 도구(superpowers, ponytail)가 공유한다 — 항목 파일
3
- // (lib/items/global.*.mjs)은 상류별 좌표와 미배선 사유만 든다.
4
- //
5
- // 전역 파일을 만지므로 repoPath 계열 가드를 일부러 안 쓴다 — 저장소 이탈이
6
- // 목적인 기구다. 항목 파일에 두지 않는 이유는 gitmessage.mjs와 같다:
7
- // loadItems가 items/의 모든 .mjs를 항목으로 읽는다.
8
- //
9
- // 하니스별 기록 위치(2026-08-15 실측, 상세는 global.superpowers.mjs):
10
- // codex $CODEX_HOME/config.toml의 [plugins."<이름>@…"] — CODEX_HOME
11
- // 재지정을 실제로 쓰는 환경(Orca)이 있어 환경변수를 따라간다.
12
- // copilot ~/.copilot/config.json의 installedPlugins[] (주석 있는 JSONC).
13
- // gemini ~/.gemini/extensions/<이름>/ (공식 extensions 문서).
14
- // opencode 전역 opencode.json의 plugin 배열 (XDG_CONFIG_HOME 존중).
15
- // grok ~/.grok/installed-plugins/registry.json의 repos[*].plugins 키
16
- // (2026-09-05 grok 1.0.5로 설치·제거 왕복 실측). 설치는
17
- // `grok plugin install <소스> --trust`가 registry와 디렉터리를 만들고
18
- // config.toml [plugins].enabled에도 이름을 넣는다. 제거
19
- // (`grok plugin uninstall <이름> --confirm`)는 디렉터리·registry만
20
- // 지우고 enabled 항목은 남기므로, 감지의 근거는 registry여야 한다 —
21
- // enabled를 보면 제거한 뒤에도 영원히 설치됨으로 읽힌다.
22
- import { existsSync, readFileSync } from 'node:fs'
23
- import { homedir } from 'node:os'
24
- import { delimiter, join } from 'node:path'
25
- import { parse as parseToml } from 'smol-toml'
26
- import { CLIS } from './clis.mjs'
27
- import { readJson, setKey, removeKey } from './jsonfile.mjs'
28
- import { msg, LocalizedError } from './i18n/index.mjs'
29
-
30
- // 실행은 cmd 셸을 거치므로(catalog.mjs makeExec) win32에서는 PATHEXT 확장자가
31
- // 있어야 실제로 부를 수 있다 — copilot.ps1만 있는 PATH는 "없음"이 맞다.
32
- function defaultHasBinary(name, env = process.env) {
33
- const dirs = (env.PATH ?? '').split(delimiter).filter(Boolean)
34
- const exts = process.platform === 'win32'
35
- ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
36
- : ['']
37
- return dirs.some((dir) => exts.some((ext) => existsSync(join(dir, name + ext))))
38
- }
39
-
40
- // superpowers@openai-curated처럼 다른 마켓플레이스로 설치된 판도 잡아야
41
- // 하므로(실측: 이런 머신이 실제로 있다) 이름이 아니라 접두사로 고른다.
42
- // 제거도 여기서 찾은 id를 그대로 쓴다 — 감지가 "있다"고 말한 판을 지워야
43
- // 상태가 맞아떨어진다.
44
- function codexPluginIds(file, pluginName) {
45
- if (!existsSync(file)) return []
46
- try {
47
- const data = parseToml(readFileSync(file, 'utf8'))
48
- return Object.keys(data.plugins ?? {}).filter((id) => id.startsWith(`${pluginName}@`))
49
- } catch {
50
- // 깨진 config.toml은 미설치로 둔다 — 설치를 시도하면 codex가 스스로
51
- // 진단하고, "설치됨"으로 오판해 제거를 그리로 보내는 것보다 낫다.
52
- return []
53
- }
54
- }
55
-
56
- function copilotInstalled(file, pluginName) {
57
- const list = readJson(file)?.installedPlugins
58
- return Array.isArray(list) && list.some((p) => p?.name === pluginName)
59
- }
60
-
61
- // ponytail(items/plugin.ponytail.mjs)과 같은 규칙: plugin 키가 배열이 아니면
62
- // 손대지 않는다.
63
- function opencodePlugins(file) {
64
- const value = readJson(file)?.plugin
65
- if (value === undefined) return []
66
- if (!Array.isArray(value)) return null
67
- return value
68
- }
69
-
70
- // config:
71
- // pluginName 감지·제거가 쓰는 상류 이름 (codex 접두사, copilot name,
72
- // gemini 확장 디렉터리 이름)
73
- // marketplaceRepo codex·copilot 마켓플레이스 (owner/repo)
74
- // installId 마켓플레이스 설치 id (<이름>@<마켓>)
75
- // gemini gemini extensions install에 줄 git URL, 없으면 null
76
- // opencode 전역 opencode.json plugin 배열에 넣을 항목, 없으면 null
77
- // grok grok plugin install에 줄 소스(마켓 id 또는 owner/repo), 없으면 null
78
- // supports는 어댑터 유무로 정해진다 — codex·copilot은 항상, gemini·opencode·grok은
79
- // 좌표가 있을 때만.
80
- export function defineGlobalPlugin(
81
- { id, label, group, note, exclusive = null, unsupported = {}, pluginName, marketplaceRepo, installId, gemini = null, opencode = null, grok = null, verified = null },
82
- { home = null, env = process.env, hasBinary = defaultHasBinary } = {},
83
- ) {
84
- const supports = ['codex', ...(gemini ? ['gemini'] : []), ...(opencode ? ['opencode'] : []), 'copilot', ...(grok ? ['grok'] : [])]
85
-
86
- const files = () => {
87
- const base = home ?? homedir()
88
- return {
89
- codex: join(env.CODEX_HOME || join(base, '.codex'), 'config.toml'),
90
- copilot: join(base, '.copilot', 'config.json'),
91
- gemini: join(base, '.gemini', 'extensions', pluginName),
92
- opencode: join(env.XDG_CONFIG_HOME || join(base, '.config'), 'opencode', 'opencode.json'),
93
- grok: join(base, '.grok', 'installed-plugins', 'registry.json'),
94
- }
95
- }
96
-
97
- // registry.json: { repos: { "<이름>-<해시>": { plugins: { "<이름>": {...} } } } }.
98
- // 이름은 repos 키(해시가 붙는다)가 아니라 plugins 키에서 본다.
99
- const grokInstalled = (file) => {
100
- const repos = readJson(file)?.repos
101
- if (!repos || typeof repos !== 'object') return false
102
- return Object.values(repos).some((r) => Object.hasOwn(r?.plugins ?? {}, pluginName))
103
- }
104
-
105
- const isOurEntry = (p) => String(p).startsWith(`${pluginName}@`)
106
- const wired = {
107
- codex: (f) => codexPluginIds(f.codex, pluginName).length > 0,
108
- copilot: (f) => copilotInstalled(f.copilot, pluginName),
109
- gemini: (f) => existsSync(f.gemini),
110
- opencode: (f) => (opencodePlugins(f.opencode) ?? []).some(isOurEntry),
111
- grok: (f) => grokInstalled(f.grok),
112
- }
113
-
114
- return {
115
- id, category: 'plugin', label, scope: 'user', group, exclusive, verified,
116
- supports: [...supports],
117
- unsupported,
118
- note,
119
-
120
- // 상태는 "이 머신에 있는 하니스" 기준이다. CLI가 없는 하니스까지 누락으로
121
- // 세면 gemini 없는 머신은 영원히 partial이라 apply가 매번 헛돈다.
122
- async detect() {
123
- const f = files()
124
- const present = supports.filter((c) => wired[c](f))
125
- const machine = supports.filter((c) => hasBinary(c))
126
- const noCli = supports.filter((c) => !machine.includes(c))
127
- const missing = machine.filter((c) => !present.includes(c))
128
- const detail = noCli.length > 0 ? msg('item.global.noCli', { list: noCli.join(', ') }) : undefined
129
- // excluded는 detail과 별도로 구조체로 넘긴다 — 행 힌트가 CLI 이름을
130
- // 나열할 때 이 머신에 없는 것을 빼고 따로 적으려면 문장이 아니라 목록이 필요하다.
131
- if (present.length === 0) return { status: 'absent', detail, excluded: noCli }
132
- if (missing.length === 0) return { status: 'installed', detail, excluded: noCli }
133
- return {
134
- status: 'partial',
135
- detail: msg('item.plugin.partial', { present: present.join(', '), missing: missing.join(', ') }),
136
- excluded: noCli,
137
- }
138
- },
139
-
140
- async install({ dryRun, exec, log = () => {}, t }) {
141
- const f = files()
142
- // opencode 설정이 손댈 수 없는 꼴이면 아무것도 만지기 전에 멈춘다 —
143
- // 절반만 설치된 채 실패하는 것보다 통째로 거절이 낫다.
144
- const needsOpencode = opencode && !wired.opencode(f) && hasBinary('opencode')
145
- if (needsOpencode && !dryRun && opencodePlugins(f.opencode) === null) {
146
- throw new LocalizedError('error.globalOpencodePlugin', { file: f.opencode, entry: opencode })
147
- }
148
- const skipped = []
149
- const failures = []
150
- const fail = (cli, output) => failures.push(`${cli}: ${String(output).trim().split('\n')[0]}`)
151
- for (const cli of supports) {
152
- if (wired[cli](f)) continue
153
- if (!hasBinary(cli)) {
154
- skipped.push(cli)
155
- continue
156
- }
157
- if (cli === 'codex') {
158
- // 마켓 등록은 이미 있어도 해가 없다 — 성패는 add가 판정한다.
159
- await exec('codex', ['plugin', 'marketplace', 'add', marketplaceRepo])
160
- const r = await exec('codex', ['plugin', 'add', installId])
161
- if (!r.ok) fail(cli, r.output)
162
- } else if (cli === 'copilot') {
163
- await exec('copilot', ['plugin', 'marketplace', 'add', marketplaceRepo])
164
- // 실측(2026-08-15): 다른 copilot 세션이 떠 있으면 os error 5로 한 번
165
- // 실패하고 재시도에 성공한다. 한 번만 다시 시도한다.
166
- let r = await exec('copilot', ['plugin', 'install', installId])
167
- if (!r.ok) r = await exec('copilot', ['plugin', 'install', installId])
168
- if (!r.ok) fail(cli, r.output)
169
- } else if (cli === 'gemini') {
170
- const r = await exec('gemini', ['extensions', 'install', gemini])
171
- if (!r.ok) fail(cli, r.output)
172
- } else if (cli === 'grok') {
173
- // --trust는 확인 프롬프트를 건너뛴다 — stdin이 닫힌 채 도는 실행이라
174
- // 프롬프트가 뜨면 영원히 멈춘다.
175
- const r = await exec('grok', ['plugin', 'install', grok, '--trust'])
176
- if (!r.ok) fail(cli, r.output)
177
- } else {
178
- if (dryRun) {
179
- log(t('log.plugin.add', { cli: CLIS.opencode.label, name: opencode }))
180
- continue
181
- }
182
- const list = opencodePlugins(f.opencode) ?? []
183
- if (list.length === 0) setKey(f.opencode, ['plugin'], [opencode])
184
- else setKey(f.opencode, ['plugin', list.length], opencode)
185
- }
186
- }
187
- if (failures.length > 0) throw new LocalizedError('error.globalExec', { detail: failures.join(' / ') })
188
- if (skipped.length > 0) return { message: msg('item.global.skipped', { list: skipped.join(', ') }) }
189
- },
190
-
191
- async uninstall({ dryRun, exec, log = () => {}, t }) {
192
- const f = files()
193
- const skipped = []
194
- const failures = []
195
- const fail = (cli, output) => failures.push(`${cli}: ${String(output).trim().split('\n')[0]}`)
196
- for (const cli of supports) {
197
- if (!wired[cli](f)) continue
198
- if (cli === 'opencode') {
199
- if (dryRun) {
200
- log(t('log.plugin.remove', { cli: CLIS.opencode.label, name: opencode }))
201
- continue
202
- }
203
- const kept = (opencodePlugins(f.opencode) ?? []).filter((p) => !isOurEntry(p))
204
- // 우리 항목만 빼고 비면 키째 지운다 — 빈 배열은 우리가 남긴 흔적이다.
205
- if (kept.length === 0) removeKey(f.opencode, ['plugin'])
206
- else setKey(f.opencode, ['plugin'], kept)
207
- continue
208
- }
209
- if (!hasBinary(cli)) {
210
- skipped.push(cli)
211
- continue
212
- }
213
- if (cli === 'codex') {
214
- for (const pid of codexPluginIds(f.codex, pluginName)) {
215
- const r = await exec('codex', ['plugin', 'remove', pid])
216
- if (!r.ok) fail(cli, r.output)
217
- }
218
- // 마켓플레이스 등록은 남긴다 — 우리가 등록했다는 보장이 없고,
219
- // 같은 마켓의 다른 플러그인이 쓰고 있을 수 있다.
220
- } else if (cli === 'copilot') {
221
- let r = await exec('copilot', ['plugin', 'uninstall', pluginName])
222
- if (!r.ok) r = await exec('copilot', ['plugin', 'uninstall', pluginName])
223
- if (!r.ok) fail(cli, r.output)
224
- } else if (cli === 'grok') {
225
- // --confirm은 한 저장소에 플러그인이 여럿일 때의 확인을 건너뛴다.
226
- // config.toml [plugins].enabled의 이름은 상류가 남긴다 — 우리가
227
- // 등록한 줄이라는 보장이 없어 건드리지 않는다(note에 적는다).
228
- const r = await exec('grok', ['plugin', 'uninstall', pluginName, '--confirm'])
229
- if (!r.ok) fail(cli, r.output)
230
- } else {
231
- const r = await exec('gemini', ['extensions', 'uninstall', pluginName])
232
- if (!r.ok) fail(cli, r.output)
233
- }
234
- }
235
- if (failures.length > 0) throw new LocalizedError('error.globalExec', { detail: failures.join(' / ') })
236
- if (skipped.length > 0) return { message: msg('item.global.skipped', { list: skipped.join(', ') }) }
237
- },
238
- }
239
- }