@rch4com/agent-setup 1.19.0 → 1.21.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 CHANGED
@@ -125,7 +125,11 @@ async function main() {
125
125
  // 살아남는다. 조용히 되돌아가는 대신 화면이 그 사실을 알리게 한다.
126
126
  const localeForced = isLocaleForced({ flag, env: process.env })
127
127
 
128
- if (root === null) throw new LocalizedError('error.notGitRepo')
128
+ // 도움말은 저장소가 필요 없다. 이 검사를 먼저 하면 `--help`가 Git 저장소
129
+ // 밖에서 "Run this inside a Git repository"로 죽는다 — 사용법을 보려는
130
+ // 사람에게 그 오류를 내밀 이유가 없다.
131
+ const wantsHelp = argv.includes('-h') || argv.includes('--help')
132
+ if (root === null && !wantsHelp) throw new LocalizedError('error.notGitRepo')
129
133
 
130
134
  if (argv[0] === 'bootstrap') {
131
135
  const opts = parseBootstrapArgs(argv.slice(1), t)
@@ -140,7 +144,8 @@ async function main() {
140
144
  if (opts.help) { console.log(updateUsage(t)); return }
141
145
  // 항목 수렴을 위해 스캔이 필요해 의존성 있는 모듈에 닿는다.
142
146
  const { runUpdate } = await withDeps(() => import('./lib/update.mjs'), t)
143
- await runUpdate(root, { ...opts, t })
147
+ const { failed } = await runUpdate(root, { ...opts, t })
148
+ if (failed.length > 0) process.exitCode = 1
144
149
  return
145
150
  }
146
151
 
@@ -2,7 +2,7 @@
2
2
  // Windows는 Junction을 쓴다. 관리자 권한이 필요 없고, MSYS(Git Bash)의 ln -s가
3
3
  // 링크 대신 복사를 만드는 문제도 우회한다.
4
4
  import { cpSync, lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
5
- import { dirname, join, relative, resolve } from 'node:path'
5
+ import { basename, dirname, join, relative, resolve } from 'node:path'
6
6
  import { repoPath, repoPathStrict } from '../context.mjs'
7
7
  import { createT, msg } from '../i18n/index.mjs'
8
8
  import { pathExists } from './apply.mjs'
@@ -42,7 +42,12 @@ export function configureAdapter(root, { tool, path: rel }, { dryRun = false, sk
42
42
  const target = repoPath(root, rel)
43
43
 
44
44
  const linked = linkTarget(target)
45
- if (linked !== null) {
45
+ // 대상이 사라진 링크는 보존할 것이 없다. Windows Junction은 만들 때의 절대
46
+ // 경로를 담으므로 저장소 디렉터리를 옮기거나 이름을 바꾸면 옛 경로를
47
+ // 가리킨 채 끊긴다 — 그것을 "다른 곳을 가리키는 링크"로 보고 보존하면
48
+ // 매 실행마다 경고만 찍히고 Claude Code는 공유 스킬을 영영 보지 못한다.
49
+ const dangling = linked !== null && !pathExists(linked)
50
+ if (linked !== null && !dangling) {
46
51
  if (linked === repoPath(root, SOURCE_REL)) {
47
52
  log(t('log.skill.linkOk', { tool, path: rel }))
48
53
  return { ok: true, action: 'skip', path: rel }
@@ -51,8 +56,8 @@ export function configureAdapter(root, { tool, path: rel }, { dryRun = false, sk
51
56
  return { ok: true, action: 'warn', path: rel, message: msg('msg.foreignLink') }
52
57
  }
53
58
 
54
- const managedCopy = pathExists(target) && pathExists(join(target, MARKER))
55
- if (pathExists(target) && !managedCopy) {
59
+ const managedCopy = !dangling && pathExists(target) && pathExists(join(target, MARKER))
60
+ if (!dangling && pathExists(target) && !managedCopy) {
56
61
  log(t('log.skill.warnUnmanaged', { path: rel }))
57
62
  return { ok: true, action: 'warn', path: rel, message: msg('msg.unmanagedExisting') }
58
63
  }
@@ -62,7 +67,14 @@ export function configureAdapter(root, { tool, path: rel }, { dryRun = false, sk
62
67
  // 쓰기만 !dryRun으로 막는다 — apply.mjs의 ensureDirs와 같은 형태.
63
68
  // source(.agents/skills)는 실제로 읽어 복사하는 대상이므로 여기서 함께 검사한다.
64
69
  const source = repoPathStrict(root, SOURCE_REL)
65
- const strictTarget = repoPathStrict(root, rel)
70
+ // 끊긴 링크는 realpath가 ENOENT로 실패해 자기 자신을 엄격 검사할 수 없다.
71
+ // 지울 것은 링크 항목 하나뿐이니 그것이 놓인 부모 디렉터리를 검사한다 —
72
+ // 부모가 저장소 안이면 그 안의 항목 하나를 지우는 것도 저장소 안이다.
73
+ const strictTarget = dangling
74
+ ? join(repoPathStrict(root, dirname(rel)), basename(rel))
75
+ : repoPathStrict(root, rel)
76
+
77
+ if (dangling) log(t('log.skill.relinkDangling', { path: rel }))
66
78
 
67
79
  if (dryRun) {
68
80
  log(t('log.skill.plan', { tool, path: rel, mode: skillMode }))
@@ -74,6 +86,8 @@ export function configureAdapter(root, { tool, path: rel }, { dryRun = false, sk
74
86
  // 마커가 확인된 복사본만 지운다.
75
87
  rmSync(strictTarget, { recursive: true, force: true })
76
88
  }
89
+ // 링크 자체만 지운다 — rmSync는 링크를 따라 내려가지 않는다(대상이 없기도 하다).
90
+ if (dangling) rmSync(strictTarget, { recursive: true, force: true })
77
91
 
78
92
  mkdirSync(dirname(strictTarget), { recursive: true })
79
93
 
package/lib/catalog.mjs CHANGED
@@ -67,6 +67,12 @@ const execFileAsync = promisify(execFile)
67
67
  // 보고된다. 호출부가 opts.timeout으로 바꿀 수 있다.
68
68
  export const EXEC_TIMEOUT_MS = 10 * 60 * 1000
69
69
 
70
+ // 자식 출력의 상한. execFile 기본값(1MB)을 넘기면 Node가 자식을 **죽이고**
71
+ // "maxBuffer length exceeded"로 실패시킨다 — gsd(3,500파일)·hallmark
72
+ // (references 100개 이상)처럼 파일마다 한 줄씩 찍는 설치가 반쯤 깔린 채
73
+ // 실패로 보고됐다. 64MB면 어떤 설치 로그도 넉넉히 담긴다.
74
+ export const EXEC_MAX_BUFFER = 64 * 1024 * 1024
75
+
70
76
  // 비동기다. 동기 실행은 이벤트 루프를 통째로 막아, npx가 도는 수십 초 동안
71
77
  // 진행 화면을 한 번도 다시 그릴 수 없었다. 반환 형태({ ok, output })는
72
78
  // 그대로라 호출부는 await만 더하면 된다.
@@ -91,6 +97,7 @@ export function makeExec(dryRun, log = console.log, t = null) {
91
97
  const p = execFileAsync(file, fileArgs, {
92
98
  encoding: 'utf8',
93
99
  timeout: EXEC_TIMEOUT_MS,
100
+ maxBuffer: EXEC_MAX_BUFFER,
94
101
  ...opts,
95
102
  shell,
96
103
  })
@@ -1,5 +1,5 @@
1
1
  import { scan, planChanges, apply } from '../engine.mjs'
2
- import { loadCatalog, saveCatalog, buildItems, allEntries, sha256, resolveTokens, netFetch, CATALOG_PATH } from './catalog.mjs'
2
+ import { loadCatalog, saveCatalog, buildItems, allEntries, sha256, resolveTokens, netFetch, isSafeSegment, CATALOG_PATH } from './catalog.mjs'
3
3
  import { PROVIDERS } from './providers/index.mjs'
4
4
  import { discoverSources, extraDirsFromEnv } from './scan.mjs'
5
5
  import { makeOpener, openPreview } from './open.mjs'
@@ -90,7 +90,11 @@ export async function refreshCatalog({ dryRun, fetchImpl, log, catalogFile = CAT
90
90
  const next = { updatedAt: new Date().toISOString(), providers: {} }
91
91
  for (const provider of PROVIDERS) {
92
92
  try {
93
- const entries = await provider.fetchCatalog(fetchImpl)
93
+ // 이름은 원격 README에서 오는 신뢰 경계 밖 값이다. buildItems가 설치
94
+ // 경로 앞에서 거르지만, 카탈로그 파일 자체에 `..` 같은 이름이 남으면
95
+ // 그 파일을 직접 읽는 번들 스크립트가 경로 이탈에 노출된다 — 저장
96
+ // 전에 거른다.
97
+ const entries = (await provider.fetchCatalog(fetchImpl)).filter((e) => isSafeSegment(e.name))
94
98
  next.providers[provider.id] = { label: provider.label, entries }
95
99
  log(t('design.provider.count', { label: provider.label, count: entries.length }))
96
100
  } catch (err) {
package/lib/gitignore.mjs CHANGED
@@ -16,3 +16,18 @@ export function ensureGitignoreEntries(root, entries) {
16
16
  const sep = text.length === 0 || text.endsWith('\n') ? '' : eol
17
17
  writeFileSync(file, text + sep + missing.join(eol) + eol)
18
18
  }
19
+
20
+ // 정확히 일치하는 줄만 걷어낸다 — 설치가 넣은 항목을 제거가 도로 가져가는
21
+ // 용도라, 사용자가 손으로 적은 비슷한 패턴은 건드리지 않는다. 줄바꿈은
22
+ // 파일의 것을 그대로 따른다.
23
+ export function removeGitignoreEntries(root, entries) {
24
+ const file = repoPathStrict(root, '.gitignore')
25
+ if (!existsSync(file)) return
26
+ const text = readFileSync(file, 'utf8')
27
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
28
+ const drop = new Set(entries)
29
+ const lines = text.split(/\r?\n/)
30
+ const kept = lines.filter((line) => !drop.has(line))
31
+ if (kept.length === lines.length) return
32
+ writeFileSync(file, kept.join(eol))
33
+ }
@@ -109,7 +109,8 @@ export default {
109
109
  'Usage: npx @rch4com/agent-setup status [options]',
110
110
  '',
111
111
  'Shows intent (the install record), reality (a scan of the repository), and',
112
- 'the running tool version side by side. Changes nothing.',
112
+ 'the running tool version side by side. Changes nothing. Asks the npm',
113
+ 'registry for the latest version (5-second limit; offline just leaves it blank).',
113
114
  '',
114
115
  'Options:',
115
116
  ' --json Print machine-readable output (for CI checks).',
@@ -150,6 +151,8 @@ export default {
150
151
  'log.record.write': 'install record written: {path}',
151
152
  'error.recordUnreadable': 'Cannot read {path} ({code})',
152
153
  'error.recordNotJson': 'Cannot read {path} — not JSON ({message})',
154
+ 'error.tomlInvalid': 'Cannot read {path} — invalid TOML: {message}. Fix the file by hand; nothing was written.',
155
+ 'error.jsonInvalid': 'Cannot read {path} — invalid JSON: {message}. Fix the file by hand; nothing was written.',
153
156
  'error.recordVersion': '{path} has format version {found}. This tool uses {expected} — upgrade the tool or recreate the record.',
154
157
 
155
158
  'log.repoRoot': 'repository root: {path}',
@@ -183,6 +186,7 @@ export default {
183
186
  'msg.readFailed': 'read failed',
184
187
  'msg.noRootObject': 'no root object',
185
188
  'msg.foreignLink': 'link pointing elsewhere',
189
+ 'log.skill.relinkDangling': 'warning: {path} is a link whose target no longer exists (the repository moved?). Recreating it.',
186
190
  'msg.unmanagedExisting': 'existing entry not under management',
187
191
  'msg.noManagedBlock': 'no managed block',
188
192
  'msg.linkFailed': 'link creation failed: {message}',
@@ -344,6 +348,7 @@ export default {
344
348
  'tui.noPreview': 'This item has no preview.',
345
349
  'tui.opened': 'Opened: {target}',
346
350
  'tui.noChanges': 'Nothing to change.',
351
+ 'tui.actionFailed': 'The action failed: {message}',
347
352
  'tui.submitCancelled': 'Submission cancelled.',
348
353
  'tui.selectedGlobal': 'Machine-global item — applies to this whole computer',
349
354
  'tui.toggledAll': 'Selected every visible item in the {tab} tab.',
@@ -103,7 +103,8 @@ export default {
103
103
  '사용법: npx @rch4com/agent-setup status [옵션]',
104
104
  '',
105
105
  '설치 기록(의도) / 실제 저장소 상태(스캔) / 실행 중 도구 버전을',
106
- '나란히 보여줍니다. 아무것도 바꾸지 않습니다.',
106
+ '나란히 보여줍니다. 아무것도 바꾸지 않습니다. 최신 버전은 npm 레지스트리에',
107
+ '묻습니다(5초 제한, 오프라인이면 그 자리만 비웁니다).',
107
108
  '',
108
109
  '옵션:',
109
110
  ' --json 기계가 읽을 형태로 출력합니다 (CI 판정용).',
@@ -144,6 +145,8 @@ export default {
144
145
  'log.record.write': '설치 기록 기록: {path}',
145
146
  'error.recordUnreadable': '{path}을 읽을 수 없습니다 ({code})',
146
147
  'error.recordNotJson': '{path}을 읽을 수 없습니다 — JSON이 아닙니다 ({message})',
148
+ 'error.tomlInvalid': '{path}을 읽을 수 없습니다 — TOML이 깨져 있습니다: {message}. 파일을 손으로 고치세요. 아무것도 쓰지 않았습니다.',
149
+ 'error.jsonInvalid': '{path}을 읽을 수 없습니다 — JSON이 깨져 있습니다: {message}. 파일을 손으로 고치세요. 아무것도 쓰지 않았습니다.',
147
150
  'error.recordVersion': '{path}의 형식 버전이 {found}입니다. 이 도구는 {expected}을 씁니다 — 도구를 올리거나 기록을 다시 만드세요.',
148
151
 
149
152
  'log.repoRoot': '저장소 루트: {path}',
@@ -177,6 +180,7 @@ export default {
177
180
  'msg.readFailed': '읽기 실패',
178
181
  'msg.noRootObject': '루트 객체 없음',
179
182
  'msg.foreignLink': '다른 위치를 가리키는 링크',
183
+ 'log.skill.relinkDangling': '경고: {path} 링크의 대상이 더는 존재하지 않습니다(저장소를 옮겼나요?). 다시 만듭니다.',
180
184
  'msg.unmanagedExisting': '관리 대상이 아닌 기존 항목',
181
185
  'msg.noManagedBlock': '관리 블록 없음',
182
186
  'msg.linkFailed': '링크 생성 실패: {message}',
@@ -334,6 +338,7 @@ export default {
334
338
  'tui.noPreview': '이 항목은 미리보기를 제공하지 않습니다.',
335
339
  'tui.opened': '열었습니다: {target}',
336
340
  'tui.noChanges': '변경할 항목이 없습니다.',
341
+ 'tui.actionFailed': '작업이 실패했습니다: {message}',
337
342
  'tui.submitCancelled': '제출을 취소했습니다.',
338
343
  'tui.selectedGlobal': '머신 전역 항목 — 이 컴퓨터 전체에 적용됩니다',
339
344
  'tui.toggledAll': '{tab} 탭의 보이는 항목을 모두 선택했습니다.',
@@ -3,10 +3,11 @@ import { join } from 'node:path'
3
3
  import { defineSkill } from '../catalog.mjs'
4
4
  import { CLI_IDS } from '../clis.mjs'
5
5
  import { repoPath, repoPathStrict } from '../context.mjs'
6
- import { ensureGitignoreEntries } from '../gitignore.mjs'
6
+ import { ensureGitignoreEntries, removeGitignoreEntries } from '../gitignore.mjs'
7
7
  import { LocalizedError, msg } from '../i18n/index.mjs'
8
8
 
9
9
  const REL_DIR = '.claude/skills/gstack'
10
+ const IGNORE_ENTRIES = ['.claude/skills/gstack', '.agents/skills/gstack']
10
11
 
11
12
  // 상류 setup은 --host codex·kiro·opencode(와 factory)도 지원하지만 이 항목은
12
13
  // 인자 없이 실행하며 setup의 기본값이 claude 단독이다. README 표에 있는
@@ -58,12 +59,16 @@ export default defineSkill({
58
59
  }
59
60
  }
60
61
  // 부트스트랩 저장소에서는 .claude/skills가 .agents/skills Junction이므로 두 경로 모두 무시 처리
61
- if (!dryRun) ensureGitignoreEntries(root, ['.claude/skills/gstack', '.agents/skills/gstack'])
62
+ if (!dryRun) ensureGitignoreEntries(root, IGNORE_ENTRIES)
62
63
  },
63
64
  async uninstall({ root, dryRun, exec }) {
64
65
  const dir = repoPathStrict(root, REL_DIR)
65
66
  if (!existsSync(dir)) return
66
67
  await exec('bash', [join(dir, 'bin', 'gstack-uninstall'), '--force'], { cwd: dir }) // 실패해도 디렉터리 삭제로 폴백
67
- if (!dryRun && existsSync(dir)) rmSync(dir, { recursive: true, force: true })
68
+ if (dryRun) return
69
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true })
70
+ // 설치가 넣은 두 줄을 도로 가져간다 — 남겨 두면 제거한 뒤에도 우리가 만든
71
+ // 흔적이 .gitignore에 계속 남는다.
72
+ removeGitignoreEntries(root, IGNORE_ENTRIES)
68
73
  },
69
74
  })
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
@@ -103,9 +103,27 @@ export function formatStatus(report, t = createT('en')) {
103
103
  return lines.join('\n')
104
104
  }
105
105
 
106
- 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') } = {}) {
107
125
  const { loadItems } = await import('./catalog.mjs')
108
- const report = await collectStatus(root, { items: await loadItems() })
126
+ const report = await collectStatus(root, { items: await loadItems(), latest: await fetchLatestVersion(fetchImpl) })
109
127
  log(json ? JSON.stringify(report, null, 2) : formatStatus(report, t))
110
128
  return report
111
129
  }
package/lib/tomlfile.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
2
2
  import { dirname } from 'node:path'
3
3
  import { parse } from 'smol-toml'
4
+ import { LocalizedError } from './i18n/index.mjs'
4
5
 
5
6
  function readText(file) {
6
7
  return existsSync(file) ? readFileSync(file, 'utf8') : ''
@@ -10,19 +11,27 @@ function escapeRegExp(text) {
10
11
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
11
12
  }
12
13
 
14
+ // 깨진 TOML은 읽기·쓰기 모두 거부한다. 예전에는 파싱 실패를 "섹션 없음"으로
15
+ // 돌려줘, 닫는 대괄호가 빠진 파일 끝에 같은 섹션이 하나 더 붙고 사용자는
16
+ // 파일이 깨진 것을 끝까지 몰랐다 — jsonfile.mjs의 JSON 규칙과 같다.
17
+ function parseStrict(text, file) {
18
+ try {
19
+ return parse(text)
20
+ } catch (err) {
21
+ throw new LocalizedError('error.tomlInvalid', { path: file, message: err.message.split('\n')[0] })
22
+ }
23
+ }
24
+
13
25
  export function hasSection(file, name) {
14
26
  const text = readText(file)
15
27
  if (!text.trim()) return false
16
- try {
17
- const data = parse(text)
18
- return data.mcp_servers != null && Object.hasOwn(data.mcp_servers, name)
19
- } catch {
20
- return false
21
- }
28
+ const data = parseStrict(text, file)
29
+ return data.mcp_servers != null && Object.hasOwn(data.mcp_servers, name)
22
30
  }
23
31
 
24
32
  export function appendSection(file, name, lines) {
25
33
  let text = readText(file)
34
+ if (text.trim()) parseStrict(text, file)
26
35
  if (text.length > 0 && !text.endsWith('\n')) text += '\n'
27
36
  const separator = text.length > 0 ? '\n' : ''
28
37
  text += `${separator}[mcp_servers.${name}]\n${lines.join('\n')}\n`
@@ -33,6 +42,7 @@ export function appendSection(file, name, lines) {
33
42
  export function removeSection(file, name) {
34
43
  const text = readText(file)
35
44
  if (!text) return
45
+ parseStrict(text, file)
36
46
  const lines = text.split('\n')
37
47
  const headerRe = new RegExp(`^\\s*\\[mcp_servers\\.${escapeRegExp(name)}(\\.|\\])`)
38
48
  const anyHeaderRe = /^\s*\[/
package/lib/tui/rows.mjs CHANGED
@@ -38,9 +38,11 @@ function groupRank(group) {
38
38
  return i === -1 ? GROUP_ORDER.length : i
39
39
  }
40
40
 
41
+ // 코드 포인트 단위로 자른다 — slice는 코드 유닛을 세어 이모지 같은 서로게이트
42
+ // 쌍 한가운데를 자를 수 있다. 이 파일의 다른 자르기(width.cut)와 같은 규칙이다.
41
43
  function short(text, n = 60) {
42
- const t = (text ?? '').replace(/\s+/g, ' ').trim()
43
- return t.length > n ? `${t.slice(0, n - 1)}…` : t
44
+ const chars = [...(text ?? '').replace(/\s+/g, ' ').trim()]
45
+ return chars.length > n ? `${chars.slice(0, n - 1).join('')}…` : chars.join('')
44
46
  }
45
47
 
46
48
  // 검색어에 섹션의 두 로케일 라벨을 모두 넣는다 — 한국어 화면에서도 영어 탭
package/lib/tui/run.mjs CHANGED
@@ -387,7 +387,15 @@ export async function runTui(root, opts = {}) {
387
387
  if (row?.id === 'action.language') { status = await cycleLanguage(); continue }
388
388
  if (row?.kind === 'action') {
389
389
  await suspend(async () => {
390
- await row.run({ root, dryRun, skillMode, fetchImpl, catalogFile, log, confirm, t })
390
+ // 작업 하나의 예외가 화면 전체를 끌고 내려가면 안 된다 — 깨진
391
+ // 기록 파일(부트스트랩), 읽기 전용 설치 위치(카탈로그 동기화)가
392
+ // 던지면 TUI가 통째로 죽고 고르던 선택이 사라졌다. 적용(runApply)이
393
+ // 항목별로 격리하는 것과 같은 규칙이다.
394
+ try {
395
+ await row.run({ root, dryRun, skillMode, fetchImpl, catalogFile, log, confirm, t })
396
+ } catch (err) {
397
+ log(`\n${t('tui.actionFailed', { message: err.key ? t(err.key, err.params) : err.message })}`)
398
+ }
391
399
  await pause()
392
400
  })
393
401
  await recollect()
package/lib/update.mjs CHANGED
@@ -11,9 +11,11 @@ import { RECORD_REL, managedKey, readRecord, toolVersion, writeRecord } from './
11
11
  import { LocalizedError, createT, toText } from './i18n/index.mjs'
12
12
 
13
13
  // git이 유일한 되돌리기 수단이다. 커밋되지 않은 변경 위에 덮어쓰면 사용자가
14
- // 잃은 것을 복구할 방법이 없다.
14
+ // 잃은 것을 복구할 방법이 없다. 추적되지 않는 파일은 세지 않는다 — 갱신
15
+ // 대상은 기록에 해시가 있는 추적 파일뿐이라, 스크래치 파일 하나가 --force를
16
+ // 막을 이유가 없다.
15
17
  function assertCleanWorktree(root) {
16
- const out = execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' })
18
+ const out = execFileSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=no'], { encoding: 'utf8' })
17
19
  if (out.trim()) {
18
20
  throw new LocalizedError('error.forceNeedsCleanTree')
19
21
  }
@@ -65,10 +67,18 @@ export async function runUpdate(root, opts = {}) {
65
67
  const drift = results.filter((r) => r.action === 'drift')
66
68
  const updated = results.filter((r) => r.action === 'update')
67
69
  const created = results.filter((r) => r.action === 'create')
70
+ // 어댑터가 격리해 돌려준 실패다. 요약이 갱신·신규·드리프트만 세면 이
71
+ // 결과는 아무 데도 나오지 않고 종료 코드도 0이라, 링크가 끊긴 채 "갱신
72
+ // 0건"으로 끝난다. flow.mjs의 bootstrap과 같은 형식으로 보고한다.
73
+ const failed = results.filter((r) => !r.ok)
68
74
 
69
75
  if (!dryRun) writeRecord(root, { ...record, managed }, { dryRun, log: say, t })
70
76
 
71
77
  log('')
78
+ if (failed.length > 0) {
79
+ say(t('bootstrap.failures', { count: failed.length }))
80
+ for (const f of failed) say(` ✖ ${f.path} — ${toText(t, f.message)}`)
81
+ }
72
82
  say(t('update.summary', { updated: updated.length, created: created.length, drift: drift.length }))
73
83
  if (drift.length > 0) {
74
84
  say(t('update.driftHeader'))
@@ -81,5 +91,5 @@ export async function runUpdate(root, opts = {}) {
81
91
  say(t('update.driftHint'))
82
92
  }
83
93
 
84
- return { results, drift, record: { ...record, managed } }
94
+ return { results, drift, failed, record: { ...record, managed } }
85
95
  }
package/lib/width.mjs CHANGED
@@ -11,7 +11,23 @@
11
11
 
12
12
  // 동아시아 글자는 터미널에서 두 칸을 차지한다. 전체 wcwidth 표 대신
13
13
  // 실제로 쓰이는 구간(한글·한자·가나·전각)만 넓게 센다.
14
+ //
15
+ // 결합 문자(악센트·변형 선택자·폭 없는 문자)는 0칸이다 — 1로 세면 `é`가
16
+ // 두 칸으로 읽혀 열이 왼쪽으로 밀린다. 이모지는 2칸이다 — DESIGN.md의
17
+ // 라벨·설명에 실려 오는데 1로 세면 그 행만 오른쪽으로 밀린다. U+2600 구간의
18
+ // 기호(☀ 등)는 터미널마다 폭이 갈리는 모호 폭이라 넣지 않는다.
14
19
  function charWidth(cp) {
20
+ if ((cp >= 0x0300 && cp <= 0x036f) // 결합 분음 부호
21
+ || (cp >= 0x1ab0 && cp <= 0x1aff) || (cp >= 0x1dc0 && cp <= 0x1dff)
22
+ || (cp >= 0x20d0 && cp <= 0x20ff) // 기호용 결합 부호
23
+ || (cp >= 0xfe00 && cp <= 0xfe0f) // 변형 선택자
24
+ || (cp >= 0xfe20 && cp <= 0xfe2f)
25
+ || (cp >= 0x200b && cp <= 0x200f) // 폭 없는 공백·결합자·방향 표식
26
+ || cp === 0xfeff) return 0
27
+ if ((cp >= 0x1f300 && cp <= 0x1f64f) // 이모지 그림·표정
28
+ || (cp >= 0x1f680 && cp <= 0x1f6ff) // 교통·지도
29
+ || (cp >= 0x1f900 && cp <= 0x1f9ff) // 보충 이모지
30
+ || (cp >= 0x1fa70 && cp <= 0x1faff)) return 2
15
31
  return (cp >= 0x1100 && cp <= 0x115f) // 한글 자모
16
32
  || (cp >= 0x2e80 && cp <= 0xa4cf) // 한자 부수·가나·한자
17
33
  || (cp >= 0xac00 && cp <= 0xd7a3) // 한글 음절
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rch4com/agent-setup",
3
- "version": "1.19.0",
3
+ "version": "1.21.0",
4
4
  "description": "Repository-scoped bootstrap and optional-item installer for using multiple coding-agent CLIs in one repository | 여러 코딩 에이전트 CLI를 한 저장소에서 함께 쓰기 위한 저장소 범위 부트스트랩과 선택 항목 설치기",
5
5
  "keywords": [
6
6
  "agents-md",