axmap-cli 0.2.0 → 1.0.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.
@@ -0,0 +1,32 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(sed -i 's|/\\\\*\\\\* 궤도 모드에서 허용하는 yaw 한계 \\(약 35°\\). cos 이 0.82 아래로 내려가지 않는다. \\\\*/\\\\nconst YAW_LIMIT = 0.62|X|' app/web/graph.js)",
5
+ "Bash(sed -i 's|const YAW_LIMIT = 0.62|const TILT_LIMIT = 0.62|; s|허용하는 yaw 한계|허용하는 기울기 한계 \\(yaw · pitch 공통\\)|' app/web/graph.js)",
6
+ "Bash(sed -i 's|인코딩은 인코딩이 아니다. cos\\(TILT_LIMIT\\)=0.82 이면 고리의 화면 반지름이\\\\n \\\\* \\\\[0.82R, R\\\\] 안에 머물러 이웃 단계와 절대 겹치지 않는다.|X|' app/web/graph.js)",
7
+ "Bash(sed -n '/인코딩은 인코딩이 아니다/,+2p' app/web/graph.js)",
8
+ "Bash(sed -i 's|import { applyEdits, draft, editRecord } from|import { applyEdits, draft } from|' app/server.mjs)",
9
+ "Bash(sed -i 's|\"test\": \"node --test test/protocol.test.mjs test/model.test.mjs test/analyze.test.mjs\"|\"test\": \"node --test test/protocol.test.mjs test/model.test.mjs test/analyze.test.mjs test/features.test.mjs\"|' package.json)",
10
+ "Bash(npm test *)",
11
+ "Bash(sed -i 's| .filter\\(\\(\\\\[t\\\\]\\) => t !== name \\\\&\\\\& !taken.has\\(t\\)\\)| .filter\\(\\([t]\\) => t !== name \\\\&\\\\& !taken.has\\(t\\) \\\\&\\\\& !tooCommon\\(t\\)\\)|' app/lib/features.mjs)",
12
+ "Bash(node -e ' *)",
13
+ "Bash(npm run *)",
14
+ "Bash(export AXMAP_AGENT=claude-fix GIT_AUTHOR_NAME=claude-fix GIT_AUTHOR_EMAIL=claude-fix@local GIT_COMMITTER_NAME=claude-fix GIT_COMMITTER_EMAIL=claude-fix@local)",
15
+ "Bash(node bin/axmap.mjs claim app/web/app.js app/web/graph.js app/web/style.css --task fix-click-color --intent \"이름 클릭=강조/더블클릭=이름변경, 단계 없을 때 색 회귀 수정\" --actor agent)",
16
+ "Bash(sed 's/\\\\$$//')",
17
+ "Bash(python -)",
18
+ "Bash(sed -n '/import 를 뽑을 수 있는 언어 이름/,/^\\\\]\\)/p' app/lib/analyze.mjs)",
19
+ "Bash(export AXMAP_AGENT=janghyojoon)",
20
+ "Bash(node bin/axmap.mjs release)",
21
+ "Bash(echo \"// 테스트\")",
22
+ "Bash(git add *)",
23
+ "Bash(git commit *)",
24
+ "Bash(git reset *)",
25
+ "Bash(git checkout *)"
26
+ ]
27
+ },
28
+ "enabledMcpjsonServers": [
29
+ "axmap"
30
+ ],
31
+ "enableAllProjectMcpServers": true
32
+ }
package/README.md CHANGED
@@ -50,7 +50,9 @@ node app/server.mjs https://github.com/pallets/flask 7777
50
50
  ⑤ 이제 당신 차례
51
51
  ```
52
52
 
53
- **팀으로 쓰려면** → [docs/ONBOARD-TEAM.md](docs/ONBOARD-TEAM.md) (3줄이면 붙는다)
53
+ **남의 저장소에서 쓰려면** → [docs/INSTALL.md](docs/INSTALL.md) (npm 두 줄이면 끝난다)
54
+
55
+ **axMap 자체를 고치려면** → [docs/ONBOARD-TEAM.md](docs/ONBOARD-TEAM.md) (3줄이면 붙는다)
54
56
 
55
57
  ---
56
58
 
@@ -179,7 +181,8 @@ npm run smoke # 🔴 화면이 실제로 뜨는지 헤드리스 크롬
179
181
  | | |
180
182
  |---|---|
181
183
  | [CLAUDE.md](CLAUDE.md) | 작업 규칙 (사람에게도 AI 에게도 같다) |
182
- | [docs/ONBOARD-TEAM.md](docs/ONBOARD-TEAM.md) | 팀원용 5분 안내 |
184
+ | [docs/INSTALL.md](docs/INSTALL.md) | **팀원이 읽을 하나** — npm 으로 깔고 쓰는 법 |
185
+ | [docs/ONBOARD-TEAM.md](docs/ONBOARD-TEAM.md) | axMap 자체를 고칠 사람용 5분 안내 |
183
186
  | [docs/DECISIONS.md](docs/DECISIONS.md) | 제품 결정 D1~D15 와 **아직 못 정한 것들** |
184
187
  | [docs/WHY-CORPUS.md](docs/WHY-CORPUS.md) | 코퍼스가 왜 필요한가 |
185
188
  | [docs/PERSONA-LOOP.md](docs/PERSONA-LOOP.md) | 시각화가 실제로 일하는지 재는 법과 결과 |
package/bin/axmap.mjs CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  noticeLine,
42
42
  registryUrl,
43
43
  } from '../src/update.mjs'
44
+ import { homeRegistrations } from '../src/mcpstate.mjs'
44
45
 
45
46
  /** 이 CLI 가 들어 있는 axMap 폴더. 갱신 확인과 setup 이 자기 위치를 알아야 한다. */
46
47
  const SELF_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
@@ -1631,6 +1632,51 @@ function cmdVerify(flags) {
1631
1632
  // 명령: audit (사후 증명)
1632
1633
  // ---------------------------------------------------------------------------
1633
1634
 
1635
+ /**
1636
+ * `--code <범위>` 가 가리키는 **코드 커밋**을 모은다 (I5 · 강제 검사의 입력).
1637
+ *
1638
+ * 🔴 **머지 커밋은 뺀다** (`--no-merges`). 서버가 만든 것이라 사람이 파일을 고른
1639
+ * 적이 없고, 그것을 위반으로 세면 MR 을 쓸수록 빨개진다 — 문을 세운 결과가
1640
+ * 문을 우회하는 것이 된다.
1641
+ *
1642
+ * 🔴 **범위를 인자로 받는다. 이력 전체를 검사하지 않는다.** 장부가 생기기 전에
1643
+ * 쌓인 커밋은 아무도 잡지 않은 채 고쳐진 것이 당연하고, 그것까지 세면 이 잡은
1644
+ * 켠 날부터 영원히 빨갛다. CI 는 이번 push 로 들어온 구간만 넘긴다.
1645
+ */
1646
+ function collectCodeCommits(root, range) {
1647
+ const ZERO = '0000000000000000000000000000000000000000'
1648
+ if (range.includes(ZERO)) {
1649
+ // 새 브랜치의 첫 push 면 GitLab 이 "이전 sha" 자리에 0 을 넣는다. 비교할 앞이 없다.
1650
+ console.log(`구간에 빈 sha 가 있습니다 (${range}) - 첫 push 로 보고 코드 대조를 건너뜁니다.`)
1651
+ return null
1652
+ }
1653
+ const NUL = String.fromCharCode(0)
1654
+ const NL = String.fromCharCode(10)
1655
+ const log = git(['log', '--no-merges', '--format=%H%x00%an%x00%ct%x00%s', range], { cwd: root })
1656
+ if (log.code !== 0) {
1657
+ // 🔴 못 읽은 것을 통과로 내지 않는다. 조용히 빈 배열을 주면 "위반 0" 이 된다.
1658
+ die([
1659
+ `코드 구간을 읽지 못했습니다 (${range}).`,
1660
+ ' 구간을 확인하세요 - 검사하지 못한 것을 통과로 내지 않습니다.',
1661
+ ].join(NL))
1662
+ }
1663
+ const out = []
1664
+ for (const line of log.out ? log.out.split(NL) : []) {
1665
+ if (!line) continue
1666
+ const [sha, author, ct, subject] = line.split(NUL)
1667
+ const files = git(['show', '--no-renames', '--name-only', '--format=', sha], { cwd: root }).out
1668
+ out.push({
1669
+ sha,
1670
+ author,
1671
+ time: Number(ct) * 1000,
1672
+ subject,
1673
+ files: (files ? files.split(NL) : []).filter(Boolean),
1674
+ })
1675
+ }
1676
+ return out
1677
+ }
1678
+
1679
+
1634
1680
  function cmdAudit(flags) {
1635
1681
  const root = repoRoot()
1636
1682
  const dir = ledgerDir(root)
@@ -1702,7 +1748,11 @@ function cmdAudit(flags) {
1702
1748
  snapshots.push({ commit: sha, time: Number(ct) * 1000, subject, claims })
1703
1749
  }
1704
1750
 
1705
- const report = auditLedger(snapshots)
1751
+ const codeCommits = typeof flags.code === 'string' && flags.code
1752
+ ? collectCodeCommits(root, flags.code)
1753
+ : null
1754
+
1755
+ const report = auditLedger(snapshots, codeCommits)
1706
1756
  if (flags.json) {
1707
1757
  console.log(JSON.stringify(report, null, 2))
1708
1758
  } else {
@@ -1803,6 +1853,17 @@ function cmdDoctor(flags) {
1803
1853
  }
1804
1854
 
1805
1855
  // 7. MCP 설정 — AI 도구가 자동으로 붙는 경로
1856
+ //
1857
+ // 🔴 붙는 길은 **둘**이다. 저장소의 `.mcp.json` 과 각 도구의 홈 설정.
1858
+ // 예전에는 앞의 것만 봤다. 그래서 npm 판을 깔고 `axmap setup` 까지 끝낸 사람에게도
1859
+ // ".mcp.json 이 없습니다 — AI 도구가 자동으로 붙지 않습니다" 라고 말했다.
1860
+ // 붙어 있는데 안 붙었다고 하는 것이라, 새로 온 팀은 첫날 이 줄을 보고 설치가
1861
+ // 잘못된 줄 안다. 홈 등록이 기본이 된 지금은 `.mcp.json` 이 **없는 것이 정상**이다.
1862
+ const homeMcp = homeRegistrations()
1863
+ const homeWord = homeMcp.found.length
1864
+ ? `홈에 등록돼 있습니다 (${homeMcp.found.join(' · ')})` +
1865
+ (homeMcp.guessed.length ? ` ※ ${homeMcp.guessed.join(' · ')} 는 설정 파일 글자만 보고 짐작한 것입니다` : '')
1866
+ : null
1806
1867
  const mcpJson = path.join(root, '.mcp.json')
1807
1868
  if (fs.existsSync(mcpJson)) {
1808
1869
  let server = null
@@ -1810,8 +1871,17 @@ function cmdDoctor(flags) {
1810
1871
  if (!server) hm('MCP 설정', '.mcp.json 은 있는데 axmap 서버를 못 찾았습니다')
1811
1872
  else if (!fs.existsSync(path.resolve(root, server))) no('MCP 설정', `서버 파일이 없습니다: ${server}`)
1812
1873
  else ok('MCP 설정', `${server} (AI 도구가 승인만 하면 붙습니다)`)
1874
+ // 둘 다 있으면 **저장소 쪽이 이긴다.** 같은 서버면 문제가 없지만, npm 판을 깔아 둔
1875
+ // 사람이 "내가 깐 것이 쓰이겠거니" 하고 여기서 엇갈린다. 그 자리에서 말해 준다.
1876
+ if (homeMcp.found.length) {
1877
+ hm('MCP 설정', `홈에도 등록돼 있습니다 (${homeMcp.found.join(' · ')}) — 이 저장소에서는 위의 .mcp.json 이 먼저 쓰입니다`)
1878
+ }
1879
+ } else if (homeWord) {
1880
+ ok('MCP 설정', `${homeWord} — 저장소에 파일이 없어도 됩니다`)
1881
+ } else if (homeMcp.unreadable.length) {
1882
+ hm('MCP 설정', `홈 설정을 읽지 못했습니다 (${homeMcp.unreadable.join(' · ')}) — 붙었는지 판단하지 않습니다`)
1813
1883
  } else {
1814
- hm('MCP 설정', '.mcp.json 없습니다 — AI 도구가 자동으로 붙지 않습니다')
1884
+ hm('MCP 설정', '아직 등록된 곳이 없습니다 — AI 도구가 자동으로 붙지 않습니다\n axmap setup')
1815
1885
  }
1816
1886
 
1817
1887
  // 8. 실제로 판정이 도는가 — 장부를 바꾸지 않고 읽기만 한다
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//name": "🔴 npm 의 'axmap' 은 2022년부터 남이 쓰고 있는 다른 꾸러미다(leozin_ 의 Map 라이브러리, 1.1.2). 그래서 `npx axmap` 은 우리 것이 아니고, 누구에게도 그렇게 안내하면 안 된다. 2026-08-28 에 axmap-cli 로 정했다 — 스코프(@사용자/이름)를 쓰지 않는 이유는 스코프가 npm 계정 이름에 묶여서 나중에 팀이나 조직으로 옮길 때 이름이 통째로 바뀌기 때문이다. 사람이 치는 **명령** 이름은 그대로 axmap 이다 (아래 bin) — 꾸러미 이름과 명령 이름은 달라도 된다.",
3
3
  "name": "axmap-cli",
4
- "version": "0.2.0",
4
+ "version": "1.0.0",
5
5
  "//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
6
6
  "//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
7
7
  "publishConfig": {
@@ -192,12 +192,64 @@ export function snapshotTime(snapshot) {
192
192
  return Math.max(snapshot.time, ...(sinces.length ? sinces : [snapshot.time]))
193
193
  }
194
194
 
195
+ // ---------------------------------------------------------------------------
196
+ // I5 · 강제 (전이 불변식)
197
+ // 커밋이 파일을 고쳤다면, 그 시각에 **누군가는** 그 파일을 잡고 있어야 한다.
198
+ //
199
+ // 🔴 이 검사가 없으면 claim 은 권고 사항이다. 지금까지 강제하는 것은 각자 PC 의
200
+ // 커밋 훅뿐이었고, 훅은 "설치했는가" 에 달려 있으며 `--no-verify` 로 넘어가고
201
+ // 웹 IDE 에는 아예 없다. 서버에서 도는 검사만이 설치 여부와 무관하다.
202
+ //
203
+ // 🔴 **"고친 사람이 곧 잡은 사람인가" 는 묻지 않는다.** 장부의 `agent` 는 세션
204
+ // 이름(`claude-code-mcpjson` 같은)이고 커밋의 author 는 git 이름(`janghyojoon`)
205
+ // 이라 서로 다른 이름 공간이다. 둘을 이름으로 맞추면 **정상 작업이 전부 위반으로
206
+ // 나온다.** 그래서 여기서는 "아무도 안 잡은 파일이 고쳐졌나" 하나만 본다 —
207
+ // 이것이 이 검사가 실제로 증명할 수 있는 문장이다. 소유자까지 맞추려면 claim
208
+ // 레코드에 git 신원을 함께 적어야 하고, 그건 별개의 변경이다.
209
+ //
210
+ // 🔴 장부보다 앞선 커밋은 **건너뛰되 세어서 보고한다.** 조용히 빼면 "위반 0" 이
211
+ // "검사했고 깨끗함" 으로 읽힌다. 검사하지 않은 것을 통과로 내지 않는다.
212
+ // ---------------------------------------------------------------------------
213
+
214
+ export function enforcementViolations(snapshots, commits) {
215
+ const snaps = [...(snapshots ?? [])].sort((a, b) => snapshotTime(a) - snapshotTime(b))
216
+ const violations = []
217
+ const skipped = []
218
+
219
+ for (const c of commits ?? []) {
220
+ // 그 커밋 시각에 유효했던 마지막 장부 스냅샷을 찾는다.
221
+ let snap = null
222
+ for (const s of snaps) {
223
+ if (snapshotTime(s) <= c.time) snap = s
224
+ else break
225
+ }
226
+ if (!snap) {
227
+ skipped.push({ commit: c.sha, subject: c.subject, reason: '장부보다 앞선 커밋' })
228
+ continue
229
+ }
230
+ const held = activeClaims(snap.claims, c.time).flatMap((cl) => cl.paths.map(normalizePath))
231
+ for (const f of (c.files ?? []).map(normalizePath)) {
232
+ if (held.some((p) => pathsOverlap(p, f))) continue
233
+ violations.push({
234
+ invariant: 'I5',
235
+ message: `${f} 를 아무도 잡지 않은 채 고쳤다`,
236
+ agents: c.author ? [c.author] : [],
237
+ paths: [f],
238
+ commit: c.sha,
239
+ subject: c.subject,
240
+ at: new Date(c.time).toISOString(),
241
+ })
242
+ }
243
+ }
244
+ return { violations, skipped }
245
+ }
246
+
195
247
  /**
196
248
  * 시간이 흐르는 것만으로는 겹침이 생기지 않는다 (만료는 claim 을 없앨 뿐이다).
197
249
  * 겹침은 오직 claim 이 추가될 때 생기고, claim 은 커밋으로만 추가된다.
198
250
  * 따라서 각 커밋 시점만 검사하면 전체 구간을 덮는다.
199
251
  */
200
- export function auditLedger(snapshots) {
252
+ export function auditLedger(snapshots, commits = null) {
201
253
  const violations = []
202
254
  for (const snap of snapshots) {
203
255
  const t = snapshotTime(snap)
@@ -205,17 +257,37 @@ export function auditLedger(snapshots) {
205
257
  violations.push({ ...v, commit: snap.commit, subject: snap.subject, at: new Date(t).toISOString() })
206
258
  }
207
259
  }
208
- return { ok: violations.length === 0, checked: snapshots.length, violations }
260
+ const code = commits ? enforcementViolations(snapshots, commits) : null
261
+ if (code) violations.push(...code.violations)
262
+ return {
263
+ ok: violations.length === 0,
264
+ checked: snapshots.length,
265
+ codeChecked: commits ? commits.length : null,
266
+ codeSkipped: code ? code.skipped : null,
267
+ violations,
268
+ }
209
269
  }
210
270
 
211
271
  export function formatAudit(report) {
272
+ // 코드 검사를 돌렸는지, 그중 몇 개를 못 봤는지는 통과·실패 어느 쪽에서도 적는다.
273
+ const codeLine =
274
+ report.codeChecked === null || report.codeChecked === undefined
275
+ ? '코드 커밋 대조: 안 함 (--code <범위> 를 주면 봅니다)'
276
+ : `코드 커밋 대조: ${report.codeChecked}개` +
277
+ (report.codeSkipped?.length ? ` (장부보다 앞서 건너뛴 것 ${report.codeSkipped.length}개)` : '')
278
+
212
279
  if (report.ok) {
213
- return (
214
- `감사 통과 - 스냅샷 ${report.checked}개\n` +
215
- '장부 이력 전체에서 상호배제(I1)가 깨진 시점이 없습니다.'
216
- )
280
+ return [
281
+ `감사 통과 - 스냅샷 ${report.checked}개`,
282
+ '장부 이력 전체에서 상호배제(I1)가 깨진 시점이 없습니다.',
283
+ codeLine,
284
+ ].join('\n')
217
285
  }
218
- const lines = [`감사 실패 - 스냅샷 ${report.checked}개 중 위반 ${report.violations.length}건\n`]
286
+ const lines = [
287
+ `감사 실패 - 스냅샷 ${report.checked}개 중 위반 ${report.violations.length}건`,
288
+ codeLine,
289
+ '',
290
+ ]
219
291
  for (const v of report.violations) {
220
292
  lines.push(` x [${v.invariant}] ${v.at} commit ${v.commit.slice(0, 7)} ${v.subject}`)
221
293
  lines.push(` ${v.message}`)
@@ -0,0 +1,90 @@
1
+ /**
2
+ * axmap MCP 가 **어디에 등록돼 있는가** 를 읽기만 하는 자리.
3
+ *
4
+ * 왜 따로 있나 — `tools/mcp-register.mjs` 는 맨 아래에서 `process.exit(main())` 를
5
+ * 부른다. 그래서 그 파일을 import 하면 **등록이 실제로 실행되고 프로세스가 죽는다.**
6
+ * 판정만 필요한 쪽(`doctor`)이 그것을 부를 수 없다. 그렇다고 doctor 안에 같은 판정을
7
+ * 한 벌 더 쓰면 두 곳이 서로 다르게 굴게 된다 — 등록기는 넣었다는데 doctor 는
8
+ * 없다고 하는 상태가 정확히 이 파일이 생긴 이유다.
9
+ *
10
+ * 여기에는 **쓰는 코드를 두지 않는다.** 무엇을 어디에 쓸지는 등록기가 정한다.
11
+ */
12
+
13
+ import fs from 'node:fs'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+
17
+ /**
18
+ * `~/.claude.json` 의 user 범위 axmap 항목.
19
+ *
20
+ * 없으면 `null`, **못 읽으면 `undefined`** 다. 이 둘을 가르는 것이 중요하다 —
21
+ * 파일이 아예 없는 것은 "등록 안 됨" 이고, 있는데 못 읽는 것은 "사고" 다.
22
+ * 사고를 "등록 안 됨" 으로 뭉개면 남의 설정을 덮어쓰자는 판단이 나온다.
23
+ */
24
+ export function claudeUserEntry() {
25
+ try {
26
+ const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'))
27
+ return (cfg && cfg.mcpServers && cfg.mcpServers.axmap) || null
28
+ } catch (e) {
29
+ return e && e.code === 'ENOENT' ? null : undefined
30
+ }
31
+ }
32
+
33
+ /** `~/.gemini/config/mcp_config.json` 의 axmap 항목. 규칙은 위와 같다. */
34
+ export function agyUserEntry() {
35
+ try {
36
+ const f = path.join(os.homedir(), '.gemini', 'config', 'mcp_config.json')
37
+ const raw = fs.readFileSync(f, 'utf8')
38
+ // agy 는 설치할 때 0바이트 파일을 만들어 둔다. 빈 파일은 사고가 아니라 "아직 없음" 이다.
39
+ if (!raw.trim()) return null
40
+ const cfg = JSON.parse(raw)
41
+ return (cfg && cfg.mcpServers && cfg.mcpServers.axmap) || null
42
+ } catch (e) {
43
+ return e && e.code === 'ENOENT' ? null : undefined
44
+ }
45
+ }
46
+
47
+ /**
48
+ * `~/.codex/config.toml` 안에 axmap 서버가 있는가.
49
+ *
50
+ * 🔴 **이것은 글자 수준의 짐작이다.** 이 저장소에는 codex 로 실제 등록한 결과물이
51
+ * 없어서(`tools/mcp-register.mjs` 의 codex 절 주석 참고) TOML 을 제대로 파싱해서
52
+ * 판정할 근거가 없다. 그래서 `[mcp_servers.axmap]` 머리표만 찾는다.
53
+ *
54
+ * 확실하지 않은 것을 확실한 척 말하지 않으려고 **반환값에 그 사실을 실어 보낸다** —
55
+ * 부르는 쪽이 "짐작" 이라고 사람에게 말할 수 있게. 실물로 확인되면 이 함수만 고친다.
56
+ */
57
+ export function codexUserEntry() {
58
+ try {
59
+ const raw = fs.readFileSync(path.join(os.homedir(), '.codex', 'config.toml'), 'utf8')
60
+ return /^\s*\[mcp_servers\.axmap\]/m.test(raw) ? { guessed: true } : null
61
+ } catch (e) {
62
+ return e && e.code === 'ENOENT' ? null : undefined
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 홈에 등록된 것을 한 번에 모은다.
68
+ *
69
+ * `found` 는 등록이 확인된 도구 이름, `unreadable` 은 설정이 있는데 못 읽은 도구다.
70
+ * 못 읽은 것을 "없음" 쪽에 넣지 않는다 — 그러면 이미 붙어 있는 사람에게
71
+ * "안 붙었습니다" 라고 말하게 되고, 그게 지금 고치는 바로 그 버그다.
72
+ */
73
+ export function homeRegistrations() {
74
+ const probes = [
75
+ ['claude', claudeUserEntry],
76
+ ['agy', agyUserEntry],
77
+ ['codex', codexUserEntry],
78
+ ]
79
+ const found = []
80
+ const unreadable = []
81
+ const guessed = []
82
+ for (const [id, probe] of probes) {
83
+ const e = probe()
84
+ if (e === undefined) { unreadable.push(id); continue }
85
+ if (!e) continue
86
+ found.push(id)
87
+ if (e.guessed) guessed.push(id)
88
+ }
89
+ return { found, unreadable, guessed }
90
+ }
@@ -41,6 +41,7 @@ import path from 'node:path'
41
41
  import { fileURLToPath } from 'node:url'
42
42
 
43
43
  import { AGENTS, resolveBin } from '../app/lib/agentcli.mjs'
44
+ import { claudeUserEntry } from '../src/mcpstate.mjs'
44
45
 
45
46
  const HERE = path.dirname(fileURLToPath(import.meta.url))
46
47
  const AXMAP_DIR = path.resolve(HERE, '..') // .../axmap
@@ -74,12 +75,95 @@ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
74
75
 
75
76
  // ── Claude Code ────────────────────────────────────────────────────────────
76
77
  //
77
- // 일이 없다. 저장소의 `.mcp.json` 스스로 발견하고 승인만 묻는다.
78
- // 그래도 찍는다 "내 CLI 는 왜 아무 말이 없지" 를 없애기 위해서다.
79
- function doClaude(repo) {
78
+ // 🔴 **2026-08-31 여기서 전역 등록을 하도록 바꿨다.** 그전에는 저장소의
79
+ // `.mcp.json` 있는지 확인만 하고 아무것도 하지 않았다.
80
+ //
81
+ // 그래서 Claude 만 **저장소에 파일을 커밋해야** 붙는 유일한 CLI 였다. npm 으로
82
+ // 설치해 남의 저장소에서 쓰는 사람은 그 파일을 넣을 권한이 없을 수도 있고,
83
+ // 넣더라도 "도구는 나가고 데이터만 남는다" 는 이 저장소의 방침에 어긋난다.
84
+ // `mcp/install.ps1` 은 이미 전역 등록을 하고 있었다 — 설치 경로 둘이 서로 다르게
85
+ // 굴어서, 둘 다 돌린 PC 에는 **범위가 충돌하는 항목**이 남았다(claude mcp list 가
86
+ // "Conflicting scopes" 로 알려 준다). 두 경로를 같은 동작으로 맞춘다.
87
+ //
88
+ // 설정: `~/.claude.json` 의 최상위 `mcpServers` (user 범위)
89
+ // 형식: `claude mcp add <이름> -s user [-e K=V] -- <명령> <인수...>`
90
+ //
91
+ // 🔴 `~/.claude.json` 을 **우리가 쓰지 않는다.** 그 파일에는 Claude Code 의 세션
92
+ // 상태까지 들어 있어 읽고 다시 쓰는 순간 남의 것을 깨뜨릴 위험이 생긴다.
93
+ // 있는지 **읽는 것만** 우리가 하고, 쓰는 일은 claude 자신에게 맡긴다 —
94
+ // doCodex 와 같은 태도다.
95
+
96
+ // `claudeUserEntry` 는 `src/mcpstate.mjs` 에 있다 (위 import).
97
+ // 여기 두었다가 doctor 가 같은 판정을 한 벌 더 쓰게 됐고, 둘이 어긋나면
98
+ // "등록기는 넣었다는데 doctor 는 없다고 한다" 가 된다. 판정은 한 곳에만 둔다.
99
+
100
+ /**
101
+ * 이미 쓸 만한 항목인가.
102
+ *
103
+ * env 는 **우리가 넣는 키만** 본다. 남이 `AXMAP_AGENT` 를 손으로 넣어 뒀을 수 있고,
104
+ * 그건 그 사람의 이름이라 지우면 안 된다 (install.ps1 이 실제로 넣는다).
105
+ */
106
+ const sameEntry = (cur, want) =>
107
+ !!cur && cur.command === want.command &&
108
+ JSON.stringify(cur.args || []) === JSON.stringify(want.args) &&
109
+ Object.entries(want.env).every(([k, v]) => ((cur.env || {})[k] === v))
110
+
111
+ function doClaude(bin, repo) {
112
+ const run = (args) => spawnSync(bin, args, { encoding: 'utf8', windowsHide: true })
113
+ const want = entry()
114
+
115
+ const cur = claudeUserEntry()
116
+ if (cur === undefined) {
117
+ no('claude', '~/.claude.json 을 읽지 못했습니다 — 건드리지 않았습니다')
118
+ snippetClaude()
119
+ return
120
+ }
121
+ if (sameEntry(cur, want)) { ok('claude', '이미 등록돼 있습니다 (user 범위)'); return }
122
+
123
+ // 문서에 있다고 믿고 부르지 않는다 — 먼저 물어본다. doCodex 와 같다.
124
+ const help = run(['mcp', 'add', '--help'])
125
+ if (help.status !== 0 || !/--scope/.test(`${help.stdout}${help.stderr}`)) {
126
+ hm('claude', "'claude mcp add -s user' 를 확인하지 못했습니다 — 손으로 넣으세요")
127
+ snippetClaude()
128
+ return
129
+ }
130
+
131
+ const add = ['mcp', 'add', 'axmap', '-s', 'user',
132
+ ...Object.entries(want.env).flatMap(([k, v]) => ['-e', `${k}=${v}`]),
133
+ '--', want.command, ...want.args]
134
+
135
+ if (DRY) {
136
+ hm('claude', `(dry-run) ${cur ? 'claude mcp remove axmap -s user 뒤 ' : ''}claude ${add.join(' ')}`)
137
+ return
138
+ }
139
+
140
+ // 자리를 비워야 add 가 덮어쓴다. 지우는 것은 **우리 이름(`axmap`)의 항목뿐**이다.
141
+ if (cur) run(['mcp', 'remove', 'axmap', '-s', 'user'])
142
+
143
+ const r = run(add)
144
+ if (r.status === 0 && sameEntry(claudeUserEntry(), want)) {
145
+ ok('claude', 'user 범위에 등록했습니다 — 어느 폴더에서 열어도 붙습니다')
146
+ } else {
147
+ no('claude', `claude mcp add 가 실패했습니다 (${(r.stderr || r.stdout || '').trim().split('\n')[0] || `exit ${r.status}`})`)
148
+ snippetClaude()
149
+ }
150
+
151
+ // 저장소에 `.mcp.json` 이 남아 있으면 **그쪽이 이긴다.** 조용히 두면 방금 등록한
152
+ // 것이 왜 안 쓰이는지 아무도 못 찾는다.
80
153
  const f = path.join(repo, '.mcp.json')
81
- if (fs.existsSync(f)) ok('claude', '.mcp.json 폴더를 열면 스스로 발견합니다 (승인만 누르면 됩니다)')
82
- else no('claude', `.mcp.json 이 없습니다: ${f}`)
154
+ if (fs.existsSync(f)) hm('claude', `저장소의 .mcp.json 이 먼저 쓰입니다 (같은 서버라면 문제 없습니다): ${f}`)
155
+ }
156
+
157
+ /** 손으로 넣을 한 줄. 경로에 공백이 있을 수 있어 겹따옴표로 감싼다. */
158
+ function snippetClaude() {
159
+ console.log('')
160
+ console.log(' 아래 한 줄을 치세요:')
161
+ console.log('')
162
+ console.log(` claude mcp add axmap -s user -e AXMAP_ACTOR=agent -e AXMAP_TTL=45m -- node "${SERVER}"`)
163
+ console.log('')
164
+ console.log(' AXMAP_AGENT 는 적지 마세요 — 여러 사람이 같은 이름이 되면')
165
+ console.log(' 서로의 선점을 겹침으로 보지 못합니다.')
166
+ console.log('')
83
167
  }
84
168
 
85
169
  // ── Antigravity CLI (agy) ──────────────────────────────────────────────────
@@ -253,7 +337,7 @@ function main() {
253
337
  const bin = resolveBin(a.bin)
254
338
  if (!bin) continue
255
339
  any = true
256
- if (a.id === 'claude') doClaude(root)
340
+ if (a.id === 'claude') doClaude(bin, root)
257
341
  else if (a.id === 'agy') doAgy()
258
342
  else if (a.id === 'codex') doCodex(bin)
259
343
  }