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/.claude/commands/ax-setup.md +9 -12
- package/.claude/commands/ax-start.md +2 -1
- package/CLAUDE.md +20 -73
- package/README.ko.md +122 -0
- package/README.md +79 -174
- package/app/lib/ssot.mjs +1 -1
- package/bin/axmap.mjs +62 -33
- package/governance/GOVERNANCE.md +7 -668
- package/governance/policy.example.json +31 -0
- package/mcp/README.md +16 -63
- package/mcp/SETUP-FOR-AI.md +13 -175
- package/mcp/install.ps1 +2 -1
- package/mcp/install.sh +2 -2
- package/mcp/server.mjs +123 -100
- package/package.json +6 -2
- package/src/overlap.mjs +1 -1
- package/src/repolock.mjs +31 -0
- package/src/repotarget.mjs +3 -0
- package/tools/mcp-register.mjs +44 -14
- package/tools/setup.mjs +29 -66
- package/tools/smoke.mjs +9 -1
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
|
|
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
|
-
'
|
|
391
|
+
'명시적으로 장부와 부가 저장소를 준비한다. 일반적인 선점·조회는 장부를 자동으로 준비하므로 먼저 부를 필요가 없다. '
|
|
385
392
|
+ '이미 있으면 아무것도 바꾸지 않으므로 여러 번 불러도 안전하다. '
|
|
386
|
-
+ '
|
|
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
|
-
|
|
695
|
-
|
|
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
|
-
|
|
818
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
1017
|
-
if (req.
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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.
|
|
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://
|
|
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
|
*
|
package/src/repolock.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/repotarget.mjs
CHANGED
package/tools/mcp-register.mjs
CHANGED
|
@@ -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',
|
|
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
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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',
|
|
282
|
-
if (
|
|
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)
|
|
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'
|
|
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())
|