@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.
@@ -18,27 +18,31 @@ import { msg } from '../i18n/index.mjs'
18
18
  //
19
19
  // opencode는 프로젝트 항목(plugin.ponytail)이 opencode.jsonc의 plugin 배열로
20
20
  // 이미 배선한다 — 전역 판에서 겹치면 같은 플러그인이 두 번 등록된다. claude도
21
- // 같은 이유로 프로젝트 항목의 자리다. grok은 상류 명령이 있으나 감지·제거
22
- // 경로를 실측할 CLI가 없어 superpowers 전역 판과 같은 기준으로 제외한다.
21
+ // 같은 이유로 프로젝트 항목의 자리다.
22
+ // grok — `grok plugin install DietrichGebert/ponytail --trust`. 2026-09-05
23
+ // 이 머신(grok 1.0.5)에서 설치·감지·제거 왕복을 실측했다. 상류
24
+ // README는 "기본 꺼짐이라 /plugins에서 켜라"고 적지만 1.0.5는
25
+ // --trust 설치 직후 config.toml [plugins].enabled에 이름을 넣었다.
23
26
  // kiro·vscode는 상류가 룰 파일 수동 배치만 안내하고, kilo·kimi는 경로가 없다.
24
27
  //
25
28
  // 상류는 플러그인 제거 전에 node scripts/uninstall.js로 ~/.config/ponytail
26
29
  // 설정과 statusLine 항목을 정리하라고 안내한다 — 하니스마다 스크립트 위치가
27
30
  // 달라 자동화하지 않고 note로 알린다. 이 항목은 플러그인만 제거한다.
28
- const SUPPORTS = ['codex', 'gemini', 'copilot']
31
+ const SUPPORTS = ['codex', 'gemini', 'copilot', 'grok']
29
32
 
30
33
  const CONFIG = {
31
34
  id: 'global.ponytail', label: 'Ponytail', group: '__token',
32
35
  note: 'item.global.ponytail.note',
36
+ verified: '2026-09-05',
33
37
  pluginName: 'ponytail',
34
38
  marketplaceRepo: 'DietrichGebert/ponytail',
35
39
  installId: 'ponytail@ponytail',
36
40
  gemini: 'https://github.com/DietrichGebert/ponytail',
41
+ grok: 'DietrichGebert/ponytail',
37
42
  unsupported: Object.fromEntries(
38
43
  CLI_IDS.filter((c) => !SUPPORTS.includes(c)).map((c) => [
39
44
  c,
40
45
  ['claude', 'opencode'].includes(c) ? msg('item.unsupported.globalProjectItem', { item: 'Ponytail' })
41
- : c === 'grok' ? msg('item.unsupported.ponytailGlobalGrok')
42
46
  : ['kiro', 'vscode'].includes(c) ? msg('item.unsupported.ponytailRules')
43
47
  : msg('item.unsupported.upstreamNone'),
44
48
  ]),
@@ -14,31 +14,37 @@ import { msg } from '../i18n/index.mjs'
14
14
  // opencode— 상류 .opencode/INSTALL.md가 전역 opencode.json의 plugin 배열에
15
15
  // 아래 항목 한 줄을 더하라고 안내한다.
16
16
  //
17
- // grok은 상류 명령(grok plugin install superpowers@xai-official --trust)이
18
- // 있지만 감지·제거 경로를 실측할 CLI가 없어 아직 배선하지 않고, kimi는 설치가
19
- // 대화형 /plugins뿐이라 헤드리스 경로가 없다. claude는 프로젝트 스코프 항목
20
- // (plugin.superpowers)의 자리다.
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
  ),
@@ -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
  ),
@@ -50,6 +50,7 @@ export default {
50
50
  ]),
51
51
  ),
52
52
  note: 'item.plugin.ponytail.note',
53
+ verified: '2026-08-15',
53
54
 
54
55
  async detect({ root }) {
55
56
  const present = []
@@ -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
  ),
@@ -13,4 +13,5 @@ export default defineRegistrySkill({
13
13
  source: 'https://github.com/vercel-labs/agent-browser',
14
14
  skill: 'agent-browser',
15
15
  note: 'item.skill.agent-browser.note',
16
+ verified: '2026-08-15',
16
17
  })
@@ -9,4 +9,5 @@ export default defineRegistrySkill({
9
9
  source: 'https://github.com/vercel-labs/skills',
10
10
  skill: 'find-skills',
11
11
  note: 'item.skill.find-skills.note',
12
+ verified: '2026-08-15',
12
13
  })
@@ -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,
@@ -7,4 +7,5 @@ export default defineRegistrySkill({
7
7
  source: 'https://github.com/nutlope/hallmark',
8
8
  skill: 'hallmark',
9
9
  note: 'item.skill.hallmark.note',
10
+ verified: '2026-08-10',
10
11
  })
@@ -10,4 +10,5 @@ export default defineRegistrySkill({
10
10
  source: 'https://github.com/anthropics/skills',
11
11
  skill: 'mcp-builder',
12
12
  note: 'item.skill.mcp-builder.note',
13
+ verified: '2026-08-15',
13
14
  })
@@ -9,4 +9,5 @@ export default defineRegistrySkill({
9
9
  skill: '*',
10
10
  anchor: 'penetration-testing-with-strix',
11
11
  note: 'item.skill.strix.note',
12
+ verified: '2026-09-02',
12
13
  })
package/lib/jsonfile.mjs CHANGED
@@ -1,22 +1,41 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
2
2
  import { dirname } from 'node:path'
3
- import { parse, modify, applyEdits } from 'jsonc-parser'
3
+ import { parse, modify, applyEdits, printParseErrorCode } from 'jsonc-parser'
4
+ import { LocalizedError } from './i18n/index.mjs'
4
5
 
5
6
  const FORMAT = { formattingOptions: { insertSpaces: true, tabSize: 2 } }
7
+ // 이 파일들은 도구가 읽는 JSONC다 — 주석과 후행 콤마는 정상이다.
8
+ const PARSE = { allowTrailingComma: true }
6
9
 
7
10
  function readText(file) {
8
11
  return existsSync(file) ? readFileSync(file, 'utf8') : ''
9
12
  }
10
13
 
14
+ // jsonc-parser의 parse는 던지지 않는다 — 닫는 괄호가 빠진 파일도 "복구한"
15
+ // 값을 돌려준다. 그대로 두면 깨진 파일에 키를 끼워 넣고 has()는 설치됨으로
16
+ // 읽어, 사용자는 파일이 깨진 것을 끝까지 모른다. 오류 목록을 받아 하나라도
17
+ // 있으면 record.mjs의 readRecord처럼 어느 파일이 왜 깨졌는지 말하며 던진다.
18
+ function parseStrict(text, file) {
19
+ const errors = []
20
+ const data = parse(text, errors, PARSE)
21
+ if (errors.length > 0) {
22
+ const [first] = errors
23
+ const line = text.slice(0, first.offset).split('\n').length
24
+ throw new LocalizedError('error.jsonInvalid', { path: file, message: `${printParseErrorCode(first.error)} (line ${line})` })
25
+ }
26
+ return data
27
+ }
28
+
11
29
  export function readJson(file) {
12
30
  const text = readText(file)
13
31
  if (!text.trim()) return undefined
14
- return parse(text)
32
+ return parseStrict(text, file)
15
33
  }
16
34
 
17
35
  export function setKey(file, path, value) {
18
36
  let text = readText(file)
19
37
  if (!text.trim()) text = '{}\n'
38
+ else parseStrict(text, file)
20
39
  const edits = modify(text, path, value, FORMAT)
21
40
  mkdirSync(dirname(file), { recursive: true })
22
41
  writeFileSync(file, applyEdits(text, edits))
@@ -25,7 +44,7 @@ export function setKey(file, path, value) {
25
44
  export function removeKey(file, path) {
26
45
  const text = readText(file)
27
46
  if (!text.trim()) return
28
- if (getIn(parse(text), path) === undefined) return
47
+ if (getIn(parseStrict(text, file), path) === undefined) return
29
48
  const edits = modify(text, path, undefined, FORMAT)
30
49
  writeFileSync(file, applyEdits(text, edits))
31
50
  }
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
- export async function collectStatus(root, { manifest = MANIFEST, items = [], latest } = {}) {
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,13 +95,35 @@ 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
  }
87
105
 
88
- export async function runStatus(root, { json = false, log = console.log, t = createT('en') } = {}) {
106
+ // 레지스트리의 최신 발행 버전. status는 진단 명령이라 네트워크가 없거나
107
+ // 느려도 죽으면 안 된다 — 어떤 실패든 null로 떨어뜨리고 화면은 "최신"
108
+ // 자리만 비운다. 시간 제한은 짧게 둔다: 이 한 줄 때문에 status가 20초를
109
+ // 기다리면 진단 도구가 아니다.
110
+ export const LATEST_TIMEOUT_MS = 5000
111
+ const LATEST_URL = 'https://registry.npmjs.org/@rch4com%2Fagent-setup/latest'
112
+
113
+ export async function fetchLatestVersion(fetchImpl = fetch) {
114
+ try {
115
+ const res = await fetchImpl(LATEST_URL, { signal: AbortSignal.timeout(LATEST_TIMEOUT_MS) })
116
+ if (!res.ok) return null
117
+ const version = (await res.json())?.version
118
+ return typeof version === 'string' && version ? version : null
119
+ } catch {
120
+ return null
121
+ }
122
+ }
123
+
124
+ export async function runStatus(root, { json = false, log = console.log, fetchImpl = fetch, t = createT('en') } = {}) {
89
125
  const { loadItems } = await import('./catalog.mjs')
90
- const report = await collectStatus(root, { items: await loadItems() })
126
+ const report = await collectStatus(root, { items: await loadItems(), latest: await fetchLatestVersion(fetchImpl) })
91
127
  log(json ? JSON.stringify(report, null, 2) : formatStatus(report, t))
92
128
  return report
93
129
  }
@@ -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
- const nameWidth = Math.max(...supports.map((c) => width(c)))
57
- return supports.map((cli, i) => {
58
- const head = i === 0 ? pad(t('detail.wired'), lead) : ' '.repeat(lead)
59
- const file = showFile && CLIS[cli]?.file ? ` ${CLIS[cli].file}` : ''
60
- // 이름 뒤에 열이 없으면 맞출 대상도 없다 — 경로가 붙는 mcp에서만 이름을 편다.
61
- const name = showFile ? pad(cli, nameWidth) : cli
62
- return cut(`${head}✔ ${name}${file}`, w)
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
- out.push(cut(`${head}✖ ${group.clis.join('·')}`, w))
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) => {