@rch4com/agent-setup 1.20.0 → 1.21.1

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/README.md CHANGED
@@ -78,6 +78,14 @@ show up in `status` and can be pulled in later with `update --force`.
78
78
 
79
79
  ## Safety
80
80
 
81
+ The rules below are what the **bootstrap** (`bootstrap`, `update`, `status`)
82
+ upholds. The optional-item picker runs third-party installers, and its two
83
+ `global.*` items (`global.superpowers`, `global.ponytail`) deliberately write
84
+ to per-user CLI configuration under your home directory — the screen marks
85
+ them as machine-global and `--set` never removes them. The full list of what
86
+ each item runs is in the
87
+ [usage guide](https://github.com/rch4com/Agent-Setup/blob/main/AgentSetup-README.md#what-the-installer-runs).
88
+
81
89
  - Runs only inside a Git repository, and never writes outside the repository root.
82
90
  - Never reads or modifies global configuration in your home directory.
83
91
  - Never overwrites existing configuration files.
@@ -176,6 +184,14 @@ npx @rch4com/agent-setup bootstrap --adopt
176
184
 
177
185
  ## 안전 원칙
178
186
 
187
+ 아래는 **부트스트랩**(`bootstrap`·`update`·`status`)이 지키는 규칙입니다.
188
+ 선택 항목 화면은 제3자 설치기를 실행하며, 그중 `global.*` 두 항목
189
+ (`global.superpowers`, `global.ponytail`)은 의도적으로 홈 디렉터리 아래
190
+ CLI별 사용자 설정에 씁니다 — 화면이 머신 전역임을 표시하고 `--set`은 이
191
+ 항목을 제거하지 않습니다. 항목마다 무엇을 실행하는지는
192
+ [사용 안내](https://github.com/rch4com/Agent-Setup/blob/main/AgentSetup-README.ko.md#설치기가-실행하는-것)에
193
+ 있습니다.
194
+
179
195
  - 반드시 Git 저장소 안에서만 실행되며, 저장소 루트 밖에는 쓰지 않습니다.
180
196
  - 홈 디렉터리의 글로벌 설정을 읽거나 수정하지 않습니다.
181
197
  - 기존 설정 파일을 덮어쓰지 않습니다.
package/lib/args.mjs CHANGED
@@ -17,7 +17,9 @@ export const designUsage = (t) => t('usage.design')
17
17
  export const updateUsage = (t) => t('usage.update')
18
18
  export const statusUsage = (t) => t('usage.status')
19
19
 
20
- const SKILL_MODES = ['auto', 'link', 'copy']
20
+ // 허용값의 단일 출처. flow.mjs(실행 시 검증)와 record.mjs(기록 읽기)가 함께 쓴다 —
21
+ // 셋이 따로 들고 있으면 한 곳에 값이 늘 때 나머지가 그 값을 거부한다.
22
+ export const SKILL_MODES = ['auto', 'link', 'copy']
21
23
 
22
24
  // 인자 사양: 플래그 이름 → 'bool'(값 없음) | 'value'(값 하나를 소비).
23
25
  // 미지 인자를 조용히 삼키지 않기 위한 것이다 — 오타(`--dryrun`)나 지원하지
@@ -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
 
@@ -5,6 +5,7 @@ import { dirname } from 'node:path'
5
5
  import { repoPath, repoPathStrict } from '../context.mjs'
6
6
  import { ensureGitignoreEntries } from '../gitignore.mjs'
7
7
  import { createT, msg } from '../i18n/index.mjs'
8
+ import { escapeRegExp } from '../untrusted.mjs'
8
9
  import { hashBody, normalizeBody } from './text.mjs'
9
10
  import { BEGIN_MARKER, END_MARKER, extractBlock, managedKey } from './record.mjs'
10
11
  import { configureAdapter } from './adapter.mjs'
@@ -231,12 +232,13 @@ export function ensureJsonKeys(root, entries, { dryRun = false, log, t = createT
231
232
  return { ok: true, action: 'warn', path: rel, message: msg('msg.readFailed') }
232
233
  }
233
234
 
234
- // 주석 안에 있어도 건드리지 않는다 — 사용자가 언급한 키를 스크립트가
235
- // 되살리지 않는 편이 "기존 설정을 덮어쓰지 않는다"는 원칙에 맞는다.
236
- // 문자열 포함 검사라 키 이름이 어떤 값 안에 들어 있어도 건너뛴다. 보수적인
237
- // 쪽으로 틀리는 것(넣지 않음)이라 파일을 망가뜨리지는 않는다. 정확히 하려면
238
- // 최상위 키만 보는 파서가 필요한데, 부트스트랩은 의존성을 쓸 수 없다.
239
- if (text.includes(JSON.stringify(key))) {
235
+ // 줄 머리에 `"키":` 꼴로 놓인 것만 "있음"으로 본다. 단순 문자열 포함
236
+ // 검사는 주석(`// see "chat.useAgentsMdFile"`)이나 다른 키의 값 안에 든
237
+ // 이름까지 "있음"으로 읽어, 키를 넣지 않고도 성공 로그를 냈다 — VS Code
238
+ // Copilot이 AGENTS.md를 영영 읽지 못하는데 화면은 성공이다. 정확히 하려면
239
+ // 최상위 키만 보는 파서가 필요한데 부트스트랩은 의존성을 쓸 수 없다 —
240
+ // 중첩 객체 안의 같은 키를 최상위로 오판하는 쪽(넣지 않음)으로만 틀린다.
241
+ if (new RegExp(`^\\s*${escapeRegExp(JSON.stringify(key))}\\s*:`, 'm').test(text)) {
240
242
  log(t('log.json.keep', { path: rel, key }))
241
243
  return { ok: true, action: 'skip', path: rel }
242
244
  }
@@ -5,11 +5,10 @@ import {
5
5
  configureAdapterSafe, ensureBlocks, ensureDirs, ensureFiles, ensureIgnore, ensureJsonKeys,
6
6
  } from './apply.mjs'
7
7
  import { RECORD_REL, collectManaged, emptyRecord, readRecord, writeRecord } from './record.mjs'
8
+ import { SKILL_MODES } from '../args.mjs'
8
9
  import { createT, LocalizedError, toText } from '../i18n/index.mjs'
9
10
  import { width } from '../width.mjs'
10
11
 
11
- const SKILL_MODES = ['auto', 'link', 'copy']
12
-
13
12
  // 오른쪽 정렬. width.mjs의 pad()는 오른쪽에 공백을 채워 왼쪽 정렬만 하므로
14
13
  // 숫자에는 못 쓴다 — 자릿수가 9에서 10으로 늘 때 열이 흔들리지 않으려면
15
14
  // 왼쪽에 채워야 한다. 숫자만 다루니 실제로는 문자 수와 같지만, 이 저장소의
@@ -10,6 +10,7 @@
10
10
  // 부트스트랩 그래프에 속하므로 node: 내장 모듈만 쓴다.
11
11
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
12
12
  import { dirname } from 'node:path'
13
+ import { SKILL_MODES } from '../args.mjs'
13
14
  import { repoPath, repoPathStrict } from '../context.mjs'
14
15
  import { LOCALES, LocalizedError } from '../i18n/index.mjs'
15
16
  import { hashBody, normalizeBody } from './text.mjs'
@@ -66,12 +67,14 @@ export function readRecord(root) {
66
67
  return {
67
68
  formatVersion: parsed.formatVersion,
68
69
  pinnedVersion: parsed.pinnedVersion ?? null,
69
- skillMode: parsed.skillMode ?? 'auto',
70
70
  // 손으로 편집된 값 때문에 도구가 죽으면 안 된다. 모르는 값은 없는 것으로 본다.
71
+ // skillMode도 같다 — 걸러 두지 않으면 update가 그 값을 어댑터까지 흘려보낸다.
72
+ skillMode: SKILL_MODES.includes(parsed.skillMode) ? parsed.skillMode : 'auto',
71
73
  lang: LOCALES.includes(parsed.lang) ? parsed.lang : null,
72
74
  items: Array.isArray(parsed.items) ? parsed.items : [],
73
75
  design: Array.isArray(parsed.design) ? parsed.design : [],
74
- managed: parsed.managed && typeof parsed.managed === 'object' ? parsed.managed : {},
76
+ // 배열도 typeof가 object다 — managed[rel] 조회가 undefined만 돌려주는 빈 껍데기가 된다.
77
+ managed: parsed.managed && typeof parsed.managed === 'object' && !Array.isArray(parsed.managed) ? parsed.managed : {},
75
78
  }
76
79
  }
77
80
 
package/lib/catalog.mjs CHANGED
@@ -7,6 +7,9 @@ import { CLIS, CLI_IDS, MCP_CLI_IDS } from './clis.mjs'
7
7
  import { isPluginEnabled, enablePlugin, disablePlugin } from './claude-plugins.mjs'
8
8
  import { repoPath, repoPathStrict } from './context.mjs'
9
9
  import { LocalizedError, msg } from './i18n/index.mjs'
10
+ // design-md가 아니라 잎 모듈에서 가져온다 — deps.test.mjs의 "의존성 없는
11
+ // 트리"는 design-md를 통째로 빼고 --list를 돌린다(bootstrap.isolation.test.mjs).
12
+ import { escapeRegExp, isSafeSegment } from './untrusted.mjs'
10
13
 
11
14
  const ITEMS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'items')
12
15
 
@@ -240,11 +243,18 @@ function findSkillDir(root, skill) {
240
243
  const file = join(base, name, 'SKILL.md')
241
244
  if (!existsSync(file)) continue
242
245
  const head = readFileSync(file, 'utf8').slice(0, 2000)
243
- if (new RegExp(`^name:\\s*['"]?${skill}['"]?\\s*$`, 'm').test(head)) return name
246
+ if (new RegExp(`^name:\\s*['"]?${escapeRegExp(skill)}['"]?\\s*$`, 'm').test(head)) return name
244
247
  }
245
248
  return null
246
249
  }
247
250
 
251
+ // 잠금 파일의 이름은 상류 SKILL.md frontmatter에서 온다 — 신뢰 경계 밖이다.
252
+ // `npx skills remove <이름>`의 인자이자 `.agents/skills/<이름>` 경로가 되므로,
253
+ // 플래그로 읽히는 선행 대시와 경로 구분자·상위 이동은 어느 쪽으로도 쓸 수 없다.
254
+ function isSafeSkillName(name) {
255
+ return isSafeSegment(name) && !name.startsWith('-')
256
+ }
257
+
248
258
  // 레지스트리가 저장소 루트에 남기는 잠금 파일. 스킬 이름마다 어느 출처에서
249
259
  // 왔는지를 적어 두므로, 한 상류가 여러 스킬을 깔았을 때 "우리가 넣은 것"만
250
260
  // 골라 지울 수 있는 유일한 근거다.
@@ -321,6 +331,9 @@ export function defineRegistrySkill({ id, label, source, skill, anchor = null, n
321
331
  const names = skill === '*' ? lockedSkills(root, source) : [skill]
322
332
  const targets = names.length > 0 ? names : [probe]
323
333
  for (const name of targets) {
334
+ // 인자·경로로 쓸 수 없는 이름은 건너뛴다. 잠금 항목은 아래 pruneLock이
335
+ // 함께 지운다 — 남기면 다음 제거가 같은 자리에서 또 멈춘다.
336
+ if (!isSafeSkillName(name)) continue
324
337
  // 레지스트리의 remove는 --agent universal에서 "Done!"을 찍고도 디렉터리를
325
338
  // 남긴다(실측). 그래서 성공/실패와 무관하게 남은 디렉터리를 우리가 지운다 —
326
339
  // 남겨 두면 detect가 계속 installed로 읽어 제거가 안 된 채 성공으로 보인다.
package/lib/deps.mjs CHANGED
@@ -9,9 +9,11 @@ export async function withDeps(load, t) {
9
9
  try {
10
10
  return await load()
11
11
  } catch (err) {
12
- // 모듈이 정말 없을 때만 안내로 바꾼다. 로드된 모듈 안에서 난 오류는
12
+ // 패키지가 정말 없을 때만 안내로 바꾼다. 로드된 모듈 안에서 난 오류는
13
13
  // 그대로 올려보내야 한다 — 삼키면 진짜 버그가 "의존성 없음"으로 둔갑한다.
14
- if (err.code !== 'ERR_MODULE_NOT_FOUND') throw err
14
+ // 상대 경로 import 오타도 같은 코드로 오지만 Node는 문구로 가른다: 패키지
15
+ // 부재는 "Cannot find package", 파일 부재는 "Cannot find module".
16
+ if (err.code !== 'ERR_MODULE_NOT_FOUND' || !/Cannot find package/.test(err.message)) throw err
15
17
  throw new LocalizedError('error.depsMissing')
16
18
  }
17
19
  }
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto'
5
5
  import { repoPath, repoPathStrict } from '../context.mjs'
6
6
  import { PROVIDERS, getProvider } from './providers/index.mjs'
7
7
  import { createT, LocalizedError } from '../i18n/index.mjs'
8
+ import { isSafeSegment } from '../untrusted.mjs'
8
9
 
9
10
  const HERE = dirname(fileURLToPath(import.meta.url))
10
11
  export const CATALOG_PATH = join(HERE, 'catalog.json')
@@ -84,16 +85,11 @@ export async function netFetch(url, opts = {}) {
84
85
  }
85
86
  }
86
87
 
87
- // 경로 세그먼트 하나로 안전한 이름인지 본다. 한글 등 유니코드는 허용하되
88
- // 경로 구분자·상위 이동·Windows 금지 문자·제어문자는 막는다.
88
+ // 경로 세그먼트 가드는 잎 모듈(lib/untrusted.mjs)에 있다 — lib/catalog.mjs도
89
+ // 쓰는데 그쪽은 design-md에 정적으로 닿을 수 없다. 이 모듈의 소비자
90
+ // (flow·scan·refresh-bundle)가 계속 여기서 가져갈 수 있게 다시 내보낸다.
89
91
  // (name은 원격 README 파싱이나 디렉터리 스캔에서 오므로 신뢰 경계 밖이다.)
90
- export function isSafeSegment(text) {
91
- const value = String(text ?? '')
92
- // 선행·후행 점은 숨김 파일이자 Windows에서 다루기 어려운 이름이다('.', '..' 포함).
93
- if (!value || value.startsWith('.') || value.endsWith('.')) return false
94
- // eslint-disable-next-line no-control-regex
95
- return !/[\\/:*?"<>|\x00-\x1f]/.test(value)
96
- }
92
+ export { isSafeSegment }
97
93
 
98
94
  // 라벨·카테고리·설명은 원격 README나 DESIGN.md 본문에서 온다. 목록과 TUI가
99
95
  // 이 값을 터미널에 그대로 찍으므로 제어문자를 걷어낸다 — ANSI 이스케이프가
@@ -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,7 @@ 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.',
153
155
  'error.jsonInvalid': 'Cannot read {path} — invalid JSON: {message}. Fix the file by hand; nothing was written.',
154
156
  'error.recordVersion': '{path} has format version {found}. This tool uses {expected} — upgrade the tool or recreate the record.',
155
157
 
@@ -184,6 +186,7 @@ export default {
184
186
  'msg.readFailed': 'read failed',
185
187
  'msg.noRootObject': 'no root object',
186
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.',
187
190
  'msg.unmanagedExisting': 'existing entry not under management',
188
191
  'msg.noManagedBlock': 'no managed block',
189
192
  'msg.linkFailed': 'link creation failed: {message}',
@@ -345,6 +348,7 @@ export default {
345
348
  'tui.noPreview': 'This item has no preview.',
346
349
  'tui.opened': 'Opened: {target}',
347
350
  'tui.noChanges': 'Nothing to change.',
351
+ 'tui.actionFailed': 'The action failed: {message}',
348
352
  'tui.submitCancelled': 'Submission cancelled.',
349
353
  'tui.selectedGlobal': 'Machine-global item — applies to this whole computer',
350
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,7 @@ 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}. 파일을 손으로 고치세요. 아무것도 쓰지 않았습니다.',
147
149
  'error.jsonInvalid': '{path}을 읽을 수 없습니다 — JSON이 깨져 있습니다: {message}. 파일을 손으로 고치세요. 아무것도 쓰지 않았습니다.',
148
150
  'error.recordVersion': '{path}의 형식 버전이 {found}입니다. 이 도구는 {expected}을 씁니다 — 도구를 올리거나 기록을 다시 만드세요.',
149
151
 
@@ -178,6 +180,7 @@ export default {
178
180
  'msg.readFailed': '읽기 실패',
179
181
  'msg.noRootObject': '루트 객체 없음',
180
182
  'msg.foreignLink': '다른 위치를 가리키는 링크',
183
+ 'log.skill.relinkDangling': '경고: {path} 링크의 대상이 더는 존재하지 않습니다(저장소를 옮겼나요?). 다시 만듭니다.',
181
184
  'msg.unmanagedExisting': '관리 대상이 아닌 기존 항목',
182
185
  'msg.noManagedBlock': '관리 블록 없음',
183
186
  'msg.linkFailed': '링크 생성 실패: {message}',
@@ -335,6 +338,7 @@ export default {
335
338
  'tui.noPreview': '이 항목은 미리보기를 제공하지 않습니다.',
336
339
  'tui.opened': '열었습니다: {target}',
337
340
  'tui.noChanges': '변경할 항목이 없습니다.',
341
+ 'tui.actionFailed': '작업이 실패했습니다: {message}',
338
342
  'tui.submitCancelled': '제출을 취소했습니다.',
339
343
  'tui.selectedGlobal': '머신 전역 항목 — 이 컴퓨터 전체에 적용됩니다',
340
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/tomlfile.mjs CHANGED
@@ -1,28 +1,34 @@
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'
5
+ import { escapeRegExp } from './untrusted.mjs'
4
6
 
5
7
  function readText(file) {
6
8
  return existsSync(file) ? readFileSync(file, 'utf8') : ''
7
9
  }
8
10
 
9
- function escapeRegExp(text) {
10
- return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
11
+ // 깨진 TOML은 읽기·쓰기 모두 거부한다. 예전에는 파싱 실패를 "섹션 없음"으로
12
+ // 돌려줘, 닫는 대괄호가 빠진 파일 끝에 같은 섹션이 하나 더 붙고 사용자는
13
+ // 파일이 깨진 것을 끝까지 몰랐다 — jsonfile.mjs의 JSON 규칙과 같다.
14
+ function parseStrict(text, file) {
15
+ try {
16
+ return parse(text)
17
+ } catch (err) {
18
+ throw new LocalizedError('error.tomlInvalid', { path: file, message: err.message.split('\n')[0] })
19
+ }
11
20
  }
12
21
 
13
22
  export function hasSection(file, name) {
14
23
  const text = readText(file)
15
24
  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
- }
25
+ const data = parseStrict(text, file)
26
+ return data.mcp_servers != null && Object.hasOwn(data.mcp_servers, name)
22
27
  }
23
28
 
24
29
  export function appendSection(file, name, lines) {
25
30
  let text = readText(file)
31
+ if (text.trim()) parseStrict(text, file)
26
32
  if (text.length > 0 && !text.endsWith('\n')) text += '\n'
27
33
  const separator = text.length > 0 ? '\n' : ''
28
34
  text += `${separator}[mcp_servers.${name}]\n${lines.join('\n')}\n`
@@ -33,6 +39,7 @@ export function appendSection(file, name, lines) {
33
39
  export function removeSection(file, name) {
34
40
  const text = readText(file)
35
41
  if (!text) return
42
+ parseStrict(text, file)
36
43
  const lines = text.split('\n')
37
44
  const headerRe = new RegExp(`^\\s*\\[mcp_servers\\.${escapeRegExp(name)}(\\.|\\])`)
38
45
  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()
@@ -0,0 +1,26 @@
1
+ // 신뢰 경계 밖에서 온 문자열의 가드. 잎 모듈이다 — bootstrap/apply.mjs가
2
+ // 쓰므로 의존성이 들어오면 npm install 없이 도는 부트스트랩
3
+ // (bootstrap.isolation.test.mjs)이 깨지고, lib/catalog.mjs가 쓰므로 design-md
4
+ // 아래에 두면 design-md를 뺀 "의존성 없는 트리"(deps.test.mjs)에서 --list가
5
+ // 원시 "Cannot find module"로 죽는다.
6
+ //
7
+ // 이 저장소의 방어는 "쓰기 경로만 엄격 검사"인데, 그 규칙은 읽은 데이터가
8
+ // 경로·명령 인자·정규식이 되는 자리를 각각 덮지는 않는다. 그래서 그 셋을
9
+ // 한 곳에 모아 둔다 — 원격 README의 항목 이름, 잠금 파일의 스킬 이름,
10
+ // 디렉터리 스캔의 폴더 이름이 전부 여기를 지난다.
11
+
12
+ // 경로 세그먼트 하나로 안전한 이름인지 본다. 한글 등 유니코드는 허용하되
13
+ // 경로 구분자·상위 이동·Windows 금지 문자·제어문자는 막는다.
14
+ export function isSafeSegment(text) {
15
+ const value = String(text ?? '')
16
+ // 선행·후행 점은 숨김 파일이자 Windows에서 다루기 어려운 이름이다('.', '..' 포함).
17
+ if (!value || value.startsWith('.') || value.endsWith('.')) return false
18
+ // eslint-disable-next-line no-control-regex
19
+ return !/[\\/:*?"<>|\x00-\x1f]/.test(value)
20
+ }
21
+
22
+ // RegExp 본문에 넣기 전의 이스케이프. 잠금 파일의 스킬 이름처럼 상류에서 온
23
+ // 문자열이 `(`를 품으면 RegExp 생성 자체가 던져 제거가 중간에 죽었다.
24
+ export function escapeRegExp(text) {
25
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
26
+ }
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.20.0",
3
+ "version": "1.21.1",
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",