@rch4com/agent-setup 1.9.0 → 1.10.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 (49) 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 -425
  26. package/lib/i18n/catalog/ko.mjs +411 -410
  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/items/skill.hallmark.mjs +10 -0
  38. package/lib/jsonfile.mjs +40 -40
  39. package/lib/status.mjs +93 -93
  40. package/lib/tomlfile.mjs +60 -60
  41. package/lib/tui/detail.mjs +127 -127
  42. package/lib/tui/progress.mjs +160 -160
  43. package/lib/tui/render.mjs +268 -268
  44. package/lib/tui/rows.mjs +317 -317
  45. package/lib/tui/run.mjs +396 -396
  46. package/lib/tui/state.mjs +249 -249
  47. package/lib/update.mjs +85 -85
  48. package/lib/width.mjs +115 -115
  49. package/package.json +36 -36
@@ -1,140 +1,140 @@
1
- // 부트스트랩 진입점 — 순서와 보고만 담당한다.
2
- // 무엇을 만들지는 manifest.mjs가, 어떻게 만들지는 apply.mjs·adapter.mjs가 안다.
3
- import { MANIFEST } from './manifest.mjs'
4
- import {
5
- configureAdapterSafe, ensureBlocks, ensureDirs, ensureFiles, ensureIgnore, ensureJsonKeys,
6
- } from './apply.mjs'
7
- import { RECORD_REL, collectManaged, emptyRecord, readRecord, writeRecord } from './record.mjs'
8
- import { createT, LocalizedError, toText } from '../i18n/index.mjs'
9
- import { width } from '../width.mjs'
10
-
11
- const SKILL_MODES = ['auto', 'link', 'copy']
12
-
13
- // 오른쪽 정렬. width.mjs의 pad()는 오른쪽에 공백을 채워 왼쪽 정렬만 하므로
14
- // 숫자에는 못 쓴다 — 자릿수가 9에서 10으로 늘 때 열이 흔들리지 않으려면
15
- // 왼쪽에 채워야 한다. 숫자만 다루니 실제로는 문자 수와 같지만, 이 저장소의
16
- // 다른 폭 계산과 방식을 맞추려고 width()로 잰다.
17
- function alignRight(text, w) {
18
- return ' '.repeat(Math.max(0, w - width(text))) + text
19
- }
20
-
21
- // "[ 3/39]" 꼴의 단계 번호. total의 자릿수에 맞춰 n을 오른쪽 정렬한다.
22
- function counterTag(n, total) {
23
- const totalText = String(total)
24
- return `[${alignRight(String(n), width(totalText))}/${totalText}]`
25
- }
26
-
27
- export function runBootstrap(root, opts = {}) {
28
- const { dryRun = false, skillMode = 'auto', adopt = false, log = console.log, manifest = MANIFEST, onProgress = null, t = createT('en') } = opts
29
-
30
- if (!SKILL_MODES.includes(skillMode)) {
31
- throw new LocalizedError('error.badSkillModeRuntime', { list: SKILL_MODES.join(', '), value: skillMode })
32
- }
33
-
34
- const say = (message) => log(`[agent-setup] ${message}`)
35
- const ctx = { dryRun, log: say, t }
36
-
37
- say(t('log.repoRoot', { path: root }))
38
- say(t('log.noGlobalWrites'))
39
-
40
- // 총 단계 수. 동기 파일 작업이라 단계 바만 돌지만, 무엇이 남았는지는
41
- // 알려 줄 수 있다. adopt는 파일을 만들지 않으므로 기록 쓰기 한 단계뿐이다.
42
- // 예측일 뿐이다 — ensureIgnore는 부정 항목이 기존 .gitignore에 가려지면
43
- // manifest.ignore.length보다 많은 결과(경고 포함)를 돌려줄 수 있다.
44
- let total = adopt
45
- ? 1
46
- : manifest.dirs.length + manifest.files.length + (manifest.settings ?? []).length
47
- + manifest.blocks.length + manifest.adapters.length + manifest.ignore.length + 1
48
- let done = 0
49
- const step = (results) => {
50
- const list = Array.isArray(results) ? results : [results]
51
- for (const r of list) {
52
- done++
53
- // 실제 진행이 예측을 앞지르면 천장을 함께 올린다. total을 고정해 두면
54
- // done > total인 알림이 나가 진행률이 100%를 넘고 바가 자기 너비를
55
- // 넘겨 그려진다 — done을 total에 맞춰 죽이면 화면은 멈춘 것처럼 보이는데
56
- // 남은 단계가 조용히 계속 도는, 더 나쁜 오류가 된다.
57
- if (done > total) total = done
58
- if (onProgress) onProgress({ done, total, path: r?.path ?? null })
59
- }
60
- return list
61
- }
62
-
63
- // 단계 안에서 나온 로그는 모아 뒀다가 step()이 done/total을 확정한 뒤에
64
- // 번호를 붙여 낸다. ensureDirs는 이미 있는 항목을 건너뛸 때 아예 로그를
65
- // 내지 않고, ensureIgnore는 여러 항목을 한 줄로 묶어 보고한다 — "몇 번째
66
- // 줄이 찍혔는가"와 "몇 번째 항목을 처리했는가"가 그래서 어긋난다. 번호는
67
- // 항목 순서가 아니라 처리 위치(done)를 보여 주는 것이 목적이므로, 이
68
- // 구간이 시작한 위치(before)부터 실제로 찍힌 줄 수만큼만 순서대로 매긴다.
69
- // 못 채운 자리는 건너뛰지만, 다음 구간은 항상 정확한 위치에서 이어져
70
- // 마지막 줄은 반드시 total 가까이에서 끝난다.
71
- //
72
- // onProgress는 그대로 step()이 몬다 — 기계가 읽는 진행 이벤트와 사람이
73
- // 읽는 로그 줄은 타이밍이 다르다(ensureX 호출이 끝나야 결과 배열이
74
- // 나오고, 그제서야 done/total이 확정된다). 두 소비자를 하나의 흐름으로
75
- // 우겨넣는 대신, 같은 done/total 계산을 공유하는 별도 경로로 뒀다.
76
- const runStep = (run) => {
77
- const buffer = []
78
- const before = done
79
- const list = step(run({ ...ctx, log: (message) => buffer.push(message) }))
80
- let n = before
81
- for (const message of buffer) {
82
- n++
83
- say(`${counterTag(n, total)} ${message}`)
84
- }
85
- return list
86
- }
87
-
88
- // --adopt는 이미 있는 저장소를 기록 체계로 끌어오는 용도라 파일을 만들지
89
- // 않는다. 설치기를 복사해 쓰던 저장소가 여기로 들어온다.
90
- const results = adopt ? [] : [
91
- ...runStep((c) => ensureDirs(root, manifest.dirs, c)),
92
- ...runStep((c) => ensureFiles(root, manifest.files, c)),
93
- ...runStep((c) => ensureJsonKeys(root, manifest.settings ?? [], c)),
94
- ...runStep((c) => ensureBlocks(root, manifest.blocks, c)),
95
- ]
96
-
97
- if (!adopt) {
98
- // 어댑터는 항목별로 실패를 격리한다 — 하나가 실패해도 나머지를 계속한다.
99
- // update.mjs도 같은 격리를 써야 하므로 apply.mjs가 단일 출처다.
100
- for (const entry of manifest.adapters) {
101
- results.push(...runStep((c) => configureAdapterSafe(root, entry, { ...c, skillMode })))
102
- }
103
-
104
- results.push(...runStep((c) => ensureIgnore(root, manifest.ignore, c)))
105
- }
106
-
107
- // 기존 기록의 items·design은 보존한다 — 부트스트랩은 배선만 다루므로
108
- // 사용자가 고른 설치 항목을 지울 권한이 없다. dry-run에서는 읽지 않는다:
109
- // 깨진 기록이 "무엇이 바뀔지 확인"까지 막을 이유가 없다.
110
- const previous = dryRun ? null : readRecord(root)
111
- const record = {
112
- ...emptyRecord({ skillMode }),
113
- // 부트스트랩은 배선만 다룬다. 사용자가 고른 언어·항목을 지울 권한이 없다.
114
- lang: previous?.lang ?? null,
115
- items: previous?.items ?? [],
116
- design: previous?.design ?? [],
117
- managed: collectManaged(root, manifest),
118
- }
119
- results.push(...runStep((c) => writeRecord(root, record, c)))
120
-
121
- // 실제 결과 수가 예측과 다르면 마지막 알림으로 100%를 맞춘다.
122
- // 진행 바가 87%에서 끝나면 사용자는 무엇이 실패했다고 읽는다.
123
- if (onProgress && done !== total) onProgress({ done: total, total, path: null })
124
-
125
- const failed = results.filter((r) => !r.ok)
126
- log('')
127
- if (failed.length > 0) {
128
- say(t('bootstrap.failures', { count: failed.length }))
129
- for (const f of failed) say(` ✖ ${f.path} — ${toText(t, f.message)}`)
130
- }
131
- say(t('bootstrap.done'))
132
- say(t('bootstrap.sharedGuide'))
133
- say(t('bootstrap.sharedSkills'))
134
- say(t('bootstrap.tools', { list: manifest.tools.join(', ') }))
135
- say(t('bootstrap.repoOnly'))
136
- say(t('bootstrap.noOverwrite'))
137
- say(t('bootstrap.record', { path: RECORD_REL }))
138
-
139
- return { results, failed, record }
140
- }
1
+ // 부트스트랩 진입점 — 순서와 보고만 담당한다.
2
+ // 무엇을 만들지는 manifest.mjs가, 어떻게 만들지는 apply.mjs·adapter.mjs가 안다.
3
+ import { MANIFEST } from './manifest.mjs'
4
+ import {
5
+ configureAdapterSafe, ensureBlocks, ensureDirs, ensureFiles, ensureIgnore, ensureJsonKeys,
6
+ } from './apply.mjs'
7
+ import { RECORD_REL, collectManaged, emptyRecord, readRecord, writeRecord } from './record.mjs'
8
+ import { createT, LocalizedError, toText } from '../i18n/index.mjs'
9
+ import { width } from '../width.mjs'
10
+
11
+ const SKILL_MODES = ['auto', 'link', 'copy']
12
+
13
+ // 오른쪽 정렬. width.mjs의 pad()는 오른쪽에 공백을 채워 왼쪽 정렬만 하므로
14
+ // 숫자에는 못 쓴다 — 자릿수가 9에서 10으로 늘 때 열이 흔들리지 않으려면
15
+ // 왼쪽에 채워야 한다. 숫자만 다루니 실제로는 문자 수와 같지만, 이 저장소의
16
+ // 다른 폭 계산과 방식을 맞추려고 width()로 잰다.
17
+ function alignRight(text, w) {
18
+ return ' '.repeat(Math.max(0, w - width(text))) + text
19
+ }
20
+
21
+ // "[ 3/39]" 꼴의 단계 번호. total의 자릿수에 맞춰 n을 오른쪽 정렬한다.
22
+ function counterTag(n, total) {
23
+ const totalText = String(total)
24
+ return `[${alignRight(String(n), width(totalText))}/${totalText}]`
25
+ }
26
+
27
+ export function runBootstrap(root, opts = {}) {
28
+ const { dryRun = false, skillMode = 'auto', adopt = false, log = console.log, manifest = MANIFEST, onProgress = null, t = createT('en') } = opts
29
+
30
+ if (!SKILL_MODES.includes(skillMode)) {
31
+ throw new LocalizedError('error.badSkillModeRuntime', { list: SKILL_MODES.join(', '), value: skillMode })
32
+ }
33
+
34
+ const say = (message) => log(`[agent-setup] ${message}`)
35
+ const ctx = { dryRun, log: say, t }
36
+
37
+ say(t('log.repoRoot', { path: root }))
38
+ say(t('log.noGlobalWrites'))
39
+
40
+ // 총 단계 수. 동기 파일 작업이라 단계 바만 돌지만, 무엇이 남았는지는
41
+ // 알려 줄 수 있다. adopt는 파일을 만들지 않으므로 기록 쓰기 한 단계뿐이다.
42
+ // 예측일 뿐이다 — ensureIgnore는 부정 항목이 기존 .gitignore에 가려지면
43
+ // manifest.ignore.length보다 많은 결과(경고 포함)를 돌려줄 수 있다.
44
+ let total = adopt
45
+ ? 1
46
+ : manifest.dirs.length + manifest.files.length + (manifest.settings ?? []).length
47
+ + manifest.blocks.length + manifest.adapters.length + manifest.ignore.length + 1
48
+ let done = 0
49
+ const step = (results) => {
50
+ const list = Array.isArray(results) ? results : [results]
51
+ for (const r of list) {
52
+ done++
53
+ // 실제 진행이 예측을 앞지르면 천장을 함께 올린다. total을 고정해 두면
54
+ // done > total인 알림이 나가 진행률이 100%를 넘고 바가 자기 너비를
55
+ // 넘겨 그려진다 — done을 total에 맞춰 죽이면 화면은 멈춘 것처럼 보이는데
56
+ // 남은 단계가 조용히 계속 도는, 더 나쁜 오류가 된다.
57
+ if (done > total) total = done
58
+ if (onProgress) onProgress({ done, total, path: r?.path ?? null })
59
+ }
60
+ return list
61
+ }
62
+
63
+ // 단계 안에서 나온 로그는 모아 뒀다가 step()이 done/total을 확정한 뒤에
64
+ // 번호를 붙여 낸다. ensureDirs는 이미 있는 항목을 건너뛸 때 아예 로그를
65
+ // 내지 않고, ensureIgnore는 여러 항목을 한 줄로 묶어 보고한다 — "몇 번째
66
+ // 줄이 찍혔는가"와 "몇 번째 항목을 처리했는가"가 그래서 어긋난다. 번호는
67
+ // 항목 순서가 아니라 처리 위치(done)를 보여 주는 것이 목적이므로, 이
68
+ // 구간이 시작한 위치(before)부터 실제로 찍힌 줄 수만큼만 순서대로 매긴다.
69
+ // 못 채운 자리는 건너뛰지만, 다음 구간은 항상 정확한 위치에서 이어져
70
+ // 마지막 줄은 반드시 total 가까이에서 끝난다.
71
+ //
72
+ // onProgress는 그대로 step()이 몬다 — 기계가 읽는 진행 이벤트와 사람이
73
+ // 읽는 로그 줄은 타이밍이 다르다(ensureX 호출이 끝나야 결과 배열이
74
+ // 나오고, 그제서야 done/total이 확정된다). 두 소비자를 하나의 흐름으로
75
+ // 우겨넣는 대신, 같은 done/total 계산을 공유하는 별도 경로로 뒀다.
76
+ const runStep = (run) => {
77
+ const buffer = []
78
+ const before = done
79
+ const list = step(run({ ...ctx, log: (message) => buffer.push(message) }))
80
+ let n = before
81
+ for (const message of buffer) {
82
+ n++
83
+ say(`${counterTag(n, total)} ${message}`)
84
+ }
85
+ return list
86
+ }
87
+
88
+ // --adopt는 이미 있는 저장소를 기록 체계로 끌어오는 용도라 파일을 만들지
89
+ // 않는다. 설치기를 복사해 쓰던 저장소가 여기로 들어온다.
90
+ const results = adopt ? [] : [
91
+ ...runStep((c) => ensureDirs(root, manifest.dirs, c)),
92
+ ...runStep((c) => ensureFiles(root, manifest.files, c)),
93
+ ...runStep((c) => ensureJsonKeys(root, manifest.settings ?? [], c)),
94
+ ...runStep((c) => ensureBlocks(root, manifest.blocks, c)),
95
+ ]
96
+
97
+ if (!adopt) {
98
+ // 어댑터는 항목별로 실패를 격리한다 — 하나가 실패해도 나머지를 계속한다.
99
+ // update.mjs도 같은 격리를 써야 하므로 apply.mjs가 단일 출처다.
100
+ for (const entry of manifest.adapters) {
101
+ results.push(...runStep((c) => configureAdapterSafe(root, entry, { ...c, skillMode })))
102
+ }
103
+
104
+ results.push(...runStep((c) => ensureIgnore(root, manifest.ignore, c)))
105
+ }
106
+
107
+ // 기존 기록의 items·design은 보존한다 — 부트스트랩은 배선만 다루므로
108
+ // 사용자가 고른 설치 항목을 지울 권한이 없다. dry-run에서는 읽지 않는다:
109
+ // 깨진 기록이 "무엇이 바뀔지 확인"까지 막을 이유가 없다.
110
+ const previous = dryRun ? null : readRecord(root)
111
+ const record = {
112
+ ...emptyRecord({ skillMode }),
113
+ // 부트스트랩은 배선만 다룬다. 사용자가 고른 언어·항목을 지울 권한이 없다.
114
+ lang: previous?.lang ?? null,
115
+ items: previous?.items ?? [],
116
+ design: previous?.design ?? [],
117
+ managed: collectManaged(root, manifest),
118
+ }
119
+ results.push(...runStep((c) => writeRecord(root, record, c)))
120
+
121
+ // 실제 결과 수가 예측과 다르면 마지막 알림으로 100%를 맞춘다.
122
+ // 진행 바가 87%에서 끝나면 사용자는 무엇이 실패했다고 읽는다.
123
+ if (onProgress && done !== total) onProgress({ done: total, total, path: null })
124
+
125
+ const failed = results.filter((r) => !r.ok)
126
+ log('')
127
+ if (failed.length > 0) {
128
+ say(t('bootstrap.failures', { count: failed.length }))
129
+ for (const f of failed) say(` ✖ ${f.path} — ${toText(t, f.message)}`)
130
+ }
131
+ say(t('bootstrap.done'))
132
+ say(t('bootstrap.sharedGuide'))
133
+ say(t('bootstrap.sharedSkills'))
134
+ say(t('bootstrap.tools', { list: manifest.tools.join(', ') }))
135
+ say(t('bootstrap.repoOnly'))
136
+ say(t('bootstrap.noOverwrite'))
137
+ say(t('bootstrap.record', { path: RECORD_REL }))
138
+
139
+ return { results, failed, record }
140
+ }
@@ -1,154 +1,154 @@
1
- // 저장소에 커밋되는 설치 기록.
2
- //
3
- // 이 파일은 "의도"다 — 실제 상태의 근거는 여전히 스캔이다. 기록이 더하는
4
- // 것은 재현성(팀원이 같은 결과를 얻는다)과 버전 고정, 그리고 "우리가 쓴
5
- // 그대로인가"를 판정할 해시다.
6
- //
7
- // 설치기 안의 manifest.mjs(무엇을 생성할지 선언)와 이름이 겹치지 않게
8
- // 저장소 쪽 파일은 '설치 기록'이라 부른다.
9
- //
10
- // 부트스트랩 그래프에 속하므로 node: 내장 모듈만 쓴다.
11
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
12
- import { dirname } from 'node:path'
13
- import { repoPath, repoPathStrict } from '../context.mjs'
14
- import { LOCALES, LocalizedError } from '../i18n/index.mjs'
15
- import { hashBody, normalizeBody } from './text.mjs'
16
-
17
- export const RECORD_REL = '.agent-kit/agent-setup.json'
18
- export const FORMAT_VERSION = 1
19
-
20
- // 발행된 패키지에도 package.json은 항상 들어간다(npm이 무조건 포함한다).
21
- // createRequire 대신 URL로 읽어 의존성 0을 유지한다.
22
- let cachedVersion
23
- export function toolVersion() {
24
- if (!cachedVersion) {
25
- const url = new URL('../../package.json', import.meta.url)
26
- cachedVersion = JSON.parse(readFileSync(url, 'utf8')).version
27
- }
28
- return cachedVersion
29
- }
30
-
31
- export function emptyRecord({ skillMode = 'auto', lang = null } = {}) {
32
- return {
33
- formatVersion: FORMAT_VERSION,
34
- pinnedVersion: toolVersion(),
35
- skillMode,
36
- lang,
37
- items: [],
38
- design: [],
39
- managed: {},
40
- }
41
- }
42
-
43
- // 없으면 null. 있으면 필드를 채워 돌려준다 — 손으로 편집해 필드가 빠져도
44
- // 죽지 않아야 한다. 형식 버전이 다르면 추측하지 않고 던진다.
45
- export function readRecord(root) {
46
- const target = repoPath(root, RECORD_REL)
47
- let text
48
- try {
49
- text = readFileSync(target, 'utf8')
50
- } catch (err) {
51
- if (err.code === 'ENOENT') return null
52
- throw new LocalizedError('error.recordUnreadable', { path: RECORD_REL, code: err.code ?? err.message })
53
- }
54
-
55
- let parsed
56
- try {
57
- parsed = JSON.parse(text)
58
- } catch (err) {
59
- throw new LocalizedError('error.recordNotJson', { path: RECORD_REL, message: err.message })
60
- }
61
-
62
- if (parsed.formatVersion !== FORMAT_VERSION) {
63
- throw new LocalizedError('error.recordVersion', { path: RECORD_REL, found: parsed.formatVersion, expected: FORMAT_VERSION })
64
- }
65
-
66
- return {
67
- formatVersion: parsed.formatVersion,
68
- pinnedVersion: parsed.pinnedVersion ?? null,
69
- skillMode: parsed.skillMode ?? 'auto',
70
- // 손으로 편집된 값 때문에 도구가 죽으면 안 된다. 모르는 값은 없는 것으로 본다.
71
- lang: LOCALES.includes(parsed.lang) ? parsed.lang : null,
72
- items: Array.isArray(parsed.items) ? parsed.items : [],
73
- design: Array.isArray(parsed.design) ? parsed.design : [],
74
- managed: parsed.managed && typeof parsed.managed === 'object' ? parsed.managed : {},
75
- }
76
- }
77
-
78
- // pinnedVersion은 여기서 실행 중 버전으로 맞춘다 — 기록을 쓰는 명령만
79
- // 버전을 옮길 수 있어야 고정이 거짓말하지 않는다.
80
- //
81
- // log를 주면 t도 반드시 줘야 한다. 영어 리터럴 폴백을 두면 t를 빠뜨린
82
- // 호출부가 한국어 출력 한가운데에 영어 한 줄을 흘리면서도 아무 신호를 내지
83
- // 않는다 — update.mjs가 실제로 그렇게 새어 나갔다.
84
- export function writeRecord(root, record, { dryRun = false, log, t } = {}) {
85
- const target = repoPathStrict(root, RECORD_REL)
86
- const body = `${JSON.stringify({ ...record, pinnedVersion: toolVersion() }, null, 2)}\n`
87
- log?.(t('log.record.write', { path: RECORD_REL }))
88
- if (!dryRun) {
89
- mkdirSync(dirname(target), { recursive: true })
90
- writeFileSync(target, body, { encoding: 'utf8' })
91
- }
92
- return { ok: true, action: dryRun ? 'skip' : 'write', path: RECORD_REL }
93
- }
94
-
95
- // 언어만 바꾼다. 기록이 없으면 만든다 — 부트스트랩 전에 언어를 골라도
96
- // 유지되어야 하기 때문이다. 나머지 필드는 있는 그대로 옮긴다.
97
- export function writeLang(root, lang, { dryRun = false, log, t } = {}) {
98
- const previous = readRecord(root)
99
- return writeRecord(root, { ...(previous ?? emptyRecord()), lang }, { dryRun, log, t })
100
- }
101
-
102
- // 마커는 apply.mjs의 ensureBlocks(생성)와 updateBlocks(갱신), 그리고 여기의
103
- // extractBlock(판정)이 함께 쓴다. 정의가 둘로 갈리면 한쪽만 고치는 회귀가
104
- // 생기므로 이 모듈이 단일 출처가 되고 apply.mjs가 import한다.
105
- export const BEGIN_MARKER = '<!-- agent-kit:begin -->'
106
- export const END_MARKER = '<!-- agent-kit:end -->'
107
-
108
- // 블록은 파일 전체가 아니라 마커 사이 본문만 관리 대상이다. 파일 전체 해시와
109
- // 섞이지 않게 키에 접미사를 붙인다.
110
- export const BLOCK_SUFFIX = '#agent-kit'
111
-
112
- export function managedKey(rel, isBlock) {
113
- return isBlock ? `${rel}${BLOCK_SUFFIX}` : rel
114
- }
115
-
116
- // 마커 사이 본문을 정규화해 돌려준다. 마커가 없거나 순서가 뒤집혀 있으면 null.
117
- export function extractBlock(text) {
118
- const begin = text.indexOf(BEGIN_MARKER)
119
- if (begin === -1) return null
120
- const end = text.indexOf(END_MARKER, begin + BEGIN_MARKER.length)
121
- if (end === -1) return null
122
- return normalizeBody(text.slice(begin + BEGIN_MARKER.length, end))
123
- }
124
-
125
- function readOrNull(root, rel) {
126
- try {
127
- return readFileSync(repoPath(root, rel), 'utf8')
128
- } catch {
129
- return null
130
- }
131
- }
132
-
133
- // 채택 규칙: 현재 내용이 이 버전의 템플릿과 정규화 후 일치하는 것만 해시를
134
- // 남긴다. 그러지 않으면 사용자가 이미 고쳐 둔 파일이 "우리가 쓴 그대로"로
135
- // 위장되어 다음 update에 날아간다. 값이 null인 키는 update가 절대 덮어쓰지
136
- // 않으며, 없는 파일도 키를 남겨 update의 생성 분기가 집어간다.
137
- export function collectManaged(root, manifest) {
138
- const managed = {}
139
-
140
- for (const { path: rel, template } of manifest.files ?? []) {
141
- const text = readOrNull(root, rel)
142
- const wanted = hashBody(template)
143
- managed[rel] = text !== null && hashBody(text) === wanted ? wanted : null
144
- }
145
-
146
- for (const { path: rel, block } of manifest.blocks ?? []) {
147
- const text = readOrNull(root, rel)
148
- const wanted = hashBody(extractBlock(block) ?? block)
149
- const current = text === null ? null : extractBlock(text)
150
- managed[managedKey(rel, true)] = current !== null && hashBody(current) === wanted ? wanted : null
151
- }
152
-
153
- return managed
154
- }
1
+ // 저장소에 커밋되는 설치 기록.
2
+ //
3
+ // 이 파일은 "의도"다 — 실제 상태의 근거는 여전히 스캔이다. 기록이 더하는
4
+ // 것은 재현성(팀원이 같은 결과를 얻는다)과 버전 고정, 그리고 "우리가 쓴
5
+ // 그대로인가"를 판정할 해시다.
6
+ //
7
+ // 설치기 안의 manifest.mjs(무엇을 생성할지 선언)와 이름이 겹치지 않게
8
+ // 저장소 쪽 파일은 '설치 기록'이라 부른다.
9
+ //
10
+ // 부트스트랩 그래프에 속하므로 node: 내장 모듈만 쓴다.
11
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
12
+ import { dirname } from 'node:path'
13
+ import { repoPath, repoPathStrict } from '../context.mjs'
14
+ import { LOCALES, LocalizedError } from '../i18n/index.mjs'
15
+ import { hashBody, normalizeBody } from './text.mjs'
16
+
17
+ export const RECORD_REL = '.agent-kit/agent-setup.json'
18
+ export const FORMAT_VERSION = 1
19
+
20
+ // 발행된 패키지에도 package.json은 항상 들어간다(npm이 무조건 포함한다).
21
+ // createRequire 대신 URL로 읽어 의존성 0을 유지한다.
22
+ let cachedVersion
23
+ export function toolVersion() {
24
+ if (!cachedVersion) {
25
+ const url = new URL('../../package.json', import.meta.url)
26
+ cachedVersion = JSON.parse(readFileSync(url, 'utf8')).version
27
+ }
28
+ return cachedVersion
29
+ }
30
+
31
+ export function emptyRecord({ skillMode = 'auto', lang = null } = {}) {
32
+ return {
33
+ formatVersion: FORMAT_VERSION,
34
+ pinnedVersion: toolVersion(),
35
+ skillMode,
36
+ lang,
37
+ items: [],
38
+ design: [],
39
+ managed: {},
40
+ }
41
+ }
42
+
43
+ // 없으면 null. 있으면 필드를 채워 돌려준다 — 손으로 편집해 필드가 빠져도
44
+ // 죽지 않아야 한다. 형식 버전이 다르면 추측하지 않고 던진다.
45
+ export function readRecord(root) {
46
+ const target = repoPath(root, RECORD_REL)
47
+ let text
48
+ try {
49
+ text = readFileSync(target, 'utf8')
50
+ } catch (err) {
51
+ if (err.code === 'ENOENT') return null
52
+ throw new LocalizedError('error.recordUnreadable', { path: RECORD_REL, code: err.code ?? err.message })
53
+ }
54
+
55
+ let parsed
56
+ try {
57
+ parsed = JSON.parse(text)
58
+ } catch (err) {
59
+ throw new LocalizedError('error.recordNotJson', { path: RECORD_REL, message: err.message })
60
+ }
61
+
62
+ if (parsed.formatVersion !== FORMAT_VERSION) {
63
+ throw new LocalizedError('error.recordVersion', { path: RECORD_REL, found: parsed.formatVersion, expected: FORMAT_VERSION })
64
+ }
65
+
66
+ return {
67
+ formatVersion: parsed.formatVersion,
68
+ pinnedVersion: parsed.pinnedVersion ?? null,
69
+ skillMode: parsed.skillMode ?? 'auto',
70
+ // 손으로 편집된 값 때문에 도구가 죽으면 안 된다. 모르는 값은 없는 것으로 본다.
71
+ lang: LOCALES.includes(parsed.lang) ? parsed.lang : null,
72
+ items: Array.isArray(parsed.items) ? parsed.items : [],
73
+ design: Array.isArray(parsed.design) ? parsed.design : [],
74
+ managed: parsed.managed && typeof parsed.managed === 'object' ? parsed.managed : {},
75
+ }
76
+ }
77
+
78
+ // pinnedVersion은 여기서 실행 중 버전으로 맞춘다 — 기록을 쓰는 명령만
79
+ // 버전을 옮길 수 있어야 고정이 거짓말하지 않는다.
80
+ //
81
+ // log를 주면 t도 반드시 줘야 한다. 영어 리터럴 폴백을 두면 t를 빠뜨린
82
+ // 호출부가 한국어 출력 한가운데에 영어 한 줄을 흘리면서도 아무 신호를 내지
83
+ // 않는다 — update.mjs가 실제로 그렇게 새어 나갔다.
84
+ export function writeRecord(root, record, { dryRun = false, log, t } = {}) {
85
+ const target = repoPathStrict(root, RECORD_REL)
86
+ const body = `${JSON.stringify({ ...record, pinnedVersion: toolVersion() }, null, 2)}\n`
87
+ log?.(t('log.record.write', { path: RECORD_REL }))
88
+ if (!dryRun) {
89
+ mkdirSync(dirname(target), { recursive: true })
90
+ writeFileSync(target, body, { encoding: 'utf8' })
91
+ }
92
+ return { ok: true, action: dryRun ? 'skip' : 'write', path: RECORD_REL }
93
+ }
94
+
95
+ // 언어만 바꾼다. 기록이 없으면 만든다 — 부트스트랩 전에 언어를 골라도
96
+ // 유지되어야 하기 때문이다. 나머지 필드는 있는 그대로 옮긴다.
97
+ export function writeLang(root, lang, { dryRun = false, log, t } = {}) {
98
+ const previous = readRecord(root)
99
+ return writeRecord(root, { ...(previous ?? emptyRecord()), lang }, { dryRun, log, t })
100
+ }
101
+
102
+ // 마커는 apply.mjs의 ensureBlocks(생성)와 updateBlocks(갱신), 그리고 여기의
103
+ // extractBlock(판정)이 함께 쓴다. 정의가 둘로 갈리면 한쪽만 고치는 회귀가
104
+ // 생기므로 이 모듈이 단일 출처가 되고 apply.mjs가 import한다.
105
+ export const BEGIN_MARKER = '<!-- agent-kit:begin -->'
106
+ export const END_MARKER = '<!-- agent-kit:end -->'
107
+
108
+ // 블록은 파일 전체가 아니라 마커 사이 본문만 관리 대상이다. 파일 전체 해시와
109
+ // 섞이지 않게 키에 접미사를 붙인다.
110
+ export const BLOCK_SUFFIX = '#agent-kit'
111
+
112
+ export function managedKey(rel, isBlock) {
113
+ return isBlock ? `${rel}${BLOCK_SUFFIX}` : rel
114
+ }
115
+
116
+ // 마커 사이 본문을 정규화해 돌려준다. 마커가 없거나 순서가 뒤집혀 있으면 null.
117
+ export function extractBlock(text) {
118
+ const begin = text.indexOf(BEGIN_MARKER)
119
+ if (begin === -1) return null
120
+ const end = text.indexOf(END_MARKER, begin + BEGIN_MARKER.length)
121
+ if (end === -1) return null
122
+ return normalizeBody(text.slice(begin + BEGIN_MARKER.length, end))
123
+ }
124
+
125
+ function readOrNull(root, rel) {
126
+ try {
127
+ return readFileSync(repoPath(root, rel), 'utf8')
128
+ } catch {
129
+ return null
130
+ }
131
+ }
132
+
133
+ // 채택 규칙: 현재 내용이 이 버전의 템플릿과 정규화 후 일치하는 것만 해시를
134
+ // 남긴다. 그러지 않으면 사용자가 이미 고쳐 둔 파일이 "우리가 쓴 그대로"로
135
+ // 위장되어 다음 update에 날아간다. 값이 null인 키는 update가 절대 덮어쓰지
136
+ // 않으며, 없는 파일도 키를 남겨 update의 생성 분기가 집어간다.
137
+ export function collectManaged(root, manifest) {
138
+ const managed = {}
139
+
140
+ for (const { path: rel, template } of manifest.files ?? []) {
141
+ const text = readOrNull(root, rel)
142
+ const wanted = hashBody(template)
143
+ managed[rel] = text !== null && hashBody(text) === wanted ? wanted : null
144
+ }
145
+
146
+ for (const { path: rel, block } of manifest.blocks ?? []) {
147
+ const text = readOrNull(root, rel)
148
+ const wanted = hashBody(extractBlock(block) ?? block)
149
+ const current = text === null ? null : extractBlock(text)
150
+ managed[managedKey(rel, true)] = current !== null && hashBody(current) === wanted ? wanted : null
151
+ }
152
+
153
+ return managed
154
+ }