@rch4com/agent-setup 1.1.0 → 1.4.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 (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +197 -55
  3. package/install.mjs +152 -101
  4. package/lib/args.mjs +276 -311
  5. package/lib/bootstrap/adapter.mjs +96 -95
  6. package/lib/bootstrap/apply.mjs +380 -379
  7. package/lib/bootstrap/flow.mjs +73 -70
  8. package/lib/bootstrap/record.mjs +154 -142
  9. package/lib/bootstrap/templates.mjs +148 -148
  10. package/lib/bootstrap/text.mjs +19 -19
  11. package/lib/catalog.mjs +213 -145
  12. package/lib/claude-plugins.mjs +64 -64
  13. package/lib/clis.mjs +102 -102
  14. package/lib/context.mjs +66 -65
  15. package/lib/deps.mjs +5 -7
  16. package/lib/design-md/cache/awesome-design-md/LICENSE.md +38 -0
  17. package/lib/design-md/catalog.json +454 -454
  18. package/lib/design-md/catalog.mjs +266 -268
  19. package/lib/design-md/flow.mjs +181 -166
  20. package/lib/design-md/open.mjs +51 -50
  21. package/lib/design-md/providers/awesome-design-md.mjs +124 -114
  22. package/lib/design-md/providers/index.mjs +9 -9
  23. package/lib/design-md/scan.mjs +14 -10
  24. package/lib/engine.mjs +48 -44
  25. package/lib/gitignore.mjs +18 -18
  26. package/lib/i18n/catalog/en.mjs +369 -0
  27. package/lib/i18n/catalog/ko.mjs +359 -0
  28. package/lib/i18n/detect.mjs +39 -0
  29. package/lib/i18n/index.mjs +93 -0
  30. package/lib/items/mcp.codebase-memory.mjs +6 -6
  31. package/lib/items/mcp.graphify.mjs +8 -0
  32. package/lib/items/mcp.headroom.mjs +8 -0
  33. package/lib/items/mcp.notion.mjs +6 -6
  34. package/lib/items/mcp.supabase.mjs +6 -6
  35. package/lib/items/mcp.vercel.mjs +6 -6
  36. package/lib/items/plugin.bkit.mjs +7 -7
  37. package/lib/items/plugin.ecc.mjs +11 -0
  38. package/lib/items/plugin.impeccable.mjs +14 -0
  39. package/lib/items/plugin.mattpocock-skills.mjs +2 -2
  40. package/lib/items/plugin.ponytail.mjs +104 -0
  41. package/lib/items/plugin.superpowers.mjs +7 -7
  42. package/lib/items/plugin.understand-anything.mjs +10 -0
  43. package/lib/items/skill.caveman.mjs +7 -0
  44. package/lib/items/skill.gsd.mjs +25 -24
  45. package/lib/items/skill.gstack.mjs +40 -39
  46. package/lib/items/skill.karpathy.mjs +7 -0
  47. package/lib/items/skill.taste.mjs +9 -0
  48. package/lib/jsonfile.mjs +40 -40
  49. package/lib/status.mjs +93 -92
  50. package/lib/tomlfile.mjs +60 -60
  51. package/lib/tui/render.mjs +41 -63
  52. package/lib/tui/rows.mjs +278 -206
  53. package/lib/tui/run.mjs +64 -28
  54. package/lib/update.mjs +85 -85
  55. package/lib/width.mjs +57 -0
  56. package/package.json +36 -36
package/lib/tui/rows.mjs CHANGED
@@ -1,206 +1,278 @@
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 { runBootstrap } from '../bootstrap/flow.mjs'
8
- import { MANIFEST } from '../bootstrap/manifest.mjs'
9
- import { loadCatalog, buildItems, netFetch, CATALOG_PATH } from '../design-md/catalog.mjs'
10
- import { discoverSources, extraDirsFromEnv } from '../design-md/scan.mjs'
11
- import { refreshCatalog, updateInstalled, findStale } from '../design-md/flow.mjs'
12
-
13
- const AGENT_SECTION = { plugin: 'PLUGIN', mcp: 'MCP', skill: 'SKILL' }
14
- const STATUS_LABEL = { installed: '설치됨', partial: '일부 설치됨', absent: '미설치' }
15
- const DESIGN_STATUS = { installed: '설치됨', partial: '일부', absent: '미설치' }
16
-
17
- export const ACTION_SECTION = '작업'
18
- // scan.mjs의 BUNDLE_CATEGORY와 같은 값이다 — 카테고리를 못 얻은 항목이 모이는 자리.
19
- export const CATCH_ALL_CATEGORY = '기타'
20
- export const SECTION_ORDER = [ACTION_SECTION, 'PLUGIN', 'MCP', 'SKILL', 'DESIGN.MD']
21
-
22
- function short(text, n = 60) {
23
- const t = (text ?? '').replace(/\s+/g, ' ').trim()
24
- return t.length > n ? `${t.slice(0, n - 1)}…` : t
25
- }
26
-
27
- // 에이전트 항목 힌트 — 상태·설치 위치·미지원 CLI 사유까지 한 줄에 담는다.
28
- export function agentHint(item, state) {
29
- const parts = []
30
- if (state.status !== 'absent') parts.push(STATUS_LABEL[state.status])
31
- if (state.detail) parts.push(state.detail)
32
- if (item.scope === 'user') parts.push('설치 위치: 사용자 글로벌')
33
- const un = Object.entries(item.unsupported ?? {})
34
- if (item.category === 'mcp' && un.length > 0) {
35
- parts.push(`미지원: ${un.map(([cli, why]) => `${cli}(${why})`).join(', ')}`)
36
- }
37
- if (item.supports?.length === 1 && item.supports[0] === 'claude') parts.push('Claude Code 전용')
38
- if (item.note) parts.push(item.note)
39
- return parts.join(' · ')
40
- }
41
-
42
- export function designHint(state, multiProvider = false) {
43
- const parts = []
44
- if (multiProvider) parts.push(state.item.providerId)
45
- parts.push(state.item.designCategory)
46
- if (state.status !== 'absent') parts.push(DESIGN_STATUS[state.status])
47
- if (state.item.description) parts.push(short(state.item.description))
48
- return parts.filter(Boolean).join(' · ')
49
- }
50
-
51
- // group = 탭 **안쪽**의 소분류 헤더. design.md만 76개라 카테고리로 갈라야 읽힌다.
52
- // 나머지 탭은 항목이 적어 그룹 없이 평평하게 둔다.
53
- function itemRow({ id, section, label, hint, status, previewTarget = null, item, extra = '', group = null }) {
54
- return {
55
- kind: 'item',
56
- id,
57
- section,
58
- group,
59
- label,
60
- hint,
61
- status,
62
- previewTarget,
63
- item,
64
- searchText: `${label} ${hint} ${section} ${extra}`.toLowerCase(),
65
- }
66
- }
67
-
68
- function actionRow({ id, label, hint, run }) {
69
- return {
70
- kind: 'action',
71
- id,
72
- section: ACTION_SECTION,
73
- group: null,
74
- label,
75
- hint,
76
- status: 'absent',
77
- previewTarget: null,
78
- run,
79
- searchText: `${label} ${hint} ${ACTION_SECTION}`.toLowerCase(),
80
- }
81
- }
82
-
83
- // 부트스트랩이 만들 파일 중 이미 있는 수 — 실행 전에 무엇이 남았는지 알려 준다.
84
- function bootstrapHint(root) {
85
- const present = MANIFEST.files.filter((f) => existsSync(join(root, f.path))).length
86
- return `지침 · 스킬 · 도구별 설정 · 파일 ${MANIFEST.files.length}개 중 ${present}개 존재`
87
- }
88
-
89
- export function buildActions(root, { designItems = [] } = {}) {
90
- return [
91
- actionRow({
92
- id: 'action.bootstrap',
93
- label: '부트스트랩 실행',
94
- hint: bootstrapHint(root),
95
- run: ({ dryRun, skillMode, log }) => runBootstrap(root, { dryRun, skillMode, log }),
96
- }),
97
- actionRow({
98
- id: 'action.sync.installed',
99
- label: '설치본 업데이트',
100
- hint: 'design.md 설치본을 원본 최신으로 다시 받는다',
101
- run: ({ dryRun, log }) => updateInstalled(root, designItems, { dryRun, log }),
102
- }),
103
- actionRow({
104
- id: 'action.sync.catalog',
105
- label: '카탈로그 새로고침',
106
- hint: 'design.md 목록·카테고리를 소스에서 다시 만든다',
107
- run: ({ dryRun, fetchImpl, log, catalogFile }) => refreshCatalog({ dryRun, fetchImpl, log, catalogFile }),
108
- }),
109
- actionRow({
110
- id: 'action.sync.stale',
111
- label: '오래된 항목 확인',
112
- hint: '설치본을 원본과 해시 비교한다',
113
- run: async ({ dryRun, log, confirm }) => {
114
- const stale = await findStale(root, designItems, { log })
115
- if (stale.length === 0) { log('모든 설치본이 최신입니다.'); return }
116
- log(`오래된 항목 ${stale.length}개: ${stale.map((i) => i.name).join(', ')}`)
117
- if (dryRun) return
118
- if (!(await confirm(`${stale.length}개를 지금 업데이트할까요?`))) return
119
- for (const item of stale) {
120
- try {
121
- await item.install({ root, dryRun, fresh: true })
122
- log(` ✔ 업데이트 ${item.label}`)
123
- } catch (err) {
124
- log(` ✖ 업데이트 ${item.label} — ${err.message}`)
125
- }
126
- }
127
- },
128
- }),
129
- ]
130
- }
131
-
132
- // 순수 조립 — 이미 스캔된 상태를 받아 행 배열을 만든다. 테스트는 여기를 겨눈다.
133
- export function buildRows({ actions = [], agentStates = [], designStates = [], multiProvider = false }) {
134
- const agents = agentStates.map((s) =>
135
- itemRow({
136
- id: s.item.id,
137
- section: AGENT_SECTION[s.item.category] ?? s.item.category.toUpperCase(),
138
- label: s.item.label,
139
- hint: agentHint(s.item, s),
140
- status: s.status,
141
- item: s.item,
142
- extra: s.item.id,
143
- }),
144
- )
145
-
146
- // 카테고리 → 라벨 순으로 정렬한다. 그룹 헤더는 인접한 같은 group을 하나로 묶으므로
147
- // 정렬이 흐트러지면 같은 카테고리가 여러 번 쪼개져 나온다.
148
- const designs = designStates
149
- .map((s) =>
150
- itemRow({
151
- id: s.item.id,
152
- section: 'DESIGN.MD',
153
- group: s.item.designCategory || CATCH_ALL_CATEGORY,
154
- label: s.item.label,
155
- hint: designHint(s, multiProvider),
156
- status: s.status,
157
- previewTarget: s.item.webUrl ?? s.item.previewPath ?? null,
158
- item: s.item,
159
- extra: `${s.item.name} ${s.item.providerId}`,
160
- }),
161
- )
162
- // 분류 못 한 항목을 모아 둔 '기타'는 맨 뒤로 보낸다 — catch-all이 목록 머리를 차지하면
163
- // 실제 카테고리를 훑기 전에 잡동사니부터 읽게 된다.
164
- .sort((a, b) =>
165
- (a.group === CATCH_ALL_CATEGORY ? 1 : 0) - (b.group === CATCH_ALL_CATEGORY ? 1 : 0)
166
- || a.group.localeCompare(b.group)
167
- || a.label.localeCompare(b.label))
168
-
169
- const rows = [...actions, ...agents, ...designs]
170
- // 섹션 순서를 고정한다 — displayList가 인접한 같은 섹션을 하나로 묶기 때문이다.
171
- return rows.sort((a, b) => SECTION_ORDER.indexOf(a.section) - SECTION_ORDER.indexOf(b.section))
172
- }
173
-
174
- // 스캔까지 포함한 수집. 적용·액션 실행 뒤 다시 부른다.
175
- export async function collectRows(root, opts = {}) {
176
- const { fetchImpl = netFetch, designDirs = [], env = process.env, catalogFile = CATALOG_PATH, log = () => {} } = opts
177
-
178
- const agentItems = await loadItems()
179
- const catalog = loadCatalog(catalogFile)
180
- const sources = opts.sources ?? discoverSources({
181
- ...(opts.bundleDir ? { bundleDir: opts.bundleDir } : {}),
182
- extraDirs: [...extraDirsFromEnv(env), ...designDirs],
183
- log,
184
- })
185
- const designItems = buildItems(catalog, { fetchImpl, sources })
186
-
187
- const [agentStates, designStates] = await Promise.all([scan(root, agentItems), scan(root, designItems)])
188
- const multiProvider = new Set(designItems.map((i) => i.providerId)).size > 1
189
-
190
- return {
191
- rows: buildRows({
192
- actions: buildActions(root, { designItems }),
193
- agentStates,
194
- designStates,
195
- multiProvider,
196
- }),
197
- agentStates,
198
- designStates,
199
- designItems,
200
- }
201
- }
202
-
203
- // 설치된 항목 = 시작 시 체크된 항목.
204
- export function installedIds(rows) {
205
- return rows.filter((r) => r.kind === 'item' && r.status !== 'absent').map((r) => r.id)
206
- }
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
+
15
+ // 섹션은 탭 이름이자 정렬 키이자 state.tabs의 원소다. 표시 문자열을 그대로
16
+ // 쓰면 번역하는 순간 정렬이 깨진다 — id를 두고 render.mjs가 표시할 때만
17
+ // t로 바꾼다. 덕분에 state.mjs는 계속 아무것도 import 하지 않는다.
18
+ export const ACTION_SECTION = 'action'
19
+ // design-md/scan.mjs의 BUNDLE_CATEGORY와 같은 값이다 — 카테고리를 못 얻은
20
+ // 항목이 모이는 자리. 두 값이 갈리면 같은 그룹이 두 개로 쪼개진다.
21
+ export const CATCH_ALL_CATEGORY = '__other'
22
+ export const SECTION_ORDER = [ACTION_SECTION, 'plugin', 'mcp', 'skill', 'design']
23
+
24
+ // 탭 안쪽 소분류는 **성격**으로 가른다 — 같은 탭에 "무엇으로 설치되는가"가 같고
25
+ // "무엇을 해 주는가"가 다른 항목이 섞여 있어서, 기구(plugin·mcp·skill)만으로는
26
+ // 고를 때 비교가 되지 않는다. 표시 순서는 이 배열이 정한다.
27
+ // `__`로 시작하는 id만 categoryLabel이 번역한다(design.md 카테고리와 같은 규칙).
28
+ export const GROUP_ORDER = ['__token', '__context', '__style', '__flow', '__service']
29
+
30
+ function groupRank(group) {
31
+ const i = GROUP_ORDER.indexOf(group)
32
+ // 그룹이 없거나 목록에 없는 항목은 맨 뒤로 — 헤더 없이 평평하게 붙는다.
33
+ return i === -1 ? GROUP_ORDER.length : i
34
+ }
35
+
36
+ function short(text, n = 60) {
37
+ const t = (text ?? '').replace(/\s+/g, ' ').trim()
38
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t
39
+ }
40
+
41
+ // 검색어에 섹션의 두 로케일 라벨을 모두 넣는다 — 한국어 화면에서도 영어 탭
42
+ // 이름(plugin 등)으로 찾을 수 있어야 한다. 두 라벨이 같으면(PLUGIN·MCP처럼
43
+ // 번역하지 않는 값) 중복으로 넣지 않는다.
44
+ const EN_T = createT('en')
45
+
46
+ function sectionTerms(t, section) {
47
+ const here = t(`section.${section}`)
48
+ const base = EN_T(`section.${section}`)
49
+ return here === base ? `${section} ${base}` : `${section} ${base} ${here}`
50
+ }
51
+
52
+ // 에이전트 항목 힌트 — 상태·설치 위치·미지원 CLI 사유까지 한 줄에 담는다.
53
+ // 미지원 CLI를 **같은 사유끼리 묶어** 한 줄로 만든다. 사유 하나가 CLI 아홉 개에
54
+ // 그대로 반복되면 줄만 길어지고 "무엇이 왜 빠졌는가"는 오히려 묻힌다.
55
+ // 사유가 둘 이상이면(ponytail처럼) 갈래가 그대로 보인다.
56
+ export function unsupportedText(item, t) {
57
+ const entries = Object.entries(item.unsupported ?? {})
58
+ if (entries.length === 0) return null
59
+ const byReason = new Map()
60
+ for (const [cli, why] of entries) {
61
+ const text = toText(t, why)
62
+ if (!byReason.has(text)) byReason.set(text, [])
63
+ byReason.get(text).push(cli)
64
+ }
65
+ const groups = [...byReason].map(([why, clis]) => t('item.unsupportedGroup', { clis: clis.join('·'), why }))
66
+ return t('item.unsupportedList', { count: entries.length, groups: groups.join(' / ') })
67
+ }
68
+
69
+ // 에이전트 항목 힌트 — 상태·CLI 커버리지·설치 위치·미지원 사유까지 한 줄에 담는다.
70
+ export function agentHint(item, state, t = createT('en')) {
71
+ const parts = []
72
+ if (state.status !== 'absent') parts.push(t(`status.${state.status}`))
73
+ // 커버리지는 상태 바로 뒤에 둔다. 힌트 끝은 좁은 터미널에서 잘려 나가는데,
74
+ // 하필 그때가 "이 항목이 내가 쓰는 CLI에서 되나"를 가장 알고 싶은 순간이다.
75
+ // 전부 지원할 때도 찍는다 — 표시가 없는 것과 "10/10"은 뜻이 다르다.
76
+ if (item.supports) parts.push(t('item.cliCoverage', { covered: item.supports.length, total: CLI_IDS.length }))
77
+ const detail = toText(t, state.detail)
78
+ if (detail) parts.push(detail)
79
+ if (item.scope === 'user') parts.push(t('item.location.user'))
80
+ // note는 이제 카탈로그 키다.
81
+ if (item.note) parts.push(t(item.note))
82
+ // 사유는 길어질 수 있어 맨 뒤에 둔다 — 앞의 커버리지가 이미 "빠진 게 있다"를
83
+ // 알려 주므로, 잘려도 존재 자체를 놓치지는 않는다.
84
+ const un = unsupportedText(item, t)
85
+ if (un) parts.push(un)
86
+ return parts.join(' · ')
87
+ }
88
+
89
+ export function designHint(state, multiProvider = false, t) {
90
+ const parts = []
91
+ if (multiProvider) parts.push(state.item.providerId)
92
+ // 카탈로그가 준 카테고리는 그대로, 우리가 만든 catch-all(__other·__local)만
93
+ // categoryLabel이 번역한다.
94
+ parts.push(categoryLabel(t, state.item.designCategory))
95
+ if (state.status !== 'absent') parts.push(t(`status.${state.status}`))
96
+ if (state.item.description) parts.push(short(state.item.description))
97
+ return parts.filter(Boolean).join(' · ')
98
+ }
99
+
100
+ // group = 탭 **안쪽**의 소분류 헤더. design.md만 76개라 카테고리로 갈라야 읽힌다.
101
+ // 나머지 탭은 항목이 적어 그룹 없이 평평하게 둔다.
102
+ function itemRow({ id, section, label, hint, status, previewTarget = null, item, extra = '', group = null, t = createT('en') }) {
103
+ return {
104
+ kind: 'item',
105
+ id,
106
+ section,
107
+ group,
108
+ label,
109
+ hint,
110
+ status,
111
+ previewTarget,
112
+ item,
113
+ searchText: `${label} ${hint} ${sectionTerms(t, section)} ${extra}`.toLowerCase(),
114
+ }
115
+ }
116
+
117
+ function actionRow({ id, label, hint, run = null, t = createT('en') }) {
118
+ return {
119
+ kind: 'action',
120
+ id,
121
+ section: ACTION_SECTION,
122
+ group: null,
123
+ label,
124
+ hint,
125
+ status: 'absent',
126
+ previewTarget: null,
127
+ run,
128
+ searchText: `${label} ${hint} ${sectionTerms(t, ACTION_SECTION)}`.toLowerCase(),
129
+ }
130
+ }
131
+
132
+ // 부트스트랩이 만들 파일 중 이미 있는 수 — 실행 전에 무엇이 남았는지 알려 준다.
133
+ function bootstrapHint(root, t) {
134
+ const present = MANIFEST.files.filter((f) => existsSync(join(root, f.path))).length
135
+ return t('action.bootstrap.hint', { present, total: MANIFEST.files.length })
136
+ }
137
+
138
+ export function buildActions(root, { designItems = [], t = createT('en') } = {}) {
139
+ return [
140
+ // 맨 위 상주 행. 실행은 run.mjs가 특수 처리한다 — 화면을 벗어나지 않고
141
+ // 그 자리에서 t를 갈아끼워야 하므로 다른 액션과 흐름이 다르다(run은 null).
142
+ actionRow({
143
+ id: 'action.language',
144
+ label: t('action.language.label'),
145
+ hint: t('action.language.hint', { current: t(`locale.${t.locale}`) }),
146
+ t,
147
+ }),
148
+ actionRow({
149
+ id: 'action.bootstrap',
150
+ label: t('action.bootstrap.label'),
151
+ hint: bootstrapHint(root, t),
152
+ run: ({ dryRun, skillMode, log, t: rt }) => runBootstrap(root, { dryRun, skillMode, log, t: rt }),
153
+ t,
154
+ }),
155
+ actionRow({
156
+ id: 'action.sync.installed',
157
+ label: t('action.sync.installed.label'),
158
+ hint: t('action.sync.installed.hint'),
159
+ run: ({ dryRun, log, t: rt }) => updateInstalled(root, designItems, { dryRun, log, t: rt }),
160
+ t,
161
+ }),
162
+ actionRow({
163
+ id: 'action.sync.catalog',
164
+ label: t('action.sync.catalog.label'),
165
+ hint: t('action.sync.catalog.hint'),
166
+ run: ({ dryRun, fetchImpl, log, catalogFile, t: rt }) => refreshCatalog({ dryRun, fetchImpl, log, catalogFile, t: rt }),
167
+ t,
168
+ }),
169
+ actionRow({
170
+ id: 'action.sync.stale',
171
+ label: t('action.sync.stale.label'),
172
+ hint: t('action.sync.stale.hint'),
173
+ run: async ({ dryRun, log, confirm, t: rt }) => {
174
+ const stale = await findStale(root, designItems, { log, t: rt })
175
+ // 이 네 줄은 flow.mjs의 runSync(design --sync=stale)가 쓰는 문구와 같다 —
176
+ // 새 키를 만들지 않고 그 키(design.allCurrent 등)를 그대로 재사용한다.
177
+ if (stale.length === 0) { log(rt('design.allCurrent')); return }
178
+ log(rt('design.staleList', { count: stale.length, names: stale.map((i) => i.name).join(', ') }))
179
+ if (dryRun) return
180
+ if (!(await confirm(rt('design.staleConfirm', { count: stale.length })))) return
181
+ for (const item of stale) {
182
+ try {
183
+ await item.install({ root, dryRun, fresh: true })
184
+ log(rt('design.updated', { label: item.label }))
185
+ } catch (err) {
186
+ log(rt('design.updateFailed', { label: item.label, message: err.message }))
187
+ }
188
+ }
189
+ },
190
+ t,
191
+ }),
192
+ ]
193
+ }
194
+
195
+ // 순수 조립 — 이미 스캔된 상태를 받아 행 배열을 만든다. 테스트는 여기를 겨눈다.
196
+ export function buildRows({ actions = [], agentStates = [], designStates = [], multiProvider = false, t = createT('en') }) {
197
+ const agents = agentStates
198
+ .map((s) =>
199
+ itemRow({
200
+ id: s.item.id,
201
+ section: s.item.category,
202
+ group: s.item.group ?? null,
203
+ label: s.item.label,
204
+ hint: agentHint(s.item, s, t),
205
+ status: s.status,
206
+ item: s.item,
207
+ extra: s.item.id,
208
+ t,
209
+ }),
210
+ )
211
+ // 성격 → 라벨 순. 헤더는 인접한 같은 group을 하나로 묶으므로, 정렬이
212
+ // 흐트러지면 같은 성격이 한 탭에서 여러 번 쪼개져 나온다(design과 같은 규칙).
213
+ .sort((a, b) => groupRank(a.group) - groupRank(b.group) || a.label.localeCompare(b.label))
214
+
215
+ // 카테고리 → 라벨 순으로 정렬한다. 그룹 헤더는 인접한 같은 group을 하나로 묶으므로
216
+ // 정렬이 흐트러지면 같은 카테고리가 여러 번 쪼개져 나온다.
217
+ const designs = designStates
218
+ .map((s) =>
219
+ itemRow({
220
+ id: s.item.id,
221
+ section: 'design',
222
+ group: s.item.designCategory || CATCH_ALL_CATEGORY,
223
+ label: s.item.label,
224
+ hint: designHint(s, multiProvider, t),
225
+ status: s.status,
226
+ previewTarget: s.item.webUrl ?? s.item.previewPath ?? null,
227
+ item: s.item,
228
+ extra: `${s.item.name} ${s.item.providerId}`,
229
+ t,
230
+ }),
231
+ )
232
+ // 분류 못 한 항목을 모아 둔 '기타'는 맨 뒤로 보낸다 — catch-all이 목록 머리를 차지하면
233
+ // 실제 카테고리를 훑기 전에 잡동사니부터 읽게 된다.
234
+ .sort((a, b) =>
235
+ (a.group === CATCH_ALL_CATEGORY ? 1 : 0) - (b.group === CATCH_ALL_CATEGORY ? 1 : 0)
236
+ || a.group.localeCompare(b.group)
237
+ || a.label.localeCompare(b.label))
238
+
239
+ const rows = [...actions, ...agents, ...designs]
240
+ // 섹션 순서를 고정한다 — displayList가 인접한 같은 섹션을 하나로 묶기 때문이다.
241
+ return rows.sort((a, b) => SECTION_ORDER.indexOf(a.section) - SECTION_ORDER.indexOf(b.section))
242
+ }
243
+
244
+ // 스캔까지 포함한 수집. 적용·액션 실행 뒤 다시 부른다.
245
+ export async function collectRows(root, opts = {}) {
246
+ const { fetchImpl = netFetch, designDirs = [], env = process.env, catalogFile = CATALOG_PATH, log = () => {}, t = createT('en') } = opts
247
+
248
+ const agentItems = await loadItems()
249
+ const catalog = loadCatalog(catalogFile)
250
+ const sources = opts.sources ?? discoverSources({
251
+ ...(opts.bundleDir ? { bundleDir: opts.bundleDir } : {}),
252
+ extraDirs: [...extraDirsFromEnv(env), ...designDirs],
253
+ log,
254
+ t,
255
+ })
256
+ const designItems = buildItems(catalog, { fetchImpl, sources })
257
+
258
+ const [agentStates, designStates] = await Promise.all([scan(root, agentItems), scan(root, designItems)])
259
+ const multiProvider = new Set(designItems.map((i) => i.providerId)).size > 1
260
+
261
+ return {
262
+ rows: buildRows({
263
+ actions: buildActions(root, { designItems, t }),
264
+ agentStates,
265
+ designStates,
266
+ multiProvider,
267
+ t,
268
+ }),
269
+ agentStates,
270
+ designStates,
271
+ designItems,
272
+ }
273
+ }
274
+
275
+ // 설치된 항목 = 시작 시 체크된 항목.
276
+ export function installedIds(rows) {
277
+ return rows.filter((r) => r.kind === 'item' && r.status !== 'absent').map((r) => r.id)
278
+ }