@rch4com/agent-setup 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/install.mjs +83 -0
  4. package/lib/args.mjs +266 -0
  5. package/lib/bootstrap/adapter.mjs +95 -0
  6. package/lib/bootstrap/apply.mjs +269 -0
  7. package/lib/bootstrap/flow.mjs +54 -0
  8. package/lib/bootstrap/manifest.mjs +74 -0
  9. package/lib/bootstrap/templates.mjs +148 -0
  10. package/lib/catalog.mjs +145 -0
  11. package/lib/claude-plugins.mjs +64 -0
  12. package/lib/clis.mjs +102 -0
  13. package/lib/context.mjs +65 -0
  14. package/lib/deps.mjs +19 -0
  15. package/lib/design-md/cache/awesome-design-md/airbnb/DESIGN.md +545 -0
  16. package/lib/design-md/cache/awesome-design-md/airtable/DESIGN.md +554 -0
  17. package/lib/design-md/cache/awesome-design-md/apple/DESIGN.md +562 -0
  18. package/lib/design-md/cache/awesome-design-md/binance/DESIGN.md +634 -0
  19. package/lib/design-md/cache/awesome-design-md/bmw/DESIGN.md +544 -0
  20. package/lib/design-md/cache/awesome-design-md/bmw-m/DESIGN.md +503 -0
  21. package/lib/design-md/cache/awesome-design-md/bugatti/DESIGN.md +454 -0
  22. package/lib/design-md/cache/awesome-design-md/cal/DESIGN.md +542 -0
  23. package/lib/design-md/cache/awesome-design-md/claude/DESIGN.md +589 -0
  24. package/lib/design-md/cache/awesome-design-md/clay/DESIGN.md +541 -0
  25. package/lib/design-md/cache/awesome-design-md/clickhouse/DESIGN.md +544 -0
  26. package/lib/design-md/cache/awesome-design-md/cohere/DESIGN.md +451 -0
  27. package/lib/design-md/cache/awesome-design-md/coinbase/DESIGN.md +570 -0
  28. package/lib/design-md/cache/awesome-design-md/composio/DESIGN.md +506 -0
  29. package/lib/design-md/cache/awesome-design-md/cursor/DESIGN.md +537 -0
  30. package/lib/design-md/cache/awesome-design-md/dell-1996/DESIGN.md +632 -0
  31. package/lib/design-md/cache/awesome-design-md/elevenlabs/DESIGN.md +504 -0
  32. package/lib/design-md/cache/awesome-design-md/expo/DESIGN.md +526 -0
  33. package/lib/design-md/cache/awesome-design-md/ferrari/DESIGN.md +531 -0
  34. package/lib/design-md/cache/awesome-design-md/figma/DESIGN.md +578 -0
  35. package/lib/design-md/cache/awesome-design-md/framer/DESIGN.md +544 -0
  36. package/lib/design-md/cache/awesome-design-md/hashicorp/DESIGN.md +575 -0
  37. package/lib/design-md/cache/awesome-design-md/hp/DESIGN.md +670 -0
  38. package/lib/design-md/cache/awesome-design-md/ibm/DESIGN.md +550 -0
  39. package/lib/design-md/cache/awesome-design-md/intercom/DESIGN.md +546 -0
  40. package/lib/design-md/cache/awesome-design-md/kraken/DESIGN.md +125 -0
  41. package/lib/design-md/cache/awesome-design-md/lamborghini/DESIGN.md +288 -0
  42. package/lib/design-md/cache/awesome-design-md/linear.app/DESIGN.md +548 -0
  43. package/lib/design-md/cache/awesome-design-md/lovable/DESIGN.md +298 -0
  44. package/lib/design-md/cache/awesome-design-md/mastercard/DESIGN.md +365 -0
  45. package/lib/design-md/cache/awesome-design-md/meta/DESIGN.md +683 -0
  46. package/lib/design-md/cache/awesome-design-md/minimax/DESIGN.md +746 -0
  47. package/lib/design-md/cache/awesome-design-md/mintlify/DESIGN.md +852 -0
  48. package/lib/design-md/cache/awesome-design-md/miro/DESIGN.md +825 -0
  49. package/lib/design-md/cache/awesome-design-md/mistral.ai/DESIGN.md +773 -0
  50. package/lib/design-md/cache/awesome-design-md/mongodb/DESIGN.md +767 -0
  51. package/lib/design-md/cache/awesome-design-md/nike/DESIGN.md +575 -0
  52. package/lib/design-md/cache/awesome-design-md/nintendo-2001/DESIGN.md +649 -0
  53. package/lib/design-md/cache/awesome-design-md/notion/DESIGN.md +821 -0
  54. package/lib/design-md/cache/awesome-design-md/nvidia/DESIGN.md +640 -0
  55. package/lib/design-md/cache/awesome-design-md/ollama/DESIGN.md +539 -0
  56. package/lib/design-md/cache/awesome-design-md/opencode.ai/DESIGN.md +521 -0
  57. package/lib/design-md/cache/awesome-design-md/pinterest/DESIGN.md +597 -0
  58. package/lib/design-md/cache/awesome-design-md/playstation/DESIGN.md +661 -0
  59. package/lib/design-md/cache/awesome-design-md/posthog/DESIGN.md +690 -0
  60. package/lib/design-md/cache/awesome-design-md/raycast/DESIGN.md +669 -0
  61. package/lib/design-md/cache/awesome-design-md/renault/DESIGN.md +589 -0
  62. package/lib/design-md/cache/awesome-design-md/replicate/DESIGN.md +616 -0
  63. package/lib/design-md/cache/awesome-design-md/resend/DESIGN.md +585 -0
  64. package/lib/design-md/cache/awesome-design-md/revolut/DESIGN.md +636 -0
  65. package/lib/design-md/cache/awesome-design-md/runwayml/DESIGN.md +244 -0
  66. package/lib/design-md/cache/awesome-design-md/sanity/DESIGN.md +357 -0
  67. package/lib/design-md/cache/awesome-design-md/sentry/DESIGN.md +551 -0
  68. package/lib/design-md/cache/awesome-design-md/shopify/DESIGN.md +516 -0
  69. package/lib/design-md/cache/awesome-design-md/slack/DESIGN.md +482 -0
  70. package/lib/design-md/cache/awesome-design-md/spacex/DESIGN.md +363 -0
  71. package/lib/design-md/cache/awesome-design-md/spotify/DESIGN.md +246 -0
  72. package/lib/design-md/cache/awesome-design-md/starbucks/DESIGN.md +580 -0
  73. package/lib/design-md/cache/awesome-design-md/stripe/DESIGN.md +487 -0
  74. package/lib/design-md/cache/awesome-design-md/supabase/DESIGN.md +462 -0
  75. package/lib/design-md/cache/awesome-design-md/superhuman/DESIGN.md +448 -0
  76. package/lib/design-md/cache/awesome-design-md/tesla/DESIGN.md +286 -0
  77. package/lib/design-md/cache/awesome-design-md/theverge/DESIGN.md +339 -0
  78. package/lib/design-md/cache/awesome-design-md/together.ai/DESIGN.md +633 -0
  79. package/lib/design-md/cache/awesome-design-md/uber/DESIGN.md +636 -0
  80. package/lib/design-md/cache/awesome-design-md/vercel/DESIGN.md +736 -0
  81. package/lib/design-md/cache/awesome-design-md/vodafone/DESIGN.md +538 -0
  82. package/lib/design-md/cache/awesome-design-md/voltagent/DESIGN.md +521 -0
  83. package/lib/design-md/cache/awesome-design-md/warp/DESIGN.md +526 -0
  84. package/lib/design-md/cache/awesome-design-md/webflow/DESIGN.md +588 -0
  85. package/lib/design-md/cache/awesome-design-md/wired/DESIGN.md +497 -0
  86. package/lib/design-md/cache/awesome-design-md/wise/DESIGN.md +544 -0
  87. package/lib/design-md/cache/awesome-design-md/x.ai/DESIGN.md +465 -0
  88. package/lib/design-md/cache/awesome-design-md/zapier/DESIGN.md +537 -0
  89. package/lib/design-md/cache/feelanet/faux/design.md +288 -0
  90. package/lib/design-md/cache/feelanet/faux-teams/design.md +382 -0
  91. package/lib/design-md/catalog.json +454 -0
  92. package/lib/design-md/catalog.mjs +268 -0
  93. package/lib/design-md/flow.mjs +166 -0
  94. package/lib/design-md/open.mjs +50 -0
  95. package/lib/design-md/providers/awesome-design-md.mjs +114 -0
  96. package/lib/design-md/providers/index.mjs +9 -0
  97. package/lib/design-md/scan.mjs +291 -0
  98. package/lib/engine.mjs +44 -0
  99. package/lib/gitignore.mjs +18 -0
  100. package/lib/items/.omc/state/sessions/a859aff7-59d9-4ff6-afc6-8cafe4471dc4/pre-tool-advisory-throttle.json +14 -0
  101. package/lib/items/mcp.codebase-memory.mjs +6 -0
  102. package/lib/items/mcp.notion.mjs +6 -0
  103. package/lib/items/mcp.supabase.mjs +6 -0
  104. package/lib/items/mcp.vercel.mjs +6 -0
  105. package/lib/items/plugin.bkit.mjs +7 -0
  106. package/lib/items/plugin.mattpocock-skills.mjs +8 -0
  107. package/lib/items/plugin.superpowers.mjs +7 -0
  108. package/lib/items/skill.gsd.mjs +24 -0
  109. package/lib/items/skill.gstack.mjs +39 -0
  110. package/lib/jsonfile.mjs +40 -0
  111. package/lib/tomlfile.mjs +60 -0
  112. package/lib/tui/render.mjs +192 -0
  113. package/lib/tui/rows.mjs +206 -0
  114. package/lib/tui/run.mjs +257 -0
  115. package/lib/tui/state.mjs +188 -0
  116. package/package.json +36 -0
@@ -0,0 +1,192 @@
1
+ // 순수 렌더 — 상태와 크기를 받아 화면 줄 배열을 돌려준다.
2
+ // 커서 이동·지우기 같은 제어 시퀀스는 run.mjs가 맡는다.
3
+ import { displayList, tabCounts, activeTab } from './state.mjs'
4
+
5
+ const ESC = String.fromCharCode(27)
6
+ const DIM = `${ESC}[2m`
7
+ const BOLD = `${ESC}[1m`
8
+ const REVERSE = `${ESC}[7m`
9
+ const RESET = `${ESC}[0m`
10
+
11
+ // 머리글·탭줄·검색줄·구분 공백·바닥글이 차지하는 줄 수.
12
+ const CHROME = 6
13
+ const LABEL_WIDTH = 24
14
+
15
+ export function bodyHeight(height) {
16
+ return Math.max(3, height - CHROME)
17
+ }
18
+
19
+ // 동아시아 글자는 터미널에서 두 칸을 차지한다. 전체 wcwidth 표 대신
20
+ // 실제로 쓰이는 구간(한글·한자·가나·전각)만 넓게 센다 — 액션 행 라벨이 전부 한글이라
21
+ // 한 칸씩 밀리면 첫 화면부터 열이 어긋난다.
22
+ function charWidth(cp) {
23
+ return (cp >= 0x1100 && cp <= 0x115f) // 한글 자모
24
+ || (cp >= 0x2e80 && cp <= 0xa4cf) // 한자 부수·가나·한자
25
+ || (cp >= 0xac00 && cp <= 0xd7a3) // 한글 음절
26
+ || (cp >= 0xf900 && cp <= 0xfaff) // 한자 호환
27
+ || (cp >= 0xfe30 && cp <= 0xfe6f) // 전각 형태
28
+ || (cp >= 0xff00 && cp <= 0xff60) // 전각 영숫자
29
+ || (cp >= 0xffe0 && cp <= 0xffe6)
30
+ ? 2 : 1
31
+ }
32
+
33
+ export function width(text) {
34
+ let w = 0
35
+ for (const ch of String(text ?? '')) w += charWidth(ch.codePointAt(0))
36
+ return w
37
+ }
38
+
39
+ // 색 코드는 폭에 포함되면 안 되므로, 자르기는 항상 색을 입히기 **전에** 한다.
40
+ export function cut(text, limit) {
41
+ if (limit <= 0) return ''
42
+ const s = String(text ?? '')
43
+ if (width(s) <= limit) return s
44
+ let out = ''
45
+ let w = 0
46
+ for (const ch of s) {
47
+ const cw = charWidth(ch.codePointAt(0))
48
+ if (w + cw > limit - 1) break
49
+ out += ch
50
+ w += cw
51
+ }
52
+ return `${out}…`
53
+ }
54
+
55
+ function pad(text, limit) {
56
+ const s = cut(text, limit)
57
+ return s + ' '.repeat(Math.max(0, limit - width(s)))
58
+ }
59
+
60
+ const MARK = { action: '▶', on: '×', off: ' ' }
61
+
62
+ function checkbox(row, selected) {
63
+ if (row.kind === 'action') return `[${MARK.action}]`
64
+ return `[${selected.has(row.id) ? MARK.on : MARK.off}]`
65
+ }
66
+
67
+ // 탭 줄. 검색 중이면 탭마다 적중 수를 보여 준다 —
68
+ // 검색은 활성 탭 안으로만 걸리므로, 다른 탭에 결과가 있다는 사실을 여기서 알린다.
69
+ // 폭이 모자라면 활성 탭 하나 + 위치 표시로 줄인다(줄바꿈은 화면을 무너뜨린다).
70
+ export function tabBar(state, { width: limit, color = false, searching = false } = {}) {
71
+ const counts = tabCounts(state)
72
+ if (counts.length === 0) return ''
73
+ const active = activeTab(state)
74
+
75
+ const segs = counts.map(({ tab, shown, total }) => ({
76
+ tab,
77
+ text: searching ? `${tab} ${shown}/${total}` : `${tab} ${total}`,
78
+ active: tab === active,
79
+ empty: searching && shown === 0,
80
+ }))
81
+
82
+ const SEP = ' '
83
+ const plain = segs.map((s) => s.text).join(SEP)
84
+ if (width(plain) > limit) {
85
+ const i = segs.findIndex((s) => s.active)
86
+ const compact = `‹ ${segs[i]?.text ?? ''} › ${i + 1}/${segs.length}`
87
+ return color ? `${BOLD}${cut(compact, limit)}${RESET}` : cut(compact, limit)
88
+ }
89
+ if (!color) return segs.map((s) => (s.active ? `[${s.text}]` : ` ${s.text} `)).join('')
90
+ return segs
91
+ .map((s) => (s.active ? `${REVERSE} ${s.text} ${RESET}` : s.empty ? `${DIM} ${s.text} ${RESET}` : ` ${s.text} `))
92
+ .join('')
93
+ }
94
+
95
+ // 검색줄 = 하나의 입력칸이다. 포커스가 여기 있으면 입력 커서(▌)로 드러내고,
96
+ // 컬러에서는 줄 전체를 반전시켜 "지금 여기에 타이핑된다"를 분명히 한다 —
97
+ // 이 상태에서만 스페이스가 선택이 아니라 검색어로 들어가기 때문이다.
98
+ function searchLine(state, { limit, color, paint }) {
99
+ const prefix = '검색 › '
100
+ const room = Math.max(0, limit - width(prefix))
101
+ if (state.focus === 'search') {
102
+ const text = `${prefix}${cut(`${state.query}▌`, room)}`
103
+ return color ? `${REVERSE}${pad(text, limit)}${RESET}` : text
104
+ }
105
+ if (state.query) return `${prefix}${cut(state.query, room)}`
106
+ return `${prefix}${paint(DIM, cut('타이핑하면 검색 · ↓ 로 목록', room))}`
107
+ }
108
+
109
+ export function render(state, opts = {}) {
110
+ // columns로 받는다 — width로 두면 모듈의 width() 함수를 함수 스코프 전체에서 가린다.
111
+ const { width: columns = 80, height = 24, repo = '', dryRun = false, color = false, status = '' } = opts
112
+
113
+ // 마지막 칸은 비워 둔다 — 폭을 꽉 채우면 터미널이 줄을 넘긴다.
114
+ const w = Math.max(24, columns - 1)
115
+ const paint = (code, text) => (color ? `${code}${text}${RESET}` : text)
116
+
117
+ const items = state.rows.filter((r) => r.kind === 'item')
118
+ const picked = items.filter((r) => state.selected.has(r.id)).length
119
+
120
+ const title = `agent-installer${dryRun ? ' (dry-run)' : ''}`
121
+ const counts = `선택 ${picked} / 전체 ${items.length}`
122
+ const head = cut(`${title} ${counts} ${repo}`, w)
123
+ const searching = String(state.query ?? '').trim() !== ''
124
+
125
+ const lines = [
126
+ color ? `${BOLD}${title}${RESET}${cut(` ${counts} ${repo}`, Math.max(0, w - width(title)))}` : head,
127
+ tabBar(state, { width: w, color, searching }),
128
+ searchLine(state, { limit: w, color, paint }),
129
+ '',
130
+ ]
131
+
132
+ const body = bodyHeight(height)
133
+ const all = displayList(state)
134
+
135
+ if (all.length === 0) {
136
+ lines.push(paint(DIM, cut(searching ? ' 이 탭에는 일치하는 항목이 없습니다. Tab으로 다른 탭을 보세요.' : ' 항목이 없습니다.', w)))
137
+ for (let i = 1; i < body; i++) lines.push('')
138
+ } else {
139
+ const window = all.slice(state.offset, state.offset + body)
140
+ for (const entry of window) {
141
+ if (entry.type === 'header') {
142
+ const count = entry.shown === entry.total ? `${entry.total}` : `${entry.shown}/${entry.total}`
143
+ lines.push(paint(DIM, cut(` ${entry.section} (${count})`, w)))
144
+ continue
145
+ }
146
+ const { row, index } = entry
147
+ const here = index === state.cursor
148
+ const hintWidth = Math.max(0, w - LABEL_WIDTH - 6)
149
+ const text = cut(`${here ? '❯' : ' '} ${checkbox(row, state.selected)} ${pad(row.label, LABEL_WIDTH)} ${cut(row.hint, hintWidth)}`, w)
150
+ lines.push(here ? paint(REVERSE, text) : text)
151
+ }
152
+ for (let i = window.length; i < body; i++) lines.push('')
153
+ }
154
+
155
+ const hint = state.focus === 'search'
156
+ ? '입력=검색어(스페이스 포함) ↓ 목록으로 Tab 탭이동 Esc 검색해제'
157
+ : 'Space 선택 ↑↓ 이동(맨 위 ↑=검색칸) Tab 탭 Enter 실행/제출 Ctrl+A 전체 Ctrl+O 미리보기'
158
+ lines.push('')
159
+ lines.push(paint(DIM, cut(status || hint, w)))
160
+ return lines
161
+ }
162
+
163
+ const CHANGE_MARK = { install: '+', complete: '±', uninstall: '−' }
164
+ const CHANGE_LABEL = { install: '설치', complete: '보완 설치', uninstall: '제거' }
165
+
166
+ // 제출 검토 화면 — 적용 직전에 무엇이 바뀌는지만 보여 준다.
167
+ // 목록이 길면 잘라내고 남은 건수를 알린다(스크롤 대신) — 여기서 길을 잃을 이유는 없다.
168
+ export function renderReview(changes, opts = {}) {
169
+ const { width: columns = 80, height = 24, dryRun = false, color = false } = opts
170
+ const w = Math.max(24, columns - 1)
171
+ const paint = (code, text) => (color ? `${code}${text}${RESET}` : text)
172
+
173
+ const title = `제출 검토 — 변경 ${changes.length}건${dryRun ? ' (dry-run)' : ''}`
174
+ const lines = [color ? `${BOLD}${cut(title, w)}${RESET}` : cut(title, w), '']
175
+
176
+ const body = bodyHeight(height)
177
+ const room = Math.max(1, body - 1)
178
+ const shown = changes.slice(0, room)
179
+ for (const c of shown) {
180
+ lines.push(cut(` ${CHANGE_MARK[c.action] ?? '?'} ${pad(CHANGE_LABEL[c.action] ?? c.action, 10)} ${c.item.label}`, w))
181
+ }
182
+ if (changes.length > shown.length) {
183
+ lines.push(paint(DIM, cut(` …외 ${changes.length - shown.length}건`, w)))
184
+ } else {
185
+ lines.push('')
186
+ }
187
+ for (let i = shown.length + 1; i < body; i++) lines.push('')
188
+
189
+ lines.push('')
190
+ lines.push(paint(DIM, cut('Enter 적용 Esc 취소', w)))
191
+ return lines
192
+ }
@@ -0,0 +1,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 { 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
+ }
@@ -0,0 +1,257 @@
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 { collectRows, installedIds } from './rows.mjs'
8
+ import {
9
+ createState, setQuery, setFocus, move, moveTab, toggle, toggleVisible, scroll, currentRow, replaceRows, activeTab,
10
+ } from './state.mjs'
11
+ import { render, renderReview, bodyHeight } from './render.mjs'
12
+
13
+ const ESC = String.fromCharCode(27)
14
+ const HIDE_CURSOR = `${ESC}[?25l`
15
+ const SHOW_CURSOR = `${ESC}[?25h`
16
+ const ALT_ON = `${ESC}[?1049h`
17
+ const ALT_OFF = `${ESC}[?1049l`
18
+ const HOME = `${ESC}[H`
19
+ const CLEAR_LINE = `${ESC}[K`
20
+ const CLEAR_DOWN = `${ESC}[J`
21
+
22
+ const ACTION_LABEL = { install: '설치', complete: '보완 설치', uninstall: '제거' }
23
+
24
+ // 키를 큐에 쌓아 두고 하나씩 꺼내 쓴다. 이벤트 상태 기계 대신
25
+ // `for(;;) await next()` 루프로 흐름을 읽을 수 있게 하기 위해서다.
26
+ function keyReader(stdin) {
27
+ const queue = []
28
+ let waiting = null
29
+ const onKey = (str, key) => {
30
+ const event = { str, ...(key ?? {}) }
31
+ if (waiting) { const resolve = waiting; waiting = null; resolve(event) }
32
+ else queue.push(event)
33
+ }
34
+ stdin.on('keypress', onKey)
35
+ return {
36
+ next: () => (queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((r) => { waiting = r })),
37
+ stop: () => stdin.off('keypress', onKey),
38
+ }
39
+ }
40
+
41
+ function itemStates(rows) {
42
+ return rows.filter((r) => r.kind === 'item').map((r) => ({ item: r.item, status: r.status }))
43
+ }
44
+
45
+ function printPlain(rows, log) {
46
+ let section = null
47
+ for (const row of rows) {
48
+ if (row.section !== section) { section = row.section; log(`[${section}]`) }
49
+ const mark = row.kind === 'action' ? '▶' : row.status === 'absent' ? ' ' : '×'
50
+ log(` [${mark}] ${row.label}${row.hint ? ` — ${row.hint}` : ''}`)
51
+ }
52
+ }
53
+
54
+ // 화면에 찍히는 글자인가(스페이스 포함). 검색칸 포커스에서 검색어로 들어갈 후보다.
55
+ // 스페이스의 뜻은 포커스가 가른다: 검색칸에서는 검색어, 목록에서는 선택.
56
+ // 그래서 목록에서 타이핑으로 검색칸에 올라갈 때는 호출부에서 스페이스를 뺀다.
57
+ function isPrintable(key) {
58
+ return Boolean(key.str) && !key.ctrl && !key.meta && key.str >= ' ' && key.str !== ESC
59
+ }
60
+
61
+ export async function runTui(root, opts = {}) {
62
+ const {
63
+ dryRun = false,
64
+ skillMode = 'auto',
65
+ fetchImpl = netFetch,
66
+ designDirs = [],
67
+ env = process.env,
68
+ catalogFile = CATALOG_PATH,
69
+ log = console.log,
70
+ stdin = process.stdin,
71
+ stdout = process.stdout,
72
+ } = opts
73
+ const opener = opts.opener ?? makeOpener(dryRun, log)
74
+
75
+ let collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, log: () => {} })
76
+
77
+ // raw 모드를 켤 수 없으면(CI·파이프) 목록만 내고 끝낸다.
78
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
79
+ printPlain(collected.rows, log)
80
+ log('\n대화형 화면은 터미널에서만 열립니다. --list · --set 으로도 다룰 수 있습니다.')
81
+ return { interactive: false }
82
+ }
83
+
84
+ // 처음엔 목록에 포커스를 둔다 — 곧바로 화살표 이동·Space 선택·Enter 실행이 되게.
85
+ // 타이핑하면 검색칸으로 올라간다.
86
+ let state = createState(collected.rows, { selectedIds: installedIds(collected.rows), focus: 'list' })
87
+ let status = ''
88
+
89
+ emitKeypressEvents(stdin)
90
+ stdin.setRawMode(true)
91
+ stdin.resume()
92
+ const keys = keyReader(stdin)
93
+ stdout.write(ALT_ON + HIDE_CURSOR)
94
+
95
+ const color = !env.NO_COLOR && stdout.isTTY
96
+ const draw = (lines) => stdout.write(HOME + lines.map((l) => l + CLEAR_LINE).join('\n') + '\n' + CLEAR_DOWN)
97
+ const paint = () => {
98
+ const height = stdout.rows ?? 24
99
+ state = scroll(state, bodyHeight(height))
100
+ draw(render(state, { width: stdout.columns ?? 80, height, repo: root, dryRun, color, status }))
101
+ }
102
+
103
+ // 로그는 화면 안에 우겨넣지 않는다 — 실패 메시지가 길고, 잘리면 진단이 불가능해진다.
104
+ const suspend = async (fn) => {
105
+ stdout.write(ALT_OFF + SHOW_CURSOR)
106
+ try { return await fn() } finally { stdout.write(ALT_ON + HIDE_CURSOR) }
107
+ }
108
+ const confirm = async (message) => {
109
+ stdout.write(`\n${message} [y/N] `)
110
+ const key = await keys.next()
111
+ stdout.write('\n')
112
+ return String(key.str ?? '').toLowerCase() === 'y'
113
+ }
114
+ const pause = async () => {
115
+ stdout.write('\n계속하려면 아무 키나 누르세요…')
116
+ await keys.next()
117
+ }
118
+
119
+ const recollect = async () => {
120
+ collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, log: () => {} })
121
+ state = replaceRows(state, collected.rows, installedIds(collected.rows))
122
+ }
123
+
124
+ const select = () => {
125
+ const row = currentRow(state)
126
+ if (row?.kind !== 'item') return '이 행은 Enter로 실행합니다.'
127
+ state = toggle(state)
128
+ return ''
129
+ }
130
+
131
+ // 미리보기는 두 포커스에서 모두 통해야 한다 — 검색으로 찾은 직후가 가장 열어 보고 싶은 순간이다.
132
+ const preview = () => {
133
+ const row = currentRow(state)
134
+ if (row?.kind !== 'item' || !row.previewTarget) return '이 항목은 미리보기를 제공하지 않습니다.'
135
+ const notes = []
136
+ openPreview(opener, row.item, (m) => notes.push(String(m).trim()))
137
+ return notes.length > 0 ? notes.join(' ') : `열었습니다: ${row.previewTarget}`
138
+ }
139
+
140
+ // 제출 검토 — 적용 직전에 변경 목록을 보여 주고 Enter/Esc만 받는다.
141
+ const review = async (changes) => {
142
+ for (;;) {
143
+ draw(renderReview(changes, { width: stdout.columns ?? 80, height: stdout.rows ?? 24, dryRun, color }))
144
+ const key = await keys.next()
145
+ if (key.ctrl && key.name === 'c') return 'quit'
146
+ if (key.name === 'escape') return 'cancel'
147
+ if (key.name === 'return' || key.name === 'enter') return 'apply'
148
+ }
149
+ }
150
+
151
+ try {
152
+ for (;;) {
153
+ paint()
154
+ const key = await keys.next()
155
+ status = ''
156
+
157
+ if (key.ctrl && key.name === 'c') break
158
+
159
+ // ── 검색칸에 포커스: 타이핑이 곧 검색어다(스페이스 포함, 두 단어 검색 가능).
160
+ if (state.focus === 'search') {
161
+ // Esc: 검색어가 있으면 지우고 검색칸에 남는다. 이미 비었으면 목록으로 내려간다.
162
+ if (key.name === 'escape') {
163
+ state = state.query ? setQuery(state, '') : setFocus(state, 'list')
164
+ continue
165
+ }
166
+ // Enter·↓: 검색을 마치고 목록으로 내려간다. 결과의 첫 항목이 커서다.
167
+ if (key.name === 'return' || key.name === 'enter' || key.name === 'down') { state = setFocus(state, 'list'); continue }
168
+ if (key.name === 'backspace') { state = setQuery(state, state.query.slice(0, -1)); continue }
169
+ if (key.name === 'tab') { state = moveTab(state, key.shift ? -1 : 1); continue }
170
+ if (key.ctrl && key.name === 'o') { status = preview(); continue }
171
+ if (isPrintable(key)) state = setQuery(state, state.query + key.str)
172
+ continue
173
+ }
174
+
175
+ // ── 목록에 포커스
176
+ if (key.name === 'escape') {
177
+ if (state.query) { state = setQuery(state, ''); continue }
178
+ break
179
+ }
180
+
181
+ // / 또는 글자를 누르면 검색칸으로 올라가 타이핑을 시작한다. 스페이스는 선택이므로 제외한다.
182
+ if (key.str === '/') { state = setFocus(state, 'search'); continue }
183
+ if (key.name !== 'space' && isPrintable(key)) {
184
+ state = setQuery(setFocus(state, 'search'), state.query + key.str)
185
+ continue
186
+ }
187
+
188
+ // 맨 위에서 ↑를 누르면 검색칸으로 되돌아간다 — 목록과 검색칸이 위아래로 이어진 느낌.
189
+ if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
190
+ state = state.cursor === 0 ? setFocus(state, 'search') : move(state, -1)
191
+ continue
192
+ }
193
+ if (key.name === 'down' || (key.ctrl && key.name === 'n')) { state = move(state, 1); continue }
194
+ if (key.name === 'pageup') { state = move(state, -bodyHeight(stdout.rows ?? 24)); continue }
195
+ if (key.name === 'pagedown') { state = move(state, bodyHeight(stdout.rows ?? 24)); continue }
196
+
197
+ // 탭 이동 — Tab/Shift+Tab, 좌우 화살표.
198
+ if (key.name === 'tab') { state = moveTab(state, key.shift ? -1 : 1); continue }
199
+ if (key.name === 'right') { state = moveTab(state, 1); continue }
200
+ if (key.name === 'left') { state = moveTab(state, -1); continue }
201
+
202
+ // 선택 토글 — 액션 행에서는 아무 일도 하지 않는다(제거 개념이 없다).
203
+ if (key.name === 'space') { status = select(); continue }
204
+
205
+ // 지금 탭에서 보이는 항목 전체 토글.
206
+ if (key.ctrl && key.name === 'a') {
207
+ const before = state.selected.size
208
+ state = toggleVisible(state)
209
+ status = `${activeTab(state)} 탭의 보이는 항목을 ${state.selected.size >= before ? '모두 선택' : '모두 해제'}했습니다.`
210
+ continue
211
+ }
212
+
213
+ if (key.ctrl && key.name === 'o') { status = preview(); continue }
214
+
215
+ if (key.name === 'return' || key.name === 'enter') {
216
+ // 액션 행은 그 자리에서 실행한다. 나머지는 제출(검토 → 일괄 적용)이다.
217
+ const row = currentRow(state)
218
+ if (row?.kind === 'action') {
219
+ await suspend(async () => {
220
+ await row.run({ root, dryRun, skillMode, fetchImpl, catalogFile, log, confirm })
221
+ await pause()
222
+ })
223
+ await recollect()
224
+ continue
225
+ }
226
+
227
+ const changes = planChanges(itemStates(state.rows), state.selected)
228
+ if (changes.length === 0) { status = '변경할 항목이 없습니다.'; continue }
229
+
230
+ const verdict = await review(changes)
231
+ if (verdict === 'quit') break
232
+ if (verdict === 'cancel') { status = '제출을 취소했습니다.'; continue }
233
+
234
+ await suspend(async () => {
235
+ log(`\n적용할 변경 ${changes.length}건${dryRun ? ' (dry-run)' : ''}:`)
236
+ for (const c of changes) log(` ${ACTION_LABEL[c.action]} ${c.item.label}`)
237
+ const results = await apply(root, changes, { dryRun, log })
238
+ for (const r of results) log(` ${r.ok ? '✔' : '✖'} ${ACTION_LABEL[r.action]} ${r.item.label}${r.message ? ` — ${r.message}` : ''}`)
239
+ if (results.some((r) => !r.ok)) process.exitCode = 1
240
+ log('\n설정 파일 변경 내용은 git diff로 확인할 수 있습니다.')
241
+ await pause()
242
+ })
243
+ await recollect()
244
+ continue
245
+ }
246
+ }
247
+ } finally {
248
+ keys.stop()
249
+ stdout.write(ALT_OFF + SHOW_CURSOR)
250
+ stdin.setRawMode(false)
251
+ stdin.pause()
252
+ }
253
+
254
+ return { interactive: true }
255
+ }
256
+
257
+ export { printPlain, itemStates }