@rch4com/agent-setup 1.4.0 → 1.6.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.
@@ -1,14 +1,28 @@
1
1
  import { definePlugin } from '../catalog.mjs'
2
+ import { CLI_IDS } from '../clis.mjs'
3
+ import { msg } from '../i18n/index.mjs'
2
4
  // 상류의 멀티 CLI 경로(`npx impeccable install --providers=... --scope=project`)는
3
5
  // 쓰지 않는다. 실측 결과 그 명령이 `.claude/skills`를 **실제 디렉터리로 갈아치워**
4
6
  // 부트스트랩이 만든 `.agents/skills` Junction을 끊는다 — 공유 스킬이 전부
5
- // Claude Code 화면에서 사라진다. 플러그인 경로는 `.claude/skills`를 건드리지 않는다.
7
+ // Claude Code 화면에서 사라진다. 버그가 아니라 설계다: 상류
8
+ // `cli/bin/commands/skills.mjs`의 isInProjectProviderLink가 "프로젝트 안 다른
9
+ // 프로바이더를 가리키는 링크"를 의도적으로 unlink한다(3.5.0 재확인, 2026-08-02).
10
+ // 플러그인 경로는 `.claude/skills`를 건드리지 않는다.
6
11
  // 또 그 명령은 `--providers`와 `--scope`를 함께 주지 않으면 대화형으로 물으며,
7
12
  // 비TTY에서는 조용히 global을 골라 홈에 설치한다.
13
+ // 상류 공식 프로바이더 14개 중 이 저장소의 CLI와 겹치는 것(아래 UPSTREAM).
14
+ // Antigravity는 README에만 있고 설치 CLI는 거부한다(수동 복사 전용).
15
+ const UPSTREAM = ['codex', 'gemini', 'opencode', 'kiro', 'grok', 'copilot']
8
16
  export default definePlugin({
9
17
  id: 'plugin.impeccable', label: 'impeccable', group: '__style',
10
18
  installId: 'impeccable@impeccable',
11
19
  detectIds: ['impeccable@impeccable'],
12
20
  marketplace: { name: 'impeccable', repo: 'pbakaus/impeccable' },
13
21
  note: 'item.plugin.impeccable.note',
22
+ unsupported: Object.fromEntries(
23
+ CLI_IDS.filter((c) => c !== 'claude').map((c) => [
24
+ c,
25
+ UPSTREAM.includes(c) ? msg('item.unsupported.impeccableJunction') : msg('item.unsupported.upstreamNone'),
26
+ ]),
27
+ ),
14
28
  })
@@ -67,8 +67,8 @@ export default {
67
67
  async install({ root, dryRun, exec, log = () => {}, t }) {
68
68
  let deferred = false
69
69
  if (!isPluginEnabled(root, [INSTALL_ID])) {
70
- exec('claude', ['plugin', 'marketplace', 'add', MARKETPLACE.repo], { cwd: root })
71
- const r = exec('claude', ['plugin', 'install', INSTALL_ID, '--scope', 'project'], { cwd: root })
70
+ await exec('claude', ['plugin', 'marketplace', 'add', MARKETPLACE.repo], { cwd: root })
71
+ const r = await exec('claude', ['plugin', 'install', INSTALL_ID, '--scope', 'project'], { cwd: root })
72
72
  if (!r.ok) {
73
73
  if (!dryRun) enablePlugin(root, INSTALL_ID, MARKETPLACE)
74
74
  deferred = true
@@ -86,7 +86,7 @@ export default {
86
86
 
87
87
  async uninstall({ root, dryRun, exec, log = () => {}, t }) {
88
88
  if (isPluginEnabled(root, [INSTALL_ID])) {
89
- const r = exec('claude', ['plugin', 'uninstall', INSTALL_ID], { cwd: root })
89
+ const r = await exec('claude', ['plugin', 'uninstall', INSTALL_ID], { cwd: root })
90
90
  if (!r.ok && !dryRun) disablePlugin(root, [INSTALL_ID])
91
91
  }
92
92
  if (!hasOpencode(root)) return
@@ -1,7 +1,23 @@
1
1
  import { definePlugin } from '../catalog.mjs'
2
+ import { CLI_IDS } from '../clis.mjs'
3
+ import { msg } from '../i18n/index.mjs'
4
+
5
+ // 상류(obra/superpowers v6.2.0)는 Codex·Antigravity·Copilot CLI 등 11개
6
+ // 하니스를 지원하지만 설치 명령이 하니스마다 전부 달라 공유 경로가 없다
7
+ // (README: "install Superpowers separately for each one"). 이 항목이 배선하는
8
+ // 것은 Claude Code 플러그인 판뿐이다. Kilo·Kiro·Grok·VS Code는 상류에 공식
9
+ // 설치 경로가 없다(2026-08-02 검증).
10
+ const SEPARATE = ['codex', 'gemini', 'opencode', 'kimi', 'copilot']
11
+
2
12
  export default definePlugin({
3
13
  id: 'plugin.superpowers', label: 'superpowers', group: '__flow',
4
14
  installId: 'superpowers@claude-plugins-official',
5
15
  detectIds: ['superpowers@claude-plugins-official', 'superpowers@superpowers-marketplace'],
6
16
  note: 'item.plugin.superpowers.note',
17
+ unsupported: Object.fromEntries(
18
+ CLI_IDS.filter((c) => c !== 'claude').map((c) => [
19
+ c,
20
+ SEPARATE.includes(c) ? msg('item.unsupported.superpowersSeparate') : msg('item.unsupported.upstreamNone'),
21
+ ]),
22
+ ),
7
23
  })
@@ -1,25 +1,44 @@
1
1
  import { existsSync, readdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { defineSkill } from '../catalog.mjs'
4
- import { LocalizedError } from '../i18n/index.mjs'
4
+ import { CLI_IDS } from '../clis.mjs'
5
+ import { LocalizedError, msg } from '../i18n/index.mjs'
5
6
 
6
7
  function hasGsdFiles(dir) {
7
8
  return existsSync(dir) && readdirSync(dir).some((f) => f.startsWith('gsd-'))
8
9
  }
9
10
 
11
+ // 상류(v1.9.1)는 런타임 플래그 18개(--codex·--antigravity 등)를 지원하지만
12
+ // 이 항목은 --claude만 배선한다. Gemini CLI는 상류가 명시 제거했다(2026-06-18
13
+ // Google 종료, --gemini는 --antigravity 안내 후 종료). 상류의 --kilo는
14
+ // OpenCode 파생 "Kilo"(~/.config/kilo)라 이 저장소의 Kilo Code(.kilocode)와
15
+ // 같은 제품인지 미확인이라 지원으로 표기하지 않는다(2026-08-02 검증).
16
+ const FLAGS = ['codex', 'opencode', 'copilot', 'kimi']
17
+
10
18
  export default defineSkill({
11
19
  id: 'skill.gsd', label: 'GSD (Get Shit Done)', group: '__flow', scope: 'project',
12
20
  note: 'item.skill.gsd.note',
21
+ unsupported: Object.fromEntries(
22
+ CLI_IDS.filter((c) => c !== 'claude').map((c) => [
23
+ c,
24
+ FLAGS.includes(c) ? msg('item.unsupported.gsdFlag')
25
+ : c === 'gemini' ? msg('item.unsupported.gsdGemini')
26
+ : msg('item.unsupported.upstreamNone'),
27
+ ]),
28
+ ),
13
29
  async detect({ root }) {
14
30
  const found = hasGsdFiles(join(root, '.claude', 'commands')) || hasGsdFiles(join(root, '.claude', 'skills'))
15
31
  return { status: found ? 'installed' : 'absent' }
16
32
  },
17
33
  async install({ root, exec }) {
18
- const r = exec('npx', ['-y', '@opengsd/gsd-core@latest', '--claude', '--local'], { cwd: root })
34
+ const r = await exec('npx', ['-y', '@opengsd/gsd-core@latest', '--claude', '--local'], { cwd: root })
19
35
  if (!r.ok) throw new LocalizedError('error.gsdInstall', { output: r.output })
20
36
  },
21
37
  async uninstall({ root, exec }) {
22
- const r = exec('npx', ['-y', '@opengsd/gsd-core@latest', '--uninstall'], { cwd: root })
38
+ // 런타임 플래그가 없으면 상류가 기본값 claude를 지운다 — 지금은 --claude만
39
+ // 설치하므로 맞지만, 다른 런타임을 설치하게 되면 여기에도 같은 플래그를
40
+ // 넘겨야 엉뚱한 설치본이 지워지지 않는다.
41
+ const r = await exec('npx', ['-y', '@opengsd/gsd-core@latest', '--uninstall'], { cwd: root })
23
42
  if (!r.ok) throw new LocalizedError('error.gsdUninstall', { output: r.output })
24
43
  },
25
44
  })
@@ -1,15 +1,29 @@
1
1
  import { existsSync, rmSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { defineSkill } from '../catalog.mjs'
4
+ import { CLI_IDS } from '../clis.mjs'
4
5
  import { repoPath, repoPathStrict } from '../context.mjs'
5
6
  import { ensureGitignoreEntries } from '../gitignore.mjs'
6
- import { LocalizedError } from '../i18n/index.mjs'
7
+ import { LocalizedError, msg } from '../i18n/index.mjs'
7
8
 
8
9
  const REL_DIR = '.claude/skills/gstack'
9
10
 
11
+ // 상류 setup은 --host codex·kiro·opencode(와 factory)도 지원하지만 이 항목은
12
+ // 인자 없이 실행하며 setup의 기본값이 claude 단독이다. README 표에 있는
13
+ // --host cursor·slate는 setup의 case 문이 거부해 exit 1로 죽는다 —
14
+ // 확장한다면 안전한 값은 claude·codex·kiro·factory·opencode·auto뿐이다
15
+ // (2026-08-02 검증).
16
+ const HOSTS = ['codex', 'kiro', 'opencode']
17
+
10
18
  export default defineSkill({
11
19
  id: 'skill.gstack', label: 'gstack', group: '__flow', scope: 'project',
12
20
  note: 'item.skill.gstack.note',
21
+ unsupported: Object.fromEntries(
22
+ CLI_IDS.filter((c) => c !== 'claude').map((c) => [
23
+ c,
24
+ HOSTS.includes(c) ? msg('item.unsupported.gstackHost') : msg('item.unsupported.upstreamNone'),
25
+ ]),
26
+ ),
13
27
  async detect({ root }) {
14
28
  return { status: existsSync(repoPath(root, REL_DIR)) ? 'installed' : 'absent' }
15
29
  },
@@ -19,9 +33,9 @@ export default defineSkill({
19
33
  // .agents/skills Junction은 저장소 안이라 그대로 통과한다).
20
34
  const dir = repoPathStrict(root, REL_DIR)
21
35
  if (!existsSync(dir)) {
22
- const clone = exec('git', ['clone', '--single-branch', '--depth', '1', 'https://github.com/garrytan/gstack.git', dir])
36
+ const clone = await exec('git', ['clone', '--single-branch', '--depth', '1', 'https://github.com/garrytan/gstack.git', dir])
23
37
  if (!clone.ok) throw new LocalizedError('error.gstackClone', { output: clone.output })
24
- const setup = exec('bash', ['./setup'], { cwd: dir })
38
+ const setup = await exec('bash', ['./setup'], { cwd: dir })
25
39
  if (!setup.ok) {
26
40
  // setup 실패 잔존물이 detect를 installed로 오판시키지 않도록 정리한다.
27
41
  rmSync(dir, { recursive: true, force: true })
@@ -34,7 +48,7 @@ export default defineSkill({
34
48
  async uninstall({ root, dryRun, exec }) {
35
49
  const dir = repoPathStrict(root, REL_DIR)
36
50
  if (!existsSync(dir)) return
37
- exec('bash', [join(dir, 'bin', 'gstack-uninstall'), '--force'], { cwd: dir }) // 실패해도 디렉터리 삭제로 폴백
51
+ await exec('bash', [join(dir, 'bin', 'gstack-uninstall'), '--force'], { cwd: dir }) // 실패해도 디렉터리 삭제로 폴백
38
52
  if (!dryRun && existsSync(dir)) rmSync(dir, { recursive: true, force: true })
39
53
  },
40
54
  })
@@ -0,0 +1,118 @@
1
+ // 상세 패널 — 커서가 놓인 항목의 전문을 여러 줄로 편다.
2
+ // 순수 함수 모듈이다. 색도 커서도 터미널도 모른다 — render.mjs가 배치하고 칠한다.
3
+ //
4
+ // rows.mjs를 import 하지 않는 것이 규칙이다. render.mjs가 이 모듈을 쓰므로,
5
+ // 여기서 rows를 끌어오면 render → detail → rows 방향이 생겨 rows.mjs가 순수
6
+ // 렌더 층의 의존성이 된다. 그래서 사유 그룹핑이 rows가 아니라 여기 있고,
7
+ // rows.mjs가 반대로 여기서 가져다 쓴다.
8
+ import { toText, createT } from '../i18n/index.mjs'
9
+ import { CLIS } from '../clis.mjs'
10
+ import { categoryLabel } from '../design-md/flow.mjs'
11
+ import { cut, pad, width, wrap } from '../width.mjs'
12
+
13
+ // 미배선 사유를 **같은 사유끼리** 묶는다. 사유 하나가 CLI 아홉 개에 그대로
14
+ // 반복되면 줄만 길어지고 "무엇이 왜 빠졌는가"는 오히려 묻힌다.
15
+ // 사유가 둘 이상이면(ponytail처럼) 갈래가 그대로 보인다.
16
+ export function unsupportedGroups(item, t) {
17
+ const entries = Object.entries(item?.unsupported ?? {})
18
+ if (entries.length === 0) return []
19
+ const byReason = new Map()
20
+ for (const [cli, why] of entries) {
21
+ const text = toText(t, why)
22
+ if (!byReason.has(text)) byReason.set(text, [])
23
+ byReason.get(text).push(cli)
24
+ }
25
+ return [...byReason].map(([why, clis]) => ({ clis, why }))
26
+ }
27
+
28
+ // '배선 ' / '미배선 ' 라벨 자리. 로케일마다 길이가 달라 상수로 박으면 열이
29
+ // 어긋난다 — 그 로케일의 실제 라벨에서 폭을 뽑는다.
30
+ function leadWidth(t) {
31
+ return Math.max(width(t('detail.wired')), width(t('detail.unwired'))) + 1
32
+ }
33
+
34
+ function headLine(row, w, t) {
35
+ const item = row.item
36
+ // item.scope는 오늘 모든 항목 종류가 채운다 — 그래도 이 값 하나로 화면
37
+ // 전체가 죽을 수는 없다. t()는 모르는 키에 던지고(i18n/index.mjs) 이
38
+ // 함수는 render() 안에서 돈다 — 항목 하나의 결손이 패널 하나가 아니라
39
+ // TUI 전체를 끌고 내려간다.
40
+ const bits = [item.category, item.scope ? t(`detail.scope.${item.scope}`) : null, t(`status.${row.status}`)]
41
+ return cut(`${item.label} ${bits.filter(Boolean).join(' · ')}`, w)
42
+ }
43
+
44
+ function wiredLines(item, w, lead, t) {
45
+ const supports = item.supports ?? []
46
+ if (supports.length === 0) return []
47
+ // 경로는 MCP에서만 붙인다. 그때만 clis.mjs 어댑터가 경로의 유일한 진실이다 —
48
+ // plugin·skill은 설치 자리가 항목마다 달라 어댑터가 알지 못한다.
49
+ const showFile = item.category === 'mcp'
50
+ const nameWidth = Math.max(...supports.map((c) => width(c)))
51
+ return supports.map((cli, i) => {
52
+ const head = i === 0 ? pad(t('detail.wired'), lead) : ' '.repeat(lead)
53
+ const file = showFile && CLIS[cli]?.file ? ` ${CLIS[cli].file}` : ''
54
+ // 이름 뒤에 열이 없으면 맞출 대상도 없다 — 경로가 붙는 mcp에서만 이름을 편다.
55
+ const name = showFile ? pad(cli, nameWidth) : cli
56
+ return cut(`${head}✔ ${name}${file}`, w)
57
+ })
58
+ }
59
+
60
+ function unwiredLines(item, w, lead, t) {
61
+ const out = []
62
+ unsupportedGroups(item, t).forEach((group, i) => {
63
+ const head = i === 0 ? pad(t('detail.unwired'), lead) : ' '.repeat(lead)
64
+ out.push(cut(`${head}✖ ${group.clis.join('·')}`, w))
65
+ // 사유는 한 칸 더 들여 이어 붙인다. 첫 줄만 └를 달아 어느 CLI 묶음의
66
+ // 사유인지 눈으로 잇는다.
67
+ wrap(group.why, Math.max(1, w - lead - 3)).forEach((line, j) => {
68
+ out.push(cut(`${' '.repeat(lead + 1)}${j === 0 ? '└ ' : ' '}${line}`, w))
69
+ })
70
+ })
71
+ return out
72
+ }
73
+
74
+ function agentLines(row, w, t) {
75
+ const item = row.item
76
+ const lead = leadWidth(t)
77
+ const out = [headLine(row, w, t)]
78
+ if (item.note) out.push(...wrap(t(item.note), w))
79
+ if (row.statusDetail) out.push(...wrap(row.statusDetail, w))
80
+ out.push(...wiredLines(item, w, lead, t))
81
+ out.push(...unwiredLines(item, w, lead, t))
82
+ return out
83
+ }
84
+
85
+ function designLines(row, w, t) {
86
+ const item = row.item
87
+ const lead = leadWidth(t)
88
+ const bits = [item.category, categoryLabel(t, item.designCategory), t(`status.${row.status}`)]
89
+ const out = [cut(`${item.label} ${bits.filter(Boolean).join(' · ')}`, w)]
90
+ if (item.providerId) out.push(cut(`${pad(t('detail.provider'), lead)}${item.providerId}`, w))
91
+ const target = row.previewTarget ?? item.webUrl ?? item.previewPath
92
+ if (target) out.push(cut(`${pad(t('detail.preview'), lead)}${target}`, w))
93
+ if (item.description) out.push(...wrap(item.description, w))
94
+ return out
95
+ }
96
+
97
+ // 액션 행은 체크 대상이 아니라 실행 대상이다 — 배선표가 없다.
98
+ function actionLines(row, w) {
99
+ return [cut(row.label, w), ...wrap(row.fullHint ?? row.hint ?? '', w)]
100
+ }
101
+
102
+ // 행 하나와 지면 크기를 받아 줄 배열을 돌려준다. 색도 커서도 모른다.
103
+ export function detailLines(row, { width: columns = 80, height = 8, t = createT('en') } = {}) {
104
+ if (!row || height <= 0) return []
105
+ const w = Math.max(20, columns)
106
+ const lines = row.kind === 'action'
107
+ ? actionLines(row, w)
108
+ : row.item?.category === 'design' ? designLines(row, w, t) : agentLines(row, w, t)
109
+
110
+ if (lines.length <= height) return lines
111
+ // 넘치면 조용히 자르지 않는다 — 남은 줄 수와 펼치는 길을 함께 알린다.
112
+ // 안내줄이 한 줄을 차지하므로 본문은 height - 1줄만 쓴다. height===1이면
113
+ // 그 한 줄조차 없어 안내줄 하나가 전부를 대신하고, 그때는 "한 줄 빼고 남은
114
+ // 수"가 아니라 "전부"를 세어야 거짓말이 되지 않는다.
115
+ const room = height - 1
116
+ const marker = cut(t('detail.more', { count: lines.length - Math.max(0, room) }), w)
117
+ return room <= 0 ? [marker] : [...lines.slice(0, room), marker]
118
+ }
@@ -0,0 +1,160 @@
1
+ // 진행 화면 — 순수 함수 모듈이다. 터미널도 시계도 모른다.
2
+ //
3
+ // 시각을 인자로 받는 것이 핵심이다. 모듈 안에서 Date.now()를 부르면 테스트가
4
+ // 실제 시간에 묶여 "14초 경과"를 검증할 방법이 없다.
5
+ import { createT } from '../i18n/index.mjs'
6
+ import { cut, labelWidth, pad, width } from '../width.mjs'
7
+
8
+ const ESC = String.fromCharCode(27)
9
+ const DIM = `${ESC}[2m`
10
+ const BOLD = `${ESC}[1m`
11
+ const RESET = `${ESC}[0m`
12
+
13
+ const MARK = { done: '✔', failed: '✖', running: '▸', pending: ' ', skipped: '–' }
14
+
15
+ export function createProgress(changes) {
16
+ return {
17
+ total: changes.length,
18
+ startedAt: null,
19
+ aborted: false,
20
+ entries: changes.map(({ item, action }) => ({
21
+ item, action, state: 'pending', ok: null, ms: 0, command: null, startedAt: null,
22
+ })),
23
+ }
24
+ }
25
+
26
+ // 이벤트 하나를 접어 넣는다. 새 상태를 돌려준다 — 화면은 이 값만 보고 그린다.
27
+ export function applyEvent(progress, event, now) {
28
+ const entries = progress.entries.slice()
29
+ const at = entries[event.index]
30
+ if (!at) return progress
31
+ if (event.phase === 'start') entries[event.index] = { ...at, state: 'running', startedAt: now }
32
+ else if (event.phase === 'command') entries[event.index] = { ...at, command: event.command }
33
+ else if (event.phase === 'done') {
34
+ entries[event.index] = { ...at, state: event.ok ? 'done' : 'failed', ok: event.ok, ms: event.ms ?? 0 }
35
+ }
36
+ return { ...progress, entries, startedAt: progress.startedAt ?? now }
37
+ }
38
+
39
+ function seconds(ms) {
40
+ return (Math.max(0, ms) / 1000).toFixed(1).replace(/\.0$/, '')
41
+ }
42
+
43
+ function elapsedText(ms, t) {
44
+ const total = Math.floor(Math.max(0, ms) / 1000)
45
+ return total >= 60
46
+ ? t('progress.elapsedMin', { minutes: Math.floor(total / 60), seconds: total % 60 })
47
+ : t('progress.elapsed', { seconds: total })
48
+ }
49
+
50
+ function bar(done, total, room) {
51
+ if (room <= 2) return ''
52
+ const inner = room - 2
53
+ const filled = total === 0 ? 0 : Math.round((done / total) * inner)
54
+ return `[${'█'.repeat(filled)}${'░'.repeat(Math.max(0, inner - filled))}]`
55
+ }
56
+
57
+ // 항목 한 개가 차지하는 줄. 실행 중이면 경과와 명령을 함께 낸다.
58
+ function entryLines(entry, w, actionWidth, now, t) {
59
+ let tail = ''
60
+ if (entry.state === 'running') {
61
+ const ran = Math.floor(Math.max(0, now - (entry.startedAt ?? now)) / 1000)
62
+ tail = ` ${t('progress.running', { seconds: ran })}`
63
+ } else if (entry.state === 'done' || entry.state === 'failed') {
64
+ tail = ` ${t('progress.elapsed', { seconds: seconds(entry.ms) })}`
65
+ } else if (entry.state === 'skipped') {
66
+ tail = ` ${t('progress.skipped')}`
67
+ }
68
+ const mark = MARK[entry.state] ?? ' '
69
+ const out = [cut(`${mark} ${pad(t(`change.${entry.action}`), actionWidth)} ${entry.item.label}${tail}`, w)]
70
+ // 명령은 실행 중일 때만. 끝난 뒤에도 남기면 화면이 명령 목록이 된다.
71
+ if (entry.state === 'running' && entry.command) out.push(cut(` ${entry.command}`, w))
72
+ return out
73
+ }
74
+
75
+ // 중단되면 아직 시작하지 않은(pending) 항목이 곧 건너뛴 항목이다. applyEvent는
76
+ // Task 9의 이벤트(start·command·done)만 받아 항목 상태가 'skipped'가 되는
77
+ // 경로가 없다 — 그래서 화면을 그릴 때만 pending을 skipped로 겹쳐 본다.
78
+ // 집계와 표시가 같은 규칙을 쓰게 해, "건너뜀 0건"처럼 숫자와 말이 어긋나지 않게 한다.
79
+ function viewEntry(entry, aborted) {
80
+ return aborted && entry.state === 'pending' ? { ...entry, state: 'skipped' } : entry
81
+ }
82
+
83
+ export function progressLines(progress, opts = {}) {
84
+ const { width: columns = 80, height = 24, color = false, dryRun = false, now = 0, t = createT('en') } = opts
85
+ const w = Math.max(24, columns - 1)
86
+ const paint = (code, text) => (color ? `${code}${text}${RESET}` : text)
87
+
88
+ const done = progress.entries.filter((e) => e.state === 'done' || e.state === 'failed').length
89
+ const skipped = progress.aborted ? progress.entries.filter((e) => e.state === 'pending').length : 0
90
+ const percent = progress.total === 0 ? 100 : Math.round((done / progress.total) * 100)
91
+ const counter = t('progress.counter', { done, total: progress.total, percent })
92
+ const elapsed = progress.startedAt === null ? '' : ` ${elapsedText(now - progress.startedAt, t)}`
93
+
94
+ const title = t('progress.title', { count: progress.total, suffix: dryRun ? ' (dry-run)' : '' })
95
+ const lines = [paint(BOLD, cut(title, w)), '']
96
+
97
+ const meta = ` ${counter}${elapsed}`
98
+ lines.push(cut(`${bar(done, progress.total, Math.max(0, w - width(meta)))}${meta}`, w))
99
+ lines.push('')
100
+
101
+ const actionWidth = labelWidth(t, ['change.install', 'change.complete', 'change.uninstall'])
102
+ const body = Math.max(1, height - lines.length - 2)
103
+
104
+ // 지면이 모자라면 **완료된 것부터** 접는다. 지금 무엇이 도는지가 가장 알고
105
+ // 싶은 정보라, 실행 중 항목은 언제나 화면에 남는다.
106
+ //
107
+ // 실행 중 항목(없으면 마지막으로 끝난 항목)을 기준점으로 잡고 지면이
108
+ // 허락하는 만큼만 위로 거슬러 올라간다. 기준점에서 시작하므로 그 줄이
109
+ // 잘려 나갈 수 없다 — "완료분부터 접는다"를 규칙이 아니라 구조로 보장한다.
110
+ const blocks = progress.entries.map((e) => entryLines(viewEntry(e, progress.aborted), w, actionWidth, now, t))
111
+ const running = progress.entries.findIndex((e) => e.state === 'running')
112
+ const anchor = running === -1 ? Math.max(0, done - 1) : running
113
+
114
+ let from = anchor
115
+ let used = blocks.slice(anchor).reduce((n, b) => n + b.length, 0)
116
+ while (from > 0 && used + blocks[from - 1].length <= body - 1) {
117
+ from--
118
+ used += blocks[from].length
119
+ }
120
+ // '더보기' 줄은 부가 정보일 뿐이다. 지면이 1줄밖에 없으면 실행 중 항목이
121
+ // 이긴다 — 더보기 줄에 밀려 정작 지금 도는 항목이 사라지면 안 된다.
122
+ const showMore = from > 0 && body > 1
123
+ const shown = blocks.slice(from).flat().slice(0, Math.max(1, body - (showMore ? 1 : 0)))
124
+ if (showMore) lines.push(paint(DIM, cut(t('progress.more', { count: from }), w)))
125
+ for (const line of shown) lines.push(line)
126
+ for (let i = lines.length; i < height - 2; i++) lines.push('')
127
+
128
+ lines.push('')
129
+ // done 분기(아래)는 aborted가 아닐 때만 오고, 그때 skipped는 위에서 늘
130
+ // 0이다 — 그래서 skippedSuffix는 항상 빈 문자열이다. progress.doneSkipped
131
+ // 키는 이 분기가 실제로 채울 일이 없어 지웠다(더는 어디서도 쓰지 않는다).
132
+ const foot = progress.aborted
133
+ ? t('progress.aborted', { count: skipped })
134
+ : done < progress.total
135
+ ? t('progress.abortHint')
136
+ : t('progress.done', {
137
+ ok: progress.entries.filter((e) => e.state === 'done').length,
138
+ failed: progress.entries.filter((e) => e.state === 'failed').length,
139
+ skippedSuffix: '',
140
+ })
141
+ lines.push(paint(DIM, cut(foot, w)))
142
+ return lines.slice(0, height)
143
+ }
144
+
145
+ // 비TTY(CI·파이프)용. 바를 그리지 않고 항목마다 한 줄씩 흘린다 —
146
+ // ANSI 제어문자로 CI 로그를 더럽히지 않는다.
147
+ export function plainLine(event, t = createT('en')) {
148
+ if (event.phase === 'start') {
149
+ return t('progress.plain', {
150
+ index: event.index + 1,
151
+ total: event.total,
152
+ action: t(`change.${event.action}`),
153
+ label: event.item.label,
154
+ })
155
+ }
156
+ if (event.phase === 'done') {
157
+ return t('progress.plainDone', { mark: event.ok ? '✔' : '✖', seconds: seconds(event.ms ?? 0) })
158
+ }
159
+ return null
160
+ }
@@ -1,10 +1,11 @@
1
1
  // 순수 렌더 — 상태와 크기를 받아 화면 줄 배열을 돌려준다.
2
2
  // 커서 이동·지우기 같은 제어 시퀀스는 run.mjs가 맡는다.
3
- import { displayList, tabCounts, activeTab } from './state.mjs'
3
+ import { displayList, tabCounts, activeTab, currentRow } from './state.mjs'
4
4
  import { CLI_IDS } from '../clis.mjs'
5
5
  import { createT } from '../i18n/index.mjs'
6
6
  import { categoryLabel } from '../design-md/flow.mjs'
7
7
  import { cut, pad, width } from '../width.mjs'
8
+ import { detailLines } from './detail.mjs'
8
9
 
9
10
  // 폭 계산은 화면 전용이 아니다 — 비대화형 목록도 같은 열 맞춤이 필요해
10
11
  // lib/width.mjs에 있다. 여기서 다시 내보내는 것은 호출부(테스트 포함)가
@@ -21,7 +22,34 @@ const RESET = `${ESC}[0m`
21
22
  const CHROME = 6
22
23
  export const LABEL_WIDTH = 24
23
24
 
24
- export function bodyHeight(height) {
25
+ // 상세 패널은 목록과 화면을 나눠 갖는다. 높이를 커서가 아니라 **터미널
26
+ // 크기로만** 정하는 것이 핵심이다 — 커서를 옮길 때마다 높이가 변하면
27
+ // 목록이 출렁이고, 그것이 아코디언(행 펼침)을 기각한 이유였다.
28
+ const PANEL_SHARE = 0.4
29
+ const PANEL_MIN = 4
30
+ const PANEL_MAX = 12
31
+ // 목록이 3줄 밑으로 내려가는 쪽이 패널이 사라지는 것보다 나쁘다.
32
+ const PANEL_FLOOR = PANEL_MIN + 3
33
+
34
+ export function panelHeight(height, expanded = false) {
35
+ const room = Math.max(0, height - CHROME)
36
+ if (expanded) return room
37
+ if (room < PANEL_FLOOR) return 0
38
+ return Math.min(PANEL_MAX, Math.max(PANEL_MIN, Math.round(room * PANEL_SHARE)))
39
+ }
40
+
41
+ export function bodyHeight(height, expanded = false) {
42
+ const room = Math.max(0, height - CHROME)
43
+ const panel = panelHeight(height, expanded)
44
+ // 패널이 없을 때는 예전과 같이 최소 3줄을 보장한다.
45
+ return panel === 0 ? Math.max(3, room) : room - panel
46
+ }
47
+
48
+ // 제출 검토 화면(renderReview) 전용 — 거긴 패널을 그리지 않는다. bodyHeight를
49
+ // 그대로 재사용하면 그리지도 않는 패널 몫을 미리 떼어 두게 되고, 적용 직전
50
+ // 마지막 확인 화면이 화면에 여유가 남았는데도 변경 목록을 이유 없이 잘라
51
+ // "…외 N건"으로 감춘다. 그래서 패널을 도입하기 전의 옛 산식을 그대로 쓴다.
52
+ export function reviewBodyHeight(height) {
25
53
  return Math.max(3, height - CHROME)
26
54
  }
27
55
 
@@ -32,20 +60,22 @@ function checkbox(row, selected) {
32
60
  return `[${selected.has(row.id) ? MARK.on : MARK.off}]`
33
61
  }
34
62
 
35
- // 탭 줄. 검색 중이면 탭마다 적중 수를 보여 준다 —
36
- // 검색은 활성 탭 안으로만 걸리므로, 다른 탭에 결과가 있다는 사실을 여기서 알린다.
63
+ // 탭 줄. 목록이 좁혀졌으면(검색어·CLI 필터 어느 쪽이든) 탭마다 적중 수를
64
+ // 보여 준다 — 검색·필터는 활성 탭 안으로만 걸리므로, 다른 탭에 결과가
65
+ // 있다는 사실을 여기서 알린다. 그래야 필터가 사용자를 빈 탭에 가둬 두고
66
+ // 아무 설명도 없이 방치하는 일이 없다.
37
67
  // 폭이 모자라면 활성 탭 하나 + 위치 표시로 줄인다(줄바꿈은 화면을 무너뜨린다).
38
68
  // tab 자체는 rows.mjs가 만든 소문자 id다 — 화면에는 t로 번역한 이름만 낸다.
39
- export function tabBar(state, { width: limit, color = false, searching = false, t = createT('en') } = {}) {
69
+ export function tabBar(state, { width: limit, color = false, narrowed = false, t = createT('en') } = {}) {
40
70
  const counts = tabCounts(state)
41
71
  if (counts.length === 0) return ''
42
72
  const active = activeTab(state)
43
73
 
44
74
  const segs = counts.map(({ tab, shown, total }) => ({
45
75
  tab,
46
- text: searching ? `${t(`section.${tab}`)} ${shown}/${total}` : `${t(`section.${tab}`)} ${total}`,
76
+ text: narrowed ? `${t(`section.${tab}`)} ${shown}/${total}` : `${t(`section.${tab}`)} ${total}`,
47
77
  active: tab === active,
48
- empty: searching && shown === 0,
78
+ empty: narrowed && shown === 0,
49
79
  }))
50
80
 
51
81
  const SEP = ' '
@@ -65,23 +95,52 @@ export function tabBar(state, { width: limit, color = false, searching = false,
65
95
  .join('')
66
96
  }
67
97
 
98
+ // 필터 표시. 검색줄 오른쪽 끝에 붙는다 — 새 줄을 만들면 CHROME이 늘어
99
+ // 목록이 그만큼 준다. 필터가 없으면 아무것도 내지 않는다(잡음 방지).
100
+ //
101
+ // 색은 여기서 입히지 않는다 — 이 반환값이 searchLine의 tailWidth 계산에도
102
+ // 그대로 쓰이는데, 색 코드를 섞으면 ESC 시퀀스가 표시 폭에 끼어든다(폭은
103
+ // 항상 색을 입히기 전에 재야 한다). 칠하는 일은 호출부가 폭 계산을 끝낸
104
+ // 뒤 마지막에 한다.
105
+ export function filterSegment(state, { t = createT('en'), options = [] } = {}) {
106
+ if (!state.cliFilter) return ''
107
+ const at = options.indexOf(state.cliFilter)
108
+ const pos = at === -1 ? '' : ` (${t('tui.filter.position', { current: at + 1, total: options.length })})`
109
+ return `${t('tui.filter.prefix')}${state.cliFilter}${pos}`
110
+ }
111
+
68
112
  // 검색줄 = 하나의 입력칸이다. 포커스가 여기 있으면 입력 커서(▌)로 드러내고,
69
- // 컬러에서는 줄 전체를 반전시켜 "지금 여기에 타이핑된다"를 분명히 한다 —
113
+ // 컬러에서는 입력 영역을 반전시켜 "지금 여기에 타이핑된다"를 분명히 한다 —
70
114
  // 이 상태에서만 스페이스가 선택이 아니라 검색어로 들어가기 때문이다.
71
- function searchLine(state, { limit, color, paint, t }) {
115
+ //
116
+ // 반전은 **입력 영역까지만** 칠한다. 줄 끝까지 채워 반전시키면 오른쪽 필터
117
+ // 표시가 반전에 먹혀 읽히지 않는다.
118
+ function searchLine(state, { limit, color, paint, t, filter = '' }) {
72
119
  const prefix = t('tui.search.prefix')
73
- const room = Math.max(0, limit - width(prefix))
120
+ // filter는 항상 순수 텍스트다(filterSegment 참고) — 폭은 색을 입히기 전에
121
+ // 재고, tail을 화면에 낼 때만 paint로 칠한다.
122
+ const tailWidth = filter ? width(filter) + 2 : 0
123
+ const tail = filter ? ` ${paint(BOLD, filter)}` : ''
124
+ const room = Math.max(0, limit - width(prefix) - tailWidth)
125
+
126
+ // 필터를 넣을 자리가 없으면 필터를 버린다 — 타이핑 중인 글자가 사라지는
127
+ // 쪽이 더 나쁘다. 탭 줄의 shown/total 표기가 필터가 걸려 있음을 이미 알린다.
128
+ if (room < 8) return searchLine(state, { limit, color, paint, t, filter: '' })
129
+
74
130
  if (state.focus === 'search') {
75
- const text = `${prefix}${cut(`${state.query}▌`, room)}`
76
- return color ? `${REVERSE}${pad(text, limit)}${RESET}` : text
131
+ const field = `${prefix}${cut(`${state.query}▌`, room)}`
132
+ const body = color ? `${REVERSE}${pad(field, limit - tailWidth)}${RESET}` : field
133
+ return `${body}${tail}`
77
134
  }
78
- if (state.query) return `${prefix}${cut(state.query, room)}`
79
- return `${prefix}${paint(DIM, cut(t('tui.search.placeholder'), room))}`
135
+ const body = state.query
136
+ ? `${prefix}${cut(state.query, room)}`
137
+ : `${prefix}${paint(DIM, cut(t('tui.search.placeholder'), room))}`
138
+ return `${body}${tail}`
80
139
  }
81
140
 
82
141
  export function render(state, opts = {}) {
83
142
  // columns로 받는다 — width로 두면 모듈의 width() 함수를 함수 스코프 전체에서 가린다.
84
- const { width: columns = 80, height = 24, repo = '', dryRun = false, color = false, status = '', t = createT('en') } = opts
143
+ const { width: columns = 80, height = 24, repo = '', dryRun = false, color = false, status = '', detailExpanded = false, cliOptions = [], t = createT('en') } = opts
85
144
 
86
145
  // 마지막 칸은 비워 둔다 — 폭을 꽉 채우면 터미널이 줄을 넘긴다.
87
146
  const w = Math.max(24, columns - 1)
@@ -94,19 +153,31 @@ export function render(state, opts = {}) {
94
153
  const counts = t('tui.counts', { picked, total: items.length })
95
154
  const head = cut(`${title} ${counts} ${repo}`, w)
96
155
  const searching = String(state.query ?? '').trim() !== ''
156
+ // 목록이 좁혀졌는가 — 검색어·CLI 필터 어느 쪽이든 활성 탭 안에서만 걸리는
157
+ // 좁힘이라, 다른 탭에 뭐가 남았는지 알리는 몫(탭 줄의 shown/total)은
158
+ // 둘이 똑같이 진다.
159
+ const narrowed = searching || Boolean(state.cliFilter)
97
160
 
161
+ const filter = filterSegment(state, { t, options: cliOptions })
98
162
  const lines = [
99
163
  color ? `${BOLD}${title}${RESET}${cut(` ${counts} ${repo}`, Math.max(0, w - width(title)))}` : head,
100
- tabBar(state, { width: w, color, searching, t }),
101
- searchLine(state, { limit: w, color, paint, t }),
164
+ tabBar(state, { width: w, color, narrowed, t }),
165
+ searchLine(state, { limit: w, color, paint, t, filter }),
102
166
  '',
103
167
  ]
104
168
 
105
- const body = bodyHeight(height)
169
+ const body = bodyHeight(height, detailExpanded)
106
170
  const all = displayList(state)
107
171
 
108
172
  if (all.length === 0) {
109
- lines.push(paint(DIM, cut(searching ? t('tui.empty.filtered') : t('tui.empty.none'), w)))
173
+ // 빈 이유가 검색어 때문일 수도 있는데, cliFilter를 먼저 물으면 "이 CLI에는
174
+ // 배선된 게 없다"는 문구가 거짓으로 뜰 수 있다 — 그 CLI가 이 탭의 다른
175
+ // 항목들을 지원하더라도, 검색어가 전부 걸러내면 이 분기에 들어온다.
176
+ // 검색어가 있으면 그쪽이 항상 참이므로 우선한다.
177
+ const empty = searching
178
+ ? t('tui.empty.filtered')
179
+ : state.cliFilter ? t('tui.filter.empty', { cli: state.cliFilter }) : t('tui.empty.none')
180
+ if (body > 0) lines.push(paint(DIM, cut(empty, w)))
110
181
  for (let i = 1; i < body; i++) lines.push('')
111
182
  } else {
112
183
  const window = all.slice(state.offset, state.offset + body)
@@ -128,6 +199,18 @@ export function render(state, opts = {}) {
128
199
  for (let i = window.length; i < body; i++) lines.push('')
129
200
  }
130
201
 
202
+ // 상세 패널 — 목록 아래 고정 자리. 높이가 커서와 무관하므로 목록이
203
+ // 출렁이지 않는다. 지면이 모자라면 panelHeight가 0을 돌려 통째로 빠진다.
204
+ const panel = panelHeight(height, detailExpanded)
205
+ if (panel > 0) {
206
+ lines.push(paint(DIM, '─'.repeat(w)))
207
+ const room = panel - 1
208
+ const detail = detailLines(currentRow(state), { width: w, height: room, t })
209
+ const shown = detail.length > 0 ? detail : [paint(DIM, cut(t('detail.empty'), w))]
210
+ for (const line of shown.slice(0, room)) lines.push(line)
211
+ for (let i = shown.length; i < room; i++) lines.push('')
212
+ }
213
+
131
214
  const hint = state.focus === 'search' ? t('tui.hint.search') : t('tui.hint.list')
132
215
  lines.push('')
133
216
  lines.push(paint(DIM, cut(status || hint, w)))
@@ -146,7 +229,7 @@ export function renderReview(changes, opts = {}) {
146
229
  const title = `${t('tui.review.title', { count: changes.length })}${dryRun ? ' (dry-run)' : ''}`
147
230
  const lines = [color ? `${BOLD}${cut(title, w)}${RESET}` : cut(title, w), '']
148
231
 
149
- const body = bodyHeight(height)
232
+ const body = reviewBodyHeight(height)
150
233
  const room = Math.max(1, body - 1)
151
234
  const shown = changes.slice(0, room)
152
235
  for (const c of shown) {