axmap-cli 1.2.5 → 1.3.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/mcp/server.mjs CHANGED
@@ -46,8 +46,6 @@ import path from 'node:path'
46
46
  import readline from 'node:readline'
47
47
  import { fileURLToPath } from 'node:url'
48
48
 
49
- import { readTarget } from '../src/repotarget.mjs'
50
-
51
49
  const HERE = path.dirname(fileURLToPath(import.meta.url))
52
50
 
53
51
  /**
@@ -59,6 +57,10 @@ const HERE = path.dirname(fileURLToPath(import.meta.url))
59
57
  */
60
58
  const SELF = path.resolve(HERE, '..')
61
59
  const CLI = path.join(SELF, 'bin', 'axmap.mjs')
60
+ // Vendored copies may omit package.json; the protocol handshake must still work.
61
+ const VERSION = (() => {
62
+ try { return JSON.parse(fs.readFileSync(path.join(SELF, 'package.json'), 'utf8')).version } catch { return 'unknown' }
63
+ })()
62
64
 
63
65
  /**
64
66
  * 대상 저장소 — **지금 이 서버가 겨누고 있는 폴더.**
@@ -67,32 +69,52 @@ const CLI = path.join(SELF, 'bin', 'axmap.mjs')
67
69
  * 끝이어서, 사람이 앱에서 다른 저장소를 열어도 MCP 는 처음 폴더를 계속 붙잡고 있었다.
68
70
  * 지금은 **도구를 부를 때마다** `syncRepo()` 가 다시 정한다.
69
71
  */
72
+ let clientRoots = null
73
+ let rootsError = null
74
+ let rootsSupported = false
75
+ let rootsTask = null
76
+ let rootsGeneration = 0
77
+ let rootRequestId = 0
78
+ const pendingRoots = new Map()
70
79
  let REPO = resolveRepo()
71
80
 
72
- /**
73
- * 대상 저장소를 정하는 규칙.
74
- *
75
- * 🔴 **사람에게는 아무것도 묻지 않는다.** 파일 한 개를 읽고 git 에게 한 번 물어보는
76
- * 것이 전부다. 사람의 입력은 앱에서 폴더를 고르는 그 한 번뿐이다.
77
- *
78
- * 순서에 근거가 있다.
79
- *
80
- * 1. `AXMAP_REPO` — 사람이 "여기로 고정" 이라고 **명시한** 것. 명시는 항상 이긴다.
81
- * 뒤로 미루면 고정해 둔 사람이 "왜 안 지켜지지" 를 겪고, 그러면 설정을 못 믿는다.
82
- * (`tools/mcp-register.mjs` 는 이 값을 **일부러 안 적는다** — 적어 두면 등록은
83
- * 전역인데 대상은 한 곳으로 굳어, 다른 저장소를 열어도 엉뚱한 장부에 쌓인다.)
84
- * 2. `~/.axmap/current.json` — 앱에서 [폴더 열기] 로 **고른** 것. (`src/repotarget.mjs`)
85
- * 3. git 루트 — CLI 를 `FE/` 같은 하위 폴더에서 켰어도 저장소 하나를 가리키게 한다.
86
- * `bin/axmap.mjs` 의 `repoRoot()` 가 처음부터 이렇게 했다. 여기만 안 하고 있었다.
87
- * 4. cwd — git 저장소가 아닐 때의 마지막 답.
88
- */
89
- function resolveRepo() {
90
- if (process.env.AXMAP_REPO) return path.resolve(process.env.AXMAP_REPO)
91
- const chosen = readTarget()
92
- if (chosen) return chosen
93
- const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', windowsHide: true })
81
+ function gitRoot(cwd) {
82
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', windowsHide: true })
94
83
  const top = r.status === 0 ? (r.stdout ?? '').trim() : ''
95
- return path.resolve(top || process.cwd())
84
+ return top ? fs.realpathSync(top) : null
85
+ }
86
+
87
+ /** Explicit pin > per-call cwd > client roots > launch cwd.
88
+ * A global desktop selection must never redirect another CLI session. */
89
+ function resolveRepo(args = {}) {
90
+ if (process.env.AXMAP_REPO) {
91
+ const pinned = path.resolve(process.env.AXMAP_REPO)
92
+ return gitRoot(pinned) || pinned
93
+ }
94
+ if (args.cwd !== undefined) {
95
+ if (typeof args.cwd !== 'string' || !path.isAbsolute(args.cwd)) {
96
+ throw new Error('cwd 는 현재 작업 폴더의 절대 경로여야 합니다.')
97
+ }
98
+ const top = gitRoot(args.cwd)
99
+ if (!top) throw new Error(`작업 폴더에서 Git 저장소를 찾지 못했습니다: ${args.cwd}`)
100
+ return top
101
+ }
102
+ if (rootsError) throw new Error(`작업 폴더를 확인하지 못했습니다: ${rootsError}`)
103
+ if (clientRoots !== null) {
104
+ const dirs = [...new Set(clientRoots.map((root) => {
105
+ if (typeof root?.uri !== 'string' || !root.uri.startsWith('file:')) {
106
+ throw new Error('클라이언트가 유효한 file:// 작업 폴더를 보내지 않았습니다.')
107
+ }
108
+ const top = gitRoot(fileURLToPath(root.uri))
109
+ if (!top) throw new Error(`작업 폴더에서 Git 저장소를 찾지 못했습니다: ${root.uri}`)
110
+ return top
111
+ }))]
112
+ if (dirs.length === 1) return dirs[0]
113
+ const launched = gitRoot(process.cwd())
114
+ if (dirs.includes(launched)) return launched
115
+ throw new Error('작업 저장소를 하나로 정할 수 없습니다. 도구의 cwd 에 작업 폴더 절대 경로를 지정하세요.')
116
+ }
117
+ return gitRoot(process.cwd()) || process.cwd()
96
118
  }
97
119
 
98
120
  /** axMap 저장소 자신을 겨누고 있는가. 브리핑이 무엇을 읽을지가 여기서 갈린다. */
@@ -363,28 +385,13 @@ const TOOLS = [
363
385
  inputSchema: { type: 'object', properties: {} },
364
386
  },
365
387
 
366
- /**
367
- * 🔴 왜 `init` 이 MCP 도구여야 하는가.
368
- *
369
- * 장부 없는 저장소에서 첫 `ax_claim` 은 이렇게 떨어진다:
370
- *
371
- * 장부가 없습니다. 먼저 실행하세요:
372
- * axmap init
373
- *
374
- * 메시지는 정확한데 **그 명령을 부를 방법이 도구 목록에 없었다.** 에이전트는
375
- * 지시를 읽고도 실행할 수 없고, 사람이 셸로 내려가야만 풀린다. 붙이자마자
376
- * 막히는 벽이 정확히 여기였다 — 그리고 이 저장소 안에서만 테스트하면
377
- * 장부가 이미 있으므로 한 번도 안 밟는다.
378
- *
379
- * 이 도구는 판정을 새로 하지 않는다. `bin/axmap.mjs init` 을 그대로 부른다.
380
- */
381
388
  {
382
389
  name: 'ax_init',
383
390
  description:
384
- '이 저장소에 장부가 없을 때 한 번 부른다. 장부 worktree(.axmap/ledger)와 브랜치(axmap/claims)를 만든다. '
391
+ '명시적으로 장부와 부가 저장소를 준비한다. 일반적인 선점·조회는 장부를 자동으로 준비하므로 먼저 부를 필요가 없다. '
385
392
  + '이미 있으면 아무것도 바꾸지 않으므로 여러 번 불러도 안전하다. '
386
- + 'ax_claim 이 "장부가 없습니다" 로 실패하면 이것을 부른 뒤 다시 claim 하십시오.',
387
- inputSchema: { type: 'object', properties: {} },
393
+ + '원격을 직접 고르려면 remote 에 Git 원격 이름을 지정한다.',
394
+ inputSchema: { type: 'object', properties: { remote: { type: 'string', description: 'Git 원격 이름' } } },
388
395
  },
389
396
  {
390
397
  name: 'ax_claim',
@@ -473,6 +480,13 @@ const TOOLS = [
473
480
 
474
481
  // ── 브리핑 ──────────────────────────────────────────────────────────────────
475
482
 
483
+ for (const tool of TOOLS) {
484
+ tool.inputSchema.properties.cwd = {
485
+ type: 'string',
486
+ description: '현재 작업 폴더의 절대 경로. roots 미지원 또는 여러 repo가 열려 있을 때 지정. 생략하면 세션의 작업 repo를 사용한다.',
487
+ }
488
+ }
489
+
476
490
  // `SELF` · `REPO` · `isForeign()` 은 파일 맨 위에 있다. 기준은 거기 적어 두었다.
477
491
 
478
492
  const readFrom = (base, rel) => {
@@ -691,14 +705,8 @@ function brief() {
691
705
  }
692
706
  }
693
707
 
694
- // 🔴 막다른 길을 미리 없앤다. 장부가 없으면 claim 이 전부 실패하는데,
695
- // 브리핑은 보통 claim 보다 먼저 불린다. 여기서 말해 주면 한 번에 이어진다.
696
- if (!fs.existsSync(path.join(REPO, '.axmap', 'ledger', '.git'))) {
697
- out.push('## 🔴 이 저장소에는 아직 장부가 없다')
698
- out.push('')
699
- out.push('`ax_init` 을 먼저 부른다. 그 전에는 claim 이 전부 실패한다.')
700
- out.push('')
701
- }
708
+ out.push('장부는 선점·조회 시 자동으로 준비한다. 별도 `ax_init` 호출은 필요 없다.')
709
+ out.push('')
702
710
 
703
711
  out.push('## 이 도구를 쓰는 순서')
704
712
  out.push('')
@@ -725,7 +733,7 @@ function brief() {
725
733
  * 지금 `REPO` 가 아니라 **떠나는 저장소**를 겨눠야 한다.
726
734
  */
727
735
  function releaseAllIn(dir) {
728
- const r = spawnSync(process.execPath, [CLI, 'release'], {
736
+ const r = spawnSync(process.execPath, [CLI, 'release', '--if-present'], {
729
737
  cwd: dir,
730
738
  encoding: 'utf8',
731
739
  windowsHide: true,
@@ -738,7 +746,7 @@ function releaseAllIn(dir) {
738
746
  }
739
747
 
740
748
  /**
741
- * 사람이 앱에서 다른 저장소를 열었으면 따라간다.
749
+ * 이 세션의 클라이언트 작업 폴더가 바뀌면 따라간다.
742
750
  *
743
751
  * 🔴 **떠나기 전에 잡아둔 것을 전부 반납한다.**
744
752
  *
@@ -754,8 +762,8 @@ function releaseAllIn(dir) {
754
762
  * 반납 실패를 **숨기지 않는다.** 조용히 넘어가면 위의 상황이 그대로 일어나는데
755
763
  * 아무도 그것을 모른다.
756
764
  */
757
- function syncRepo() {
758
- const next = resolveRepo()
765
+ function syncRepo(args) {
766
+ const next = resolveRepo(args)
759
767
  if (next === REPO) return null
760
768
  const from = REPO
761
769
  const r = releaseAllIn(from)
@@ -785,37 +793,10 @@ ${s.out}`
785
793
  * `ax_claim` 이 쓰는 장부가 서로 다른 저장소일 수 있다. 그러면 에이전트는
786
794
  * 자기가 본 것을 근거로 확신 있게 틀린 답을 만든다 — 사람이 못 잡는 종류다.
787
795
  */
788
- /**
789
- * 🔴 **막다른 길을 남기지 않는다 — 한 곳에서.**
790
- *
791
- * 장부가 없는 저장소에서 CLI 는 어느 명령이든 이렇게 답한다:
792
- *
793
- * 장부가 없습니다. 먼저 실행하세요:
794
- * axmap init
795
- *
796
- * 메시지는 정확한데 **에이전트에게는 셸이 없다.** 부를 수 있는 이름으로 바꿔줘야
797
- * 한다. 예전에는 그 변환이 `ax_claim` 안에만 있었고, `ax_check` · `ax_status` ·
798
- * `ax_release` · `ax_renew` 는 원문을 그대로 냈다. 하필 `ax_check` 는 설명이
799
- * "작업 계획을 세울 때 쓴다" 라 **claim 보다 먼저 불리는 도구**다 — 처음 온
800
- * 사람은 고쳐둔 안내를 못 보고 원래의 막다른 길을 먼저 밟았다.
801
- *
802
- * 그래서 도구마다 복사하지 않고 **길목에서 한 번** 본다. 도구가 늘어나도
803
- * 새 도구가 이 안내를 빠뜨릴 수 없고, 다섯 군데가 따로 낡지도 않는다.
804
- * 판정하지 않고 **CLI 가 낸 말을 볼 뿐**이라는 점이 중요하다 — 도구 이름
805
- * 목록으로 거르면 그 목록이 코드보다 먼저 낡는다.
806
- */
807
- const INIT_HINT = '\n\n→ MCP 에서는 ax_init 을 부르십시오. 그 뒤 이 도구를 다시 시도하면 됩니다.'
808
-
809
- function withInitHint(r) {
810
- if (r.ok) return r
811
- const t = r.text ?? ''
812
- if (!t.includes('장부가 없습니다') || t.includes('ax_init')) return r
813
- return { ...r, text: t + INIT_HINT }
814
- }
815
-
816
796
  function callTool(name, args = {}) {
817
- const switched = syncRepo()
818
- const r = withInitHint(dispatch(name, args))
797
+ let switched
798
+ try { switched = syncRepo(args) } catch (e) { return { ok: false, text: e.message } }
799
+ const r = dispatch(name, args)
819
800
  return switched ? { ...r, text: `${switchNotice(switched)}
820
801
 
821
802
  ${r.text}` } : r
@@ -828,7 +809,7 @@ function dispatch(name, args = {}) {
828
809
  case 'ax_brief':
829
810
  return { ok: true, text: brief() }
830
811
  case 'ax_init': {
831
- const r = cli(['init'])
812
+ const r = cli(['init', ...(args.remote ? ['--remote', String(args.remote)] : [])])
832
813
  return {
833
814
  ok: r.code === 0,
834
815
  text: r.code === 0
@@ -984,26 +965,57 @@ function send(msg) {
984
965
  process.stdout.write(JSON.stringify(msg) + '\n')
985
966
  }
986
967
 
987
- function handle(req) {
968
+ function refreshRoots() {
969
+ const generation = ++rootsGeneration
970
+ const id = `axmap-roots-${++rootRequestId}`
971
+ rootsTask = new Promise((resolve) => {
972
+ const timer = setTimeout(() => {
973
+ pendingRoots.delete(id)
974
+ if (generation === rootsGeneration) rootsError = 'roots/list 응답 시간 초과'
975
+ resolve()
976
+ }, 5000)
977
+ pendingRoots.set(id, (msg) => {
978
+ clearTimeout(timer)
979
+ if (generation === rootsGeneration) {
980
+ const roots = msg.result?.roots
981
+ rootsError = msg.error?.message || (!Array.isArray(roots) ? '잘못된 roots/list 응답' : null)
982
+ clientRoots = Array.isArray(roots) ? roots : null
983
+ }
984
+ resolve()
985
+ })
986
+ send({ jsonrpc: '2.0', id, method: 'roots/list' })
987
+ })
988
+ }
989
+
990
+ async function handle(req) {
988
991
  const { id, method, params } = req
989
992
 
990
993
  if (method === 'initialize') {
994
+ rootsSupported = !!params?.capabilities?.roots
991
995
  return {
992
996
  protocolVersion: '2024-11-05',
993
997
  capabilities: { tools: {} },
994
- serverInfo: { name: 'axmap', version: '0.1.0' },
998
+ serverInfo: { name: 'axmap', version: VERSION },
995
999
  }
996
1000
  }
997
1001
  if (method === 'tools/list') return { tools: TOOLS }
998
1002
  if (method === 'tools/call') {
1003
+ if (rootsSupported && !process.env.AXMAP_REPO) {
1004
+ if (!rootsTask || rootsError) refreshRoots()
1005
+ // A change notification may replace the task while the previous reply arrives.
1006
+ let task
1007
+ do { task = rootsTask; await task } while (task !== rootsTask)
1008
+ }
999
1009
  const r = callTool(params?.name, params?.arguments ?? {})
1000
- return { content: [{ type: 'text', text: r.text + unreadBanner(params?.name, r.text) }], isError: !r.ok }
1010
+ return { content: [{ type: 'text', text: r.text + (r.ok ? unreadBanner(params?.name, r.text) : '') }], isError: !r.ok }
1001
1011
  }
1002
1012
  if (method === 'ping') return {}
1003
1013
  return null // 알림이거나 지원하지 않는 메서드
1004
1014
  }
1005
1015
 
1006
1016
  const rl = readline.createInterface({ input: process.stdin })
1017
+ // Execute tools in order, but keep reading client replies while a tool awaits roots.
1018
+ let requests = Promise.resolve()
1007
1019
  rl.on('line', (line) => {
1008
1020
  const s = line.trim()
1009
1021
  if (!s) return
@@ -1013,18 +1025,29 @@ rl.on('line', (line) => {
1013
1025
  } catch {
1014
1026
  return
1015
1027
  }
1016
- // 알림(id 없음)에는 답하지 않는다
1017
- if (req.id === undefined) return
1018
- try {
1019
- const result = handle(req)
1020
- if (result === null) {
1021
- send({ jsonrpc: '2.0', id: req.id, error: { code: -32601, message: `지원하지 않는 메서드: ${req.method}` } })
1022
- } else {
1023
- send({ jsonrpc: '2.0', id: req.id, result })
1024
- }
1025
- } catch (e) {
1026
- send({ jsonrpc: '2.0', id: req.id, error: { code: -32603, message: e.message } })
1028
+ if (!req || typeof req !== 'object') return
1029
+ if (!req.method) {
1030
+ const done = pendingRoots.get(req.id)
1031
+ if (done) { pendingRoots.delete(req.id); done(req) }
1032
+ return
1033
+ }
1034
+ if (req.id === undefined) {
1035
+ if (rootsSupported && !process.env.AXMAP_REPO &&
1036
+ ['notifications/initialized', 'notifications/roots/list_changed'].includes(req.method)) refreshRoots()
1037
+ return
1027
1038
  }
1039
+ requests = requests.then(async () => {
1040
+ try {
1041
+ const result = await handle(req)
1042
+ if (result === null) {
1043
+ send({ jsonrpc: '2.0', id: req.id, error: { code: -32601, message: `지원하지 않는 메서드: ${req.method}` } })
1044
+ } else {
1045
+ send({ jsonrpc: '2.0', id: req.id, result })
1046
+ }
1047
+ } catch (e) {
1048
+ send({ jsonrpc: '2.0', id: req.id, error: { code: -32603, message: e.message } })
1049
+ }
1050
+ })
1028
1051
  })
1029
1052
 
1030
1053
  process.stderr.write(`axMap MCP · 저장소 ${REPO} · 에이전트 ${AGENT} (${ACTOR})\n`)
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": "1.2.5",
4
+ "version": "1.3.1",
5
5
  "//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
6
6
  "//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
7
7
  "publishConfig": {
@@ -12,7 +12,7 @@
12
12
  "license": "MIT",
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "git+https://lab.ssafy.com/rleaderjoon/axmap.git"
15
+ "url": "git+https://github.com/rl2aderjoon/axmap.git"
16
16
  },
17
17
  "keywords": [
18
18
  "ai-agent",
@@ -52,5 +52,9 @@
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=20"
55
+ },
56
+ "homepage": "https://github.com/rl2aderjoon/axmap#readme",
57
+ "bugs": {
58
+ "url": "https://github.com/rl2aderjoon/axmap/issues"
55
59
  }
56
60
  }
package/src/overlap.mjs CHANGED
@@ -27,7 +27,7 @@
27
27
  *
28
28
  * 실측으로 확인했다. 두 검사는 **정확히 엇갈린다** —
29
29
  * 뒤처짐 검사는 20:15 건을 못 잡고, 안 합쳐진 브랜치 검사는 22:31 건을 못 잡는다.
30
- * 그래서 둘 다 있어야 한다. (근거: axMap `docs/26.09.11/04-무엇으로-막나.md`)
30
+ * 그래서 둘 다 있어야 한다. (근거: axMap `docs/26.09/26.09.11/04-무엇으로-막나.md`)
31
31
  *
32
32
  * ── 🔴 왜 숫자를 경로로 자르는가 ────────────────────────────────────────────
33
33
  *
@@ -0,0 +1,31 @@
1
+ import fs from 'node:fs'
2
+ import crypto from 'node:crypto'
3
+
4
+ /** Serialize bootstrap across processes and linked worktrees. Never steal a lock:
5
+ * a slow remote must not let a second initializer change the same refs/index. */
6
+ export function withRepoLock(file, action, timeoutMs = 30000) {
7
+ const owner = JSON.stringify({ pid: process.pid, token: crypto.randomUUID() })
8
+ const deadline = Date.now() + timeoutMs
9
+ for (;;) {
10
+ try {
11
+ fs.writeFileSync(file, owner, { flag: 'wx' })
12
+ break
13
+ } catch (e) {
14
+ if (e.code !== 'EEXIST') throw e
15
+ if (Date.now() >= deadline) {
16
+ throw new Error(`장부 준비 잠금을 기다리다 중단했습니다: ${file}\n` +
17
+ '다른 초기화가 끝난 뒤 다시 시도하세요. 비정상 종료로 남은 잠금은 소유 프로세스가 종료됐는지 확인한 뒤 제거하세요.')
18
+ }
19
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50)
20
+ }
21
+ }
22
+ const release = () => {
23
+ try { if (fs.readFileSync(file, 'utf8') === owner) fs.unlinkSync(file) } catch { /* already released */ }
24
+ }
25
+ // CLI errors use process.exit; finally alone would leave a permanent lock.
26
+ process.once('exit', release)
27
+ try { return action() } finally {
28
+ release()
29
+ process.removeListener('exit', release)
30
+ }
31
+ }
@@ -1,4 +1,7 @@
1
1
  /**
2
+ * 1.3.0: 이 파일은 데스크톱 선택 상태다. MCP는 세션의 roots/cwd를 사용하며
3
+ * 전역 선택을 읽지 않는다. 아래는 데스크톱과 MCP가 공유하던 이전 설계 기록이다.
4
+ *
2
5
  * 지금 사람이 보고 있는 저장소가 어디인지를 **프로세스 사이에 전달하는 한 칸.**
3
6
  *
4
7
  * ── 왜 파일 한 개인가 ────────────────────────────────────────────────────────
@@ -105,6 +105,7 @@ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
105
105
  */
106
106
  const sameEntry = (cur, want) =>
107
107
  !!cur && cur.command === want.command &&
108
+ !cur.cwd && !Object.hasOwn(cur.env ?? {}, 'AXMAP_REPO') &&
108
109
  JSON.stringify(cur.args || []) === JSON.stringify(want.args) &&
109
110
  Object.entries(want.env).every(([k, v]) => ((cur.env || {})[k] === v))
110
111
 
@@ -118,6 +119,8 @@ function doClaude(bin, repo) {
118
119
  snippetClaude()
119
120
  return
120
121
  }
122
+ want.env = { ...(cur?.env ?? {}), ...want.env }
123
+ delete want.env.AXMAP_REPO
121
124
  if (sameEntry(cur, want)) { ok('claude', '이미 등록돼 있습니다 (user 범위)'); return }
122
125
 
123
126
  // 문서에 있다고 믿고 부르지 않는다 — 먼저 물어본다. doCodex 와 같다.
@@ -133,7 +136,7 @@ function doClaude(bin, repo) {
133
136
  '--', want.command, ...want.args]
134
137
 
135
138
  if (DRY) {
136
- hm('claude', `(dry-run) ${cur ? 'claude mcp remove axmap -s user 뒤 ' : ''}claude ${add.join(' ')}`)
139
+ hm('claude', '(dry-run) Claude 사용자 설정의 axmap 서버 경로·환경을 갱신합니다')
137
140
  return
138
141
  }
139
142
 
@@ -220,6 +223,8 @@ function mergeJson(label, file) {
220
223
  }
221
224
 
222
225
  const servers = cfg.mcpServers ?? {}
226
+ want.env = { ...(servers.axmap?.env ?? {}), ...want.env }
227
+ delete want.env.AXMAP_REPO
223
228
  if (same(servers.axmap, want)) { ok(label, `이미 등록돼 있습니다: ${file}`); return }
224
229
 
225
230
  const had = servers.axmap !== undefined
@@ -261,31 +266,56 @@ function snippetJson() {
261
266
  // 🔴 TOML 을 직접 파싱하지 않는다. 파서를 새로 짜는 것은 이 저장소의 "의존성 없음" 과
262
267
  // "판정을 복잡하게 만들지 않는다" 에 둘 다 어긋나고, 어설픈 파서가 남의 config.toml 을
263
268
  // 깨뜨리는 것이 이 작업에서 나올 수 있는 최악의 결과다. 쓰는 일은 codex 자신에게 맡긴다.
264
- function doCodex(bin) {
265
- const run = (args) => spawnSync(bin, args, { encoding: 'utf8', windowsHide: true })
269
+ export function doCodex(bin, { run: injectedRun, dry = DRY } = {}) {
270
+ const run = injectedRun ?? ((args) => spawnSync(bin, args, { cwd: os.homedir(), encoding: 'utf8', windowsHide: true }))
266
271
 
267
272
  const help = run(['mcp', '--help'])
268
273
  if (help.status !== 0 || !/\badd\b/.test(`${help.stdout}${help.stderr}`)) {
269
274
  hm('codex', "'codex mcp add' 를 확인하지 못했습니다 — 손으로 넣으세요")
270
275
  snippetToml()
271
- return
276
+ return false
272
277
  }
273
278
 
274
- // 이미 들어 있으면 다시 넣지 않는다. setup 은 몇 번을 돌려도 안전해야 한다.
275
- const list = run(['mcp', 'list'])
276
- if (list.status === 0 && /\baxmap\b/.test(`${list.stdout}`)) {
277
- ok('codex', `이미 등록돼 있습니다 (codex mcp list)`)
278
- return
279
+ // Check the actual command and environment, not just the server name. An old
280
+ // registration may still point at a temporary install or pin AXMAP_REPO.
281
+ const list = run(['mcp', 'list', '--json'])
282
+ let servers
283
+ try { servers = JSON.parse(list.stdout) } catch { /* reported below */ }
284
+ if (list.status !== 0 || !Array.isArray(servers)) {
285
+ no('codex', '기존 MCP 설정을 확인하지 못했습니다 — 설정을 덮어쓰지 않았습니다')
286
+ return false
287
+ }
288
+ const current = servers.find((s) => s.name === 'axmap')
289
+ const transport = current?.transport
290
+ const want = entry()
291
+ const wantedEnv = { ...(transport?.env ?? {}), ...want.env }
292
+ delete wantedEnv.AXMAP_REPO
293
+ if (transport?.type === 'stdio' && sameEntry(transport, { ...want, env: wantedEnv }) &&
294
+ !transport.cwd && !Object.hasOwn(transport.env ?? {}, 'AXMAP_REPO') && current.enabled !== false) {
295
+ ok('codex', '전역 등록이 최신입니다 — 실행 위치의 repo를 사용합니다')
296
+ return true
279
297
  }
280
298
 
281
- const add = ['mcp', 'add', 'axmap', '--env', 'AXMAP_ACTOR=agent', '--env', 'AXMAP_TTL=45m', '--', 'node', SERVER]
282
- if (DRY) { hm('codex', `(dry-run) codex ${add.join(' ')}`); return }
299
+ const add = ['mcp', 'add', 'axmap', ...Object.entries(wantedEnv).flatMap(([k, v]) => ['--env', `${k}=${v}`]), '--', want.command, ...want.args]
300
+ if (dry) { hm('codex', '(dry-run) codex mcp add로 전역 axmap 서버 경로·환경을 갱신합니다'); return true }
283
301
 
284
302
  const r = run(add)
285
- if (r.status === 0) ok('codex', 'codex mcp add 로 등록했습니다')
303
+ if (r.status === 0) {
304
+ const check = run(['mcp', 'list', '--json'])
305
+ let saved
306
+ try { saved = JSON.parse(check.stdout)?.find((s) => s.name === 'axmap') } catch { /* below */ }
307
+ if (check.status === 0 && sameEntry(saved?.transport, { ...want, env: wantedEnv }) &&
308
+ !saved.transport.cwd && !Object.hasOwn(saved.transport.env ?? {}, 'AXMAP_REPO')) {
309
+ ok('codex', '전역 등록을 갱신했습니다 — 실행 위치의 repo를 사용합니다')
310
+ return true
311
+ }
312
+ no('codex', '등록 후 서버 경로·작업 폴더 설정이 일치하지 않습니다')
313
+ return false
314
+ }
286
315
  else {
287
316
  no('codex', `codex mcp add 가 실패했습니다 (${(r.stderr || r.stdout || '').trim().split('\n')[0] || `exit ${r.status}`})`)
288
317
  snippetToml()
318
+ return false
289
319
  }
290
320
  }
291
321
 
@@ -339,7 +369,7 @@ function main() {
339
369
  any = true
340
370
  if (a.id === 'claude') doClaude(bin, root)
341
371
  else if (a.id === 'agy') doAgy()
342
- else if (a.id === 'codex') doCodex(bin)
372
+ else if (a.id === 'codex' && !doCodex(bin)) return 1
343
373
  }
344
374
 
345
375
  if (!any) hm('CLI', '이 PC 에서 AI CLI 를 찾지 못했습니다 — 나중에 다시 실행하세요')
@@ -350,4 +380,4 @@ function main() {
350
380
  return 0
351
381
  }
352
382
 
353
- process.exit(main())
383
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exit(main())