@rch4com/agent-setup 1.28.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +31 -20
  2. package/install.mjs +25 -8
  3. package/lib/activation.mjs +31 -0
  4. package/lib/args.mjs +5 -2
  5. package/lib/bootstrap/manifest.mjs +21 -47
  6. package/lib/bootstrap/templates.mjs +7 -60
  7. package/lib/catalog.mjs +22 -241
  8. package/lib/clis.mjs +11 -118
  9. package/lib/commands.mjs +47 -0
  10. package/lib/engine.mjs +16 -11
  11. package/lib/i18n/catalog/en.mjs +58 -49
  12. package/lib/i18n/catalog/ko.mjs +58 -49
  13. package/lib/items/mcp.codebase-memory.mjs +6 -4
  14. package/lib/items/mcp.graft.mjs +6 -4
  15. package/lib/items/mcp.graphify.mjs +6 -4
  16. package/lib/items/mcp.headroom.mjs +6 -4
  17. package/lib/items/mcp.notion.mjs +7 -4
  18. package/lib/items/mcp.supabase.mjs +7 -4
  19. package/lib/items/mcp.vercel.mjs +7 -4
  20. package/lib/items/plugin.ai-memory.mjs +29 -0
  21. package/lib/items/plugin.cli-anything.mjs +18 -15
  22. package/lib/items/plugin.ecc.mjs +22 -20
  23. package/lib/items/plugin.hyperframes.mjs +17 -13
  24. package/lib/items/plugin.impeccable.mjs +19 -22
  25. package/lib/items/plugin.mattpocock-skills.mjs +23 -17
  26. package/lib/items/plugin.ponytail.mjs +24 -106
  27. package/lib/items/plugin.superpowers.mjs +20 -24
  28. package/lib/items/plugin.understand-anything.mjs +21 -19
  29. package/lib/items/skill.agent-browser.mjs +6 -6
  30. package/lib/items/skill.agent-handoff-manager.mjs +14 -0
  31. package/lib/items/skill.archify.mjs +5 -5
  32. package/lib/items/skill.caveman.mjs +5 -5
  33. package/lib/items/skill.diagram-design.mjs +5 -5
  34. package/lib/items/skill.eli5.mjs +6 -6
  35. package/lib/items/skill.find-skills.mjs +5 -5
  36. package/lib/items/skill.gsd.mjs +28 -89
  37. package/lib/items/skill.gstack.mjs +58 -65
  38. package/lib/items/skill.hallmark.mjs +5 -5
  39. package/lib/items/skill.html-plan.mjs +15 -0
  40. package/lib/items/skill.i-have-adhd.mjs +6 -6
  41. package/lib/items/skill.im-not-ai.mjs +5 -6
  42. package/lib/items/skill.impeccable.mjs +6 -6
  43. package/lib/items/skill.karpathy.mjs +5 -5
  44. package/lib/items/skill.mattpocock-skills.mjs +5 -6
  45. package/lib/items/skill.mcp-builder.mjs +5 -5
  46. package/lib/items/skill.officecli.mjs +5 -5
  47. package/lib/items/skill.prompt-master.mjs +5 -5
  48. package/lib/items/skill.remotion.mjs +5 -5
  49. package/lib/items/skill.strix.mjs +5 -6
  50. package/lib/items/skill.superpowers.mjs +6 -7
  51. package/lib/items/skill.taste.mjs +5 -5
  52. package/lib/items/skill.unslop.mjs +12 -0
  53. package/lib/probe.mjs +136 -0
  54. package/lib/recipe-helpers.mjs +281 -0
  55. package/lib/recipe.mjs +181 -0
  56. package/lib/skills-registry.mjs +93 -0
  57. package/lib/status.mjs +7 -3
  58. package/lib/tui/detail.mjs +5 -25
  59. package/lib/tui/rows.mjs +25 -14
  60. package/lib/tui/run.mjs +35 -8
  61. package/package.json +4 -3
  62. package/lib/claude-plugins.mjs +0 -64
  63. package/lib/global-plugin.mjs +0 -239
  64. package/lib/items/global.ponytail.mjs +0 -56
  65. package/lib/items/global.superpowers.mjs +0 -58
  66. package/lib/tomlfile.mjs +0 -67
@@ -0,0 +1,93 @@
1
+ // 공유 스킬 디렉터리(.agents/skills)와 레지스트리 잠금 파일을 읽고 정리하는
2
+ // 보조 함수. 감지(probe)와 레지스트리 스킬 레시피가 함께 쓴다.
3
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { repoPath, repoPathStrict } from './context.mjs'
6
+ import { escapeRegExp, isSafeSegment } from './untrusted.mjs'
7
+
8
+ // 이 저장소가 공유 스킬 자리로 쓰는 디렉터리. vercel-labs/skills 레지스트리의
9
+ // `--agent universal` 프로젝트 경로와 같은 값이다 — 그래서 레지스트리 설치가
10
+ // 우리 배선 위에 그대로 얹힌다.
11
+ export const SHARED_SKILLS = '.agents/skills'
12
+
13
+ function skillDirs(root) {
14
+ const dir = repoPath(root, SHARED_SKILLS)
15
+ if (!existsSync(dir)) return []
16
+ return readdirSync(dir).filter((name) => {
17
+ try {
18
+ return statSync(join(dir, name)).isDirectory()
19
+ } catch {
20
+ return false
21
+ }
22
+ })
23
+ }
24
+
25
+ // 설치된 디렉터리 이름이 스킬 이름과 다를 수 있다(레지스트리는 SKILL.md의
26
+ // frontmatter name으로 고르는데, 저장소의 폴더 이름은 다른 경우가 있다 —
27
+ // taste-skill/이 design-taste-frontend를 담는 식이다). 이름이 같은 디렉터리를
28
+ // 먼저 보고, 없으면 frontmatter를 읽어 찾는다. 디렉터리 이름만 보면 설치해
29
+ // 놓고도 계속 '미설치'로 읽힌다.
30
+ export function findSkillDir(root, skill) {
31
+ const base = repoPath(root, SHARED_SKILLS)
32
+ if (existsSync(join(base, skill, 'SKILL.md'))) return skill
33
+ for (const name of skillDirs(root)) {
34
+ const file = join(base, name, 'SKILL.md')
35
+ if (!existsSync(file)) continue
36
+ const head = readFileSync(file, 'utf8').slice(0, 2000)
37
+ if (new RegExp(`^name:\\s*['"]?${escapeRegExp(skill)}['"]?\\s*$`, 'm').test(head)) return name
38
+ }
39
+ return null
40
+ }
41
+
42
+ // 잠금 파일의 이름은 상류 SKILL.md frontmatter에서 온다 — 신뢰 경계 밖이다.
43
+ // `npx skills remove <이름>`의 인자이자 `.agents/skills/<이름>` 경로가 되므로,
44
+ // 플래그로 읽히는 선행 대시와 경로 구분자·상위 이동은 어느 쪽으로도 쓸 수 없다.
45
+ export function isSafeSkillName(name) {
46
+ return isSafeSegment(name) && !name.startsWith('-')
47
+ }
48
+
49
+ // 레지스트리가 저장소 루트에 남기는 잠금 파일. 스킬 이름마다 어느 출처에서
50
+ // 왔는지를 적어 두므로, 한 상류가 여러 스킬을 깔았을 때 "우리가 넣은 것"만
51
+ // 골라 지울 수 있는 유일한 근거다.
52
+ export const SKILLS_LOCK = 'skills-lock.json'
53
+
54
+ // 잠금 파일의 source는 `owner/repo`로 정규화돼 있고 항목은 URL을 넘긴다.
55
+ export function sourceKey(source) {
56
+ return String(source).replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '').replace(/\/+$/, '')
57
+ }
58
+
59
+ function readLock(root) {
60
+ const file = repoPath(root, SKILLS_LOCK)
61
+ if (!existsSync(file)) return null
62
+ try {
63
+ return JSON.parse(readFileSync(file, 'utf8'))
64
+ } catch {
65
+ // 잠금 파일이 깨졌다고 제거를 막지는 않는다 — 디렉터리 스캔 폴백이 있다.
66
+ return null
67
+ }
68
+ }
69
+
70
+ export function lockedSkills(root, source) {
71
+ const want = sourceKey(source)
72
+ return Object.entries(readLock(root)?.skills ?? {})
73
+ .filter(([, v]) => sourceKey(v?.source ?? '') === want)
74
+ .map(([name]) => name)
75
+ }
76
+
77
+ // 레지스트리의 remove는 디렉터리도, 잠금 파일의 항목도 남긴다(실측). 디렉터리는
78
+ // 아래에서 우리가 지우는데 잠금 항목을 그대로 두면 파일이 "설치돼 있다"고
79
+ // 거짓말한다 — 커밋해서 팀과 나누는 파일이라, 동료가 experimental_install로
80
+ // 복원하면 지운 스킬이 되살아난다.
81
+ export function pruneLock(root, names) {
82
+ const parsed = readLock(root)
83
+ const skills = parsed?.skills
84
+ if (!skills) return
85
+ const gone = names.filter((n) => Object.hasOwn(skills, n))
86
+ if (gone.length === 0) return
87
+ for (const n of gone) delete skills[n]
88
+ const file = repoPathStrict(root, SKILLS_LOCK)
89
+ // 남은 스킬이 없으면 파일째 지운다. 이 파일은 레지스트리가 만든 산출물이라
90
+ // 빈 껍데기를 남기면 우리가 만든 흔적이 저장소에 계속 남는다.
91
+ if (Object.keys(skills).length === 0) rmSync(file, { force: true })
92
+ else writeFileSync(file, `${JSON.stringify(parsed, null, 2)}\n`)
93
+ }
package/lib/status.mjs CHANGED
@@ -7,6 +7,8 @@ import { MANIFEST } from './bootstrap/manifest.mjs'
7
7
  import { updateBlocks, updateFiles } from './bootstrap/apply.mjs'
8
8
  import { readRecord, toolVersion } from './bootstrap/record.mjs'
9
9
  import { createT } from './i18n/index.mjs'
10
+ import { makeExec } from './catalog.mjs'
11
+ import { createProbe } from './probe.mjs'
10
12
 
11
13
  // 미배선 사유는 상류를 실측한 날의 사실이다. 그 날짜(item.verified)가 이보다
12
14
  // 오래되면 status가 알린다 — 사유 문장이 화면에 낡은 채 남는 것을 막는 유일한
@@ -18,7 +20,7 @@ function daysSince(iso, now) {
18
20
  return Number.isFinite(ms) ? Math.floor(ms / 86_400_000) : 0
19
21
  }
20
22
 
21
- export async function collectStatus(root, { manifest = MANIFEST, items = [], latest, now = Date.now() } = {}) {
23
+ export async function collectStatus(root, { manifest = MANIFEST, items = [], latest, now = Date.now(), probe = null } = {}) {
22
24
  const record = readRecord(root)
23
25
 
24
26
  // dry-run으로 갱신 판정만 얻는다 — 판정 로직을 두 벌 두면 status와 update가
@@ -33,10 +35,12 @@ export async function collectStatus(root, { manifest = MANIFEST, items = [], lat
33
35
 
34
36
  const count = (action) => verdicts.filter((v) => v.action === action).length
35
37
 
38
+ // 화면·--list와 같은 감지 경로 — 레시피 항목은 CLI 목록 캐시(probe)를 읽는다.
39
+ const shared = probe ?? createProbe({ root, scope: 'project', exec: makeExec(false) })
36
40
  const states = []
37
41
  for (const item of items) {
38
42
  try {
39
- const r = await item.detect({ root })
43
+ const r = await item.detect({ root, probe: shared })
40
44
  states.push({ id: item.id, status: r.status })
41
45
  } catch {
42
46
  // 감지 실패로 status 전체가 죽으면 진단 도구가 아니다.
@@ -123,7 +127,7 @@ export async function fetchLatestVersion(fetchImpl = fetch) {
123
127
 
124
128
  export async function runStatus(root, { json = false, log = console.log, fetchImpl = fetch, t = createT('en') } = {}) {
125
129
  const { loadItems } = await import('./catalog.mjs')
126
- const report = await collectStatus(root, { items: await loadItems(), latest: await fetchLatestVersion(fetchImpl) })
130
+ const report = await collectStatus(root, { items: await loadItems({ scope: 'project' }), latest: await fetchLatestVersion(fetchImpl) })
127
131
  log(json ? JSON.stringify(report, null, 2) : formatStatus(report, t))
128
132
  return report
129
133
  }
@@ -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,14 @@
1
1
  {
2
2
  "name": "@rch4com/agent-setup",
3
- "version": "1.28.0",
3
+ "version": "2.2.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
+ "github-copilot",
11
12
  "mcp",
12
13
  "agent-skills"
13
14
  ],
@@ -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
- }