@rch4com/agent-setup 1.8.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 -197
  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/run.mjs CHANGED
@@ -1,396 +1,396 @@
1
- // 유일한 부수효과 지점 — 키 입력, 화면 그리기, 액션 실행, 적용.
2
- // 무엇을 보여줄지(render)와 무엇이 선택됐는지(state)는 순수 모듈이 정한다.
3
- import { emitKeypressEvents } from 'node:readline'
4
- import { planChanges, apply } from '../engine.mjs'
5
- import { makeOpener, openPreview } from '../design-md/open.mjs'
6
- import { netFetch, CATALOG_PATH } from '../design-md/catalog.mjs'
7
- import { createT, toText, LOCALES } from '../i18n/index.mjs'
8
- import { RECORD_REL, writeLang } from '../bootstrap/record.mjs'
9
- import { collectRows, installedIds } from './rows.mjs'
10
- import { CLI_IDS } from '../clis.mjs'
11
- import {
12
- createState, setQuery, setFocus, move, moveTab, toggle, toggleVisible, scroll, currentRow, replaceRows, activeTab,
13
- cycleCliFilter,
14
- } from './state.mjs'
15
- import { render, renderReview, bodyHeight } from './render.mjs'
16
- import { createProgress, applyEvent, progressLines } from './progress.mjs'
17
-
18
- const ESC = String.fromCharCode(27)
19
- const HIDE_CURSOR = `${ESC}[?25l`
20
- const SHOW_CURSOR = `${ESC}[?25h`
21
- const ALT_ON = `${ESC}[?1049h`
22
- const ALT_OFF = `${ESC}[?1049l`
23
- const HOME = `${ESC}[H`
24
- const CLEAR_LINE = `${ESC}[K`
25
- const CLEAR_DOWN = `${ESC}[J`
26
-
27
- // 키를 큐에 쌓아 두고 하나씩 꺼내 쓴다. 이벤트 상태 기계 대신
28
- // `for(;;) await next()` 루프로 흐름을 읽을 수 있게 하기 위해서다.
29
- function keyReader(stdin) {
30
- const queue = []
31
- let waiting = null
32
- const onKey = (str, key) => {
33
- const event = { str, ...(key ?? {}) }
34
- if (waiting) { const resolve = waiting; waiting = null; resolve(event) }
35
- else queue.push(event)
36
- }
37
- stdin.on('keypress', onKey)
38
- return {
39
- next: () => (queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((r) => { waiting = r })),
40
- // 적용 중 중단 감시용. 큐를 **소비하지 않고** Ctrl+C만 들여다본다.
41
- // next()로 기다리는 두 번째 소비자를 두면 적용이 끝난 뒤 눌린 키가
42
- // 그 대기로 흘러 사라지고, 본 루프의 "아무 키나" 대기가 멈춘다.
43
- // Ctrl+C가 있을 때만 거기까지를 버린다.
44
- hasAbort: () => {
45
- const at = queue.findIndex((k) => k.ctrl && k.name === 'c')
46
- if (at === -1) return false
47
- queue.splice(0, at + 1)
48
- return true
49
- },
50
- stop: () => stdin.off('keypress', onKey),
51
- }
52
- }
53
-
54
- function itemStates(rows) {
55
- return rows.filter((r) => r.kind === 'item').map((r) => ({ item: r.item, status: r.status }))
56
- }
57
-
58
- // row.section은 이제 소문자 id다(rows.mjs Task 9) — t 없이 그대로 찍으면
59
- // 화면에 'action'·'plugin' 같은 raw id가 새어 나간다. 이 경로도 사용자가
60
- // 보는 출력이라 여기서도 t를 받는다.
61
- function printPlain(rows, log, t = createT('en')) {
62
- let section = null
63
- for (const row of rows) {
64
- if (row.section !== section) { section = row.section; log(`[${t(`section.${section}`)}]`) }
65
- const mark = row.kind === 'action' ? '▶' : row.status === 'absent' ? ' ' : '×'
66
- // 화면 폭 제약이 없는 자리다 — 여기서는 긴 힌트가 낫다.
67
- const hint = row.fullHint ?? row.hint
68
- log(` [${mark}] ${row.label}${hint ? ` — ${hint}` : ''}`)
69
- }
70
- }
71
-
72
- // 화면에 찍히는 글자인가(스페이스 포함). 검색칸 포커스에서 검색어로 들어갈 후보다.
73
- // 스페이스의 뜻은 포커스가 가른다: 검색칸에서는 검색어, 목록에서는 선택.
74
- // 그래서 목록에서 타이핑으로 검색칸에 올라갈 때는 호출부에서 스페이스를 뺀다.
75
- function isPrintable(key) {
76
- return Boolean(key.str) && !key.ctrl && !key.meta && key.str >= ' ' && key.str !== ESC
77
- }
78
-
79
- export async function runTui(root, opts = {}) {
80
- const {
81
- dryRun = false,
82
- skillMode = 'auto',
83
- fetchImpl = netFetch,
84
- designDirs = [],
85
- env = process.env,
86
- catalogFile = CATALOG_PATH,
87
- log = console.log,
88
- stdin = process.stdin,
89
- stdout = process.stdout,
90
- } = opts
91
- const opener = opts.opener ?? makeOpener(dryRun, log)
92
- // let으로 둔다 — 언어 전환 행(cycleLanguage)이 실행 중에 갈아끼운다.
93
- let { t = createT('en'), localeForced = false } = opts
94
-
95
- let collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, log: () => {}, t })
96
-
97
- // raw 모드를 켤 수 없으면(CI·파이프) 목록만 내고 끝낸다.
98
- if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
99
- printPlain(collected.rows, log, t)
100
- log(`\n${t('tui.nonInteractive')}`)
101
- return { interactive: false }
102
- }
103
-
104
- // 처음엔 목록에 포커스를 둔다 — 곧바로 화살표 이동·Space 선택·Enter 실행이 되게.
105
- // 타이핑하면 검색칸으로 올라간다.
106
- let state = createState(collected.rows, { selectedIds: installedIds(collected.rows), focus: 'list' })
107
- let status = ''
108
- // 순환 대상: 전체(null) + CLI 10개. state.mjs는 아무것도 import 하지 않으므로
109
- // 목록을 여기서 만들어 넘긴다.
110
- const CLI_OPTIONS = [null, ...CLI_IDS]
111
- let detailExpanded = false
112
-
113
- emitKeypressEvents(stdin)
114
- stdin.setRawMode(true)
115
- stdin.resume()
116
- const keys = keyReader(stdin)
117
- stdout.write(ALT_ON + HIDE_CURSOR)
118
-
119
- const color = !env.NO_COLOR && stdout.isTTY
120
- const draw = (lines) => stdout.write(HOME + lines.map((l) => l + CLEAR_LINE).join('\n') + '\n' + CLEAR_DOWN)
121
- const paint = () => {
122
- const height = stdout.rows ?? 24
123
- state = scroll(state, bodyHeight(height, detailExpanded))
124
- draw(render(state, {
125
- width: stdout.columns ?? 80, height, repo: root, dryRun, color, status, t,
126
- detailExpanded, cliOptions: CLI_OPTIONS,
127
- }))
128
- }
129
-
130
- // 로그는 화면 안에 우겨넣지 않는다 — 실패 메시지가 길고, 잘리면 진단이 불가능해진다.
131
- const suspend = async (fn) => {
132
- stdout.write(ALT_OFF + SHOW_CURSOR)
133
- try { return await fn() } finally { stdout.write(ALT_ON + HIDE_CURSOR) }
134
- }
135
- const confirm = async (message) => {
136
- stdout.write(`\n${message}${t('tui.confirmSuffix')}`)
137
- const key = await keys.next()
138
- stdout.write('\n')
139
- return String(key.str ?? '').toLowerCase() === 'y'
140
- }
141
- const pause = async () => {
142
- stdout.write(`\n${t('tui.pressAnyKey')}`)
143
- await keys.next()
144
- }
145
-
146
- // 언어 전환은 선택을 보존해야 한다. 적용·액션 실행 뒤 재스캔은 그 반대로
147
- // 실제 설치 상태로 되돌려야 한다 — 둘을 한 함수로 묶으면 언어를 바꿀 때
148
- // 사용자가 고르던 항목이 조용히 날아간다.
149
- const rebuild = async (keepSelection) => {
150
- collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, t, log: () => {} })
151
- const ids = keepSelection ? [...state.selected] : installedIds(collected.rows)
152
- state = replaceRows(state, collected.rows, ids)
153
- }
154
- const recollect = () => rebuild(false)
155
-
156
- const cycleLanguage = async () => {
157
- const next = LOCALES[(LOCALES.indexOf(t.locale) + 1) % LOCALES.length]
158
- t = createT(next)
159
- let note
160
- try {
161
- writeLang(root, next, { dryRun, log: () => {}, t })
162
- note = dryRun ? t('tui.lang.dryRun') : t('tui.lang.saved', { path: RECORD_REL })
163
- } catch (err) {
164
- // 언어는 부수적 설정이다. 저장에 실패했다고 화면이 죽으면 설치기를 못 쓴다.
165
- note = t('tui.lang.saveFailed', { message: err.message })
166
- }
167
- // 전환은 이번 실행에 이미 적용됐다(위에서 t를 갈아끼우고 아래에서 다시
168
- // 그린다) — 읽을 수 없는 언어에 갇힌 사용자가 빠져나올 길이 있어야 한다.
169
- // 알릴 것은 "다음 실행에서는 플래그·환경변수가 다시 이긴다"뿐이다.
170
- // note는 이미 "저장했다"를 말하므로 겹치지 않게 이어 붙인다.
171
- if (localeForced) note = `${note} · ${t('tui.lang.overridden')}`
172
- await rebuild(true)
173
- return note
174
- }
175
-
176
- const select = () => {
177
- const row = currentRow(state)
178
- if (row?.kind !== 'item') return t('tui.notItemRow')
179
- const before = state.selected
180
- state = toggle(state)
181
- // 배타 묶음에서 형제가 함께 꺼졌으면 그 사실을 말한다. 체크가 조용히
182
- // 뒤집히면 사용자는 자기가 잘못 눌렀다고 읽는다 — 검색으로 형제가
183
- // 화면 밖에 있을 때는 아예 무슨 일이 일어났는지 볼 수조차 없다.
184
- const dropped = state.rows.filter((r) => r.id !== row.id && before.has(r.id) && !state.selected.has(r.id))
185
- if (dropped.length > 0) return t('tui.exclusiveSwitched', { kept: row.label, dropped: dropped.map((r) => r.label).join(', ') })
186
- return ''
187
- }
188
-
189
- // 미리보기는 두 포커스에서 모두 통해야 한다 — 검색으로 찾은 직후가 가장 열어 보고 싶은 순간이다.
190
- const preview = () => {
191
- const row = currentRow(state)
192
- if (row?.kind !== 'item' || !row.previewTarget) return t('tui.noPreview')
193
- const notes = []
194
- openPreview(opener, row.item, (m) => notes.push(String(m).trim()), t)
195
- return notes.length > 0 ? notes.join(' ') : t('tui.opened', { target: row.previewTarget })
196
- }
197
-
198
- // 제출 검토 — 적용 직전에 변경 목록을 보여 주고 Enter/Esc만 받는다.
199
- const review = async (changes) => {
200
- for (;;) {
201
- draw(renderReview(changes, { width: stdout.columns ?? 80, height: stdout.rows ?? 24, dryRun, color, t }))
202
- const key = await keys.next()
203
- if (key.ctrl && (key.name === 'c' || key.name === 'q')) return 'quit'
204
- if (key.name === 'escape') return 'cancel'
205
- if (key.name === 'return' || key.name === 'enter') return 'apply'
206
- }
207
- }
208
-
209
- // 적용 중에는 alt 화면을 떠나지 않는다. exec가 비동기가 된 덕에 이벤트
210
- // 루프가 살아 있어, 100ms 타이머가 경과 시간을 실제로 흘려 준다.
211
- const runApply = async (changes) => {
212
- let progress = { ...createProgress(changes), startedAt: Date.now() }
213
- let stopRequested = false
214
-
215
- // 중단 요청은 큐를 엿봐서 안다. 한 번 서면 되돌리지 않는다.
216
- const checkAbort = () => {
217
- if (!stopRequested && keys.hasAbort()) stopRequested = true
218
- return stopRequested
219
- }
220
- const drawProgress = () => draw(progressLines(progress, {
221
- width: stdout.columns ?? 80, height: stdout.rows ?? 24, color, dryRun, now: Date.now(), t,
222
- }))
223
-
224
- drawProgress()
225
- const timer = setInterval(() => { checkAbort(); drawProgress() }, 100)
226
- // 타이머가 프로세스를 붙잡지 않게 한다 — 화면 갱신은 종료를 미룰 이유가 없다.
227
- timer.unref?.()
228
-
229
- try {
230
- const results = await apply(root, changes, {
231
- dryRun,
232
- log: () => {}, // 로그는 진행 화면이 대신한다
233
- t,
234
- shouldStop: checkAbort,
235
- onProgress: (event) => {
236
- progress = applyEvent(progress, event, Date.now())
237
- drawProgress()
238
- },
239
- })
240
- // aborted는 apply()가 끝난 뒤에만 세운다 — 도중에 세우면 실행 중 항목이
241
- // aborted:true와 공존해, progressLines의 pending→skipped 겹쳐 보기가
242
- // 아직 끝나지 않은 항목까지 건너뛴 것으로 잘못 그린다. entries 자체는
243
- // 건드리지 않는다 — 시도조차 하지 않은 항목은 그대로 pending으로
244
- // 남아 있어야 progressLines(viewEntry)가 그 상태를 skipped로 겹쳐
245
- // 보며 표시와 집계("N건 건너뜀")를 함께 맞출 수 있다. 여기서 먼저
246
- // state를 skipped로 바꿔 버리면 그 겹쳐 보기가 더는 pending을 찾지
247
- // 못해 집계가 항상 0이 된다.
248
- progress = { ...progress, aborted: stopRequested }
249
- drawProgress()
250
- if (results.some((r) => !r.ok && !r.skipped)) process.exitCode = 1
251
- return results
252
- } finally {
253
- clearInterval(timer)
254
- }
255
- }
256
-
257
- try {
258
- for (;;) {
259
- paint()
260
- const key = await keys.next()
261
- status = ''
262
-
263
- if (key.ctrl && key.name === 'c') break
264
-
265
- // Ctrl 조합은 두 포커스에서 모두 통한다 — 검색으로 좁힌 직후가 CLI
266
- // 필터를 겹쳐 걸고 싶은 순간이다. 글자 키(c·d)를 쓰지 않는 이유는
267
- // 목록 포커스에서 아무 글자나 누르면 검색칸으로 올라가기 때문이다:
268
- // c를 필터에 배정하면 codex·claude를 검색어로 칠 수 없다.
269
- // 종료 전용 키. Ctrl+C는 "중단"이고 Esc는 검색어·포커스를 먼저 되돌리느라
270
- // 최대 세 번을 눌러야 나간다 — 어느 쪽도 "나가는 키"로 배우기 어렵다.
271
- // Ctrl+Q는 두 포커스와 검토 화면에서 한 번에 통하며, 화면 오른쪽 끝에
272
- // 늘 표시된다(render의 고정 자리). 글자 q를 쓰지 않는 이유는 Ctrl+F·
273
- // Ctrl+D와 같다: 목록에서 아무 글자나 누르면 검색칸으로 올라간다.
274
- // raw 모드가 IXON(흐름 제어)을 끄므로 Ctrl+Q는 터미널에 먹히지 않는다.
275
- if (key.ctrl && key.name === 'q') break
276
-
277
- if (key.ctrl && (key.name === 'f' || key.name === 'b')) {
278
- state = cycleCliFilter(state, key.name === 'f' ? 1 : -1, CLI_OPTIONS)
279
- continue
280
- }
281
- if (key.ctrl && key.name === 'd') { detailExpanded = !detailExpanded; continue }
282
-
283
- // ── 검색칸에 포커스: 타이핑이 곧 검색어다(스페이스 포함, 두 단어 검색 가능).
284
- if (state.focus === 'search') {
285
- // Esc: 검색어가 있으면 지우고 검색칸에 남는다. 이미 비었으면 목록으로 내려간다.
286
- if (key.name === 'escape') {
287
- state = state.query ? setQuery(state, '') : setFocus(state, 'list')
288
- continue
289
- }
290
- // Enter·↓: 검색을 마치고 목록으로 내려간다. 결과의 첫 항목이 커서다.
291
- if (key.name === 'return' || key.name === 'enter' || key.name === 'down') { state = setFocus(state, 'list'); continue }
292
- if (key.name === 'backspace') { state = setQuery(state, state.query.slice(0, -1)); continue }
293
- if (key.name === 'tab') { state = moveTab(state, key.shift ? -1 : 1); continue }
294
- if (key.ctrl && key.name === 'o') { status = preview(); continue }
295
- if (isPrintable(key)) state = setQuery(state, state.query + key.str)
296
- continue
297
- }
298
-
299
- // ── 목록에 포커스
300
- if (key.name === 'escape') {
301
- if (state.query) { state = setQuery(state, ''); continue }
302
- break
303
- }
304
-
305
- // / 또는 글자를 누르면 검색칸으로 올라가 타이핑을 시작한다. 스페이스는 선택이므로 제외한다.
306
- if (key.str === '/') { state = setFocus(state, 'search'); continue }
307
- if (key.name !== 'space' && isPrintable(key)) {
308
- state = setQuery(setFocus(state, 'search'), state.query + key.str)
309
- continue
310
- }
311
-
312
- // 맨 위에서 ↑를 누르면 검색칸으로 되돌아간다 — 목록과 검색칸이 위아래로 이어진 느낌.
313
- if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
314
- state = state.cursor === 0 ? setFocus(state, 'search') : move(state, -1)
315
- continue
316
- }
317
- if (key.name === 'down' || (key.ctrl && key.name === 'n')) { state = move(state, 1); continue }
318
- if (key.name === 'pageup') { state = move(state, -bodyHeight(stdout.rows ?? 24, detailExpanded)); continue }
319
- if (key.name === 'pagedown') { state = move(state, bodyHeight(stdout.rows ?? 24, detailExpanded)); continue }
320
-
321
- // 탭 이동 — Tab/Shift+Tab, 좌우 화살표.
322
- if (key.name === 'tab') { state = moveTab(state, key.shift ? -1 : 1); continue }
323
- if (key.name === 'right') { state = moveTab(state, 1); continue }
324
- if (key.name === 'left') { state = moveTab(state, -1); continue }
325
-
326
- // 선택 토글 — 액션 행에서는 아무 일도 하지 않는다(제거 개념이 없다).
327
- if (key.name === 'space') { status = select(); continue }
328
-
329
- // 지금 탭에서 보이는 항목 전체 토글.
330
- if (key.ctrl && key.name === 'a') {
331
- const before = state.selected.size
332
- state = toggleVisible(state)
333
- const statusKey = state.selected.size >= before ? 'tui.toggledAll' : 'tui.toggledNone'
334
- status = t(statusKey, { tab: t(`section.${activeTab(state)}`) })
335
- continue
336
- }
337
-
338
- if (key.ctrl && key.name === 'o') { status = preview(); continue }
339
-
340
- if (key.name === 'return' || key.name === 'enter') {
341
- // 액션 행은 그 자리에서 실행한다. 나머지는 제출(검토 → 일괄 적용)이다.
342
- const row = currentRow(state)
343
- // 언어 행은 화면을 벗어나지 않는다 — 그 자리에서 t를 갈아끼우고 다시 그린다.
344
- if (row?.id === 'action.language') { status = await cycleLanguage(); continue }
345
- if (row?.kind === 'action') {
346
- await suspend(async () => {
347
- await row.run({ root, dryRun, skillMode, fetchImpl, catalogFile, log, confirm, t })
348
- await pause()
349
- })
350
- await recollect()
351
- continue
352
- }
353
-
354
- const changes = planChanges(itemStates(state.rows), state.selected)
355
- if (changes.length === 0) { status = t('tui.noChanges'); continue }
356
-
357
- const verdict = await review(changes)
358
- if (verdict === 'quit') break
359
- if (verdict === 'cancel') { status = t('tui.submitCancelled'); continue }
360
-
361
- const results = await runApply(changes)
362
-
363
- // 실패가 있으면 자세한 사연을 화면 밖에서 보여 준다 — 실패 메시지는
364
- // 길고, 화면 안에서 잘리면 진단이 불가능해진다. 성공해도 message가
365
- // 붙은 결과(definePlugin·ponytail이 claude CLI 부재로 config 직접
366
- // 기록으로 대체할 때 등)는 똑같이 사연이 있다 — ok만 보고 걸러내면
367
- // 그 사연이 조용히 사라진다. 건너뜀은 실패가 아니다 — 진행 화면에
368
- // 이미 나타났고, 다시 여기 나열하면 사용자가 스스로 취소한 일을
369
- // 실패처럼 읽게 된다.
370
- const notable = results.filter((r) => !r.skipped && (!r.ok || r.message))
371
- await suspend(async () => {
372
- if (notable.length > 0) {
373
- log('')
374
- for (const r of notable) {
375
- const message = toText(t, r.message)
376
- log(` ${r.ok ? '✔' : '✖'} ${t(`change.${r.action}`)} ${r.item.label}${message ? ` — ${message}` : ''}`)
377
- }
378
- }
379
- log(`\n${t('apply.seeGitDiff')}`)
380
- await pause()
381
- })
382
- await recollect()
383
- continue
384
- }
385
- }
386
- } finally {
387
- keys.stop()
388
- stdout.write(ALT_OFF + SHOW_CURSOR)
389
- stdin.setRawMode(false)
390
- stdin.pause()
391
- }
392
-
393
- return { interactive: true, state }
394
- }
395
-
396
- export { printPlain, itemStates }
1
+ // 유일한 부수효과 지점 — 키 입력, 화면 그리기, 액션 실행, 적용.
2
+ // 무엇을 보여줄지(render)와 무엇이 선택됐는지(state)는 순수 모듈이 정한다.
3
+ import { emitKeypressEvents } from 'node:readline'
4
+ import { planChanges, apply } from '../engine.mjs'
5
+ import { makeOpener, openPreview } from '../design-md/open.mjs'
6
+ import { netFetch, CATALOG_PATH } from '../design-md/catalog.mjs'
7
+ import { createT, toText, LOCALES } from '../i18n/index.mjs'
8
+ import { RECORD_REL, writeLang } from '../bootstrap/record.mjs'
9
+ import { collectRows, installedIds } from './rows.mjs'
10
+ import { CLI_IDS } from '../clis.mjs'
11
+ import {
12
+ createState, setQuery, setFocus, move, moveTab, toggle, toggleVisible, scroll, currentRow, replaceRows, activeTab,
13
+ cycleCliFilter,
14
+ } from './state.mjs'
15
+ import { render, renderReview, bodyHeight } from './render.mjs'
16
+ import { createProgress, applyEvent, progressLines } from './progress.mjs'
17
+
18
+ const ESC = String.fromCharCode(27)
19
+ const HIDE_CURSOR = `${ESC}[?25l`
20
+ const SHOW_CURSOR = `${ESC}[?25h`
21
+ const ALT_ON = `${ESC}[?1049h`
22
+ const ALT_OFF = `${ESC}[?1049l`
23
+ const HOME = `${ESC}[H`
24
+ const CLEAR_LINE = `${ESC}[K`
25
+ const CLEAR_DOWN = `${ESC}[J`
26
+
27
+ // 키를 큐에 쌓아 두고 하나씩 꺼내 쓴다. 이벤트 상태 기계 대신
28
+ // `for(;;) await next()` 루프로 흐름을 읽을 수 있게 하기 위해서다.
29
+ function keyReader(stdin) {
30
+ const queue = []
31
+ let waiting = null
32
+ const onKey = (str, key) => {
33
+ const event = { str, ...(key ?? {}) }
34
+ if (waiting) { const resolve = waiting; waiting = null; resolve(event) }
35
+ else queue.push(event)
36
+ }
37
+ stdin.on('keypress', onKey)
38
+ return {
39
+ next: () => (queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((r) => { waiting = r })),
40
+ // 적용 중 중단 감시용. 큐를 **소비하지 않고** Ctrl+C만 들여다본다.
41
+ // next()로 기다리는 두 번째 소비자를 두면 적용이 끝난 뒤 눌린 키가
42
+ // 그 대기로 흘러 사라지고, 본 루프의 "아무 키나" 대기가 멈춘다.
43
+ // Ctrl+C가 있을 때만 거기까지를 버린다.
44
+ hasAbort: () => {
45
+ const at = queue.findIndex((k) => k.ctrl && k.name === 'c')
46
+ if (at === -1) return false
47
+ queue.splice(0, at + 1)
48
+ return true
49
+ },
50
+ stop: () => stdin.off('keypress', onKey),
51
+ }
52
+ }
53
+
54
+ function itemStates(rows) {
55
+ return rows.filter((r) => r.kind === 'item').map((r) => ({ item: r.item, status: r.status }))
56
+ }
57
+
58
+ // row.section은 이제 소문자 id다(rows.mjs Task 9) — t 없이 그대로 찍으면
59
+ // 화면에 'action'·'plugin' 같은 raw id가 새어 나간다. 이 경로도 사용자가
60
+ // 보는 출력이라 여기서도 t를 받는다.
61
+ function printPlain(rows, log, t = createT('en')) {
62
+ let section = null
63
+ for (const row of rows) {
64
+ if (row.section !== section) { section = row.section; log(`[${t(`section.${section}`)}]`) }
65
+ const mark = row.kind === 'action' ? '▶' : row.status === 'absent' ? ' ' : '×'
66
+ // 화면 폭 제약이 없는 자리다 — 여기서는 긴 힌트가 낫다.
67
+ const hint = row.fullHint ?? row.hint
68
+ log(` [${mark}] ${row.label}${hint ? ` — ${hint}` : ''}`)
69
+ }
70
+ }
71
+
72
+ // 화면에 찍히는 글자인가(스페이스 포함). 검색칸 포커스에서 검색어로 들어갈 후보다.
73
+ // 스페이스의 뜻은 포커스가 가른다: 검색칸에서는 검색어, 목록에서는 선택.
74
+ // 그래서 목록에서 타이핑으로 검색칸에 올라갈 때는 호출부에서 스페이스를 뺀다.
75
+ function isPrintable(key) {
76
+ return Boolean(key.str) && !key.ctrl && !key.meta && key.str >= ' ' && key.str !== ESC
77
+ }
78
+
79
+ export async function runTui(root, opts = {}) {
80
+ const {
81
+ dryRun = false,
82
+ skillMode = 'auto',
83
+ fetchImpl = netFetch,
84
+ designDirs = [],
85
+ env = process.env,
86
+ catalogFile = CATALOG_PATH,
87
+ log = console.log,
88
+ stdin = process.stdin,
89
+ stdout = process.stdout,
90
+ } = opts
91
+ const opener = opts.opener ?? makeOpener(dryRun, log)
92
+ // let으로 둔다 — 언어 전환 행(cycleLanguage)이 실행 중에 갈아끼운다.
93
+ let { t = createT('en'), localeForced = false } = opts
94
+
95
+ let collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, log: () => {}, t })
96
+
97
+ // raw 모드를 켤 수 없으면(CI·파이프) 목록만 내고 끝낸다.
98
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
99
+ printPlain(collected.rows, log, t)
100
+ log(`\n${t('tui.nonInteractive')}`)
101
+ return { interactive: false }
102
+ }
103
+
104
+ // 처음엔 목록에 포커스를 둔다 — 곧바로 화살표 이동·Space 선택·Enter 실행이 되게.
105
+ // 타이핑하면 검색칸으로 올라간다.
106
+ let state = createState(collected.rows, { selectedIds: installedIds(collected.rows), focus: 'list' })
107
+ let status = ''
108
+ // 순환 대상: 전체(null) + CLI 10개. state.mjs는 아무것도 import 하지 않으므로
109
+ // 목록을 여기서 만들어 넘긴다.
110
+ const CLI_OPTIONS = [null, ...CLI_IDS]
111
+ let detailExpanded = false
112
+
113
+ emitKeypressEvents(stdin)
114
+ stdin.setRawMode(true)
115
+ stdin.resume()
116
+ const keys = keyReader(stdin)
117
+ stdout.write(ALT_ON + HIDE_CURSOR)
118
+
119
+ const color = !env.NO_COLOR && stdout.isTTY
120
+ const draw = (lines) => stdout.write(HOME + lines.map((l) => l + CLEAR_LINE).join('\n') + '\n' + CLEAR_DOWN)
121
+ const paint = () => {
122
+ const height = stdout.rows ?? 24
123
+ state = scroll(state, bodyHeight(height, detailExpanded))
124
+ draw(render(state, {
125
+ width: stdout.columns ?? 80, height, repo: root, dryRun, color, status, t,
126
+ detailExpanded, cliOptions: CLI_OPTIONS,
127
+ }))
128
+ }
129
+
130
+ // 로그는 화면 안에 우겨넣지 않는다 — 실패 메시지가 길고, 잘리면 진단이 불가능해진다.
131
+ const suspend = async (fn) => {
132
+ stdout.write(ALT_OFF + SHOW_CURSOR)
133
+ try { return await fn() } finally { stdout.write(ALT_ON + HIDE_CURSOR) }
134
+ }
135
+ const confirm = async (message) => {
136
+ stdout.write(`\n${message}${t('tui.confirmSuffix')}`)
137
+ const key = await keys.next()
138
+ stdout.write('\n')
139
+ return String(key.str ?? '').toLowerCase() === 'y'
140
+ }
141
+ const pause = async () => {
142
+ stdout.write(`\n${t('tui.pressAnyKey')}`)
143
+ await keys.next()
144
+ }
145
+
146
+ // 언어 전환은 선택을 보존해야 한다. 적용·액션 실행 뒤 재스캔은 그 반대로
147
+ // 실제 설치 상태로 되돌려야 한다 — 둘을 한 함수로 묶으면 언어를 바꿀 때
148
+ // 사용자가 고르던 항목이 조용히 날아간다.
149
+ const rebuild = async (keepSelection) => {
150
+ collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, t, log: () => {} })
151
+ const ids = keepSelection ? [...state.selected] : installedIds(collected.rows)
152
+ state = replaceRows(state, collected.rows, ids)
153
+ }
154
+ const recollect = () => rebuild(false)
155
+
156
+ const cycleLanguage = async () => {
157
+ const next = LOCALES[(LOCALES.indexOf(t.locale) + 1) % LOCALES.length]
158
+ t = createT(next)
159
+ let note
160
+ try {
161
+ writeLang(root, next, { dryRun, log: () => {}, t })
162
+ note = dryRun ? t('tui.lang.dryRun') : t('tui.lang.saved', { path: RECORD_REL })
163
+ } catch (err) {
164
+ // 언어는 부수적 설정이다. 저장에 실패했다고 화면이 죽으면 설치기를 못 쓴다.
165
+ note = t('tui.lang.saveFailed', { message: err.message })
166
+ }
167
+ // 전환은 이번 실행에 이미 적용됐다(위에서 t를 갈아끼우고 아래에서 다시
168
+ // 그린다) — 읽을 수 없는 언어에 갇힌 사용자가 빠져나올 길이 있어야 한다.
169
+ // 알릴 것은 "다음 실행에서는 플래그·환경변수가 다시 이긴다"뿐이다.
170
+ // note는 이미 "저장했다"를 말하므로 겹치지 않게 이어 붙인다.
171
+ if (localeForced) note = `${note} · ${t('tui.lang.overridden')}`
172
+ await rebuild(true)
173
+ return note
174
+ }
175
+
176
+ const select = () => {
177
+ const row = currentRow(state)
178
+ if (row?.kind !== 'item') return t('tui.notItemRow')
179
+ const before = state.selected
180
+ state = toggle(state)
181
+ // 배타 묶음에서 형제가 함께 꺼졌으면 그 사실을 말한다. 체크가 조용히
182
+ // 뒤집히면 사용자는 자기가 잘못 눌렀다고 읽는다 — 검색으로 형제가
183
+ // 화면 밖에 있을 때는 아예 무슨 일이 일어났는지 볼 수조차 없다.
184
+ const dropped = state.rows.filter((r) => r.id !== row.id && before.has(r.id) && !state.selected.has(r.id))
185
+ if (dropped.length > 0) return t('tui.exclusiveSwitched', { kept: row.label, dropped: dropped.map((r) => r.label).join(', ') })
186
+ return ''
187
+ }
188
+
189
+ // 미리보기는 두 포커스에서 모두 통해야 한다 — 검색으로 찾은 직후가 가장 열어 보고 싶은 순간이다.
190
+ const preview = () => {
191
+ const row = currentRow(state)
192
+ if (row?.kind !== 'item' || !row.previewTarget) return t('tui.noPreview')
193
+ const notes = []
194
+ openPreview(opener, row.item, (m) => notes.push(String(m).trim()), t)
195
+ return notes.length > 0 ? notes.join(' ') : t('tui.opened', { target: row.previewTarget })
196
+ }
197
+
198
+ // 제출 검토 — 적용 직전에 변경 목록을 보여 주고 Enter/Esc만 받는다.
199
+ const review = async (changes) => {
200
+ for (;;) {
201
+ draw(renderReview(changes, { width: stdout.columns ?? 80, height: stdout.rows ?? 24, dryRun, color, t }))
202
+ const key = await keys.next()
203
+ if (key.ctrl && (key.name === 'c' || key.name === 'q')) return 'quit'
204
+ if (key.name === 'escape') return 'cancel'
205
+ if (key.name === 'return' || key.name === 'enter') return 'apply'
206
+ }
207
+ }
208
+
209
+ // 적용 중에는 alt 화면을 떠나지 않는다. exec가 비동기가 된 덕에 이벤트
210
+ // 루프가 살아 있어, 100ms 타이머가 경과 시간을 실제로 흘려 준다.
211
+ const runApply = async (changes) => {
212
+ let progress = { ...createProgress(changes), startedAt: Date.now() }
213
+ let stopRequested = false
214
+
215
+ // 중단 요청은 큐를 엿봐서 안다. 한 번 서면 되돌리지 않는다.
216
+ const checkAbort = () => {
217
+ if (!stopRequested && keys.hasAbort()) stopRequested = true
218
+ return stopRequested
219
+ }
220
+ const drawProgress = () => draw(progressLines(progress, {
221
+ width: stdout.columns ?? 80, height: stdout.rows ?? 24, color, dryRun, now: Date.now(), t,
222
+ }))
223
+
224
+ drawProgress()
225
+ const timer = setInterval(() => { checkAbort(); drawProgress() }, 100)
226
+ // 타이머가 프로세스를 붙잡지 않게 한다 — 화면 갱신은 종료를 미룰 이유가 없다.
227
+ timer.unref?.()
228
+
229
+ try {
230
+ const results = await apply(root, changes, {
231
+ dryRun,
232
+ log: () => {}, // 로그는 진행 화면이 대신한다
233
+ t,
234
+ shouldStop: checkAbort,
235
+ onProgress: (event) => {
236
+ progress = applyEvent(progress, event, Date.now())
237
+ drawProgress()
238
+ },
239
+ })
240
+ // aborted는 apply()가 끝난 뒤에만 세운다 — 도중에 세우면 실행 중 항목이
241
+ // aborted:true와 공존해, progressLines의 pending→skipped 겹쳐 보기가
242
+ // 아직 끝나지 않은 항목까지 건너뛴 것으로 잘못 그린다. entries 자체는
243
+ // 건드리지 않는다 — 시도조차 하지 않은 항목은 그대로 pending으로
244
+ // 남아 있어야 progressLines(viewEntry)가 그 상태를 skipped로 겹쳐
245
+ // 보며 표시와 집계("N건 건너뜀")를 함께 맞출 수 있다. 여기서 먼저
246
+ // state를 skipped로 바꿔 버리면 그 겹쳐 보기가 더는 pending을 찾지
247
+ // 못해 집계가 항상 0이 된다.
248
+ progress = { ...progress, aborted: stopRequested }
249
+ drawProgress()
250
+ if (results.some((r) => !r.ok && !r.skipped)) process.exitCode = 1
251
+ return results
252
+ } finally {
253
+ clearInterval(timer)
254
+ }
255
+ }
256
+
257
+ try {
258
+ for (;;) {
259
+ paint()
260
+ const key = await keys.next()
261
+ status = ''
262
+
263
+ if (key.ctrl && key.name === 'c') break
264
+
265
+ // Ctrl 조합은 두 포커스에서 모두 통한다 — 검색으로 좁힌 직후가 CLI
266
+ // 필터를 겹쳐 걸고 싶은 순간이다. 글자 키(c·d)를 쓰지 않는 이유는
267
+ // 목록 포커스에서 아무 글자나 누르면 검색칸으로 올라가기 때문이다:
268
+ // c를 필터에 배정하면 codex·claude를 검색어로 칠 수 없다.
269
+ // 종료 전용 키. Ctrl+C는 "중단"이고 Esc는 검색어·포커스를 먼저 되돌리느라
270
+ // 최대 세 번을 눌러야 나간다 — 어느 쪽도 "나가는 키"로 배우기 어렵다.
271
+ // Ctrl+Q는 두 포커스와 검토 화면에서 한 번에 통하며, 화면 오른쪽 끝에
272
+ // 늘 표시된다(render의 고정 자리). 글자 q를 쓰지 않는 이유는 Ctrl+F·
273
+ // Ctrl+D와 같다: 목록에서 아무 글자나 누르면 검색칸으로 올라간다.
274
+ // raw 모드가 IXON(흐름 제어)을 끄므로 Ctrl+Q는 터미널에 먹히지 않는다.
275
+ if (key.ctrl && key.name === 'q') break
276
+
277
+ if (key.ctrl && (key.name === 'f' || key.name === 'b')) {
278
+ state = cycleCliFilter(state, key.name === 'f' ? 1 : -1, CLI_OPTIONS)
279
+ continue
280
+ }
281
+ if (key.ctrl && key.name === 'd') { detailExpanded = !detailExpanded; continue }
282
+
283
+ // ── 검색칸에 포커스: 타이핑이 곧 검색어다(스페이스 포함, 두 단어 검색 가능).
284
+ if (state.focus === 'search') {
285
+ // Esc: 검색어가 있으면 지우고 검색칸에 남는다. 이미 비었으면 목록으로 내려간다.
286
+ if (key.name === 'escape') {
287
+ state = state.query ? setQuery(state, '') : setFocus(state, 'list')
288
+ continue
289
+ }
290
+ // Enter·↓: 검색을 마치고 목록으로 내려간다. 결과의 첫 항목이 커서다.
291
+ if (key.name === 'return' || key.name === 'enter' || key.name === 'down') { state = setFocus(state, 'list'); continue }
292
+ if (key.name === 'backspace') { state = setQuery(state, state.query.slice(0, -1)); continue }
293
+ if (key.name === 'tab') { state = moveTab(state, key.shift ? -1 : 1); continue }
294
+ if (key.ctrl && key.name === 'o') { status = preview(); continue }
295
+ if (isPrintable(key)) state = setQuery(state, state.query + key.str)
296
+ continue
297
+ }
298
+
299
+ // ── 목록에 포커스
300
+ if (key.name === 'escape') {
301
+ if (state.query) { state = setQuery(state, ''); continue }
302
+ break
303
+ }
304
+
305
+ // / 또는 글자를 누르면 검색칸으로 올라가 타이핑을 시작한다. 스페이스는 선택이므로 제외한다.
306
+ if (key.str === '/') { state = setFocus(state, 'search'); continue }
307
+ if (key.name !== 'space' && isPrintable(key)) {
308
+ state = setQuery(setFocus(state, 'search'), state.query + key.str)
309
+ continue
310
+ }
311
+
312
+ // 맨 위에서 ↑를 누르면 검색칸으로 되돌아간다 — 목록과 검색칸이 위아래로 이어진 느낌.
313
+ if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
314
+ state = state.cursor === 0 ? setFocus(state, 'search') : move(state, -1)
315
+ continue
316
+ }
317
+ if (key.name === 'down' || (key.ctrl && key.name === 'n')) { state = move(state, 1); continue }
318
+ if (key.name === 'pageup') { state = move(state, -bodyHeight(stdout.rows ?? 24, detailExpanded)); continue }
319
+ if (key.name === 'pagedown') { state = move(state, bodyHeight(stdout.rows ?? 24, detailExpanded)); continue }
320
+
321
+ // 탭 이동 — Tab/Shift+Tab, 좌우 화살표.
322
+ if (key.name === 'tab') { state = moveTab(state, key.shift ? -1 : 1); continue }
323
+ if (key.name === 'right') { state = moveTab(state, 1); continue }
324
+ if (key.name === 'left') { state = moveTab(state, -1); continue }
325
+
326
+ // 선택 토글 — 액션 행에서는 아무 일도 하지 않는다(제거 개념이 없다).
327
+ if (key.name === 'space') { status = select(); continue }
328
+
329
+ // 지금 탭에서 보이는 항목 전체 토글.
330
+ if (key.ctrl && key.name === 'a') {
331
+ const before = state.selected.size
332
+ state = toggleVisible(state)
333
+ const statusKey = state.selected.size >= before ? 'tui.toggledAll' : 'tui.toggledNone'
334
+ status = t(statusKey, { tab: t(`section.${activeTab(state)}`) })
335
+ continue
336
+ }
337
+
338
+ if (key.ctrl && key.name === 'o') { status = preview(); continue }
339
+
340
+ if (key.name === 'return' || key.name === 'enter') {
341
+ // 액션 행은 그 자리에서 실행한다. 나머지는 제출(검토 → 일괄 적용)이다.
342
+ const row = currentRow(state)
343
+ // 언어 행은 화면을 벗어나지 않는다 — 그 자리에서 t를 갈아끼우고 다시 그린다.
344
+ if (row?.id === 'action.language') { status = await cycleLanguage(); continue }
345
+ if (row?.kind === 'action') {
346
+ await suspend(async () => {
347
+ await row.run({ root, dryRun, skillMode, fetchImpl, catalogFile, log, confirm, t })
348
+ await pause()
349
+ })
350
+ await recollect()
351
+ continue
352
+ }
353
+
354
+ const changes = planChanges(itemStates(state.rows), state.selected)
355
+ if (changes.length === 0) { status = t('tui.noChanges'); continue }
356
+
357
+ const verdict = await review(changes)
358
+ if (verdict === 'quit') break
359
+ if (verdict === 'cancel') { status = t('tui.submitCancelled'); continue }
360
+
361
+ const results = await runApply(changes)
362
+
363
+ // 실패가 있으면 자세한 사연을 화면 밖에서 보여 준다 — 실패 메시지는
364
+ // 길고, 화면 안에서 잘리면 진단이 불가능해진다. 성공해도 message가
365
+ // 붙은 결과(definePlugin·ponytail이 claude CLI 부재로 config 직접
366
+ // 기록으로 대체할 때 등)는 똑같이 사연이 있다 — ok만 보고 걸러내면
367
+ // 그 사연이 조용히 사라진다. 건너뜀은 실패가 아니다 — 진행 화면에
368
+ // 이미 나타났고, 다시 여기 나열하면 사용자가 스스로 취소한 일을
369
+ // 실패처럼 읽게 된다.
370
+ const notable = results.filter((r) => !r.skipped && (!r.ok || r.message))
371
+ await suspend(async () => {
372
+ if (notable.length > 0) {
373
+ log('')
374
+ for (const r of notable) {
375
+ const message = toText(t, r.message)
376
+ log(` ${r.ok ? '✔' : '✖'} ${t(`change.${r.action}`)} ${r.item.label}${message ? ` — ${message}` : ''}`)
377
+ }
378
+ }
379
+ log(`\n${t('apply.seeGitDiff')}`)
380
+ await pause()
381
+ })
382
+ await recollect()
383
+ continue
384
+ }
385
+ }
386
+ } finally {
387
+ keys.stop()
388
+ stdout.write(ALT_OFF + SHOW_CURSOR)
389
+ stdin.setRawMode(false)
390
+ stdin.pause()
391
+ }
392
+
393
+ return { interactive: true, state }
394
+ }
395
+
396
+ export { printPlain, itemStates }