@rch4com/agent-setup 1.10.0 → 1.10.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.
Files changed (48) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +199 -199
  3. package/install.mjs +163 -163
  4. package/lib/args.mjs +276 -276
  5. package/lib/bootstrap/adapter.mjs +96 -96
  6. package/lib/bootstrap/apply.mjs +380 -380
  7. package/lib/bootstrap/flow.mjs +140 -140
  8. package/lib/bootstrap/record.mjs +154 -154
  9. package/lib/bootstrap/templates.mjs +148 -148
  10. package/lib/bootstrap/text.mjs +19 -19
  11. package/lib/catalog.mjs +234 -234
  12. package/lib/claude-plugins.mjs +64 -64
  13. package/lib/clis.mjs +111 -111
  14. package/lib/context.mjs +66 -66
  15. package/lib/deps.mjs +17 -17
  16. package/lib/design-md/catalog.json +454 -454
  17. package/lib/design-md/catalog.mjs +266 -266
  18. package/lib/design-md/flow.mjs +192 -192
  19. package/lib/design-md/open.mjs +51 -51
  20. package/lib/design-md/providers/awesome-design-md.mjs +124 -124
  21. package/lib/design-md/providers/index.mjs +9 -9
  22. package/lib/design-md/scan.mjs +295 -295
  23. package/lib/engine.mjs +115 -115
  24. package/lib/gitignore.mjs +18 -18
  25. package/lib/i18n/catalog/en.mjs +426 -426
  26. package/lib/i18n/catalog/ko.mjs +411 -411
  27. package/lib/i18n/detect.mjs +39 -39
  28. package/lib/i18n/index.mjs +93 -93
  29. package/lib/items/mcp.codebase-memory.mjs +6 -6
  30. package/lib/items/mcp.notion.mjs +6 -6
  31. package/lib/items/mcp.supabase.mjs +6 -6
  32. package/lib/items/mcp.vercel.mjs +6 -6
  33. package/lib/items/plugin.mattpocock-skills.mjs +8 -8
  34. package/lib/items/plugin.ponytail.mjs +104 -104
  35. package/lib/items/skill.gsd.mjs +44 -44
  36. package/lib/items/skill.gstack.mjs +54 -54
  37. package/lib/jsonfile.mjs +40 -40
  38. package/lib/status.mjs +93 -93
  39. package/lib/tomlfile.mjs +60 -60
  40. package/lib/tui/detail.mjs +127 -127
  41. package/lib/tui/progress.mjs +160 -160
  42. package/lib/tui/render.mjs +268 -268
  43. package/lib/tui/rows.mjs +317 -317
  44. package/lib/tui/run.mjs +396 -396
  45. package/lib/tui/state.mjs +249 -249
  46. package/lib/update.mjs +85 -85
  47. package/lib/width.mjs +115 -115
  48. package/package.json +36 -36
@@ -1,54 +1,54 @@
1
- import { existsSync, rmSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import { defineSkill } from '../catalog.mjs'
4
- import { CLI_IDS } from '../clis.mjs'
5
- import { repoPath, repoPathStrict } from '../context.mjs'
6
- import { ensureGitignoreEntries } from '../gitignore.mjs'
7
- import { LocalizedError, msg } from '../i18n/index.mjs'
8
-
9
- const REL_DIR = '.claude/skills/gstack'
10
-
11
- // 상류 setup은 --host codex·kiro·opencode(와 factory)도 지원하지만 이 항목은
12
- // 인자 없이 실행하며 setup의 기본값이 claude 단독이다. README 표에 있는
13
- // --host cursor·slate는 setup의 case 문이 거부해 exit 1로 죽는다 —
14
- // 확장한다면 안전한 값은 claude·codex·kiro·factory·opencode·auto뿐이다
15
- // (2026-08-02 검증).
16
- const HOSTS = ['codex', 'kiro', 'opencode']
17
-
18
- export default defineSkill({
19
- id: 'skill.gstack', label: 'gstack', group: '__flow', scope: 'project',
20
- note: 'item.skill.gstack.note',
21
- unsupported: Object.fromEntries(
22
- CLI_IDS.filter((c) => c !== 'claude').map((c) => [
23
- c,
24
- HOSTS.includes(c) ? msg('item.unsupported.gstackHost') : msg('item.unsupported.upstreamNone'),
25
- ]),
26
- ),
27
- async detect({ root }) {
28
- return { status: existsSync(repoPath(root, REL_DIR)) ? 'installed' : 'absent' }
29
- },
30
- async install({ root, dryRun, exec }) {
31
- // clone·삭제가 일어나는 경로다. 어휘적 검사만으로는 .claude/skills가
32
- // 저장소 밖을 가리키는 링크일 때를 막지 못한다(부트스트랩이 만드는
33
- // .agents/skills Junction은 저장소 안이라 그대로 통과한다).
34
- const dir = repoPathStrict(root, REL_DIR)
35
- if (!existsSync(dir)) {
36
- const clone = await exec('git', ['clone', '--single-branch', '--depth', '1', 'https://github.com/garrytan/gstack.git', dir])
37
- if (!clone.ok) throw new LocalizedError('error.gstackClone', { output: clone.output })
38
- const setup = await exec('bash', ['./setup'], { cwd: dir })
39
- if (!setup.ok) {
40
- // setup 실패 잔존물이 detect를 installed로 오판시키지 않도록 정리한다.
41
- rmSync(dir, { recursive: true, force: true })
42
- throw new LocalizedError('error.gstackSetup', { output: setup.output })
43
- }
44
- }
45
- // 부트스트랩 저장소에서는 .claude/skills가 .agents/skills Junction이므로 두 경로 모두 무시 처리
46
- if (!dryRun) ensureGitignoreEntries(root, ['.claude/skills/gstack', '.agents/skills/gstack'])
47
- },
48
- async uninstall({ root, dryRun, exec }) {
49
- const dir = repoPathStrict(root, REL_DIR)
50
- if (!existsSync(dir)) return
51
- await exec('bash', [join(dir, 'bin', 'gstack-uninstall'), '--force'], { cwd: dir }) // 실패해도 디렉터리 삭제로 폴백
52
- if (!dryRun && existsSync(dir)) rmSync(dir, { recursive: true, force: true })
53
- },
54
- })
1
+ import { existsSync, rmSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { defineSkill } from '../catalog.mjs'
4
+ import { CLI_IDS } from '../clis.mjs'
5
+ import { repoPath, repoPathStrict } from '../context.mjs'
6
+ import { ensureGitignoreEntries } from '../gitignore.mjs'
7
+ import { LocalizedError, msg } from '../i18n/index.mjs'
8
+
9
+ const REL_DIR = '.claude/skills/gstack'
10
+
11
+ // 상류 setup은 --host codex·kiro·opencode(와 factory)도 지원하지만 이 항목은
12
+ // 인자 없이 실행하며 setup의 기본값이 claude 단독이다. README 표에 있는
13
+ // --host cursor·slate는 setup의 case 문이 거부해 exit 1로 죽는다 —
14
+ // 확장한다면 안전한 값은 claude·codex·kiro·factory·opencode·auto뿐이다
15
+ // (2026-08-02 검증).
16
+ const HOSTS = ['codex', 'kiro', 'opencode']
17
+
18
+ export default defineSkill({
19
+ id: 'skill.gstack', label: 'gstack', group: '__flow', scope: 'project',
20
+ note: 'item.skill.gstack.note',
21
+ unsupported: Object.fromEntries(
22
+ CLI_IDS.filter((c) => c !== 'claude').map((c) => [
23
+ c,
24
+ HOSTS.includes(c) ? msg('item.unsupported.gstackHost') : msg('item.unsupported.upstreamNone'),
25
+ ]),
26
+ ),
27
+ async detect({ root }) {
28
+ return { status: existsSync(repoPath(root, REL_DIR)) ? 'installed' : 'absent' }
29
+ },
30
+ async install({ root, dryRun, exec }) {
31
+ // clone·삭제가 일어나는 경로다. 어휘적 검사만으로는 .claude/skills가
32
+ // 저장소 밖을 가리키는 링크일 때를 막지 못한다(부트스트랩이 만드는
33
+ // .agents/skills Junction은 저장소 안이라 그대로 통과한다).
34
+ const dir = repoPathStrict(root, REL_DIR)
35
+ if (!existsSync(dir)) {
36
+ const clone = await exec('git', ['clone', '--single-branch', '--depth', '1', 'https://github.com/garrytan/gstack.git', dir])
37
+ if (!clone.ok) throw new LocalizedError('error.gstackClone', { output: clone.output })
38
+ const setup = await exec('bash', ['./setup'], { cwd: dir })
39
+ if (!setup.ok) {
40
+ // setup 실패 잔존물이 detect를 installed로 오판시키지 않도록 정리한다.
41
+ rmSync(dir, { recursive: true, force: true })
42
+ throw new LocalizedError('error.gstackSetup', { output: setup.output })
43
+ }
44
+ }
45
+ // 부트스트랩 저장소에서는 .claude/skills가 .agents/skills Junction이므로 두 경로 모두 무시 처리
46
+ if (!dryRun) ensureGitignoreEntries(root, ['.claude/skills/gstack', '.agents/skills/gstack'])
47
+ },
48
+ async uninstall({ root, dryRun, exec }) {
49
+ const dir = repoPathStrict(root, REL_DIR)
50
+ if (!existsSync(dir)) return
51
+ await exec('bash', [join(dir, 'bin', 'gstack-uninstall'), '--force'], { cwd: dir }) // 실패해도 디렉터리 삭제로 폴백
52
+ if (!dryRun && existsSync(dir)) rmSync(dir, { recursive: true, force: true })
53
+ },
54
+ })
package/lib/jsonfile.mjs CHANGED
@@ -1,40 +1,40 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
2
- import { dirname } from 'node:path'
3
- import { parse, modify, applyEdits } from 'jsonc-parser'
4
-
5
- const FORMAT = { formattingOptions: { insertSpaces: true, tabSize: 2 } }
6
-
7
- function readText(file) {
8
- return existsSync(file) ? readFileSync(file, 'utf8') : ''
9
- }
10
-
11
- export function readJson(file) {
12
- const text = readText(file)
13
- if (!text.trim()) return undefined
14
- return parse(text)
15
- }
16
-
17
- export function setKey(file, path, value) {
18
- let text = readText(file)
19
- if (!text.trim()) text = '{}\n'
20
- const edits = modify(text, path, value, FORMAT)
21
- mkdirSync(dirname(file), { recursive: true })
22
- writeFileSync(file, applyEdits(text, edits))
23
- }
24
-
25
- export function removeKey(file, path) {
26
- const text = readText(file)
27
- if (!text.trim()) return
28
- if (getIn(parse(text), path) === undefined) return
29
- const edits = modify(text, path, undefined, FORMAT)
30
- writeFileSync(file, applyEdits(text, edits))
31
- }
32
-
33
- export function getIn(data, path) {
34
- let cur = data
35
- for (const key of path) {
36
- if (cur == null || typeof cur !== 'object') return undefined
37
- cur = cur[key]
38
- }
39
- return cur
40
- }
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+ import { parse, modify, applyEdits } from 'jsonc-parser'
4
+
5
+ const FORMAT = { formattingOptions: { insertSpaces: true, tabSize: 2 } }
6
+
7
+ function readText(file) {
8
+ return existsSync(file) ? readFileSync(file, 'utf8') : ''
9
+ }
10
+
11
+ export function readJson(file) {
12
+ const text = readText(file)
13
+ if (!text.trim()) return undefined
14
+ return parse(text)
15
+ }
16
+
17
+ export function setKey(file, path, value) {
18
+ let text = readText(file)
19
+ if (!text.trim()) text = '{}\n'
20
+ const edits = modify(text, path, value, FORMAT)
21
+ mkdirSync(dirname(file), { recursive: true })
22
+ writeFileSync(file, applyEdits(text, edits))
23
+ }
24
+
25
+ export function removeKey(file, path) {
26
+ const text = readText(file)
27
+ if (!text.trim()) return
28
+ if (getIn(parse(text), path) === undefined) return
29
+ const edits = modify(text, path, undefined, FORMAT)
30
+ writeFileSync(file, applyEdits(text, edits))
31
+ }
32
+
33
+ export function getIn(data, path) {
34
+ let cur = data
35
+ for (const key of path) {
36
+ if (cur == null || typeof cur !== 'object') return undefined
37
+ cur = cur[key]
38
+ }
39
+ return cur
40
+ }
package/lib/status.mjs CHANGED
@@ -1,93 +1,93 @@
1
- // status — 의도(설치 기록) / 실제(스캔) / 가용(최신 패키지)을 나란히 보여준다.
2
- //
3
- // 실제 상태의 근거는 여전히 스캔이다. 기록은 의도일 뿐이므로 둘이 어긋나면
4
- // 어느 쪽에만 있는지 갈라 보여준다 — 수동 설치·제거를 그대로 잡는다는
5
- // 기존 강점을 기록이 가리지 않게 한다.
6
- import { MANIFEST } from './bootstrap/manifest.mjs'
7
- import { updateBlocks, updateFiles } from './bootstrap/apply.mjs'
8
- import { readRecord, toolVersion } from './bootstrap/record.mjs'
9
- import { createT } from './i18n/index.mjs'
10
-
11
- export async function collectStatus(root, { manifest = MANIFEST, items = [], latest } = {}) {
12
- const record = readRecord(root)
13
-
14
- // dry-run으로 갱신 판정만 얻는다 — 판정 로직을 두 벌 두면 status와 update가
15
- // 다른 답을 내는 순간이 온다.
16
- const silent = () => {}
17
- const verdicts = record
18
- ? [
19
- ...updateFiles(root, manifest.files, { managed: record.managed, dryRun: true, log: silent }),
20
- ...updateBlocks(root, manifest.blocks, { managed: record.managed, dryRun: true, log: silent }),
21
- ]
22
- : []
23
-
24
- const count = (action) => verdicts.filter((v) => v.action === action).length
25
-
26
- const states = []
27
- for (const item of items) {
28
- try {
29
- const r = await item.detect({ root })
30
- states.push({ id: item.id, status: r.status })
31
- } catch {
32
- // 감지 실패로 status 전체가 죽으면 진단 도구가 아니다.
33
- states.push({ id: item.id, status: 'absent' })
34
- }
35
- }
36
- const installed = states.filter((s) => s.status !== 'absent').map((s) => s.id)
37
- const intended = new Set(record?.items ?? [])
38
-
39
- return {
40
- hasRecord: Boolean(record),
41
- tool: {
42
- pinned: record?.pinnedVersion ?? null,
43
- running: toolVersion(),
44
- latest: latest ?? null,
45
- },
46
- files: {
47
- total: verdicts.length,
48
- current: count('skip'),
49
- pending: count('update') + count('create'),
50
- drift: count('drift'),
51
- },
52
- items: {
53
- installed,
54
- recordOnly: [...intended].filter((id) => !installed.includes(id)),
55
- repoOnly: installed.filter((id) => !intended.has(id)),
56
- },
57
- }
58
- }
59
-
60
- export function formatStatus(report, t = createT('en')) {
61
- const lines = []
62
- const { tool, files, items } = report
63
-
64
- if (!report.hasRecord) {
65
- lines.push(t('status.noRecord'))
66
- lines.push(t('status.noRecord.hint1'))
67
- lines.push(t('status.noRecord.hint2'))
68
- return lines.join('\n')
69
- }
70
-
71
- const version = tool.latest && tool.latest !== tool.running
72
- ? t('status.version.latest', { pinned: tool.pinned, running: tool.running, latest: tool.latest })
73
- : t('status.version.pinned', { pinned: tool.pinned, running: tool.running })
74
- lines.push(t('status.row.tool', { version }))
75
- if (tool.pinned !== tool.running) lines.push(t('status.hint.update'))
76
-
77
- lines.push(t('status.row.files', files))
78
- if (files.pending > 0) lines.push(t('status.hint.pending'))
79
- if (files.drift > 0) lines.push(t('status.hint.drift'))
80
-
81
- lines.push(t('status.row.items', { list: items.installed.join(', ') || t('status.none') }))
82
- if (items.recordOnly.length) lines.push(t('status.row.recordOnly', { list: items.recordOnly.join(', ') }))
83
- if (items.repoOnly.length) lines.push(t('status.row.repoOnly', { list: items.repoOnly.join(', ') }))
84
-
85
- return lines.join('\n')
86
- }
87
-
88
- export async function runStatus(root, { json = false, log = console.log, t = createT('en') } = {}) {
89
- const { loadItems } = await import('./catalog.mjs')
90
- const report = await collectStatus(root, { items: await loadItems() })
91
- log(json ? JSON.stringify(report, null, 2) : formatStatus(report, t))
92
- return report
93
- }
1
+ // status — 의도(설치 기록) / 실제(스캔) / 가용(최신 패키지)을 나란히 보여준다.
2
+ //
3
+ // 실제 상태의 근거는 여전히 스캔이다. 기록은 의도일 뿐이므로 둘이 어긋나면
4
+ // 어느 쪽에만 있는지 갈라 보여준다 — 수동 설치·제거를 그대로 잡는다는
5
+ // 기존 강점을 기록이 가리지 않게 한다.
6
+ import { MANIFEST } from './bootstrap/manifest.mjs'
7
+ import { updateBlocks, updateFiles } from './bootstrap/apply.mjs'
8
+ import { readRecord, toolVersion } from './bootstrap/record.mjs'
9
+ import { createT } from './i18n/index.mjs'
10
+
11
+ export async function collectStatus(root, { manifest = MANIFEST, items = [], latest } = {}) {
12
+ const record = readRecord(root)
13
+
14
+ // dry-run으로 갱신 판정만 얻는다 — 판정 로직을 두 벌 두면 status와 update가
15
+ // 다른 답을 내는 순간이 온다.
16
+ const silent = () => {}
17
+ const verdicts = record
18
+ ? [
19
+ ...updateFiles(root, manifest.files, { managed: record.managed, dryRun: true, log: silent }),
20
+ ...updateBlocks(root, manifest.blocks, { managed: record.managed, dryRun: true, log: silent }),
21
+ ]
22
+ : []
23
+
24
+ const count = (action) => verdicts.filter((v) => v.action === action).length
25
+
26
+ const states = []
27
+ for (const item of items) {
28
+ try {
29
+ const r = await item.detect({ root })
30
+ states.push({ id: item.id, status: r.status })
31
+ } catch {
32
+ // 감지 실패로 status 전체가 죽으면 진단 도구가 아니다.
33
+ states.push({ id: item.id, status: 'absent' })
34
+ }
35
+ }
36
+ const installed = states.filter((s) => s.status !== 'absent').map((s) => s.id)
37
+ const intended = new Set(record?.items ?? [])
38
+
39
+ return {
40
+ hasRecord: Boolean(record),
41
+ tool: {
42
+ pinned: record?.pinnedVersion ?? null,
43
+ running: toolVersion(),
44
+ latest: latest ?? null,
45
+ },
46
+ files: {
47
+ total: verdicts.length,
48
+ current: count('skip'),
49
+ pending: count('update') + count('create'),
50
+ drift: count('drift'),
51
+ },
52
+ items: {
53
+ installed,
54
+ recordOnly: [...intended].filter((id) => !installed.includes(id)),
55
+ repoOnly: installed.filter((id) => !intended.has(id)),
56
+ },
57
+ }
58
+ }
59
+
60
+ export function formatStatus(report, t = createT('en')) {
61
+ const lines = []
62
+ const { tool, files, items } = report
63
+
64
+ if (!report.hasRecord) {
65
+ lines.push(t('status.noRecord'))
66
+ lines.push(t('status.noRecord.hint1'))
67
+ lines.push(t('status.noRecord.hint2'))
68
+ return lines.join('\n')
69
+ }
70
+
71
+ const version = tool.latest && tool.latest !== tool.running
72
+ ? t('status.version.latest', { pinned: tool.pinned, running: tool.running, latest: tool.latest })
73
+ : t('status.version.pinned', { pinned: tool.pinned, running: tool.running })
74
+ lines.push(t('status.row.tool', { version }))
75
+ if (tool.pinned !== tool.running) lines.push(t('status.hint.update'))
76
+
77
+ lines.push(t('status.row.files', files))
78
+ if (files.pending > 0) lines.push(t('status.hint.pending'))
79
+ if (files.drift > 0) lines.push(t('status.hint.drift'))
80
+
81
+ lines.push(t('status.row.items', { list: items.installed.join(', ') || t('status.none') }))
82
+ if (items.recordOnly.length) lines.push(t('status.row.recordOnly', { list: items.recordOnly.join(', ') }))
83
+ if (items.repoOnly.length) lines.push(t('status.row.repoOnly', { list: items.repoOnly.join(', ') }))
84
+
85
+ return lines.join('\n')
86
+ }
87
+
88
+ export async function runStatus(root, { json = false, log = console.log, t = createT('en') } = {}) {
89
+ const { loadItems } = await import('./catalog.mjs')
90
+ const report = await collectStatus(root, { items: await loadItems() })
91
+ log(json ? JSON.stringify(report, null, 2) : formatStatus(report, t))
92
+ return report
93
+ }
package/lib/tomlfile.mjs CHANGED
@@ -1,60 +1,60 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
2
- import { dirname } from 'node:path'
3
- import { parse } from 'smol-toml'
4
-
5
- function readText(file) {
6
- return existsSync(file) ? readFileSync(file, 'utf8') : ''
7
- }
8
-
9
- function escapeRegExp(text) {
10
- return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
11
- }
12
-
13
- export function hasSection(file, name) {
14
- const text = readText(file)
15
- 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
- }
22
- }
23
-
24
- export function appendSection(file, name, lines) {
25
- let text = readText(file)
26
- if (text.length > 0 && !text.endsWith('\n')) text += '\n'
27
- const separator = text.length > 0 ? '\n' : ''
28
- text += `${separator}[mcp_servers.${name}]\n${lines.join('\n')}\n`
29
- mkdirSync(dirname(file), { recursive: true })
30
- writeFileSync(file, text)
31
- }
32
-
33
- export function removeSection(file, name) {
34
- const text = readText(file)
35
- if (!text) return
36
- const lines = text.split('\n')
37
- const headerRe = new RegExp(`^\\s*\\[mcp_servers\\.${escapeRegExp(name)}(\\.|\\])`)
38
- const anyHeaderRe = /^\s*\[/
39
- const out = []
40
- let skipping = false
41
- let removedAt = -1
42
- for (const line of lines) {
43
- if (skipping && anyHeaderRe.test(line) && !headerRe.test(line)) skipping = false
44
- if (headerRe.test(line)) {
45
- skipping = true
46
- if (removedAt === -1) removedAt = out.length
47
- }
48
- if (!skipping) out.push(line)
49
- }
50
- // 제거 지점의 경계에서만 빈 줄 하나를 정리해, 다른 위치의 빈 줄들은 그대로 보존한다.
51
- if (
52
- removedAt > 0 &&
53
- removedAt < out.length &&
54
- out[removedAt - 1].trim() === '' &&
55
- (out[removedAt] ?? '').trim() === ''
56
- ) {
57
- out.splice(removedAt - 1, 1)
58
- }
59
- writeFileSync(file, out.join('\n'))
60
- }
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+ import { parse } from 'smol-toml'
4
+
5
+ function readText(file) {
6
+ return existsSync(file) ? readFileSync(file, 'utf8') : ''
7
+ }
8
+
9
+ function escapeRegExp(text) {
10
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
11
+ }
12
+
13
+ export function hasSection(file, name) {
14
+ const text = readText(file)
15
+ 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
+ }
22
+ }
23
+
24
+ export function appendSection(file, name, lines) {
25
+ let text = readText(file)
26
+ if (text.length > 0 && !text.endsWith('\n')) text += '\n'
27
+ const separator = text.length > 0 ? '\n' : ''
28
+ text += `${separator}[mcp_servers.${name}]\n${lines.join('\n')}\n`
29
+ mkdirSync(dirname(file), { recursive: true })
30
+ writeFileSync(file, text)
31
+ }
32
+
33
+ export function removeSection(file, name) {
34
+ const text = readText(file)
35
+ if (!text) return
36
+ const lines = text.split('\n')
37
+ const headerRe = new RegExp(`^\\s*\\[mcp_servers\\.${escapeRegExp(name)}(\\.|\\])`)
38
+ const anyHeaderRe = /^\s*\[/
39
+ const out = []
40
+ let skipping = false
41
+ let removedAt = -1
42
+ for (const line of lines) {
43
+ if (skipping && anyHeaderRe.test(line) && !headerRe.test(line)) skipping = false
44
+ if (headerRe.test(line)) {
45
+ skipping = true
46
+ if (removedAt === -1) removedAt = out.length
47
+ }
48
+ if (!skipping) out.push(line)
49
+ }
50
+ // 제거 지점의 경계에서만 빈 줄 하나를 정리해, 다른 위치의 빈 줄들은 그대로 보존한다.
51
+ if (
52
+ removedAt > 0 &&
53
+ removedAt < out.length &&
54
+ out[removedAt - 1].trim() === '' &&
55
+ (out[removedAt] ?? '').trim() === ''
56
+ ) {
57
+ out.splice(removedAt - 1, 1)
58
+ }
59
+ writeFileSync(file, out.join('\n'))
60
+ }