@rch4com/agent-setup 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +199 -199
  3. package/install.mjs +163 -163
  4. package/lib/args.mjs +276 -276
  5. package/lib/bootstrap/adapter.mjs +96 -96
  6. package/lib/bootstrap/apply.mjs +380 -380
  7. package/lib/bootstrap/flow.mjs +140 -140
  8. package/lib/bootstrap/record.mjs +154 -154
  9. package/lib/bootstrap/templates.mjs +148 -148
  10. package/lib/bootstrap/text.mjs +19 -19
  11. package/lib/catalog.mjs +234 -234
  12. package/lib/claude-plugins.mjs +64 -64
  13. package/lib/clis.mjs +111 -111
  14. package/lib/context.mjs +66 -66
  15. package/lib/deps.mjs +17 -17
  16. package/lib/design-md/catalog.json +454 -454
  17. package/lib/design-md/catalog.mjs +266 -266
  18. package/lib/design-md/flow.mjs +192 -192
  19. package/lib/design-md/open.mjs +51 -51
  20. package/lib/design-md/providers/awesome-design-md.mjs +124 -124
  21. package/lib/design-md/providers/index.mjs +9 -9
  22. package/lib/design-md/scan.mjs +295 -295
  23. package/lib/engine.mjs +115 -115
  24. package/lib/gitignore.mjs +18 -18
  25. package/lib/i18n/catalog/en.mjs +426 -425
  26. package/lib/i18n/catalog/ko.mjs +411 -410
  27. package/lib/i18n/detect.mjs +39 -39
  28. package/lib/i18n/index.mjs +93 -93
  29. package/lib/items/mcp.codebase-memory.mjs +6 -6
  30. package/lib/items/mcp.notion.mjs +6 -6
  31. package/lib/items/mcp.supabase.mjs +6 -6
  32. package/lib/items/mcp.vercel.mjs +6 -6
  33. package/lib/items/plugin.mattpocock-skills.mjs +8 -8
  34. package/lib/items/plugin.ponytail.mjs +104 -104
  35. package/lib/items/skill.gsd.mjs +44 -44
  36. package/lib/items/skill.gstack.mjs +54 -54
  37. package/lib/items/skill.hallmark.mjs +10 -0
  38. package/lib/jsonfile.mjs +40 -40
  39. package/lib/status.mjs +93 -93
  40. package/lib/tomlfile.mjs +60 -60
  41. package/lib/tui/detail.mjs +127 -127
  42. package/lib/tui/progress.mjs +160 -160
  43. package/lib/tui/render.mjs +268 -268
  44. package/lib/tui/rows.mjs +317 -317
  45. package/lib/tui/run.mjs +396 -396
  46. package/lib/tui/state.mjs +249 -249
  47. package/lib/update.mjs +85 -85
  48. package/lib/width.mjs +115 -115
  49. package/package.json +36 -36
package/lib/tui/rows.mjs CHANGED
@@ -1,317 +1,317 @@
1
- // 부트스트랩·에이전트·design.md를 하나의 행 배열로 합친다.
2
- // 화면을 모른다 — 폭도 색도 커서도 여기서는 다루지 않는다.
3
- import { existsSync } from 'node:fs'
4
- import { join } from 'node:path'
5
- import { scan } from '../engine.mjs'
6
- import { loadItems } from '../catalog.mjs'
7
- import { CLI_IDS } from '../clis.mjs'
8
- import { runBootstrap } from '../bootstrap/flow.mjs'
9
- import { MANIFEST } from '../bootstrap/manifest.mjs'
10
- import { createT, toText } from '../i18n/index.mjs'
11
- import { loadCatalog, buildItems, netFetch, CATALOG_PATH } from '../design-md/catalog.mjs'
12
- import { discoverSources, extraDirsFromEnv } from '../design-md/scan.mjs'
13
- import { refreshCatalog, updateInstalled, findStale, categoryLabel } from '../design-md/flow.mjs'
14
- import { unsupportedGroups } from './detail.mjs'
15
-
16
- // 섹션은 탭 이름이자 정렬 키이자 state.tabs의 원소다. 표시 문자열을 그대로
17
- // 쓰면 번역하는 순간 정렬이 깨진다 — id를 두고 render.mjs가 표시할 때만
18
- // t로 바꾼다. 덕분에 state.mjs는 계속 아무것도 import 하지 않는다.
19
- export const ACTION_SECTION = 'action'
20
- // design-md/scan.mjs의 BUNDLE_CATEGORY와 같은 값이다 — 카테고리를 못 얻은
21
- // 항목이 모이는 자리. 두 값이 갈리면 같은 그룹이 두 개로 쪼개진다.
22
- export const CATCH_ALL_CATEGORY = '__other'
23
- export const SECTION_ORDER = [ACTION_SECTION, 'plugin', 'mcp', 'skill', 'config', 'design']
24
-
25
- // 탭 안쪽 소분류는 **성격**으로 가른다 — 같은 탭에 "무엇으로 설치되는가"가 같고
26
- // "무엇을 해 주는가"가 다른 항목이 섞여 있어서, 기구(plugin·mcp·skill)만으로는
27
- // 고를 때 비교가 되지 않는다. 표시 순서는 이 배열이 정한다.
28
- // `__`로 시작하는 id만 categoryLabel이 번역한다(design.md 카테고리와 같은 규칙).
29
- // __commit은 CONFIG 탭에만 나오지만 규칙은 같다 — 헤더가 "무엇을 고르는
30
- // 자리인가"를 말한다. 커밋 템플릿은 대상 파일이 하나뿐이라 헤더에 그 파일
31
- // 이름과 "하나만"이라는 규칙까지 실어, 라디오 표시와 함께 읽히게 한다.
32
- export const GROUP_ORDER = ['__token', '__context', '__style', '__flow', '__commit', '__service']
33
-
34
- function groupRank(group) {
35
- const i = GROUP_ORDER.indexOf(group)
36
- // 그룹이 없거나 목록에 없는 항목은 맨 뒤로 — 헤더 없이 평평하게 붙는다.
37
- return i === -1 ? GROUP_ORDER.length : i
38
- }
39
-
40
- function short(text, n = 60) {
41
- const t = (text ?? '').replace(/\s+/g, ' ').trim()
42
- return t.length > n ? `${t.slice(0, n - 1)}…` : t
43
- }
44
-
45
- // 검색어에 섹션의 두 로케일 라벨을 모두 넣는다 — 한국어 화면에서도 영어 탭
46
- // 이름(plugin 등)으로 찾을 수 있어야 한다. 두 라벨이 같으면(PLUGIN·MCP처럼
47
- // 번역하지 않는 값) 중복으로 넣지 않는다.
48
- const EN_T = createT('en')
49
-
50
- function sectionTerms(t, section) {
51
- const here = t(`section.${section}`)
52
- const base = EN_T(`section.${section}`)
53
- return here === base ? `${section} ${base}` : `${section} ${base} ${here}`
54
- }
55
-
56
- // 에이전트 항목 힌트 — 상태·설치 위치·미지원 CLI 사유까지 한 줄에 담는다.
57
- // 미지원 CLI를 **같은 사유끼리 묶어** 한 줄로 만든다. 사유 하나가 CLI 아홉 개에
58
- // 그대로 반복되면 줄만 길어지고 "무엇이 왜 빠졌는가"는 오히려 묻힌다.
59
- // 사유가 둘 이상이면(ponytail처럼) 갈래가 그대로 보인다.
60
- // 그룹핑은 detail.mjs가 한다 — 상세 패널과 규칙을 한 곳에 둔다.
61
- export function unsupportedText(item, t) {
62
- const groups = unsupportedGroups(item, t)
63
- if (groups.length === 0) return null
64
- const count = groups.reduce((n, g) => n + g.clis.length, 0)
65
- const text = groups
66
- .map((g) => t('item.unsupportedGroup', { clis: g.clis.join('·'), why: g.why }))
67
- .join(' / ')
68
- return t('item.unsupportedList', { count, groups: text })
69
- }
70
-
71
- // 에이전트 항목 힌트 — 상태·CLI 커버리지·설치 위치·미지원 사유까지 한 줄에 담는다.
72
- export function agentHint(item, state, t = createT('en')) {
73
- const parts = []
74
- if (state.status !== 'absent') parts.push(t(`status.${state.status}`))
75
- // 커버리지는 상태 바로 뒤에 둔다. 힌트 끝은 좁은 터미널에서 잘려 나가는데,
76
- // 하필 그때가 "이 항목이 내가 쓰는 CLI에서 되나"를 가장 알고 싶은 순간이다.
77
- // 전부 지원할 때도 찍는다 — 표시가 없는 것과 "10/10"은 뜻이 다르다.
78
- if (item.supports) parts.push(t('item.cliCoverage', { covered: item.supports.length, total: CLI_IDS.length }))
79
- const detail = toText(t, state.detail)
80
- if (detail) parts.push(detail)
81
- // 대상 파일이 하나로 정해진 항목만 target을 갖는다. 평평한 목록(printPlain·
82
- // --list)에는 그룹 헤더가 없어 무엇을 건드리는지 알 길이 없고, 검색어로도
83
- // '.gitmessage'가 걸리지 않았다.
84
- if (item.target) parts.push(t('item.target', { path: item.target }))
85
- if (item.scope === 'user') parts.push(t('item.location.user'))
86
- // note는 이제 카탈로그 키다.
87
- if (item.note) parts.push(t(item.note))
88
- // 사유는 길어질 수 있어 맨 뒤에 둔다 — 앞의 커버리지가 이미 "빠진 게 있다"를
89
- // 알려 주므로, 잘려도 존재 자체를 놓치지는 않는다.
90
- const un = unsupportedText(item, t)
91
- if (un) parts.push(un)
92
- return parts.join(' · ')
93
- }
94
-
95
- export function designHint(state, multiProvider = false, t) {
96
- const parts = []
97
- if (multiProvider) parts.push(state.item.providerId)
98
- // 카탈로그가 준 카테고리는 그대로, 우리가 만든 catch-all(__other·__local)만
99
- // categoryLabel이 번역한다.
100
- parts.push(categoryLabel(t, state.item.designCategory))
101
- if (state.status !== 'absent') parts.push(t(`status.${state.status}`))
102
- if (state.item.description) parts.push(short(state.item.description))
103
- return parts.filter(Boolean).join(' · ')
104
- }
105
-
106
- // 목록 행에 실제로 찍히는 힌트. 80칸 터미널이면 힌트 자리는 49칸(한글 24자)뿐이라,
107
- // 예전처럼 사유까지 이어 붙이면 뒤쪽이 통째로 잘렸다. 사유·note·detail은
108
- // 상세 패널이 여러 줄로 편다 — 여기 남는 것은 잘릴 일이 없는 두 가지뿐이다.
109
- export function agentShortHint(item, state, t = createT('en')) {
110
- const parts = []
111
- if (state.status !== 'absent') parts.push(t(`status.${state.status}`))
112
- if (item.supports) parts.push(t('item.cliCoverage', { covered: item.supports.length, total: CLI_IDS.length }))
113
- return parts.join(' · ')
114
- }
115
-
116
- // design.md도 같은 이유로 줄인다. 설명 전문은 상세 패널이 맡는다.
117
- export function designShortHint(state, multiProvider = false, t = createT('en')) {
118
- const parts = []
119
- if (multiProvider) parts.push(state.item.providerId)
120
- parts.push(categoryLabel(t, state.item.designCategory))
121
- if (state.status !== 'absent') parts.push(t(`status.${state.status}`))
122
- return parts.filter(Boolean).join(' · ')
123
- }
124
-
125
- // group = 탭 **안쪽**의 소분류 헤더. design.md만 76개라 카테고리로 갈라야 읽힌다.
126
- // 나머지 탭은 항목이 적어 그룹 없이 평평하게 둔다.
127
- function itemRow({ id, section, label, hint, fullHint = hint, statusDetail = null, status, previewTarget = null, item, extra = '', group = null, t = createT('en') }) {
128
- return {
129
- kind: 'item',
130
- id,
131
- section,
132
- group,
133
- // 같은 파일을 두고 다투는 항목들의 묶음 이름(.gitmessage.txt의 두 언어판).
134
- // 선택 배타는 state.mjs가 이 값만 보고 처리한다 — 순수 리듀서가 항목의
135
- // 속내를 몰라도 되게 행에 미리 올려 둔다.
136
- exclusive: item?.exclusive ?? null,
137
- label,
138
- hint,
139
- // 화면에 안 찍히는 긴 힌트. 비대화형 목록(printPlain)과 검색이 쓴다 —
140
- // 짧은 힌트로 검색하면 'AGENTS.md'로 ponytail을 찾을 수 없게 된다.
141
- fullHint,
142
- statusDetail,
143
- status,
144
- previewTarget,
145
- item,
146
- searchText: `${label} ${fullHint} ${sectionTerms(t, section)} ${extra}`.toLowerCase(),
147
- }
148
- }
149
-
150
- function actionRow({ id, label, hint, run = null, t = createT('en') }) {
151
- return {
152
- kind: 'action',
153
- id,
154
- section: ACTION_SECTION,
155
- group: null,
156
- exclusive: null,
157
- label,
158
- hint,
159
- fullHint: hint,
160
- statusDetail: null,
161
- status: 'absent',
162
- previewTarget: null,
163
- run,
164
- searchText: `${label} ${hint} ${sectionTerms(t, ACTION_SECTION)}`.toLowerCase(),
165
- }
166
- }
167
-
168
- // 부트스트랩이 만들 파일 중 이미 있는 수 — 실행 전에 무엇이 남았는지 알려 준다.
169
- function bootstrapHint(root, t) {
170
- const present = MANIFEST.files.filter((f) => existsSync(join(root, f.path))).length
171
- return t('action.bootstrap.hint', { present, total: MANIFEST.files.length })
172
- }
173
-
174
- export function buildActions(root, { designItems = [], t = createT('en') } = {}) {
175
- return [
176
- // 맨 위 상주 행. 실행은 run.mjs가 특수 처리한다 — 화면을 벗어나지 않고
177
- // 그 자리에서 t를 갈아끼워야 하므로 다른 액션과 흐름이 다르다(run은 null).
178
- actionRow({
179
- id: 'action.language',
180
- label: t('action.language.label'),
181
- hint: t('action.language.hint', { current: t(`locale.${t.locale}`) }),
182
- t,
183
- }),
184
- actionRow({
185
- id: 'action.bootstrap',
186
- label: t('action.bootstrap.label'),
187
- hint: bootstrapHint(root, t),
188
- run: ({ dryRun, skillMode, log, t: rt }) => runBootstrap(root, { dryRun, skillMode, log, t: rt }),
189
- t,
190
- }),
191
- actionRow({
192
- id: 'action.sync.installed',
193
- label: t('action.sync.installed.label'),
194
- hint: t('action.sync.installed.hint'),
195
- run: ({ dryRun, log, t: rt }) => updateInstalled(root, designItems, { dryRun, log, t: rt }),
196
- t,
197
- }),
198
- actionRow({
199
- id: 'action.sync.catalog',
200
- label: t('action.sync.catalog.label'),
201
- hint: t('action.sync.catalog.hint'),
202
- run: ({ dryRun, fetchImpl, log, catalogFile, t: rt }) => refreshCatalog({ dryRun, fetchImpl, log, catalogFile, t: rt }),
203
- t,
204
- }),
205
- actionRow({
206
- id: 'action.sync.stale',
207
- label: t('action.sync.stale.label'),
208
- hint: t('action.sync.stale.hint'),
209
- run: async ({ dryRun, log, confirm, t: rt }) => {
210
- const stale = await findStale(root, designItems, { log, t: rt })
211
- // 이 네 줄은 flow.mjs의 runSync(design --sync=stale)가 쓰는 문구와 같다 —
212
- // 새 키를 만들지 않고 그 키(design.allCurrent 등)를 그대로 재사용한다.
213
- if (stale.length === 0) { log(rt('design.allCurrent')); return }
214
- log(rt('design.staleList', { count: stale.length, names: stale.map((i) => i.name).join(', ') }))
215
- if (dryRun) return
216
- if (!(await confirm(rt('design.staleConfirm', { count: stale.length })))) return
217
- for (const item of stale) {
218
- try {
219
- await item.install({ root, dryRun, fresh: true })
220
- log(rt('design.updated', { label: item.label }))
221
- } catch (err) {
222
- log(rt('design.updateFailed', { label: item.label, message: err.message }))
223
- }
224
- }
225
- },
226
- t,
227
- }),
228
- ]
229
- }
230
-
231
- // 순수 조립 — 이미 스캔된 상태를 받아 행 배열을 만든다. 테스트는 여기를 겨눈다.
232
- export function buildRows({ actions = [], agentStates = [], designStates = [], multiProvider = false, t = createT('en') }) {
233
- const agents = agentStates
234
- .map((s) =>
235
- itemRow({
236
- id: s.item.id,
237
- section: s.item.category,
238
- group: s.item.group ?? null,
239
- label: s.item.label,
240
- hint: agentShortHint(s.item, s, t),
241
- fullHint: agentHint(s.item, s, t),
242
- statusDetail: toText(t, s.detail) ?? null,
243
- status: s.status,
244
- item: s.item,
245
- extra: s.item.id,
246
- t,
247
- }),
248
- )
249
- // 성격 → 라벨 순. 헤더는 인접한 같은 group을 하나로 묶으므로, 정렬이
250
- // 흐트러지면 같은 성격이 한 탭에서 여러 번 쪼개져 나온다(design과 같은 규칙).
251
- .sort((a, b) => groupRank(a.group) - groupRank(b.group) || a.label.localeCompare(b.label))
252
-
253
- // 카테고리 → 라벨 순으로 정렬한다. 그룹 헤더는 인접한 같은 group을 하나로 묶으므로
254
- // 정렬이 흐트러지면 같은 카테고리가 여러 번 쪼개져 나온다.
255
- const designs = designStates
256
- .map((s) =>
257
- itemRow({
258
- id: s.item.id,
259
- section: 'design',
260
- group: s.item.designCategory || CATCH_ALL_CATEGORY,
261
- label: s.item.label,
262
- hint: designShortHint(s, multiProvider, t),
263
- fullHint: designHint(s, multiProvider, t),
264
- status: s.status,
265
- previewTarget: s.item.webUrl ?? s.item.previewPath ?? null,
266
- item: s.item,
267
- extra: `${s.item.name} ${s.item.providerId}`,
268
- t,
269
- }),
270
- )
271
- // 분류 못 한 항목을 모아 둔 '기타'는 맨 뒤로 보낸다 — catch-all이 목록 머리를 차지하면
272
- // 실제 카테고리를 훑기 전에 잡동사니부터 읽게 된다.
273
- .sort((a, b) =>
274
- (a.group === CATCH_ALL_CATEGORY ? 1 : 0) - (b.group === CATCH_ALL_CATEGORY ? 1 : 0)
275
- || a.group.localeCompare(b.group)
276
- || a.label.localeCompare(b.label))
277
-
278
- const rows = [...actions, ...agents, ...designs]
279
- // 섹션 순서를 고정한다 — displayList가 인접한 같은 섹션을 하나로 묶기 때문이다.
280
- return rows.sort((a, b) => SECTION_ORDER.indexOf(a.section) - SECTION_ORDER.indexOf(b.section))
281
- }
282
-
283
- // 스캔까지 포함한 수집. 적용·액션 실행 뒤 다시 부른다.
284
- export async function collectRows(root, opts = {}) {
285
- const { fetchImpl = netFetch, designDirs = [], env = process.env, catalogFile = CATALOG_PATH, log = () => {}, t = createT('en') } = opts
286
-
287
- const agentItems = await loadItems()
288
- const catalog = loadCatalog(catalogFile)
289
- const sources = opts.sources ?? discoverSources({
290
- ...(opts.bundleDir ? { bundleDir: opts.bundleDir } : {}),
291
- extraDirs: [...extraDirsFromEnv(env), ...designDirs],
292
- log,
293
- t,
294
- })
295
- const designItems = buildItems(catalog, { fetchImpl, sources })
296
-
297
- const [agentStates, designStates] = await Promise.all([scan(root, agentItems), scan(root, designItems)])
298
- const multiProvider = new Set(designItems.map((i) => i.providerId)).size > 1
299
-
300
- return {
301
- rows: buildRows({
302
- actions: buildActions(root, { designItems, t }),
303
- agentStates,
304
- designStates,
305
- multiProvider,
306
- t,
307
- }),
308
- agentStates,
309
- designStates,
310
- designItems,
311
- }
312
- }
313
-
314
- // 설치된 항목 = 시작 시 체크된 항목.
315
- export function installedIds(rows) {
316
- return rows.filter((r) => r.kind === 'item' && r.status !== 'absent').map((r) => r.id)
317
- }
1
+ // 부트스트랩·에이전트·design.md를 하나의 행 배열로 합친다.
2
+ // 화면을 모른다 — 폭도 색도 커서도 여기서는 다루지 않는다.
3
+ import { existsSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { scan } from '../engine.mjs'
6
+ import { loadItems } from '../catalog.mjs'
7
+ import { CLI_IDS } from '../clis.mjs'
8
+ import { runBootstrap } from '../bootstrap/flow.mjs'
9
+ import { MANIFEST } from '../bootstrap/manifest.mjs'
10
+ import { createT, toText } from '../i18n/index.mjs'
11
+ import { loadCatalog, buildItems, netFetch, CATALOG_PATH } from '../design-md/catalog.mjs'
12
+ import { discoverSources, extraDirsFromEnv } from '../design-md/scan.mjs'
13
+ import { refreshCatalog, updateInstalled, findStale, categoryLabel } from '../design-md/flow.mjs'
14
+ import { unsupportedGroups } from './detail.mjs'
15
+
16
+ // 섹션은 탭 이름이자 정렬 키이자 state.tabs의 원소다. 표시 문자열을 그대로
17
+ // 쓰면 번역하는 순간 정렬이 깨진다 — id를 두고 render.mjs가 표시할 때만
18
+ // t로 바꾼다. 덕분에 state.mjs는 계속 아무것도 import 하지 않는다.
19
+ export const ACTION_SECTION = 'action'
20
+ // design-md/scan.mjs의 BUNDLE_CATEGORY와 같은 값이다 — 카테고리를 못 얻은
21
+ // 항목이 모이는 자리. 두 값이 갈리면 같은 그룹이 두 개로 쪼개진다.
22
+ export const CATCH_ALL_CATEGORY = '__other'
23
+ export const SECTION_ORDER = [ACTION_SECTION, 'plugin', 'mcp', 'skill', 'config', 'design']
24
+
25
+ // 탭 안쪽 소분류는 **성격**으로 가른다 — 같은 탭에 "무엇으로 설치되는가"가 같고
26
+ // "무엇을 해 주는가"가 다른 항목이 섞여 있어서, 기구(plugin·mcp·skill)만으로는
27
+ // 고를 때 비교가 되지 않는다. 표시 순서는 이 배열이 정한다.
28
+ // `__`로 시작하는 id만 categoryLabel이 번역한다(design.md 카테고리와 같은 규칙).
29
+ // __commit은 CONFIG 탭에만 나오지만 규칙은 같다 — 헤더가 "무엇을 고르는
30
+ // 자리인가"를 말한다. 커밋 템플릿은 대상 파일이 하나뿐이라 헤더에 그 파일
31
+ // 이름과 "하나만"이라는 규칙까지 실어, 라디오 표시와 함께 읽히게 한다.
32
+ export const GROUP_ORDER = ['__token', '__context', '__style', '__flow', '__commit', '__service']
33
+
34
+ function groupRank(group) {
35
+ const i = GROUP_ORDER.indexOf(group)
36
+ // 그룹이 없거나 목록에 없는 항목은 맨 뒤로 — 헤더 없이 평평하게 붙는다.
37
+ return i === -1 ? GROUP_ORDER.length : i
38
+ }
39
+
40
+ function short(text, n = 60) {
41
+ const t = (text ?? '').replace(/\s+/g, ' ').trim()
42
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t
43
+ }
44
+
45
+ // 검색어에 섹션의 두 로케일 라벨을 모두 넣는다 — 한국어 화면에서도 영어 탭
46
+ // 이름(plugin 등)으로 찾을 수 있어야 한다. 두 라벨이 같으면(PLUGIN·MCP처럼
47
+ // 번역하지 않는 값) 중복으로 넣지 않는다.
48
+ const EN_T = createT('en')
49
+
50
+ function sectionTerms(t, section) {
51
+ const here = t(`section.${section}`)
52
+ const base = EN_T(`section.${section}`)
53
+ return here === base ? `${section} ${base}` : `${section} ${base} ${here}`
54
+ }
55
+
56
+ // 에이전트 항목 힌트 — 상태·설치 위치·미지원 CLI 사유까지 한 줄에 담는다.
57
+ // 미지원 CLI를 **같은 사유끼리 묶어** 한 줄로 만든다. 사유 하나가 CLI 아홉 개에
58
+ // 그대로 반복되면 줄만 길어지고 "무엇이 왜 빠졌는가"는 오히려 묻힌다.
59
+ // 사유가 둘 이상이면(ponytail처럼) 갈래가 그대로 보인다.
60
+ // 그룹핑은 detail.mjs가 한다 — 상세 패널과 규칙을 한 곳에 둔다.
61
+ export function unsupportedText(item, t) {
62
+ const groups = unsupportedGroups(item, t)
63
+ if (groups.length === 0) return null
64
+ const count = groups.reduce((n, g) => n + g.clis.length, 0)
65
+ const text = groups
66
+ .map((g) => t('item.unsupportedGroup', { clis: g.clis.join('·'), why: g.why }))
67
+ .join(' / ')
68
+ return t('item.unsupportedList', { count, groups: text })
69
+ }
70
+
71
+ // 에이전트 항목 힌트 — 상태·CLI 커버리지·설치 위치·미지원 사유까지 한 줄에 담는다.
72
+ export function agentHint(item, state, t = createT('en')) {
73
+ const parts = []
74
+ if (state.status !== 'absent') parts.push(t(`status.${state.status}`))
75
+ // 커버리지는 상태 바로 뒤에 둔다. 힌트 끝은 좁은 터미널에서 잘려 나가는데,
76
+ // 하필 그때가 "이 항목이 내가 쓰는 CLI에서 되나"를 가장 알고 싶은 순간이다.
77
+ // 전부 지원할 때도 찍는다 — 표시가 없는 것과 "10/10"은 뜻이 다르다.
78
+ if (item.supports) parts.push(t('item.cliCoverage', { covered: item.supports.length, total: CLI_IDS.length }))
79
+ const detail = toText(t, state.detail)
80
+ if (detail) parts.push(detail)
81
+ // 대상 파일이 하나로 정해진 항목만 target을 갖는다. 평평한 목록(printPlain·
82
+ // --list)에는 그룹 헤더가 없어 무엇을 건드리는지 알 길이 없고, 검색어로도
83
+ // '.gitmessage'가 걸리지 않았다.
84
+ if (item.target) parts.push(t('item.target', { path: item.target }))
85
+ if (item.scope === 'user') parts.push(t('item.location.user'))
86
+ // note는 이제 카탈로그 키다.
87
+ if (item.note) parts.push(t(item.note))
88
+ // 사유는 길어질 수 있어 맨 뒤에 둔다 — 앞의 커버리지가 이미 "빠진 게 있다"를
89
+ // 알려 주므로, 잘려도 존재 자체를 놓치지는 않는다.
90
+ const un = unsupportedText(item, t)
91
+ if (un) parts.push(un)
92
+ return parts.join(' · ')
93
+ }
94
+
95
+ export function designHint(state, multiProvider = false, t) {
96
+ const parts = []
97
+ if (multiProvider) parts.push(state.item.providerId)
98
+ // 카탈로그가 준 카테고리는 그대로, 우리가 만든 catch-all(__other·__local)만
99
+ // categoryLabel이 번역한다.
100
+ parts.push(categoryLabel(t, state.item.designCategory))
101
+ if (state.status !== 'absent') parts.push(t(`status.${state.status}`))
102
+ if (state.item.description) parts.push(short(state.item.description))
103
+ return parts.filter(Boolean).join(' · ')
104
+ }
105
+
106
+ // 목록 행에 실제로 찍히는 힌트. 80칸 터미널이면 힌트 자리는 49칸(한글 24자)뿐이라,
107
+ // 예전처럼 사유까지 이어 붙이면 뒤쪽이 통째로 잘렸다. 사유·note·detail은
108
+ // 상세 패널이 여러 줄로 편다 — 여기 남는 것은 잘릴 일이 없는 두 가지뿐이다.
109
+ export function agentShortHint(item, state, t = createT('en')) {
110
+ const parts = []
111
+ if (state.status !== 'absent') parts.push(t(`status.${state.status}`))
112
+ if (item.supports) parts.push(t('item.cliCoverage', { covered: item.supports.length, total: CLI_IDS.length }))
113
+ return parts.join(' · ')
114
+ }
115
+
116
+ // design.md도 같은 이유로 줄인다. 설명 전문은 상세 패널이 맡는다.
117
+ export function designShortHint(state, multiProvider = false, t = createT('en')) {
118
+ const parts = []
119
+ if (multiProvider) parts.push(state.item.providerId)
120
+ parts.push(categoryLabel(t, state.item.designCategory))
121
+ if (state.status !== 'absent') parts.push(t(`status.${state.status}`))
122
+ return parts.filter(Boolean).join(' · ')
123
+ }
124
+
125
+ // group = 탭 **안쪽**의 소분류 헤더. design.md만 76개라 카테고리로 갈라야 읽힌다.
126
+ // 나머지 탭은 항목이 적어 그룹 없이 평평하게 둔다.
127
+ function itemRow({ id, section, label, hint, fullHint = hint, statusDetail = null, status, previewTarget = null, item, extra = '', group = null, t = createT('en') }) {
128
+ return {
129
+ kind: 'item',
130
+ id,
131
+ section,
132
+ group,
133
+ // 같은 파일을 두고 다투는 항목들의 묶음 이름(.gitmessage.txt의 두 언어판).
134
+ // 선택 배타는 state.mjs가 이 값만 보고 처리한다 — 순수 리듀서가 항목의
135
+ // 속내를 몰라도 되게 행에 미리 올려 둔다.
136
+ exclusive: item?.exclusive ?? null,
137
+ label,
138
+ hint,
139
+ // 화면에 안 찍히는 긴 힌트. 비대화형 목록(printPlain)과 검색이 쓴다 —
140
+ // 짧은 힌트로 검색하면 'AGENTS.md'로 ponytail을 찾을 수 없게 된다.
141
+ fullHint,
142
+ statusDetail,
143
+ status,
144
+ previewTarget,
145
+ item,
146
+ searchText: `${label} ${fullHint} ${sectionTerms(t, section)} ${extra}`.toLowerCase(),
147
+ }
148
+ }
149
+
150
+ function actionRow({ id, label, hint, run = null, t = createT('en') }) {
151
+ return {
152
+ kind: 'action',
153
+ id,
154
+ section: ACTION_SECTION,
155
+ group: null,
156
+ exclusive: null,
157
+ label,
158
+ hint,
159
+ fullHint: hint,
160
+ statusDetail: null,
161
+ status: 'absent',
162
+ previewTarget: null,
163
+ run,
164
+ searchText: `${label} ${hint} ${sectionTerms(t, ACTION_SECTION)}`.toLowerCase(),
165
+ }
166
+ }
167
+
168
+ // 부트스트랩이 만들 파일 중 이미 있는 수 — 실행 전에 무엇이 남았는지 알려 준다.
169
+ function bootstrapHint(root, t) {
170
+ const present = MANIFEST.files.filter((f) => existsSync(join(root, f.path))).length
171
+ return t('action.bootstrap.hint', { present, total: MANIFEST.files.length })
172
+ }
173
+
174
+ export function buildActions(root, { designItems = [], t = createT('en') } = {}) {
175
+ return [
176
+ // 맨 위 상주 행. 실행은 run.mjs가 특수 처리한다 — 화면을 벗어나지 않고
177
+ // 그 자리에서 t를 갈아끼워야 하므로 다른 액션과 흐름이 다르다(run은 null).
178
+ actionRow({
179
+ id: 'action.language',
180
+ label: t('action.language.label'),
181
+ hint: t('action.language.hint', { current: t(`locale.${t.locale}`) }),
182
+ t,
183
+ }),
184
+ actionRow({
185
+ id: 'action.bootstrap',
186
+ label: t('action.bootstrap.label'),
187
+ hint: bootstrapHint(root, t),
188
+ run: ({ dryRun, skillMode, log, t: rt }) => runBootstrap(root, { dryRun, skillMode, log, t: rt }),
189
+ t,
190
+ }),
191
+ actionRow({
192
+ id: 'action.sync.installed',
193
+ label: t('action.sync.installed.label'),
194
+ hint: t('action.sync.installed.hint'),
195
+ run: ({ dryRun, log, t: rt }) => updateInstalled(root, designItems, { dryRun, log, t: rt }),
196
+ t,
197
+ }),
198
+ actionRow({
199
+ id: 'action.sync.catalog',
200
+ label: t('action.sync.catalog.label'),
201
+ hint: t('action.sync.catalog.hint'),
202
+ run: ({ dryRun, fetchImpl, log, catalogFile, t: rt }) => refreshCatalog({ dryRun, fetchImpl, log, catalogFile, t: rt }),
203
+ t,
204
+ }),
205
+ actionRow({
206
+ id: 'action.sync.stale',
207
+ label: t('action.sync.stale.label'),
208
+ hint: t('action.sync.stale.hint'),
209
+ run: async ({ dryRun, log, confirm, t: rt }) => {
210
+ const stale = await findStale(root, designItems, { log, t: rt })
211
+ // 이 네 줄은 flow.mjs의 runSync(design --sync=stale)가 쓰는 문구와 같다 —
212
+ // 새 키를 만들지 않고 그 키(design.allCurrent 등)를 그대로 재사용한다.
213
+ if (stale.length === 0) { log(rt('design.allCurrent')); return }
214
+ log(rt('design.staleList', { count: stale.length, names: stale.map((i) => i.name).join(', ') }))
215
+ if (dryRun) return
216
+ if (!(await confirm(rt('design.staleConfirm', { count: stale.length })))) return
217
+ for (const item of stale) {
218
+ try {
219
+ await item.install({ root, dryRun, fresh: true })
220
+ log(rt('design.updated', { label: item.label }))
221
+ } catch (err) {
222
+ log(rt('design.updateFailed', { label: item.label, message: err.message }))
223
+ }
224
+ }
225
+ },
226
+ t,
227
+ }),
228
+ ]
229
+ }
230
+
231
+ // 순수 조립 — 이미 스캔된 상태를 받아 행 배열을 만든다. 테스트는 여기를 겨눈다.
232
+ export function buildRows({ actions = [], agentStates = [], designStates = [], multiProvider = false, t = createT('en') }) {
233
+ const agents = agentStates
234
+ .map((s) =>
235
+ itemRow({
236
+ id: s.item.id,
237
+ section: s.item.category,
238
+ group: s.item.group ?? null,
239
+ label: s.item.label,
240
+ hint: agentShortHint(s.item, s, t),
241
+ fullHint: agentHint(s.item, s, t),
242
+ statusDetail: toText(t, s.detail) ?? null,
243
+ status: s.status,
244
+ item: s.item,
245
+ extra: s.item.id,
246
+ t,
247
+ }),
248
+ )
249
+ // 성격 → 라벨 순. 헤더는 인접한 같은 group을 하나로 묶으므로, 정렬이
250
+ // 흐트러지면 같은 성격이 한 탭에서 여러 번 쪼개져 나온다(design과 같은 규칙).
251
+ .sort((a, b) => groupRank(a.group) - groupRank(b.group) || a.label.localeCompare(b.label))
252
+
253
+ // 카테고리 → 라벨 순으로 정렬한다. 그룹 헤더는 인접한 같은 group을 하나로 묶으므로
254
+ // 정렬이 흐트러지면 같은 카테고리가 여러 번 쪼개져 나온다.
255
+ const designs = designStates
256
+ .map((s) =>
257
+ itemRow({
258
+ id: s.item.id,
259
+ section: 'design',
260
+ group: s.item.designCategory || CATCH_ALL_CATEGORY,
261
+ label: s.item.label,
262
+ hint: designShortHint(s, multiProvider, t),
263
+ fullHint: designHint(s, multiProvider, t),
264
+ status: s.status,
265
+ previewTarget: s.item.webUrl ?? s.item.previewPath ?? null,
266
+ item: s.item,
267
+ extra: `${s.item.name} ${s.item.providerId}`,
268
+ t,
269
+ }),
270
+ )
271
+ // 분류 못 한 항목을 모아 둔 '기타'는 맨 뒤로 보낸다 — catch-all이 목록 머리를 차지하면
272
+ // 실제 카테고리를 훑기 전에 잡동사니부터 읽게 된다.
273
+ .sort((a, b) =>
274
+ (a.group === CATCH_ALL_CATEGORY ? 1 : 0) - (b.group === CATCH_ALL_CATEGORY ? 1 : 0)
275
+ || a.group.localeCompare(b.group)
276
+ || a.label.localeCompare(b.label))
277
+
278
+ const rows = [...actions, ...agents, ...designs]
279
+ // 섹션 순서를 고정한다 — displayList가 인접한 같은 섹션을 하나로 묶기 때문이다.
280
+ return rows.sort((a, b) => SECTION_ORDER.indexOf(a.section) - SECTION_ORDER.indexOf(b.section))
281
+ }
282
+
283
+ // 스캔까지 포함한 수집. 적용·액션 실행 뒤 다시 부른다.
284
+ export async function collectRows(root, opts = {}) {
285
+ const { fetchImpl = netFetch, designDirs = [], env = process.env, catalogFile = CATALOG_PATH, log = () => {}, t = createT('en') } = opts
286
+
287
+ const agentItems = await loadItems()
288
+ const catalog = loadCatalog(catalogFile)
289
+ const sources = opts.sources ?? discoverSources({
290
+ ...(opts.bundleDir ? { bundleDir: opts.bundleDir } : {}),
291
+ extraDirs: [...extraDirsFromEnv(env), ...designDirs],
292
+ log,
293
+ t,
294
+ })
295
+ const designItems = buildItems(catalog, { fetchImpl, sources })
296
+
297
+ const [agentStates, designStates] = await Promise.all([scan(root, agentItems), scan(root, designItems)])
298
+ const multiProvider = new Set(designItems.map((i) => i.providerId)).size > 1
299
+
300
+ return {
301
+ rows: buildRows({
302
+ actions: buildActions(root, { designItems, t }),
303
+ agentStates,
304
+ designStates,
305
+ multiProvider,
306
+ t,
307
+ }),
308
+ agentStates,
309
+ designStates,
310
+ designItems,
311
+ }
312
+ }
313
+
314
+ // 설치된 항목 = 시작 시 체크된 항목.
315
+ export function installedIds(rows) {
316
+ return rows.filter((r) => r.kind === 'item' && r.status !== 'absent').map((r) => r.id)
317
+ }