@rch4com/agent-setup 1.1.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +197 -55
  3. package/install.mjs +152 -101
  4. package/lib/args.mjs +276 -311
  5. package/lib/bootstrap/adapter.mjs +96 -95
  6. package/lib/bootstrap/apply.mjs +380 -379
  7. package/lib/bootstrap/flow.mjs +73 -70
  8. package/lib/bootstrap/record.mjs +154 -142
  9. package/lib/bootstrap/templates.mjs +148 -148
  10. package/lib/bootstrap/text.mjs +19 -19
  11. package/lib/catalog.mjs +213 -145
  12. package/lib/claude-plugins.mjs +64 -64
  13. package/lib/clis.mjs +102 -102
  14. package/lib/context.mjs +66 -65
  15. package/lib/deps.mjs +5 -7
  16. package/lib/design-md/cache/awesome-design-md/LICENSE.md +38 -0
  17. package/lib/design-md/catalog.json +454 -454
  18. package/lib/design-md/catalog.mjs +266 -268
  19. package/lib/design-md/flow.mjs +181 -166
  20. package/lib/design-md/open.mjs +51 -50
  21. package/lib/design-md/providers/awesome-design-md.mjs +124 -114
  22. package/lib/design-md/providers/index.mjs +9 -9
  23. package/lib/design-md/scan.mjs +14 -10
  24. package/lib/engine.mjs +48 -44
  25. package/lib/gitignore.mjs +18 -18
  26. package/lib/i18n/catalog/en.mjs +369 -0
  27. package/lib/i18n/catalog/ko.mjs +359 -0
  28. package/lib/i18n/detect.mjs +39 -0
  29. package/lib/i18n/index.mjs +93 -0
  30. package/lib/items/mcp.codebase-memory.mjs +6 -6
  31. package/lib/items/mcp.graphify.mjs +8 -0
  32. package/lib/items/mcp.headroom.mjs +8 -0
  33. package/lib/items/mcp.notion.mjs +6 -6
  34. package/lib/items/mcp.supabase.mjs +6 -6
  35. package/lib/items/mcp.vercel.mjs +6 -6
  36. package/lib/items/plugin.bkit.mjs +7 -7
  37. package/lib/items/plugin.ecc.mjs +11 -0
  38. package/lib/items/plugin.impeccable.mjs +14 -0
  39. package/lib/items/plugin.mattpocock-skills.mjs +2 -2
  40. package/lib/items/plugin.ponytail.mjs +104 -0
  41. package/lib/items/plugin.superpowers.mjs +7 -7
  42. package/lib/items/plugin.understand-anything.mjs +10 -0
  43. package/lib/items/skill.caveman.mjs +7 -0
  44. package/lib/items/skill.gsd.mjs +25 -24
  45. package/lib/items/skill.gstack.mjs +40 -39
  46. package/lib/items/skill.karpathy.mjs +7 -0
  47. package/lib/items/skill.taste.mjs +9 -0
  48. package/lib/jsonfile.mjs +40 -40
  49. package/lib/status.mjs +93 -92
  50. package/lib/tomlfile.mjs +60 -60
  51. package/lib/tui/render.mjs +41 -63
  52. package/lib/tui/rows.mjs +278 -206
  53. package/lib/tui/run.mjs +64 -28
  54. package/lib/update.mjs +85 -85
  55. package/lib/width.mjs +57 -0
  56. package/package.json +36 -36
package/lib/tui/run.mjs CHANGED
@@ -4,6 +4,8 @@ import { emitKeypressEvents } from 'node:readline'
4
4
  import { planChanges, apply } from '../engine.mjs'
5
5
  import { makeOpener, openPreview } from '../design-md/open.mjs'
6
6
  import { netFetch, CATALOG_PATH } from '../design-md/catalog.mjs'
7
+ import { createT, toText, LOCALES } from '../i18n/index.mjs'
8
+ import { RECORD_REL, writeLang } from '../bootstrap/record.mjs'
7
9
  import { collectRows, installedIds } from './rows.mjs'
8
10
  import {
9
11
  createState, setQuery, setFocus, move, moveTab, toggle, toggleVisible, scroll, currentRow, replaceRows, activeTab,
@@ -19,8 +21,6 @@ const HOME = `${ESC}[H`
19
21
  const CLEAR_LINE = `${ESC}[K`
20
22
  const CLEAR_DOWN = `${ESC}[J`
21
23
 
22
- const ACTION_LABEL = { install: '설치', complete: '보완 설치', uninstall: '제거' }
23
-
24
24
  // 키를 큐에 쌓아 두고 하나씩 꺼내 쓴다. 이벤트 상태 기계 대신
25
25
  // `for(;;) await next()` 루프로 흐름을 읽을 수 있게 하기 위해서다.
26
26
  function keyReader(stdin) {
@@ -42,10 +42,13 @@ function itemStates(rows) {
42
42
  return rows.filter((r) => r.kind === 'item').map((r) => ({ item: r.item, status: r.status }))
43
43
  }
44
44
 
45
- function printPlain(rows, log) {
45
+ // row.section은 이제 소문자 id다(rows.mjs Task 9) — t 없이 그대로 찍으면
46
+ // 화면에 'action'·'plugin' 같은 raw id가 새어 나간다. 이 경로도 사용자가
47
+ // 보는 출력이라 여기서도 t를 받는다.
48
+ function printPlain(rows, log, t = createT('en')) {
46
49
  let section = null
47
50
  for (const row of rows) {
48
- if (row.section !== section) { section = row.section; log(`[${section}]`) }
51
+ if (row.section !== section) { section = row.section; log(`[${t(`section.${section}`)}]`) }
49
52
  const mark = row.kind === 'action' ? '▶' : row.status === 'absent' ? ' ' : '×'
50
53
  log(` [${mark}] ${row.label}${row.hint ? ` — ${row.hint}` : ''}`)
51
54
  }
@@ -71,13 +74,15 @@ export async function runTui(root, opts = {}) {
71
74
  stdout = process.stdout,
72
75
  } = opts
73
76
  const opener = opts.opener ?? makeOpener(dryRun, log)
77
+ // let으로 둔다 — 언어 전환 행(cycleLanguage)이 실행 중에 갈아끼운다.
78
+ let { t = createT('en'), localeForced = false } = opts
74
79
 
75
- let collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, log: () => {} })
80
+ let collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, log: () => {}, t })
76
81
 
77
82
  // raw 모드를 켤 수 없으면(CI·파이프) 목록만 내고 끝낸다.
78
83
  if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
79
- printPlain(collected.rows, log)
80
- log('\n대화형 화면은 터미널에서만 열립니다. --list · --set 으로도 다룰 수 있습니다.')
84
+ printPlain(collected.rows, log, t)
85
+ log(`\n${t('tui.nonInteractive')}`)
81
86
  return { interactive: false }
82
87
  }
83
88
 
@@ -97,7 +102,7 @@ export async function runTui(root, opts = {}) {
97
102
  const paint = () => {
98
103
  const height = stdout.rows ?? 24
99
104
  state = scroll(state, bodyHeight(height))
100
- draw(render(state, { width: stdout.columns ?? 80, height, repo: root, dryRun, color, status }))
105
+ draw(render(state, { width: stdout.columns ?? 80, height, repo: root, dryRun, color, status, t }))
101
106
  }
102
107
 
103
108
  // 로그는 화면 안에 우겨넣지 않는다 — 실패 메시지가 길고, 잘리면 진단이 불가능해진다.
@@ -106,24 +111,49 @@ export async function runTui(root, opts = {}) {
106
111
  try { return await fn() } finally { stdout.write(ALT_ON + HIDE_CURSOR) }
107
112
  }
108
113
  const confirm = async (message) => {
109
- stdout.write(`\n${message} [y/N] `)
114
+ stdout.write(`\n${message}${t('tui.confirmSuffix')}`)
110
115
  const key = await keys.next()
111
116
  stdout.write('\n')
112
117
  return String(key.str ?? '').toLowerCase() === 'y'
113
118
  }
114
119
  const pause = async () => {
115
- stdout.write('\n계속하려면 아무 키나 누르세요…')
120
+ stdout.write(`\n${t('tui.pressAnyKey')}`)
116
121
  await keys.next()
117
122
  }
118
123
 
119
- const recollect = async () => {
120
- collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, log: () => {} })
121
- state = replaceRows(state, collected.rows, installedIds(collected.rows))
124
+ // 언어 전환은 선택을 보존해야 한다. 적용·액션 실행 뒤 재스캔은 그 반대로
125
+ // 실제 설치 상태로 되돌려야 한다 — 둘을 한 함수로 묶으면 언어를 바꿀 때
126
+ // 사용자가 고르던 항목이 조용히 날아간다.
127
+ const rebuild = async (keepSelection) => {
128
+ collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, t, log: () => {} })
129
+ const ids = keepSelection ? [...state.selected] : installedIds(collected.rows)
130
+ state = replaceRows(state, collected.rows, ids)
131
+ }
132
+ const recollect = () => rebuild(false)
133
+
134
+ const cycleLanguage = async () => {
135
+ const next = LOCALES[(LOCALES.indexOf(t.locale) + 1) % LOCALES.length]
136
+ t = createT(next)
137
+ let note
138
+ try {
139
+ writeLang(root, next, { dryRun, log: () => {}, t })
140
+ note = dryRun ? t('tui.lang.dryRun') : t('tui.lang.saved', { path: RECORD_REL })
141
+ } catch (err) {
142
+ // 언어는 부수적 설정이다. 저장에 실패했다고 화면이 죽으면 설치기를 못 쓴다.
143
+ note = t('tui.lang.saveFailed', { message: err.message })
144
+ }
145
+ // 전환은 이번 실행에 이미 적용됐다(위에서 t를 갈아끼우고 아래에서 다시
146
+ // 그린다) — 읽을 수 없는 언어에 갇힌 사용자가 빠져나올 길이 있어야 한다.
147
+ // 알릴 것은 "다음 실행에서는 플래그·환경변수가 다시 이긴다"뿐이다.
148
+ // note는 이미 "저장했다"를 말하므로 겹치지 않게 이어 붙인다.
149
+ if (localeForced) note = `${note} · ${t('tui.lang.overridden')}`
150
+ await rebuild(true)
151
+ return note
122
152
  }
123
153
 
124
154
  const select = () => {
125
155
  const row = currentRow(state)
126
- if (row?.kind !== 'item') return '이 행은 Enter로 실행합니다.'
156
+ if (row?.kind !== 'item') return t('tui.notItemRow')
127
157
  state = toggle(state)
128
158
  return ''
129
159
  }
@@ -131,16 +161,16 @@ export async function runTui(root, opts = {}) {
131
161
  // 미리보기는 두 포커스에서 모두 통해야 한다 — 검색으로 찾은 직후가 가장 열어 보고 싶은 순간이다.
132
162
  const preview = () => {
133
163
  const row = currentRow(state)
134
- if (row?.kind !== 'item' || !row.previewTarget) return '이 항목은 미리보기를 제공하지 않습니다.'
164
+ if (row?.kind !== 'item' || !row.previewTarget) return t('tui.noPreview')
135
165
  const notes = []
136
- openPreview(opener, row.item, (m) => notes.push(String(m).trim()))
137
- return notes.length > 0 ? notes.join(' ') : `열었습니다: ${row.previewTarget}`
166
+ openPreview(opener, row.item, (m) => notes.push(String(m).trim()), t)
167
+ return notes.length > 0 ? notes.join(' ') : t('tui.opened', { target: row.previewTarget })
138
168
  }
139
169
 
140
170
  // 제출 검토 — 적용 직전에 변경 목록을 보여 주고 Enter/Esc만 받는다.
141
171
  const review = async (changes) => {
142
172
  for (;;) {
143
- draw(renderReview(changes, { width: stdout.columns ?? 80, height: stdout.rows ?? 24, dryRun, color }))
173
+ draw(renderReview(changes, { width: stdout.columns ?? 80, height: stdout.rows ?? 24, dryRun, color, t }))
144
174
  const key = await keys.next()
145
175
  if (key.ctrl && key.name === 'c') return 'quit'
146
176
  if (key.name === 'escape') return 'cancel'
@@ -206,7 +236,8 @@ export async function runTui(root, opts = {}) {
206
236
  if (key.ctrl && key.name === 'a') {
207
237
  const before = state.selected.size
208
238
  state = toggleVisible(state)
209
- status = `${activeTab(state)} 탭의 보이는 항목을 ${state.selected.size >= before ? '모두 선택' : '모두 해제'}했습니다.`
239
+ const statusKey = state.selected.size >= before ? 'tui.toggledAll' : 'tui.toggledNone'
240
+ status = t(statusKey, { tab: t(`section.${activeTab(state)}`) })
210
241
  continue
211
242
  }
212
243
 
@@ -215,9 +246,11 @@ export async function runTui(root, opts = {}) {
215
246
  if (key.name === 'return' || key.name === 'enter') {
216
247
  // 액션 행은 그 자리에서 실행한다. 나머지는 제출(검토 → 일괄 적용)이다.
217
248
  const row = currentRow(state)
249
+ // 언어 행은 화면을 벗어나지 않는다 — 그 자리에서 t를 갈아끼우고 다시 그린다.
250
+ if (row?.id === 'action.language') { status = await cycleLanguage(); continue }
218
251
  if (row?.kind === 'action') {
219
252
  await suspend(async () => {
220
- await row.run({ root, dryRun, skillMode, fetchImpl, catalogFile, log, confirm })
253
+ await row.run({ root, dryRun, skillMode, fetchImpl, catalogFile, log, confirm, t })
221
254
  await pause()
222
255
  })
223
256
  await recollect()
@@ -225,19 +258,22 @@ export async function runTui(root, opts = {}) {
225
258
  }
226
259
 
227
260
  const changes = planChanges(itemStates(state.rows), state.selected)
228
- if (changes.length === 0) { status = '변경할 항목이 없습니다.'; continue }
261
+ if (changes.length === 0) { status = t('tui.noChanges'); continue }
229
262
 
230
263
  const verdict = await review(changes)
231
264
  if (verdict === 'quit') break
232
- if (verdict === 'cancel') { status = '제출을 취소했습니다.'; continue }
265
+ if (verdict === 'cancel') { status = t('tui.submitCancelled'); continue }
233
266
 
234
267
  await suspend(async () => {
235
- log(`\n적용할 변경 ${changes.length}건${dryRun ? ' (dry-run)' : ''}:`)
236
- for (const c of changes) log(` ${ACTION_LABEL[c.action]} ${c.item.label}`)
237
- const results = await apply(root, changes, { dryRun, log })
238
- for (const r of results) log(` ${r.ok ? '✔' : '✖'} ${ACTION_LABEL[r.action]} ${r.item.label}${r.message ? ` — ${r.message}` : ''}`)
268
+ log(`\n${t('tui.applyHeader', { count: changes.length, suffix: dryRun ? ' (dry-run)' : '' })}`)
269
+ for (const c of changes) log(` ${t(`change.${c.action}`)} ${c.item.label}`)
270
+ const results = await apply(root, changes, { dryRun, log, t })
271
+ for (const r of results) {
272
+ const message = toText(t, r.message)
273
+ log(` ${r.ok ? '✔' : '✖'} ${t(`change.${r.action}`)} ${r.item.label}${message ? ` — ${message}` : ''}`)
274
+ }
239
275
  if (results.some((r) => !r.ok)) process.exitCode = 1
240
- log('\n설정 파일 변경 내용은 git diff로 확인할 수 있습니다.')
276
+ log(`\n${t('apply.seeGitDiff')}`)
241
277
  await pause()
242
278
  })
243
279
  await recollect()
@@ -251,7 +287,7 @@ export async function runTui(root, opts = {}) {
251
287
  stdin.pause()
252
288
  }
253
289
 
254
- return { interactive: true }
290
+ return { interactive: true, state }
255
291
  }
256
292
 
257
293
  export { printPlain, itemStates }
package/lib/update.mjs CHANGED
@@ -1,85 +1,85 @@
1
- // update 흐름 — 관리 파일을 최신 템플릿으로 옮기고 드리프트를 보고한다.
2
- //
3
- // 갱신 판정은 apply.mjs의 updateFiles·updateBlocks가 하고, 여기는 순서와
4
- // 보고, 그리고 기록 갱신만 담당한다. flow.mjs가 생성 쪽에서 하는 역할과 같다.
5
- import { execFileSync } from 'node:child_process'
6
- import { MANIFEST } from './bootstrap/manifest.mjs'
7
- import {
8
- configureAdapterSafe, ensureIgnore, ensureJsonKeys, updateBlocks, updateFiles,
9
- } from './bootstrap/apply.mjs'
10
- import { RECORD_REL, managedKey, readRecord, toolVersion, writeRecord } from './bootstrap/record.mjs'
11
-
12
- // git이 유일한 되돌리기 수단이다. 커밋되지 않은 변경 위에 덮어쓰면 사용자가
13
- // 잃은 것을 복구할 방법이 없다.
14
- function assertCleanWorktree(root) {
15
- const out = execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' })
16
- if (out.trim()) {
17
- throw new Error(
18
- '--force는 워킹트리가 깨끗할 때만 쓸 수 있습니다. ' +
19
- 'git이 유일한 되돌리기 수단이라 커밋되지 않은 변경 위에 덮어쓰면 복구할 수 없습니다.',
20
- )
21
- }
22
- }
23
-
24
- export async function runUpdate(root, opts = {}) {
25
- const { dryRun = false, force = false, log = console.log, manifest = MANIFEST } = opts
26
- const say = (message) => log(`[agent-setup] ${message}`)
27
-
28
- const record = readRecord(root)
29
- if (!record) {
30
- throw new Error(
31
- `${RECORD_REL}이 없습니다. 이 저장소를 기록 체계로 끌어오려면 먼저 ` +
32
- '`bootstrap --adopt`를 실행하세요 — 파일을 만들지 않고 기록만 만듭니다.',
33
- )
34
- }
35
-
36
- if (force) assertCleanWorktree(root)
37
-
38
- // 2단계에서는 버전 차이를 보고만 한다. 여기서 중단시키면 기존 사용자의
39
- // 실행이 오류로 죽어 파괴적 변경이 된다. 엄격 검사는 apply와 함께 3단계다.
40
- const running = toolVersion()
41
- if (record.pinnedVersion && record.pinnedVersion !== running) {
42
- say(`고정 ${record.pinnedVersion} → 실행 중 ${running}`)
43
- }
44
-
45
- const ctx = { dryRun, force, log: say, managed: record.managed }
46
- const results = [
47
- ...updateFiles(root, manifest.files, ctx),
48
- ...updateBlocks(root, manifest.blocks, ctx),
49
- // 키 보장과 gitignore 항목 추가는 원래 멱등이라 그대로 재실행한다.
50
- ...ensureJsonKeys(root, manifest.settings ?? [], { dryRun, log: say }),
51
- ...ensureIgnore(root, manifest.ignore, { dryRun, log: say }),
52
- ]
53
-
54
- // 어댑터는 링크가 끊겼을 수 있어 재검증한다. 항목별로 실패를 격리한다.
55
- for (const entry of manifest.adapters) {
56
- results.push(configureAdapterSafe(root, entry, {
57
- dryRun, log: say, skillMode: record.skillMode,
58
- }))
59
- }
60
-
61
- // 성공한 것만 기록에 옮긴다. 드리프트는 hash를 담지 않으므로 옛 해시가
62
- // 남아, 사용자가 원복하면 다음 update가 다시 집어간다.
63
- const managed = { ...record.managed }
64
- for (const r of results) {
65
- if (!r.hash) continue
66
- const isBlock = manifest.blocks.some((b) => b.path === r.path)
67
- managed[managedKey(r.path, isBlock)] = r.hash
68
- }
69
-
70
- const drift = results.filter((r) => r.action === 'drift')
71
- const updated = results.filter((r) => r.action === 'update')
72
- const created = results.filter((r) => r.action === 'create')
73
-
74
- if (!dryRun) writeRecord(root, { ...record, managed }, { dryRun, log: say })
75
-
76
- log('')
77
- say(`갱신 ${updated.length}건 · 신규 ${created.length}건 · 드리프트 ${drift.length}건`)
78
- if (drift.length > 0) {
79
- say('드리프트 (건드리지 않았습니다)')
80
- for (const d of drift) say(` ${d.path}${d.message ? ` — ${d.message}` : ''}`)
81
- say('최신 템플릿을 반영하려면 update --force (워킹트리가 깨끗해야 합니다)')
82
- }
83
-
84
- return { results, drift, record: { ...record, managed } }
85
- }
1
+ // update 흐름 — 관리 파일을 최신 템플릿으로 옮기고 드리프트를 보고한다.
2
+ //
3
+ // 갱신 판정은 apply.mjs의 updateFiles·updateBlocks가 하고, 여기는 순서와
4
+ // 보고, 그리고 기록 갱신만 담당한다. flow.mjs가 생성 쪽에서 하는 역할과 같다.
5
+ import { execFileSync } from 'node:child_process'
6
+ import { MANIFEST } from './bootstrap/manifest.mjs'
7
+ import {
8
+ configureAdapterSafe, ensureIgnore, ensureJsonKeys, updateBlocks, updateFiles,
9
+ } from './bootstrap/apply.mjs'
10
+ import { RECORD_REL, managedKey, readRecord, toolVersion, writeRecord } from './bootstrap/record.mjs'
11
+ import { LocalizedError, createT, toText } from './i18n/index.mjs'
12
+
13
+ // git이 유일한 되돌리기 수단이다. 커밋되지 않은 변경 위에 덮어쓰면 사용자가
14
+ // 잃은 것을 복구할 방법이 없다.
15
+ function assertCleanWorktree(root) {
16
+ const out = execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' })
17
+ if (out.trim()) {
18
+ throw new LocalizedError('error.forceNeedsCleanTree')
19
+ }
20
+ }
21
+
22
+ export async function runUpdate(root, opts = {}) {
23
+ const { dryRun = false, force = false, log = console.log, manifest = MANIFEST, t = createT('en') } = opts
24
+ const say = (message) => log(`[agent-setup] ${message}`)
25
+
26
+ const record = readRecord(root)
27
+ if (!record) {
28
+ throw new LocalizedError('error.noRecordForUpdate', { path: RECORD_REL })
29
+ }
30
+
31
+ if (force) assertCleanWorktree(root)
32
+
33
+ // 2단계에서는 버전 차이를 보고만 한다. 여기서 중단시키면 기존 사용자의
34
+ // 실행이 오류로 죽어 파괴적 변경이 된다. 엄격 검사는 apply와 함께 3단계다.
35
+ const running = toolVersion()
36
+ if (record.pinnedVersion && record.pinnedVersion !== running) {
37
+ say(t('update.versionMove', { pinned: record.pinnedVersion, running }))
38
+ }
39
+
40
+ const ctx = { dryRun, force, log: say, managed: record.managed, t }
41
+ const results = [
42
+ ...updateFiles(root, manifest.files, ctx),
43
+ ...updateBlocks(root, manifest.blocks, ctx),
44
+ // 키 보장과 gitignore 항목 추가는 원래 멱등이라 그대로 재실행한다.
45
+ ...ensureJsonKeys(root, manifest.settings ?? [], { dryRun, log: say, t }),
46
+ ...ensureIgnore(root, manifest.ignore, { dryRun, log: say, t }),
47
+ ]
48
+
49
+ // 어댑터는 링크가 끊겼을 수 있어 재검증한다. 항목별로 실패를 격리한다.
50
+ for (const entry of manifest.adapters) {
51
+ results.push(configureAdapterSafe(root, entry, {
52
+ dryRun, log: say, skillMode: record.skillMode, t,
53
+ }))
54
+ }
55
+
56
+ // 성공한 것만 기록에 옮긴다. 드리프트는 hash를 담지 않으므로 옛 해시가
57
+ // 남아, 사용자가 원복하면 다음 update가 다시 집어간다.
58
+ const managed = { ...record.managed }
59
+ for (const r of results) {
60
+ if (!r.hash) continue
61
+ const isBlock = manifest.blocks.some((b) => b.path === r.path)
62
+ managed[managedKey(r.path, isBlock)] = r.hash
63
+ }
64
+
65
+ const drift = results.filter((r) => r.action === 'drift')
66
+ const updated = results.filter((r) => r.action === 'update')
67
+ const created = results.filter((r) => r.action === 'create')
68
+
69
+ if (!dryRun) writeRecord(root, { ...record, managed }, { dryRun, log: say, t })
70
+
71
+ log('')
72
+ say(t('update.summary', { updated: updated.length, created: created.length, drift: drift.length }))
73
+ if (drift.length > 0) {
74
+ say(t('update.driftHeader'))
75
+ // 결과 message는 구조화 메시지(msg())다. 문자열 삽입 자리에 그대로
76
+ // 꽂으면 "[object Object]"가 찍히므로 toText로 활성 로케일에 맞춰 푼다.
77
+ for (const d of drift) {
78
+ const drifted = toText(t, d.message)
79
+ say(` ${d.path}${drifted ? ` — ${drifted}` : ''}`)
80
+ }
81
+ say(t('update.driftHint'))
82
+ }
83
+
84
+ return { results, drift, record: { ...record, managed } }
85
+ }
package/lib/width.mjs ADDED
@@ -0,0 +1,57 @@
1
+ // 터미널 표시 폭 계산. TUI 화면과 비대화형 목록이 함께 쓴다.
2
+ //
3
+ // 아무것도 import 하지 않는다 — install.mjs가 정적으로 끌어오므로
4
+ // bootstrap.isolation.test.mjs가 이 파일까지 의존성 0을 요구한다. 그리고
5
+ // design-md/flow.mjs도 여기에 닿는데, 이 함수들이 tui/render.mjs에 남아
6
+ // 있으면 render → flow → render 순환 import가 된다.
7
+ //
8
+ // String.padEnd는 코드 유닛을 센다. 한글 한 글자가 두 칸을 차지하는 터미널에서
9
+ // padEnd로 열을 맞추면 로케일이 바뀔 때마다 열이 어긋난다 — 그래서 폭 계산은
10
+ // 화면이든 목록이든 전부 이 모듈을 거친다.
11
+
12
+ // 동아시아 글자는 터미널에서 두 칸을 차지한다. 전체 wcwidth 표 대신
13
+ // 실제로 쓰이는 구간(한글·한자·가나·전각)만 넓게 센다.
14
+ function charWidth(cp) {
15
+ return (cp >= 0x1100 && cp <= 0x115f) // 한글 자모
16
+ || (cp >= 0x2e80 && cp <= 0xa4cf) // 한자 부수·가나·한자
17
+ || (cp >= 0xac00 && cp <= 0xd7a3) // 한글 음절
18
+ || (cp >= 0xf900 && cp <= 0xfaff) // 한자 호환
19
+ || (cp >= 0xfe30 && cp <= 0xfe6f) // 전각 형태
20
+ || (cp >= 0xff00 && cp <= 0xff60) // 전각 영숫자
21
+ || (cp >= 0xffe0 && cp <= 0xffe6)
22
+ ? 2 : 1
23
+ }
24
+
25
+ export function width(text) {
26
+ let w = 0
27
+ for (const ch of String(text ?? '')) w += charWidth(ch.codePointAt(0))
28
+ return w
29
+ }
30
+
31
+ // 색 코드는 폭에 포함되면 안 되므로, 자르기는 항상 색을 입히기 **전에** 한다.
32
+ export function cut(text, limit) {
33
+ if (limit <= 0) return ''
34
+ const s = String(text ?? '')
35
+ if (width(s) <= limit) return s
36
+ let out = ''
37
+ let w = 0
38
+ for (const ch of s) {
39
+ const cw = charWidth(ch.codePointAt(0))
40
+ if (w + cw > limit - 1) break
41
+ out += ch
42
+ w += cw
43
+ }
44
+ return `${out}…`
45
+ }
46
+
47
+ export function pad(text, limit) {
48
+ const s = cut(text, limit)
49
+ return s + ' '.repeat(Math.max(0, limit - width(s)))
50
+ }
51
+
52
+ // 상태 라벨은 로케일마다 길이가 다르다(영어 'Not installed' 13, 한국어
53
+ // '미설치' 6). 한 번 재어 상수로 박으면 다른 로케일에서 열이 깨지므로,
54
+ // 화면을 그릴 때 그 로케일의 실제 라벨에서 폭을 뽑는다.
55
+ export function labelWidth(t, keys) {
56
+ return Math.max(...keys.map((key) => width(t(key))))
57
+ }
package/package.json CHANGED
@@ -1,36 +1,36 @@
1
- {
2
- "name": "@rch4com/agent-setup",
3
- "version": "1.1.0",
4
- "description": "여러 코딩 에이전트 CLI를 한 저장소에서 함께 쓰기 위한 저장소 범위 부트스트랩과 선택 항목 설치기",
5
- "keywords": [
6
- "agents-md",
7
- "claude-code",
8
- "codex",
9
- "gemini-cli",
10
- "copilot",
11
- "mcp",
12
- "agent-skills"
13
- ],
14
- "homepage": "https://github.com/rch4com/Agent-Setup#readme",
15
- "bugs": "https://github.com/rch4com/Agent-Setup/issues",
16
- "repository": {
17
- "type": "git",
18
- "url": "git+https://github.com/rch4com/Agent-Setup.git",
19
- "directory": "agent-installer"
20
- },
21
- "license": "MIT",
22
- "type": "module",
23
- "engines": { "node": ">=20" },
24
- "bin": { "agent-setup": "install.mjs" },
25
- "files": ["install.mjs", "lib/", "README.md", "LICENSE"],
26
- "publishConfig": { "access": "public" },
27
- "scripts": {
28
- "start": "node install.mjs",
29
- "test": "node --test \"test/*.test.mjs\"",
30
- "refresh-bundle": "node scripts/refresh-bundle.mjs"
31
- },
32
- "dependencies": {
33
- "jsonc-parser": "^3.3.1",
34
- "smol-toml": "^1.3.1"
35
- }
36
- }
1
+ {
2
+ "name": "@rch4com/agent-setup",
3
+ "version": "1.4.0",
4
+ "description": "Repository-scoped bootstrap and optional-item installer for using multiple coding-agent CLIs in one repository | 여러 코딩 에이전트 CLI를 한 저장소에서 함께 쓰기 위한 저장소 범위 부트스트랩과 선택 항목 설치기",
5
+ "keywords": [
6
+ "agents-md",
7
+ "claude-code",
8
+ "codex",
9
+ "gemini-cli",
10
+ "copilot",
11
+ "mcp",
12
+ "agent-skills"
13
+ ],
14
+ "homepage": "https://github.com/rch4com/Agent-Setup#readme",
15
+ "bugs": "https://github.com/rch4com/Agent-Setup/issues",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/rch4com/Agent-Setup.git",
19
+ "directory": "agent-installer"
20
+ },
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "engines": { "node": ">=20" },
24
+ "bin": { "agent-setup": "install.mjs" },
25
+ "files": ["install.mjs", "lib/", "README.md", "LICENSE"],
26
+ "publishConfig": { "access": "public" },
27
+ "scripts": {
28
+ "start": "node install.mjs",
29
+ "test": "node --test",
30
+ "refresh-bundle": "node scripts/refresh-bundle.mjs"
31
+ },
32
+ "dependencies": {
33
+ "jsonc-parser": "^3.3.1",
34
+ "smol-toml": "^1.3.1"
35
+ }
36
+ }