@rch4com/agent-setup 1.17.0 → 1.19.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 +8 -1
- package/lib/catalog.mjs +37 -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.prompt-master.mjs +7 -0
- package/lib/items/skill.strix.mjs +13 -0
- package/lib/status.mjs +19 -1
- 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/package.json +1 -1
- package/lib/items/plugin.bkit.mjs +0 -25
|
@@ -14,31 +14,37 @@ import { msg } from '../i18n/index.mjs'
|
|
|
14
14
|
// opencode— 상류 .opencode/INSTALL.md가 전역 opencode.json의 plugin 배열에
|
|
15
15
|
// 아래 항목 한 줄을 더하라고 안내한다.
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
17
|
+
// grok — 2026-09-05 이 머신(grok 1.0.5)에서 `grok plugin install
|
|
18
|
+
// superpowers@xai-official --trust`로 설치·감지·제거 왕복을 실측.
|
|
19
|
+
// 기록은 ~/.grok/installed-plugins/registry.json(global-plugin.mjs).
|
|
20
|
+
//
|
|
21
|
+
// kimi는 설치가 대화형 /plugins뿐이라 헤드리스 경로가 없다. claude는 프로젝트
|
|
22
|
+
// 스코프 항목(plugin.superpowers)의 자리다.
|
|
21
23
|
//
|
|
22
24
|
// skill.superpowers(공유 .agents/skills)와 배타로 묶지 않는다 — 저쪽은 이
|
|
23
25
|
// 저장소에만, 이쪽은 머신 전체에 작용해 평면이 다르고, "claude는 프로젝트
|
|
24
26
|
// 플러그인 + 나머지는 전역" 같은 조합이 정당하다. 겹치는 하니스가 같은 스킬을
|
|
25
27
|
// 두 곳에서 본다는 것은 note에 적어 둔다.
|
|
26
|
-
const SUPPORTS = ['codex', 'gemini', 'opencode', 'copilot']
|
|
28
|
+
const SUPPORTS = ['codex', 'gemini', 'opencode', 'copilot', 'grok']
|
|
27
29
|
|
|
28
30
|
const CONFIG = {
|
|
29
31
|
id: 'global.superpowers', label: 'superpowers', group: '__flow',
|
|
30
32
|
note: 'item.global.superpowers.note',
|
|
33
|
+
verified: '2026-09-05',
|
|
31
34
|
pluginName: 'superpowers',
|
|
32
35
|
marketplaceRepo: 'obra/superpowers-marketplace',
|
|
33
36
|
installId: 'superpowers@superpowers-marketplace',
|
|
34
37
|
gemini: 'https://github.com/obra/superpowers',
|
|
35
38
|
opencode: 'superpowers@git+https://github.com/obra/superpowers.git',
|
|
39
|
+
grok: 'superpowers@xai-official',
|
|
36
40
|
unsupported: Object.fromEntries(
|
|
37
41
|
CLI_IDS.filter((c) => !SUPPORTS.includes(c)).map((c) => [
|
|
38
42
|
c,
|
|
39
43
|
c === 'claude' ? msg('item.unsupported.superpowersGlobalClaude')
|
|
40
|
-
: c === 'grok' ? msg('item.unsupported.superpowersGlobalGrok')
|
|
41
44
|
: c === 'kimi' ? msg('item.unsupported.superpowersGlobalKimi')
|
|
45
|
+
// 상류 README가 Antigravity 설치를 안내하지만 감지·제거 경로를 실측할
|
|
46
|
+
// CLI가 없다 — grok과 같은 기준으로 아직 배선하지 않는다.
|
|
47
|
+
: c === 'antigravity' ? msg('item.unsupported.unmeasuredUpstream')
|
|
42
48
|
: msg('item.unsupported.upstreamNone'),
|
|
43
49
|
]),
|
|
44
50
|
),
|
package/lib/items/plugin.ecc.mjs
CHANGED
|
@@ -17,6 +17,7 @@ export default definePlugin({
|
|
|
17
17
|
detectIds: ['ecc@ecc'],
|
|
18
18
|
marketplace: { name: 'ecc', repo: 'affaan-m/ECC' },
|
|
19
19
|
note: 'item.plugin.ecc.note',
|
|
20
|
+
verified: '2026-08-15',
|
|
20
21
|
unsupported: Object.fromEntries(
|
|
21
22
|
CLI_IDS.filter((c) => c !== 'claude').map((c) => [
|
|
22
23
|
c,
|
|
@@ -15,13 +15,14 @@ import { msg } from '../i18n/index.mjs'
|
|
|
15
15
|
// 상류 공식 프로바이더는 3.6.0 기준 16개(.agent=antigravity·.hermes가 새로
|
|
16
16
|
// 들어옴 — README는 아직 수동 복사만 안내해 코드가 앞선다). 이 저장소의 CLI와
|
|
17
17
|
// 겹치는 것은 아래 UPSTREAM 그대로다.
|
|
18
|
-
const UPSTREAM = ['codex', 'gemini', 'opencode', 'kiro', 'grok', 'copilot']
|
|
18
|
+
const UPSTREAM = ['codex', 'gemini', 'opencode', 'kiro', 'grok', 'copilot', 'antigravity']
|
|
19
19
|
export default definePlugin({
|
|
20
20
|
id: 'plugin.impeccable', label: 'impeccable', group: '__style',
|
|
21
21
|
installId: 'impeccable@impeccable',
|
|
22
22
|
detectIds: ['impeccable@impeccable'],
|
|
23
23
|
marketplace: { name: 'impeccable', repo: 'pbakaus/impeccable' },
|
|
24
24
|
note: 'item.plugin.impeccable.note',
|
|
25
|
+
verified: '2026-08-15',
|
|
25
26
|
unsupported: Object.fromEntries(
|
|
26
27
|
CLI_IDS.filter((c) => c !== 'claude').map((c) => [
|
|
27
28
|
c,
|
|
@@ -13,6 +13,7 @@ export default definePlugin({
|
|
|
13
13
|
detectIds: ['mattpocock-skills@mattpocock'],
|
|
14
14
|
marketplace: { name: 'mattpocock', repo: 'mattpocock/skills' },
|
|
15
15
|
note: 'item.plugin.mattpocock-skills.note',
|
|
16
|
+
verified: '2026-08-15',
|
|
16
17
|
unsupported: Object.fromEntries(
|
|
17
18
|
CLI_IDS.filter((c) => c !== 'claude').map((c) => [c, msg('item.unsupported.mattpocockRegistry')]),
|
|
18
19
|
),
|
|
@@ -10,7 +10,7 @@ import { msg } from '../i18n/index.mjs'
|
|
|
10
10
|
// Grok은 6.2.0에는 없다가 6.3.0에서 들어왔다 — `### Grok Build CLI`의
|
|
11
11
|
// `grok plugin install superpowers@xai-official --trust`다. Devin·Hermes도 같이
|
|
12
12
|
// 늘었지만 둘 다 이 저장소가 다루는 CLI가 아니다.
|
|
13
|
-
const SEPARATE = ['codex', 'gemini', 'opencode', 'kimi', 'copilot', 'grok']
|
|
13
|
+
const SEPARATE = ['codex', 'gemini', 'opencode', 'kimi', 'copilot', 'grok', 'antigravity']
|
|
14
14
|
|
|
15
15
|
export default definePlugin({
|
|
16
16
|
id: 'plugin.superpowers', label: 'superpowers', group: '__flow',
|
|
@@ -18,6 +18,7 @@ export default definePlugin({
|
|
|
18
18
|
installId: 'superpowers@claude-plugins-official',
|
|
19
19
|
detectIds: ['superpowers@claude-plugins-official', 'superpowers@superpowers-marketplace'],
|
|
20
20
|
note: 'item.plugin.superpowers.note',
|
|
21
|
+
verified: '2026-08-15',
|
|
21
22
|
unsupported: Object.fromEntries(
|
|
22
23
|
CLI_IDS.filter((c) => c !== 'claude').map((c) => [
|
|
23
24
|
c,
|
|
@@ -15,6 +15,7 @@ export default definePlugin({
|
|
|
15
15
|
detectIds: ['understand-anything@understand-anything'],
|
|
16
16
|
marketplace: { name: 'understand-anything', repo: 'Egonex-AI/Understand-Anything' },
|
|
17
17
|
note: 'item.plugin.understand-anything.note',
|
|
18
|
+
verified: '2026-08-15',
|
|
18
19
|
unsupported: Object.fromEntries(
|
|
19
20
|
CLI_IDS.filter((c) => c !== 'claude').map((c) => [c, msg('item.unsupported.uaUser')]),
|
|
20
21
|
),
|
package/lib/items/skill.gsd.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import { LocalizedError, msg } from '../i18n/index.mjs'
|
|
|
26
26
|
// kimi `--kimi --local`이 "Project-level Kimi install semantics remain
|
|
27
27
|
// deferred"를 찍고 거부한다 — 전역 설치만 가능하다.
|
|
28
28
|
// kiro·grok·vscode 상류 런타임 목록에 없다.
|
|
29
|
+
// antigravity --antigravity 플래그가 있지만 설치 경로를 실측하지 못했다.
|
|
29
30
|
const RUNTIMES = [
|
|
30
31
|
{ cli: 'claude', dirs: ['.claude/commands', '.claude/skills'] },
|
|
31
32
|
{ cli: 'codex', dirs: ['.codex/skills'] },
|
|
@@ -56,11 +57,15 @@ export default defineSkill({
|
|
|
56
57
|
id: 'skill.gsd', label: 'GSD (Get Shit Done)', group: '__flow', scope: 'project',
|
|
57
58
|
note: 'item.skill.gsd.note',
|
|
58
59
|
supports: SUPPORTS,
|
|
60
|
+
verified: '2026-08-15',
|
|
59
61
|
unsupported: Object.fromEntries(
|
|
60
62
|
CLI_IDS.filter((c) => !SUPPORTS.includes(c)).map((c) => [
|
|
61
63
|
c,
|
|
62
64
|
c === 'gemini' ? msg('item.unsupported.gsdGemini')
|
|
63
65
|
: c === 'kimi' ? msg('item.unsupported.gsdKimiLocal')
|
|
66
|
+
// --antigravity 플래그가 있지만 어느 경로에 무엇을 쓰는지 실측하지
|
|
67
|
+
// 못했다 — 실측 전에는 배선하지 않는다.
|
|
68
|
+
: c === 'antigravity' ? msg('item.unsupported.unmeasuredUpstream')
|
|
64
69
|
: msg('item.unsupported.upstreamNone'),
|
|
65
70
|
]),
|
|
66
71
|
),
|
|
@@ -29,6 +29,7 @@ const SHARED_RECURSIVE = ['codex', 'opencode']
|
|
|
29
29
|
export default defineSkill({
|
|
30
30
|
id: 'skill.gstack', label: 'gstack', group: '__flow', scope: 'project',
|
|
31
31
|
note: 'item.skill.gstack.note',
|
|
32
|
+
verified: '2026-08-15',
|
|
32
33
|
unsupported: Object.fromEntries(
|
|
33
34
|
CLI_IDS.filter((c) => c !== 'claude').map((c) => [
|
|
34
35
|
c,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { defineRegistrySkill } from '../catalog.mjs'
|
|
2
|
+
export default defineRegistrySkill({
|
|
3
|
+
id: 'skill.prompt-master', label: 'Prompt Master', group: '__flow',
|
|
4
|
+
source: 'https://github.com/nidhinjs/prompt-master',
|
|
5
|
+
skill: 'prompt-master',
|
|
6
|
+
note: 'item.skill.prompt-master.note',
|
|
7
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineRegistrySkill } from '../catalog.mjs'
|
|
2
|
+
// 상류가 skills/에 SKILL.md 9종을 두고 npx skills add usestrix/strix를 README로
|
|
3
|
+
// 직접 안내한다. 스킬 본문은 strix CLI를 구동하는 지침이라 바이너리가 사실상
|
|
4
|
+
// 전제다 — note가 알린다. 2026-09-02 스크래치 저장소에서 9종 설치와
|
|
5
|
+
// skills-lock.json의 source(usestrix/strix) 기록을 실측했다.
|
|
6
|
+
export default defineRegistrySkill({
|
|
7
|
+
id: 'skill.strix', label: 'Strix', group: '__service',
|
|
8
|
+
source: 'https://github.com/usestrix/strix',
|
|
9
|
+
skill: '*',
|
|
10
|
+
anchor: 'penetration-testing-with-strix',
|
|
11
|
+
note: 'item.skill.strix.note',
|
|
12
|
+
verified: '2026-09-02',
|
|
13
|
+
})
|
package/lib/status.mjs
CHANGED
|
@@ -8,7 +8,17 @@ import { updateBlocks, updateFiles } from './bootstrap/apply.mjs'
|
|
|
8
8
|
import { readRecord, toolVersion } from './bootstrap/record.mjs'
|
|
9
9
|
import { createT } from './i18n/index.mjs'
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// 미배선 사유는 상류를 실측한 날의 사실이다. 그 날짜(item.verified)가 이보다
|
|
12
|
+
// 오래되면 status가 알린다 — 사유 문장이 화면에 낡은 채 남는 것을 막는 유일한
|
|
13
|
+
// 장치다. 90일은 상류 하니스들의 릴리스 간격을 넉넉히 덮는 값이다.
|
|
14
|
+
export const STALE_DAYS = 90
|
|
15
|
+
|
|
16
|
+
function daysSince(iso, now) {
|
|
17
|
+
const ms = now - Date.parse(`${iso}T00:00:00Z`)
|
|
18
|
+
return Number.isFinite(ms) ? Math.floor(ms / 86_400_000) : 0
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function collectStatus(root, { manifest = MANIFEST, items = [], latest, now = Date.now() } = {}) {
|
|
12
22
|
const record = readRecord(root)
|
|
13
23
|
|
|
14
24
|
// dry-run으로 갱신 판정만 얻는다 — 판정 로직을 두 벌 두면 status와 update가
|
|
@@ -35,6 +45,9 @@ export async function collectStatus(root, { manifest = MANIFEST, items = [], lat
|
|
|
35
45
|
}
|
|
36
46
|
const installed = states.filter((s) => s.status !== 'absent').map((s) => s.id)
|
|
37
47
|
const intended = new Set(record?.items ?? [])
|
|
48
|
+
const stale = items
|
|
49
|
+
.filter((i) => i.verified && daysSince(i.verified, now) > STALE_DAYS)
|
|
50
|
+
.map((i) => ({ id: i.id, verified: i.verified }))
|
|
38
51
|
|
|
39
52
|
return {
|
|
40
53
|
hasRecord: Boolean(record),
|
|
@@ -53,6 +66,7 @@ export async function collectStatus(root, { manifest = MANIFEST, items = [], lat
|
|
|
53
66
|
installed,
|
|
54
67
|
recordOnly: [...intended].filter((id) => !installed.includes(id)),
|
|
55
68
|
repoOnly: installed.filter((id) => !intended.has(id)),
|
|
69
|
+
stale,
|
|
56
70
|
},
|
|
57
71
|
}
|
|
58
72
|
}
|
|
@@ -81,6 +95,10 @@ export function formatStatus(report, t = createT('en')) {
|
|
|
81
95
|
lines.push(t('status.row.items', { list: items.installed.join(', ') || t('status.none') }))
|
|
82
96
|
if (items.recordOnly.length) lines.push(t('status.row.recordOnly', { list: items.recordOnly.join(', ') }))
|
|
83
97
|
if (items.repoOnly.length) lines.push(t('status.row.repoOnly', { list: items.repoOnly.join(', ') }))
|
|
98
|
+
if (items.stale?.length) {
|
|
99
|
+
lines.push(t('status.row.stale', { list: items.stale.map((s) => `${s.id} (${s.verified})`).join(', ') }))
|
|
100
|
+
lines.push(t('status.hint.stale', { days: STALE_DAYS }))
|
|
101
|
+
}
|
|
84
102
|
|
|
85
103
|
return lines.join('\n')
|
|
86
104
|
}
|
package/lib/tui/detail.mjs
CHANGED
|
@@ -47,27 +47,60 @@ function headLine(row, w, t) {
|
|
|
47
47
|
return cut(`${item.label} ${bits.filter(Boolean).join(' · ')}`, w)
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// CLI 이름 나열(a·b·c)을 이름 경계에서만 접는다. width.wrap은 공백에서만
|
|
51
|
+
// 끊어, 공백 없는 나열은 낱말 중간(`ant` / `igravity`)에서 갈라졌다.
|
|
52
|
+
function wrapNames(names, limit) {
|
|
53
|
+
const lines = []
|
|
54
|
+
let cur = ''
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
const next = cur ? `${cur}·${name}` : name
|
|
57
|
+
if (cur && width(next) > limit) {
|
|
58
|
+
lines.push(cur)
|
|
59
|
+
cur = name
|
|
60
|
+
} else {
|
|
61
|
+
cur = next
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (cur) lines.push(cur)
|
|
65
|
+
return lines
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
function wiredLines(item, w, lead, t) {
|
|
51
69
|
const supports = item.supports ?? []
|
|
52
70
|
if (supports.length === 0) return []
|
|
53
71
|
// 경로는 MCP에서만 붙인다. 그때만 clis.mjs 어댑터가 경로의 유일한 진실이다 —
|
|
54
72
|
// plugin·skill은 설치 자리가 항목마다 달라 어댑터가 알지 못한다.
|
|
55
73
|
const showFile = item.category === 'mcp'
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
74
|
+
// 경로가 없으면 세로로 늘어놓을 이유가 없다 — CLI 열한 개를 한 줄에 하나씩
|
|
75
|
+
// 놓으면 80×24에서 `배선 ✔ claude` 뒤가 `…외 9줄`로 잘려 정작 "어디에
|
|
76
|
+
// 되나"를 펼쳐야만 읽을 수 있었다. 이름을 ·로 이어 접는다(미배선과 같은 꼴).
|
|
77
|
+
if (!showFile) {
|
|
78
|
+
return wrapNames(supports, Math.max(1, w - lead - 2)).map((line, i) =>
|
|
79
|
+
cut(`${i === 0 ? pad(t('detail.wired'), lead) : ' '.repeat(lead)}${i === 0 ? '✔ ' : ' '}${line}`, w))
|
|
80
|
+
}
|
|
81
|
+
// MCP는 "CLI 경로" 쌍을 한 줄에 들어가는 만큼 채운다. 한 줄에 하나씩 놓으면
|
|
82
|
+
// 열 개가 열 줄이라 80×24 패널에서 절반이 `…외 N줄`로 접혔다. 폭이 좁으면
|
|
83
|
+
// 한 줄에 하나로 자연히 돌아간다.
|
|
84
|
+
const pairs = supports.map((cli) => (CLIS[cli]?.file ? `${cli} ${CLIS[cli].file}` : cli))
|
|
85
|
+
const colW = Math.max(...pairs.map((p) => width(p))) + 3
|
|
86
|
+
const room = Math.max(1, w - lead - 2)
|
|
87
|
+
const perLine = Math.max(1, Math.floor(room / colW))
|
|
88
|
+
const rows = []
|
|
89
|
+
for (let i = 0; i < pairs.length; i += perLine) {
|
|
90
|
+
const chunk = pairs.slice(i, i + perLine)
|
|
91
|
+
rows.push(chunk.map((p, j) => (j < chunk.length - 1 ? pad(p, colW) : p)).join(''))
|
|
92
|
+
}
|
|
93
|
+
return rows.map((line, i) => cut(`${i === 0 ? pad(t('detail.wired'), lead) : ' '.repeat(lead)}${i === 0 ? '✔ ' : ' '}${line}`, w))
|
|
64
94
|
}
|
|
65
95
|
|
|
66
96
|
function unwiredLines(item, w, lead, t) {
|
|
67
97
|
const out = []
|
|
68
98
|
unsupportedGroups(item, t).forEach((group, i) => {
|
|
69
99
|
const head = i === 0 ? pad(t('detail.unwired'), lead) : ' '.repeat(lead)
|
|
70
|
-
|
|
100
|
+
// 미배선 CLI 이름도 같은 규칙으로 접는다 — 11개를 한 줄에 두면 뒤가 잘린다.
|
|
101
|
+
wrapNames(group.clis, Math.max(1, w - lead - 2)).forEach((line, j) => {
|
|
102
|
+
out.push(cut(`${j === 0 ? head : ' '.repeat(lead)}${j === 0 ? '✖ ' : ' '}${line}`, w))
|
|
103
|
+
})
|
|
71
104
|
// 사유는 한 칸 더 들여 이어 붙인다. 첫 줄만 └를 달아 어느 CLI 묶음의
|
|
72
105
|
// 사유인지 눈으로 잇는다.
|
|
73
106
|
wrap(group.why, Math.max(1, w - lead - 3)).forEach((line, j) => {
|
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
|
+
}
|