@rch4com/agent-setup 1.18.0 → 1.20.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.
- package/install.mjs +15 -3
- package/lib/catalog.mjs +44 -10
- package/lib/clis.mjs +11 -0
- package/lib/engine.mjs +4 -2
- package/lib/global-plugin.mjs +38 -6
- package/lib/i18n/catalog/en.mjs +42 -13
- package/lib/i18n/catalog/ko.mjs +42 -13
- package/lib/items/global.ponytail.mjs +8 -4
- package/lib/items/global.superpowers.mjs +12 -6
- package/lib/items/plugin.ecc.mjs +1 -0
- package/lib/items/plugin.impeccable.mjs +2 -1
- package/lib/items/plugin.mattpocock-skills.mjs +1 -0
- package/lib/items/plugin.ponytail.mjs +1 -0
- package/lib/items/plugin.superpowers.mjs +2 -1
- package/lib/items/plugin.understand-anything.mjs +1 -0
- package/lib/items/skill.agent-browser.mjs +1 -0
- package/lib/items/skill.find-skills.mjs +1 -0
- package/lib/items/skill.gsd.mjs +5 -0
- package/lib/items/skill.gstack.mjs +1 -0
- package/lib/items/skill.hallmark.mjs +1 -0
- package/lib/items/skill.mcp-builder.mjs +1 -0
- package/lib/items/skill.strix.mjs +1 -0
- package/lib/jsonfile.mjs +22 -3
- package/lib/status.mjs +39 -3
- package/lib/tui/detail.mjs +42 -9
- package/lib/tui/render.mjs +155 -23
- package/lib/tui/rows.mjs +36 -7
- package/lib/tui/run.mjs +51 -11
- package/lib/tui/state.mjs +9 -5
- package/lib/update.mjs +13 -3
- package/package.json +1 -1
package/lib/tui/render.mjs
CHANGED
|
@@ -19,9 +19,73 @@ const BOLD = `${ESC}[1m`
|
|
|
19
19
|
const REVERSE = `${ESC}[7m`
|
|
20
20
|
const RESET = `${ESC}[0m`
|
|
21
21
|
|
|
22
|
-
// 머리글·탭줄·검색줄·구분
|
|
22
|
+
// 머리글·탭줄·검색줄·구분 공백·바닥글(한 줄)이 차지하는 줄 수.
|
|
23
|
+
// 바닥글이 두 줄이 되면(footerHeight) 그만큼 더 뺀다.
|
|
23
24
|
const CHROME = 6
|
|
24
25
|
export const LABEL_WIDTH = 24
|
|
26
|
+
// 라벨 열의 상한. 터미널이 넓으면 긴 라벨(`Understand Anything (저장소)`)을
|
|
27
|
+
// 자르지 않도록 열을 늘리되, 힌트 자리(HINT_MIN)는 남긴다. 50칸은 80칸
|
|
28
|
+
// 터미널의 힌트 자리(49칸)보다 커서, 80칸에서는 옛 24칸이 그대로 유지되고
|
|
29
|
+
// 100칸쯤부터 늘기 시작한다 — 좁은 화면에서 라벨을 늘리면 전역 항목의
|
|
30
|
+
// `gemini 없음` 같은 힌트 꼬리가 대신 잘렸다.
|
|
31
|
+
const LABEL_MAX = 40
|
|
32
|
+
const HINT_MIN = 50
|
|
33
|
+
// rows.mjs의 NAME_LIMIT와 같은 규칙 — 넷 이하면 이름, 그보다 많으면 수.
|
|
34
|
+
const NAME_LIMIT = 4
|
|
35
|
+
|
|
36
|
+
// 행 라벨 열의 폭. 24칸 고정이면 넓은 화면에서도 긴 라벨이 잘렸다. 화면에
|
|
37
|
+
// 있는 가장 긴 라벨까지 늘리되 상한과 힌트 자리를 지킨다.
|
|
38
|
+
export function labelColumn(rows, w) {
|
|
39
|
+
const longest = Math.max(LABEL_WIDTH, ...rows.filter((r) => r.kind === 'item').map((r) => width(r.label ?? '')))
|
|
40
|
+
return Math.max(LABEL_WIDTH, Math.min(longest, LABEL_MAX, w - 6 - HINT_MIN))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 넓은 화면에서는 전체 경로, 자리가 없으면 마지막 디렉터리 이름만.
|
|
44
|
+
function repoLabel(repo, room) {
|
|
45
|
+
const full = String(repo ?? '')
|
|
46
|
+
if (width(full) <= room) return full
|
|
47
|
+
return full.split(/[\\/]/).filter(Boolean).pop() ?? full
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 힌트 항목 사이의 구분자. 카탈로그 문자열이 이 간격으로 항목을 나눈다 —
|
|
51
|
+
// 좁은 폭에서 두 줄로 접을 때 이 경계에서만 끊는다.
|
|
52
|
+
const HINT_SEP = ' '
|
|
53
|
+
|
|
54
|
+
// 바닥글 힌트를 폭에 맞춰 최대 두 줄로 접는다. 예전에는 한 줄로 두고 뒤를
|
|
55
|
+
// 잘랐는데, 80칸 한국어 화면에서 `Ctrl+A 전체 C…`에서 끊겨 CLI 필터(Ctrl+F)·
|
|
56
|
+
// 상세 펼침(Ctrl+D)·미리보기(Ctrl+O)를 발견할 길이 없었다. 두 줄로도 모자라면
|
|
57
|
+
// 둘째 줄을 자른다 — 세 줄은 목록 지면을 너무 갉아먹는다.
|
|
58
|
+
export function hintLines(hint, room) {
|
|
59
|
+
const lines = []
|
|
60
|
+
let cur = ''
|
|
61
|
+
for (const seg of String(hint ?? '').split(HINT_SEP)) {
|
|
62
|
+
const next = cur ? `${cur}${HINT_SEP}${seg}` : seg
|
|
63
|
+
if (cur && width(next) > room) {
|
|
64
|
+
lines.push(cur)
|
|
65
|
+
cur = seg
|
|
66
|
+
} else {
|
|
67
|
+
cur = next
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (cur) lines.push(cur)
|
|
71
|
+
if (lines.length <= 2) return lines
|
|
72
|
+
return [lines[0], cut(lines.slice(1).join(HINT_SEP), room)]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 힌트 오른쪽 끝의 종료 키를 뺀 나머지 자리. 8칸도 안 남으면 힌트를 버리고
|
|
76
|
+
// 종료 키만 남긴다(render의 바닥글과 같은 판정).
|
|
77
|
+
function hintRoom(w, t) {
|
|
78
|
+
return w - width(t('tui.hint.quit')) - 2
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 바닥글 줄 수(1 또는 2). 검색칸·목록 두 포커스의 힌트 중 긴 쪽으로 정한다 —
|
|
82
|
+
// 포커스를 옮길 때마다 목록 높이가 바뀌면 화면이 출렁인다.
|
|
83
|
+
export function footerHeight(columns = 80, t = createT('en')) {
|
|
84
|
+
const w = Math.max(24, columns - 1)
|
|
85
|
+
const room = hintRoom(w, t)
|
|
86
|
+
if (room < 8) return 1
|
|
87
|
+
return Math.max(hintLines(t('tui.hint.list'), room).length, hintLines(t('tui.hint.search'), room).length)
|
|
88
|
+
}
|
|
25
89
|
|
|
26
90
|
// 상세 패널은 목록과 화면을 나눠 갖는다. 높이를 커서가 아니라 **터미널
|
|
27
91
|
// 크기로만** 정하는 것이 핵심이다 — 커서를 옮길 때마다 높이가 변하면
|
|
@@ -32,16 +96,17 @@ const PANEL_MAX = 12
|
|
|
32
96
|
// 목록이 3줄 밑으로 내려가는 쪽이 패널이 사라지는 것보다 나쁘다.
|
|
33
97
|
const PANEL_FLOOR = PANEL_MIN + 3
|
|
34
98
|
|
|
35
|
-
|
|
36
|
-
|
|
99
|
+
// footer는 바닥글 줄 수(footerHeight)다. 기본 1이면 옛 산식과 같다.
|
|
100
|
+
export function panelHeight(height, expanded = false, footer = 1) {
|
|
101
|
+
const room = Math.max(0, height - CHROME - (footer - 1))
|
|
37
102
|
if (expanded) return room
|
|
38
103
|
if (room < PANEL_FLOOR) return 0
|
|
39
104
|
return Math.min(PANEL_MAX, Math.max(PANEL_MIN, Math.round(room * PANEL_SHARE)))
|
|
40
105
|
}
|
|
41
106
|
|
|
42
|
-
export function bodyHeight(height, expanded = false) {
|
|
43
|
-
const room = Math.max(0, height - CHROME)
|
|
44
|
-
const panel = panelHeight(height, expanded)
|
|
107
|
+
export function bodyHeight(height, expanded = false, footer = 1) {
|
|
108
|
+
const room = Math.max(0, height - CHROME - (footer - 1))
|
|
109
|
+
const panel = panelHeight(height, expanded, footer)
|
|
45
110
|
// 패널이 없을 때는 예전과 같이 최소 3줄을 보장한다.
|
|
46
111
|
return panel === 0 ? Math.max(3, room) : room - panel
|
|
47
112
|
}
|
|
@@ -54,19 +119,41 @@ export function reviewBodyHeight(height) {
|
|
|
54
119
|
return Math.max(3, height - CHROME)
|
|
55
120
|
}
|
|
56
121
|
|
|
57
|
-
|
|
122
|
+
// on = 설치돼 있고 그대로 둔다, add = 이번에 새로 고름(설치·보완 설치 예정),
|
|
123
|
+
// remove = 설치돼 있는데 체크를 풀었다(제거 예정), off = 없고 안 고름.
|
|
124
|
+
const MARK = { action: '▶', on: '×', add: '+', remove: '-', off: ' ' }
|
|
58
125
|
|
|
59
126
|
// 대괄호는 "여러 개 고를 수 있다"는 보편적 신호다 — 한 파일에 한 벌만 놓이는
|
|
60
127
|
// 배타 묶음(커밋 템플릿의 두 언어판)에 그대로 쓰면 화면이 거짓말을 한다.
|
|
61
128
|
// 둥근 괄호로 바꿔 라디오임을 드러낸다. 안쪽 글자는 그대로 둔다: ×는 이미
|
|
62
129
|
// 이 화면이 쓰는 글자라 폭 계산이 검증된 값이고, ●·•는 동아시아 폭이
|
|
63
130
|
// 모호(Ambiguous)해 터미널에 따라 두 칸으로 그려져 열을 밀 수 있다.
|
|
131
|
+
//
|
|
132
|
+
// 표식은 "지금 체크됐나"가 아니라 "Enter를 누르면 무슨 일이 나나"를 말한다.
|
|
133
|
+
// 예전에는 ×가 "설치됨(유지)"과 "새로 고름"에 똑같이 쓰였고, 설치된 항목의
|
|
134
|
+
// 체크를 풀면 빈 칸이 돼 힌트의 '설치됨'과 조합해야 제거 예정임을 알 수
|
|
135
|
+
// 있었다. +·-는 ASCII라 폭이 모호하지 않다(−·±는 동아시아 폭이 모호하다).
|
|
136
|
+
export function changeMark(row, selected) {
|
|
137
|
+
const on = selected.has(row.id)
|
|
138
|
+
if (on) return row.status === 'installed' ? MARK.on : MARK.add
|
|
139
|
+
return row.status === 'absent' ? MARK.off : MARK.remove
|
|
140
|
+
}
|
|
141
|
+
|
|
64
142
|
function checkbox(row, selected) {
|
|
65
143
|
if (row.kind === 'action') return `[${MARK.action}]`
|
|
66
|
-
const mark =
|
|
144
|
+
const mark = changeMark(row, selected)
|
|
67
145
|
return row.exclusive ? `(${mark})` : `[${mark}]`
|
|
68
146
|
}
|
|
69
147
|
|
|
148
|
+
// Enter를 누르면 바뀔 항목 수 — engine.planChanges와 같은 판정이다.
|
|
149
|
+
// add는 설치·보완 설치, remove는 제거다.
|
|
150
|
+
export function pendingCounts(state) {
|
|
151
|
+
const marks = state.rows.filter((r) => r.kind === 'item').map((r) => changeMark(r, state.selected))
|
|
152
|
+
const add = marks.filter((m) => m === MARK.add).length
|
|
153
|
+
const remove = marks.filter((m) => m === MARK.remove).length
|
|
154
|
+
return { add, remove, total: add + remove }
|
|
155
|
+
}
|
|
156
|
+
|
|
70
157
|
// 탭 줄. 목록이 좁혀졌으면(검색어·CLI 필터 어느 쪽이든) 탭마다 적중 수를
|
|
71
158
|
// 보여 준다 — 검색·필터는 활성 탭 안으로만 걸리므로, 다른 탭에 결과가
|
|
72
159
|
// 있다는 사실을 여기서 알린다. 그래야 필터가 사용자를 빈 탭에 가둬 두고
|
|
@@ -109,10 +196,15 @@ export function tabBar(state, { width: limit, color = false, narrowed = false, t
|
|
|
109
196
|
// 그대로 쓰이는데, 색 코드를 섞으면 ESC 시퀀스가 표시 폭에 끼어든다(폭은
|
|
110
197
|
// 항상 색을 입히기 전에 재야 한다). 칠하는 일은 호출부가 폭 계산을 끝낸
|
|
111
198
|
// 뒤 마지막에 한다.
|
|
199
|
+
//
|
|
200
|
+
// 위치는 CLI 사이에서만 센다. options[0]은 "전체"(null)라 순환 목록에는 있지만
|
|
201
|
+
// 사용자에게는 CLI가 아니다 — 그것까지 세면 `codex (3/11)`처럼 CLI가 열
|
|
202
|
+
// 개인데 열한 번째가 있다고 읽힌다.
|
|
112
203
|
export function filterSegment(state, { t = createT('en'), options = [] } = {}) {
|
|
113
204
|
if (!state.cliFilter) return ''
|
|
114
|
-
const
|
|
115
|
-
const
|
|
205
|
+
const clis = options.filter((o) => o !== null)
|
|
206
|
+
const at = clis.indexOf(state.cliFilter)
|
|
207
|
+
const pos = at === -1 ? '' : ` (${t('tui.filter.position', { current: at + 1, total: clis.length })})`
|
|
116
208
|
return `${t('tui.filter.prefix')}${state.cliFilter}${pos}`
|
|
117
209
|
}
|
|
118
210
|
|
|
@@ -157,8 +249,14 @@ export function render(state, opts = {}) {
|
|
|
157
249
|
const picked = items.filter((r) => state.selected.has(r.id)).length
|
|
158
250
|
|
|
159
251
|
const title = `agent-installer${dryRun ? ' (dry-run)' : ''}`
|
|
160
|
-
|
|
161
|
-
|
|
252
|
+
// 선택 수 옆에 변경 예정 건수를 둔다 — "선택 3 / 전체 106"은 설치 상태의
|
|
253
|
+
// 합계라 Enter를 누르면 무슨 일이 날지 말해 주지 않는다. 항상 찍는다:
|
|
254
|
+
// 0건이면 "지금 Enter는 아무것도 안 한다"는 뜻이라 그것대로 정보다.
|
|
255
|
+
const pending = pendingCounts(state)
|
|
256
|
+
const marks = pending.total > 0 ? t('tui.pendingMarks', { add: pending.add, remove: pending.remove }) : ''
|
|
257
|
+
const counts = `${t('tui.counts', { picked, total: items.length })} ${t('tui.pending', { count: pending.total, marks })}`
|
|
258
|
+
const repoText = repoLabel(repo, w - width(`${title} ${counts} `))
|
|
259
|
+
const head = cut(`${title} ${counts} ${repoText}`, w)
|
|
162
260
|
const searching = String(state.query ?? '').trim() !== ''
|
|
163
261
|
// 목록이 좁혀졌는가 — 검색어·CLI 필터 어느 쪽이든 활성 탭 안에서만 걸리는
|
|
164
262
|
// 좁힘이라, 다른 탭에 뭐가 남았는지 알리는 몫(탭 줄의 shown/total)은
|
|
@@ -167,13 +265,14 @@ export function render(state, opts = {}) {
|
|
|
167
265
|
|
|
168
266
|
const filter = filterSegment(state, { t, options: cliOptions })
|
|
169
267
|
const lines = [
|
|
170
|
-
color ? `${BOLD}${title}${RESET}${cut(` ${counts} ${
|
|
268
|
+
color ? `${BOLD}${title}${RESET}${cut(` ${counts} ${repoText}`, Math.max(0, w - width(title)))}` : head,
|
|
171
269
|
tabBar(state, { width: w, color, narrowed, t }),
|
|
172
270
|
searchLine(state, { limit: w, color, paint, t, filter }),
|
|
173
271
|
'',
|
|
174
272
|
]
|
|
175
273
|
|
|
176
|
-
const
|
|
274
|
+
const footer = footerHeight(columns, t)
|
|
275
|
+
const body = bodyHeight(height, detailExpanded, footer)
|
|
177
276
|
const all = displayList(state)
|
|
178
277
|
|
|
179
278
|
if (all.length === 0) {
|
|
@@ -188,6 +287,7 @@ export function render(state, opts = {}) {
|
|
|
188
287
|
for (let i = 1; i < body; i++) lines.push('')
|
|
189
288
|
} else {
|
|
190
289
|
const window = all.slice(state.offset, state.offset + body)
|
|
290
|
+
const labelCol = labelColumn(state.rows, w)
|
|
191
291
|
for (const entry of window) {
|
|
192
292
|
if (entry.type === 'header') {
|
|
193
293
|
const count = entry.shown === entry.total ? `${entry.total}` : `${entry.shown}/${entry.total}`
|
|
@@ -199,8 +299,8 @@ export function render(state, opts = {}) {
|
|
|
199
299
|
}
|
|
200
300
|
const { row, index } = entry
|
|
201
301
|
const here = index === state.cursor
|
|
202
|
-
const hintWidth = Math.max(0, w -
|
|
203
|
-
const text = cut(`${here ? '❯' : ' '} ${checkbox(row, state.selected)} ${pad(row.label,
|
|
302
|
+
const hintWidth = Math.max(0, w - labelCol - 6)
|
|
303
|
+
const text = cut(`${here ? '❯' : ' '} ${checkbox(row, state.selected)} ${pad(row.label, labelCol)} ${cut(row.hint, hintWidth)}`, w)
|
|
204
304
|
lines.push(here ? paint(REVERSE, text) : text)
|
|
205
305
|
}
|
|
206
306
|
for (let i = window.length; i < body; i++) lines.push('')
|
|
@@ -208,7 +308,7 @@ export function render(state, opts = {}) {
|
|
|
208
308
|
|
|
209
309
|
// 상세 패널 — 목록 아래 고정 자리. 높이가 커서와 무관하므로 목록이
|
|
210
310
|
// 출렁이지 않는다. 지면이 모자라면 panelHeight가 0을 돌려 통째로 빠진다.
|
|
211
|
-
const panel = panelHeight(height, detailExpanded)
|
|
311
|
+
const panel = panelHeight(height, detailExpanded, footer)
|
|
212
312
|
if (panel > 0) {
|
|
213
313
|
lines.push(paint(DIM, '─'.repeat(w)))
|
|
214
314
|
const room = panel - 1
|
|
@@ -223,13 +323,25 @@ export function render(state, opts = {}) {
|
|
|
223
323
|
// 빠져나가나"다 — 잘려도 되는 안내와 절대 사라지면 안 되는 안내를 같은
|
|
224
324
|
// 줄에 이어 붙여 두면 늘 후자가 먼저 사라진다. 상태 메시지가 힌트를
|
|
225
325
|
// 대신할 때도 이 자리는 그대로 남는다.
|
|
326
|
+
//
|
|
327
|
+
// 바닥글은 footer줄이다(1 또는 2). 힌트는 위에서부터 채우고 종료 키는 항상
|
|
328
|
+
// 마지막 줄 오른쪽 끝이다. 상태 메시지는 힌트를 통째로 대신하되 마지막
|
|
329
|
+
// 줄에 놓는다 — 폭이 달라져도 "맨 아랫줄 = 상태 + 종료 키"가 유지된다.
|
|
226
330
|
const hint = state.focus === 'search' ? t('tui.hint.search') : t('tui.hint.list')
|
|
227
331
|
const quit = t('tui.hint.quit')
|
|
228
|
-
const room = w
|
|
332
|
+
const room = hintRoom(w, t)
|
|
229
333
|
lines.push('')
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
334
|
+
if (room < 8) {
|
|
335
|
+
for (let i = 1; i < footer; i++) lines.push('')
|
|
336
|
+
lines.push(paint(BOLD, cut(quit, w)))
|
|
337
|
+
return lines
|
|
338
|
+
}
|
|
339
|
+
const shown = status ? [status] : hintLines(hint, room)
|
|
340
|
+
while (shown.length < footer) shown.unshift('')
|
|
341
|
+
shown.slice(0, footer).forEach((text, i) => {
|
|
342
|
+
const last = i === footer - 1
|
|
343
|
+
lines.push(last ? `${paint(DIM, pad(text, room))} ${paint(BOLD, quit)}` : paint(DIM, cut(text, w)))
|
|
344
|
+
})
|
|
233
345
|
return lines
|
|
234
346
|
}
|
|
235
347
|
|
|
@@ -248,9 +360,15 @@ export function renderReview(changes, opts = {}) {
|
|
|
248
360
|
// 적용 직전 마지막 화면이다. 일부 CLI에만 들어가는 항목은 여기서도 그 사실을
|
|
249
361
|
// 밝힌다 — 목록에서 지나쳤더라도 되돌릴 수 있는 마지막 지점이다.
|
|
250
362
|
// 전부 지원하는 항목은 조용히 둔다: 경고가 흔해지면 아무도 읽지 않는다.
|
|
363
|
+
// 목록과 같은 규칙으로 — 넷 이하면 이름을 적는다. 숫자만 있으면 어느
|
|
364
|
+
// CLI인지 알려고 Esc로 되돌아가야 했다.
|
|
251
365
|
const changeLine = (c) => {
|
|
252
|
-
const
|
|
253
|
-
const
|
|
366
|
+
const supports = c.item.supports
|
|
367
|
+
const partial = supports && supports.length < CLI_IDS.length
|
|
368
|
+
const covText = supports?.length > 0 && supports.length <= NAME_LIMIT
|
|
369
|
+
? supports.join('·')
|
|
370
|
+
: t('item.cliCoverage', { covered: supports?.length ?? 0, total: CLI_IDS.length })
|
|
371
|
+
const cov = partial ? ` · ${covText}` : ''
|
|
254
372
|
return cut(` ${CHANGE_MARK[c.action] ?? '?'} ${pad(t(`change.${c.action}`), 10)} ${scopedLabel(c.item, t)}${cov}`, w)
|
|
255
373
|
}
|
|
256
374
|
|
|
@@ -305,3 +423,17 @@ export function renderReview(changes, opts = {}) {
|
|
|
305
423
|
lines.push(paint(DIM, cut(t('tui.review.hint'), w)))
|
|
306
424
|
return lines
|
|
307
425
|
}
|
|
426
|
+
|
|
427
|
+
// 도움말 화면 — F1. 바닥글 두 줄로는 키의 뜻까지 담을 수 없어, 전체 키를
|
|
428
|
+
// 설명과 함께 한 화면에 편다. 아무 키나 누르면 닫힌다.
|
|
429
|
+
export function renderHelp(opts = {}) {
|
|
430
|
+
const { width: columns = 80, height = 24, color = false, t = createT('en') } = opts
|
|
431
|
+
const w = Math.max(24, columns - 1)
|
|
432
|
+
const paint = (code, text) => (color ? `${code}${text}${RESET}` : text)
|
|
433
|
+
const lines = [paint(BOLD, cut(t('help.title'), w)), '']
|
|
434
|
+
for (const line of t('help.lines').split('\n')) lines.push(cut(line, w))
|
|
435
|
+
while (lines.length < height - 2) lines.push('')
|
|
436
|
+
lines.push('')
|
|
437
|
+
lines.push(paint(DIM, cut(t('help.close'), w)))
|
|
438
|
+
return lines.slice(0, height)
|
|
439
|
+
}
|
package/lib/tui/rows.mjs
CHANGED
|
@@ -104,16 +104,38 @@ export function designHint(state, multiProvider = false, t) {
|
|
|
104
104
|
return parts.filter(Boolean).join(' · ')
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// 이름을 나열할 수 있는 최대 CLI 수. 넷까지는 이름(codex·gemini·opencode·copilot)이
|
|
108
|
+
// 80칸 힌트 자리에 들고, 그보다 많으면 "몇 개나 되나"가 더 유용하다.
|
|
109
|
+
const NAME_LIMIT = 4
|
|
110
|
+
|
|
107
111
|
// 목록 행에 실제로 찍히는 힌트. 80칸 터미널이면 힌트 자리는 49칸(한글 24자)뿐이라,
|
|
108
112
|
// 예전처럼 사유까지 이어 붙이면 뒤쪽이 통째로 잘렸다. 사유·note·detail은
|
|
109
113
|
// 상세 패널이 여러 줄로 편다 — 여기 남는 것은 잘릴 일이 없는 두 가지뿐이다.
|
|
114
|
+
//
|
|
115
|
+
// 커버리지 표기는 탭과 무관하게 한 규칙이다 — 예전에는 plugin 탭만 이름을,
|
|
116
|
+
// 나머지는 수를 찍어 한 화면 안에서 규칙이 바뀌었다. 이제 지원 CLI가 넷
|
|
117
|
+
// 이하면 이름을, 그보다 많으면 `CLI n/N`을 찍는다. 이름을 나열할 때는 이
|
|
118
|
+
// 머신에 없는 CLI(state.excluded — 전역 항목이 detect에서 알린다)를 빼고
|
|
119
|
+
// 따로 적는다: 상세에는 "CLI 없음"이라 하면서 행에는 그 이름이 그대로
|
|
120
|
+
// 찍히면 어느 쪽이 참인지 알 수 없다.
|
|
121
|
+
export function coverageText(item, state, t = createT('en')) {
|
|
122
|
+
if (!item.supports) return ''
|
|
123
|
+
if (item.supports.length > NAME_LIMIT || item.supports.length === 0) {
|
|
124
|
+
return t('item.cliCoverage', { covered: item.supports.length, total: CLI_IDS.length })
|
|
125
|
+
}
|
|
126
|
+
const excluded = state?.excluded ?? []
|
|
127
|
+
const here = item.supports.filter((c) => !excluded.includes(c))
|
|
128
|
+
const parts = []
|
|
129
|
+
if (here.length > 0) parts.push(here.join('·'))
|
|
130
|
+
if (excluded.length > 0) parts.push(t('item.cliMissing', { list: excluded.join('·') }))
|
|
131
|
+
return parts.join(' · ')
|
|
132
|
+
}
|
|
133
|
+
|
|
110
134
|
export function agentShortHint(item, state, t = createT('en')) {
|
|
111
135
|
const parts = []
|
|
112
136
|
if (state.status !== 'absent') parts.push(t(`status.${state.status}`))
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (item.category === 'plugin' && item.supports?.length) parts.push(item.supports.join('·'))
|
|
116
|
-
else if (item.supports) parts.push(t('item.cliCoverage', { covered: item.supports.length, total: CLI_IDS.length }))
|
|
137
|
+
const cov = coverageText(item, state, t)
|
|
138
|
+
if (cov) parts.push(cov)
|
|
117
139
|
return parts.join(' · ')
|
|
118
140
|
}
|
|
119
141
|
|
|
@@ -151,11 +173,14 @@ function itemRow({ id, section, label, hint, fullHint = hint, statusDetail = nul
|
|
|
151
173
|
}
|
|
152
174
|
}
|
|
153
175
|
|
|
154
|
-
|
|
176
|
+
// section 기본값은 작업 탭이다. design.md 동기화처럼 한 탭에만 관계있는
|
|
177
|
+
// 작업은 그 탭을 넘겨 그 자리 맨 위에 놓는다 — 첫 화면의 작업 탭에 design.md
|
|
178
|
+
// 전용 작업이 섞여 있으면 무엇을 위한 화면인지부터 흐려진다.
|
|
179
|
+
function actionRow({ id, label, hint, run = null, section = ACTION_SECTION, t = createT('en') }) {
|
|
155
180
|
return {
|
|
156
181
|
kind: 'action',
|
|
157
182
|
id,
|
|
158
|
-
section
|
|
183
|
+
section,
|
|
159
184
|
group: null,
|
|
160
185
|
exclusive: null,
|
|
161
186
|
label,
|
|
@@ -165,7 +190,7 @@ function actionRow({ id, label, hint, run = null, t = createT('en') }) {
|
|
|
165
190
|
status: 'absent',
|
|
166
191
|
previewTarget: null,
|
|
167
192
|
run,
|
|
168
|
-
searchText: `${label} ${hint} ${sectionTerms(t,
|
|
193
|
+
searchText: `${label} ${hint} ${sectionTerms(t, section)}`.toLowerCase(),
|
|
169
194
|
}
|
|
170
195
|
}
|
|
171
196
|
|
|
@@ -192,8 +217,10 @@ export function buildActions(root, { designItems = [], t = createT('en') } = {})
|
|
|
192
217
|
run: ({ dryRun, skillMode, log, t: rt }) => runBootstrap(root, { dryRun, skillMode, log, t: rt }),
|
|
193
218
|
t,
|
|
194
219
|
}),
|
|
220
|
+
// design.md 전용 세 작업은 DESIGN.MD 탭 맨 위에 놓는다.
|
|
195
221
|
actionRow({
|
|
196
222
|
id: 'action.sync.installed',
|
|
223
|
+
section: 'design',
|
|
197
224
|
label: t('action.sync.installed.label'),
|
|
198
225
|
hint: t('action.sync.installed.hint'),
|
|
199
226
|
run: ({ dryRun, log, t: rt }) => updateInstalled(root, designItems, { dryRun, log, t: rt }),
|
|
@@ -201,6 +228,7 @@ export function buildActions(root, { designItems = [], t = createT('en') } = {})
|
|
|
201
228
|
}),
|
|
202
229
|
actionRow({
|
|
203
230
|
id: 'action.sync.catalog',
|
|
231
|
+
section: 'design',
|
|
204
232
|
label: t('action.sync.catalog.label'),
|
|
205
233
|
hint: t('action.sync.catalog.hint'),
|
|
206
234
|
run: ({ dryRun, fetchImpl, log, catalogFile, t: rt }) => refreshCatalog({ dryRun, fetchImpl, log, catalogFile, t: rt }),
|
|
@@ -208,6 +236,7 @@ export function buildActions(root, { designItems = [], t = createT('en') } = {})
|
|
|
208
236
|
}),
|
|
209
237
|
actionRow({
|
|
210
238
|
id: 'action.sync.stale',
|
|
239
|
+
section: 'design',
|
|
211
240
|
label: t('action.sync.stale.label'),
|
|
212
241
|
hint: t('action.sync.stale.hint'),
|
|
213
242
|
run: async ({ dryRun, log, confirm, t: rt }) => {
|
package/lib/tui/run.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
createState, setQuery, setFocus, move, moveTab, toggle, toggleVisible, scroll, currentRow, replaceRows, activeTab,
|
|
13
13
|
cycleCliFilter,
|
|
14
14
|
} from './state.mjs'
|
|
15
|
-
import { render, renderReview, bodyHeight } from './render.mjs'
|
|
15
|
+
import { render, renderReview, renderHelp, bodyHeight, footerHeight } from './render.mjs'
|
|
16
16
|
import { createProgress, applyEvent, progressLines } from './progress.mjs'
|
|
17
17
|
import { scopedLabel } from '../labels.mjs'
|
|
18
18
|
|
|
@@ -119,19 +119,36 @@ export async function runTui(root, opts = {}) {
|
|
|
119
119
|
|
|
120
120
|
const color = !env.NO_COLOR && stdout.isTTY
|
|
121
121
|
const draw = (lines) => stdout.write(HOME + lines.map((l) => l + CLEAR_LINE).join('\n') + '\n' + CLEAR_DOWN)
|
|
122
|
+
// 목록 지면은 바닥글 줄 수에 따라 달라진다 — render와 같은 산식을 써야
|
|
123
|
+
// 스크롤·페이지 이동이 화면과 어긋나지 않는다. t는 언어 행이 갈아끼우므로
|
|
124
|
+
// 그때그때 다시 잰다.
|
|
125
|
+
const listHeight = () => bodyHeight(stdout.rows ?? 24, detailExpanded, footerHeight(stdout.columns ?? 80, t))
|
|
122
126
|
const paint = () => {
|
|
123
127
|
const height = stdout.rows ?? 24
|
|
124
|
-
state = scroll(state,
|
|
128
|
+
state = scroll(state, listHeight())
|
|
125
129
|
draw(render(state, {
|
|
126
130
|
width: stdout.columns ?? 80, height, repo: root, dryRun, color, status, t,
|
|
127
131
|
detailExpanded, cliOptions: CLI_OPTIONS,
|
|
128
132
|
}))
|
|
129
133
|
}
|
|
134
|
+
const paintHelp = () => draw(renderHelp({ width: stdout.columns ?? 80, height: stdout.rows ?? 24, color, t }))
|
|
135
|
+
|
|
136
|
+
// 터미널 크기가 바뀌면 지금 떠 있는 화면을 새 크기로 다시 그린다 — 예전에는
|
|
137
|
+
// 다음 키를 누를 때까지 옛 크기로 남았다. 화면마다 자기 그리기 함수를
|
|
138
|
+
// redraw에 걸고, 화면 밖(suspend)에서는 null로 둬 아무것도 안 한다.
|
|
139
|
+
// 테스트의 가짜 stdout에는 on이 없을 수 있어 선택 호출이다.
|
|
140
|
+
let redraw = paint
|
|
141
|
+
const onResize = () => {
|
|
142
|
+
try { redraw?.() } catch { /* 그리기 실패로 루프를 죽이지 않는다 */ }
|
|
143
|
+
}
|
|
144
|
+
stdout.on?.('resize', onResize)
|
|
130
145
|
|
|
131
146
|
// 로그는 화면 안에 우겨넣지 않는다 — 실패 메시지가 길고, 잘리면 진단이 불가능해진다.
|
|
132
147
|
const suspend = async (fn) => {
|
|
148
|
+
const was = redraw
|
|
149
|
+
redraw = null
|
|
133
150
|
stdout.write(ALT_OFF + SHOW_CURSOR)
|
|
134
|
-
try { return await fn() } finally { stdout.write(ALT_ON + HIDE_CURSOR) }
|
|
151
|
+
try { return await fn() } finally { stdout.write(ALT_ON + HIDE_CURSOR); redraw = was }
|
|
135
152
|
}
|
|
136
153
|
const confirm = async (message) => {
|
|
137
154
|
stdout.write(`\n${message}${t('tui.confirmSuffix')}`)
|
|
@@ -201,12 +218,18 @@ export async function runTui(root, opts = {}) {
|
|
|
201
218
|
|
|
202
219
|
// 제출 검토 — 적용 직전에 변경 목록을 보여 주고 Enter/Esc만 받는다.
|
|
203
220
|
const review = async (changes) => {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
221
|
+
const paintReview = () => draw(renderReview(changes, { width: stdout.columns ?? 80, height: stdout.rows ?? 24, dryRun, color, t }))
|
|
222
|
+
redraw = paintReview
|
|
223
|
+
try {
|
|
224
|
+
for (;;) {
|
|
225
|
+
paintReview()
|
|
226
|
+
const key = await keys.next()
|
|
227
|
+
if (key.ctrl && (key.name === 'c' || key.name === 'q')) return 'quit'
|
|
228
|
+
if (key.name === 'escape') return 'cancel'
|
|
229
|
+
if (key.name === 'return' || key.name === 'enter') return 'apply'
|
|
230
|
+
}
|
|
231
|
+
} finally {
|
|
232
|
+
redraw = paint
|
|
210
233
|
}
|
|
211
234
|
}
|
|
212
235
|
|
|
@@ -225,6 +248,7 @@ export async function runTui(root, opts = {}) {
|
|
|
225
248
|
width: stdout.columns ?? 80, height: stdout.rows ?? 24, color, dryRun, now: Date.now(), t,
|
|
226
249
|
}))
|
|
227
250
|
|
|
251
|
+
redraw = drawProgress
|
|
228
252
|
drawProgress()
|
|
229
253
|
const timer = setInterval(() => { checkAbort(); drawProgress() }, 100)
|
|
230
254
|
// 타이머가 프로세스를 붙잡지 않게 한다 — 화면 갱신은 종료를 미룰 이유가 없다.
|
|
@@ -255,6 +279,7 @@ export async function runTui(root, opts = {}) {
|
|
|
255
279
|
return results
|
|
256
280
|
} finally {
|
|
257
281
|
clearInterval(timer)
|
|
282
|
+
redraw = paint
|
|
258
283
|
}
|
|
259
284
|
}
|
|
260
285
|
|
|
@@ -284,6 +309,17 @@ export async function runTui(root, opts = {}) {
|
|
|
284
309
|
}
|
|
285
310
|
if (key.ctrl && key.name === 'd') { detailExpanded = !detailExpanded; continue }
|
|
286
311
|
|
|
312
|
+
// F1: 도움말. 바닥글 두 줄로는 키의 뜻까지 담을 수 없다. 아무 키나 닫는다 —
|
|
313
|
+
// 그 키는 소비만 하고 명령으로 해석하지 않는다(도움말을 닫으려 누른
|
|
314
|
+
// Space가 항목을 고르면 안 된다).
|
|
315
|
+
if (key.name === 'f1') {
|
|
316
|
+
redraw = paintHelp
|
|
317
|
+
paintHelp()
|
|
318
|
+
await keys.next()
|
|
319
|
+
redraw = paint
|
|
320
|
+
continue
|
|
321
|
+
}
|
|
322
|
+
|
|
287
323
|
// ── 검색칸에 포커스: 타이핑이 곧 검색어다(스페이스 포함, 두 단어 검색 가능).
|
|
288
324
|
if (state.focus === 'search') {
|
|
289
325
|
// Esc: 검색어가 있으면 지우고 검색칸에 남는다. 이미 비었으면 목록으로 내려간다.
|
|
@@ -319,8 +355,11 @@ export async function runTui(root, opts = {}) {
|
|
|
319
355
|
continue
|
|
320
356
|
}
|
|
321
357
|
if (key.name === 'down' || (key.ctrl && key.name === 'n')) { state = move(state, 1); continue }
|
|
322
|
-
if (key.name === 'pageup') { state = move(state, -
|
|
323
|
-
if (key.name === 'pagedown') { state = move(state,
|
|
358
|
+
if (key.name === 'pageup') { state = move(state, -listHeight()); continue }
|
|
359
|
+
if (key.name === 'pagedown') { state = move(state, listHeight()); continue }
|
|
360
|
+
// 76개짜리 DESIGN.MD 탭에서 끝으로 가려면 PgDn을 여러 번 눌러야 했다.
|
|
361
|
+
if (key.name === 'home') { state = move(state, -state.filtered.length); continue }
|
|
362
|
+
if (key.name === 'end') { state = move(state, state.filtered.length); continue }
|
|
324
363
|
|
|
325
364
|
// 탭 이동 — Tab/Shift+Tab, 좌우 화살표.
|
|
326
365
|
if (key.name === 'tab') { state = moveTab(state, key.shift ? -1 : 1); continue }
|
|
@@ -389,6 +428,7 @@ export async function runTui(root, opts = {}) {
|
|
|
389
428
|
}
|
|
390
429
|
} finally {
|
|
391
430
|
keys.stop()
|
|
431
|
+
stdout.off?.('resize', onResize)
|
|
392
432
|
stdout.write(ALT_OFF + SHOW_CURSOR)
|
|
393
433
|
stdin.setRawMode(false)
|
|
394
434
|
stdin.pause()
|
package/lib/tui/state.mjs
CHANGED
|
@@ -73,13 +73,17 @@ export function activeTab(state) {
|
|
|
73
73
|
// 탭별 적중 수 — 검색·필터 중에도 어느 탭에 결과가 있는지 한눈에 보이게 한다.
|
|
74
74
|
// total은 필터를 타지 않는다: 분모가 함께 줄면 "이 탭에서 몇 개가 걸러졌나"를
|
|
75
75
|
// 알 수 없다.
|
|
76
|
+
//
|
|
77
|
+
// 항목이 있는 탭은 항목만 센다 — DESIGN.MD 탭 맨 위의 작업 행 셋까지 세면
|
|
78
|
+
// 항목 76개인 탭이 79로 찍혀 머리글의 "전체"와 어긋난다. 작업만 있는 탭은
|
|
79
|
+
// 작업을 센다(0으로 두면 탭이 비어 보인다).
|
|
76
80
|
export function tabCounts(state) {
|
|
77
81
|
const hits = filterRows(state.rows, state.query).filter((r) => matchesCli(r, state.cliFilter))
|
|
78
|
-
return state.tabs.map((tab) =>
|
|
79
|
-
tab
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
})
|
|
82
|
+
return state.tabs.map((tab) => {
|
|
83
|
+
const hasItems = state.rows.some((r) => r.section === tab && r.kind === 'item')
|
|
84
|
+
const pick = (rows) => rows.filter((r) => r.section === tab && (!hasItems || r.kind === 'item'))
|
|
85
|
+
return { tab, shown: pick(hits).length, total: pick(state.rows).length }
|
|
86
|
+
})
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
export function currentRow(state) {
|
package/lib/update.mjs
CHANGED
|
@@ -11,9 +11,11 @@ import { RECORD_REL, managedKey, readRecord, toolVersion, writeRecord } from './
|
|
|
11
11
|
import { LocalizedError, createT, toText } from './i18n/index.mjs'
|
|
12
12
|
|
|
13
13
|
// git이 유일한 되돌리기 수단이다. 커밋되지 않은 변경 위에 덮어쓰면 사용자가
|
|
14
|
-
// 잃은 것을 복구할 방법이 없다.
|
|
14
|
+
// 잃은 것을 복구할 방법이 없다. 추적되지 않는 파일은 세지 않는다 — 갱신
|
|
15
|
+
// 대상은 기록에 해시가 있는 추적 파일뿐이라, 스크래치 파일 하나가 --force를
|
|
16
|
+
// 막을 이유가 없다.
|
|
15
17
|
function assertCleanWorktree(root) {
|
|
16
|
-
const out = execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' })
|
|
18
|
+
const out = execFileSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=no'], { encoding: 'utf8' })
|
|
17
19
|
if (out.trim()) {
|
|
18
20
|
throw new LocalizedError('error.forceNeedsCleanTree')
|
|
19
21
|
}
|
|
@@ -65,10 +67,18 @@ export async function runUpdate(root, opts = {}) {
|
|
|
65
67
|
const drift = results.filter((r) => r.action === 'drift')
|
|
66
68
|
const updated = results.filter((r) => r.action === 'update')
|
|
67
69
|
const created = results.filter((r) => r.action === 'create')
|
|
70
|
+
// 어댑터가 격리해 돌려준 실패다. 요약이 갱신·신규·드리프트만 세면 이
|
|
71
|
+
// 결과는 아무 데도 나오지 않고 종료 코드도 0이라, 링크가 끊긴 채 "갱신
|
|
72
|
+
// 0건"으로 끝난다. flow.mjs의 bootstrap과 같은 형식으로 보고한다.
|
|
73
|
+
const failed = results.filter((r) => !r.ok)
|
|
68
74
|
|
|
69
75
|
if (!dryRun) writeRecord(root, { ...record, managed }, { dryRun, log: say, t })
|
|
70
76
|
|
|
71
77
|
log('')
|
|
78
|
+
if (failed.length > 0) {
|
|
79
|
+
say(t('bootstrap.failures', { count: failed.length }))
|
|
80
|
+
for (const f of failed) say(` ✖ ${f.path} — ${toText(t, f.message)}`)
|
|
81
|
+
}
|
|
72
82
|
say(t('update.summary', { updated: updated.length, created: created.length, drift: drift.length }))
|
|
73
83
|
if (drift.length > 0) {
|
|
74
84
|
say(t('update.driftHeader'))
|
|
@@ -81,5 +91,5 @@ export async function runUpdate(root, opts = {}) {
|
|
|
81
91
|
say(t('update.driftHint'))
|
|
82
92
|
}
|
|
83
93
|
|
|
84
|
-
return { results, drift, record: { ...record, managed } }
|
|
94
|
+
return { results, drift, failed, record: { ...record, managed } }
|
|
85
95
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rch4com/agent-setup",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"description": "Repository-scoped bootstrap and optional-item installer for using multiple coding-agent CLIs in one repository | 여러 코딩 에이전트 CLI를 한 저장소에서 함께 쓰기 위한 저장소 범위 부트스트랩과 선택 항목 설치기",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents-md",
|