axmap-cli 1.3.0 → 1.3.2
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/README.ko.md +13 -7
- package/README.md +11 -7
- package/bin/axmap.mjs +56 -21
- package/mcp/README.md +6 -18
- package/mcp/SETUP-FOR-AI.md +2 -3
- package/mcp/install.ps1 +2 -1
- package/mcp/install.sh +2 -2
- package/mcp/server.mjs +9 -7
- package/package.json +1 -1
- package/src/identity.mjs +62 -0
- package/tools/bus.mjs +222 -89
- package/tools/mcp-register.mjs +44 -14
- package/tools/setup.mjs +67 -74
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: axMap —
|
|
2
|
+
description: axMap — 어느 폴더에서든 이 PC에 전역 설치·등록한다
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
이
|
|
6
|
-
|
|
5
|
+
이 PC에서 axMap을 전역으로 사용할 수 있게 설정해줘.
|
|
6
|
+
추가 setup 옵션: $ARGUMENTS
|
|
7
7
|
|
|
8
|
-
1. 지금 폴더가
|
|
9
|
-
axMap 의 장부와 쪽지는 git 의 고아 브랜치로 오간다 — 저장소가 없으면 claim 이
|
|
10
|
-
아무에게도 안 가면서 조용히 성공한다.
|
|
8
|
+
1. 지금 폴더가 Git 저장소가 아니어도 진행한다. setup은 PC 전역 설치이며 작업 repo는 나중에 CLI를 켠 위치에서 결정한다.
|
|
11
9
|
|
|
12
10
|
2. Bash 로 아래를 실행한다.
|
|
13
11
|
|
|
14
|
-
npx -y axmap-cli@latest setup
|
|
12
|
+
npx -y axmap-cli@latest setup $ARGUMENTS
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
추가 옵션이 없으면 setup만 실행한다. repo를 지정하거나 현재 폴더를 타겟으로 저장하지 않는다.
|
|
15
|
+
사용자가 특정 repo의 장부와 커밋 훅까지 미리 준비하길 요청한 경우에만 `--repo <경로>`를 지정한다.
|
|
18
16
|
|
|
19
17
|
먼저 무엇이 바뀔지만 보고 싶으면 `--dry-run` 을 붙인다. 아무것도 쓰지 않는다.
|
|
20
18
|
|
|
@@ -25,7 +23,6 @@ description: axMap — 이 저장소에 axMap 을 붙인다 (내 git 주소를
|
|
|
25
23
|
- AI CLI 를 **완전히 껐다가 다시 켜야** MCP 도구(`ax_*`)가 뜬다
|
|
26
24
|
- 그 뒤 `/ax-start` 로 시작하면 된다
|
|
27
25
|
|
|
28
|
-
|
|
29
|
-
`setup` 은 몇 번 돌려도 안전하지만, 이미 되는 것을 다시 설치하는 것은 시간 낭비다.
|
|
26
|
+
설치·업데이트 때 setup을 한 번 실행한다. 이후 repo를 옮길 때는 setup이나 init을 반복하지 않고 그 repo에서 `ax_status` 또는 `ax_claim`을 호출한다.
|
|
30
27
|
|
|
31
|
-
|
|
28
|
+
각 repo의 장부는 첫 조회·선점 때 자동 준비하며, 같은 원격 장부를 공유하는 세션끼리 서로의 선점이 보인다.
|
package/README.ko.md
CHANGED
|
@@ -13,22 +13,28 @@ npm 패키지 이름은 `axmap-cli`, 실행 명령은 `axmap`이다.
|
|
|
13
13
|
|
|
14
14
|
## 설치
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Git 저장소 밖을 포함해 어느 폴더에서든 실행한다.
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
19
|
npm install -g axmap-cli@latest
|
|
20
|
-
cd <프로젝트-저장소>
|
|
21
20
|
axmap setup
|
|
22
|
-
axmap doctor
|
|
23
21
|
```
|
|
24
22
|
|
|
25
|
-
`setup`은
|
|
23
|
+
`setup`은 이 PC에 axMap을 설치하고 AI 도구의 사용자 설정에 MCP를 등록한다. 실행한 폴더에 타겟을 고정하거나 repo를 초기화하지 않는다.
|
|
26
24
|
MCP를 사용하려면 설정 후 AI 도구를 다시 시작한다.
|
|
27
25
|
함께 작업하는 사람과 에이전트는 같은 원격 장부를 사용해야 서로의 선점을 볼 수 있다.
|
|
28
26
|
|
|
27
|
+
## 1.3.2: 쪽지 표시 이름과 사용자 자동 공유
|
|
28
|
+
|
|
29
|
+
대화형 `setup`에서 `장효준`, `John Smith`처럼 쪽지에 표시할 이름을 입력한다. 입력 없이 설정하거나 오타를 고치려면 `axmap setup --name "장효준"`을 실행한다. 재설치할 때는 저장된 이름을 유지하며 Git 이름·이메일 설정은 바꾸지 않는다.
|
|
30
|
+
|
|
31
|
+
각 repo에서 axMap을 처음 사용하면 해당 repo의 Git 이메일을 읽고 기존 `axmap/users` 고아 브랜치에 표시 이름을 자동 공유한다. 코드 커밋이나 repo별 수동 등록은 필요 없다. 다른 팀원은 사용자 정보 동기화 후 수신자로 발견한다. 원격 push 권한이 필요하며, 공유 실패는 경고하고 다음 사용 때 재시도한다. `setup`만 실행하고 사용한 적 없는 repo에는 등록하지 않는다.
|
|
32
|
+
|
|
33
|
+
`axmap bus contacts`로 수신자 이름·이메일을 확인한다. 아는 이름으로 보낸 쪽지는 이메일 주소로 저장하므로 이름을 고쳐도 수신·읽음 기록이 이어진다. 동명이인은 이메일로 지정한다. 쪽지 자동 등록은 투표권이나 MR 승인 권한을 부여하지 않는다.
|
|
34
|
+
|
|
29
35
|
자세한 설치·설정은 [docs/INSTALL.md](https://github.com/rl2aderjoon/axmap/blob/main/docs/INSTALL.md)를 참고한다.
|
|
30
36
|
|
|
31
|
-
## 1.3.
|
|
37
|
+
## 1.3.1: 어느 폴더에서든 setup 한 번, 이후 repo 자동 연결
|
|
32
38
|
|
|
33
39
|
axMap을 한 번 설정하면 다른 Git 저장소에서도 Codex 등 AI CLI를 열어 바로 사용할 수 있다. 세션의 작업 repo를 자동으로 선택하고, 첫 조회·선점 때 장부를 준비하므로 매번 `axmap init`을 실행하지 않아도 된다.
|
|
34
40
|
|
|
@@ -36,14 +42,14 @@ axMap을 한 번 설정하면 다른 Git 저장소에서도 Codex 등 AI CLI를
|
|
|
36
42
|
|
|
37
43
|
MCP는 클라이언트가 알려주는 작업 폴더(roots)를 따라간다. 미지원 클라이언트는 시작 폴더를 사용하며, 도중에 폴더를 바꾸면 도구의 `cwd`에 작업 폴더 절대 경로를 전달한다. 커밋 훅을 적용할 새 repo에서는 `axmap hook install`을 실행한다.
|
|
38
44
|
|
|
39
|
-
기존 설치를 업그레이드할
|
|
45
|
+
기존 설치를 업그레이드할 때도 어느 폴더에서든 아래 명령을 한 번 실행하고 AI CLI를 다시 시작한다.
|
|
40
46
|
|
|
41
47
|
```bash
|
|
42
48
|
npm install -g axmap-cli@latest
|
|
43
49
|
axmap setup
|
|
44
50
|
```
|
|
45
51
|
|
|
46
|
-
`setup`은 MCP와
|
|
52
|
+
`setup`은 MCP와 공유 훅 실행 사본 및 등록을 갱신한다. 이후 repo를 옮길 때는 setup이나 init을 반복하지 않는다. 특정 repo의 장부와 커밋 훅까지 미리 준비하려면 `axmap setup --repo <경로>`를 명시한다.
|
|
47
53
|
|
|
48
54
|
## 작업 흐름
|
|
49
55
|
|
package/README.md
CHANGED
|
@@ -13,22 +13,26 @@ The npm package is `axmap-cli`; the command is `axmap`.
|
|
|
13
13
|
|
|
14
14
|
## Installation
|
|
15
15
|
|
|
16
|
-
Run these commands
|
|
16
|
+
Run these commands from any folder, including outside a Git repository.
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
19
|
npm install -g axmap-cli@latest
|
|
20
|
-
cd <your-project-repository>
|
|
21
20
|
axmap setup
|
|
22
|
-
axmap doctor
|
|
23
21
|
```
|
|
24
22
|
|
|
25
|
-
`setup`
|
|
23
|
+
`setup` installs axMap and registers MCP in your AI tool's user settings. It does not bind the installation to the current folder or initialize a repository.
|
|
26
24
|
Restart your AI tool after setup to use MCP.
|
|
27
25
|
People and agents working together must share the same remote ledger to see each other's claims.
|
|
28
26
|
|
|
27
|
+
During interactive setup, enter the name people should use for messages, such as `John Smith` or `장효준`. To set or correct it without a prompt, run `axmap setup --name "John Smith"`. Reinstallation keeps the saved name. Git's name and email settings are unchanged.
|
|
28
|
+
|
|
29
|
+
On first use in a repository, axMap reads that repository's Git email and shares your display name through the existing `axmap/users` branch. No code commit or per-repository manual registration is needed. Teammates discover it when their user information synchronizes. This requires push access; an offline or rejected update is reported and retried. Setup alone does not announce you to repositories you have never used.
|
|
30
|
+
|
|
31
|
+
Use `axmap bus contacts` to list known names and email addresses. Messages sent to a known name are addressed to its email, so correcting the name preserves delivery and read history. If multiple people use the same name, specify the recipient's email. Messaging registration does not grant voting or MR approval rights.
|
|
32
|
+
|
|
29
33
|
See [docs/INSTALL.md](https://github.com/rl2aderjoon/axmap/blob/main/docs/INSTALL.md) for installation and configuration details (Korean).
|
|
30
34
|
|
|
31
|
-
##
|
|
35
|
+
## Global setup and automatic repositories in 1.3.1
|
|
32
36
|
|
|
33
37
|
Set up axMap once, then open Codex or another AI CLI in any Git repository. axMap selects that session's repository and prepares its ledger on the first status or claim request. There is no per-repository `axmap init` step.
|
|
34
38
|
|
|
@@ -36,14 +40,14 @@ Existing remote ledgers are reused. Linked worktrees share one ledger, and simul
|
|
|
36
40
|
|
|
37
41
|
MCP follows workspace roots supplied by the client. When roots are unsupported, it uses the launch directory; pass the absolute working directory through the tool's `cwd` argument for later directory changes. Run `axmap hook install` in each repository where you want commit enforcement.
|
|
38
42
|
|
|
39
|
-
To upgrade an existing installation, run this once from
|
|
43
|
+
To upgrade an existing installation, run this once from any folder, then restart your AI CLI:
|
|
40
44
|
|
|
41
45
|
```bash
|
|
42
46
|
npm install -g axmap-cli@latest
|
|
43
47
|
axmap setup
|
|
44
48
|
```
|
|
45
49
|
|
|
46
|
-
`setup` refreshes the installed MCP and hook
|
|
50
|
+
`setup` refreshes the installed MCP and shared hook runner as well as its registration. Subsequent repository changes do not require another setup or init. To explicitly prepare one repository and its commit hook during setup, use `axmap setup --repo <path>`.
|
|
47
51
|
|
|
48
52
|
## Workflow
|
|
49
53
|
|
package/bin/axmap.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import { stateDir } from '../src/repotarget.mjs'
|
|
|
47
47
|
import { homeRegistrations } from '../src/mcpstate.mjs'
|
|
48
48
|
import { compareBranchFor, formatOverlap } from '../src/overlap.mjs'
|
|
49
49
|
import { withRepoLock } from '../src/repolock.mjs'
|
|
50
|
+
import { readProfile, syncConfiguredProfile } from '../src/identity.mjs'
|
|
50
51
|
|
|
51
52
|
/** 이 CLI 가 들어 있는 axMap 폴더. 갱신 확인과 setup 이 자기 위치를 알아야 한다. */
|
|
52
53
|
const SELF_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
@@ -453,9 +454,17 @@ function unreadNotes(root, me) {
|
|
|
453
454
|
}
|
|
454
455
|
if (!found.length) return []
|
|
455
456
|
let seen = ''
|
|
457
|
+
const email = (process.env.AXMAP_AGENT_EMAIL || git(['config', 'user.email'], { cwd: root }).out).trim().toLowerCase()
|
|
458
|
+
const mine = new Set([me.toLowerCase(), email].filter(Boolean))
|
|
459
|
+
try { const p = readProfile(); if (p) mine.add(p.name.toLowerCase()) } catch { /* profile sync reports it */ }
|
|
460
|
+
try {
|
|
461
|
+
const state = JSON.parse(fs.readFileSync(path.join(usersFilesDir(root), `${email}.json`), 'utf8'))
|
|
462
|
+
for (const n of [state.name, ...(state.aliases ?? [])]) if (n) mine.add(n.toLowerCase())
|
|
463
|
+
seen = state.seen ?? ''
|
|
464
|
+
} catch { /* no shared state */ }
|
|
456
465
|
try {
|
|
457
466
|
const raw = JSON.parse(fs.readFileSync(path.join(root, '.axmap-bus-seen.json'), 'utf8'))
|
|
458
|
-
|
|
467
|
+
for (const [key, id] of Object.entries(raw)) if (mine.has(key.toLowerCase()) && id > seen) seen = id
|
|
459
468
|
} catch { /* 처음이면 아무것도 안 읽은 것 */ }
|
|
460
469
|
|
|
461
470
|
const out = []
|
|
@@ -471,8 +480,8 @@ function unreadNotes(root, me) {
|
|
|
471
480
|
const from = g('from')
|
|
472
481
|
const to = g('to')
|
|
473
482
|
// 내가 보낸 것은 뺀다 — 자기 쪽지에 자기가 놀라면 안 된다.
|
|
474
|
-
if (
|
|
475
|
-
if (to
|
|
483
|
+
if (g('fromEmail') ? mine.has(g('fromEmail').toLowerCase()) : mine.has(from.toLowerCase())) continue
|
|
484
|
+
if (!to.split(',').some((v) => v.trim().toLowerCase() === 'all' || mine.has(v.trim().toLowerCase()))) continue
|
|
476
485
|
out.push({ id, from, subject: g('subject') })
|
|
477
486
|
}
|
|
478
487
|
return out
|
|
@@ -504,6 +513,11 @@ function markBusSeen(root, me, id) {
|
|
|
504
513
|
all[me] = id
|
|
505
514
|
fs.writeFileSync(file, JSON.stringify(all, null, 2) + '\n')
|
|
506
515
|
} catch { /* 조용히 */ }
|
|
516
|
+
// Use the same email-keyed receipt as MCP, while retaining the legacy file
|
|
517
|
+
// for older installations that still share this checkout.
|
|
518
|
+
spawnSync(process.execPath, [path.join(SELF_ROOT, 'tools', 'bus.mjs'), 'seen', '--mine', id], {
|
|
519
|
+
cwd: root, env: { ...process.env, AXMAP_AGENT: me }, encoding: 'utf8', windowsHide: true,
|
|
520
|
+
})
|
|
507
521
|
}
|
|
508
522
|
|
|
509
523
|
/**
|
|
@@ -722,18 +736,10 @@ function claimsDir(root) {
|
|
|
722
736
|
return path.join(ledgerDir(root), 'claims')
|
|
723
737
|
}
|
|
724
738
|
|
|
725
|
-
/**
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
* 장부와 같은 `.axmap/` 아래라 같은 문제가 있지만(링크된 worktree 에는 없다),
|
|
729
|
-
* 고치려면 `tools/bus.mjs` 의 `SEEN_FILE`·쪽지함 경로까지 같이 옮겨야 한다.
|
|
730
|
-
* 한쪽만 옮기면 **읽는 곳과 쓰는 곳이 갈려** 읽음 표시가 어긋난다 —
|
|
731
|
-
* 쪽지가 안 보이는 것보다 나쁘다. 장부와 달리 여기서는 못 찾아도 죽지 않고
|
|
732
|
-
* "새 쪽지 없음" 으로 조용히 지나가므로, 링크된 worktree 에서 쪽지가 안 보이는
|
|
733
|
-
* 것은 남는 구멍으로 적어 둔다.
|
|
734
|
-
*/
|
|
739
|
+
/** Message and user worktrees follow the same common checkout as the ledger.
|
|
740
|
+
* Git identity is still read from the caller's working repository. */
|
|
735
741
|
function busDir(root) {
|
|
736
|
-
return path.join(root, BUS_REL)
|
|
742
|
+
return path.join(axmapHome(root), BUS_REL)
|
|
737
743
|
}
|
|
738
744
|
|
|
739
745
|
/** 쪽지 파일이 실제로 쌓이는 곳. 브랜치 루트에 흩뿌리지 않고 한 폴더 아래 모은다. */
|
|
@@ -742,7 +748,7 @@ function busMessagesDir(root) {
|
|
|
742
748
|
}
|
|
743
749
|
|
|
744
750
|
function usersDir(root) {
|
|
745
|
-
return path.join(root, USERS_REL)
|
|
751
|
+
return path.join(axmapHome(root), USERS_REL)
|
|
746
752
|
}
|
|
747
753
|
|
|
748
754
|
/** 사람마다의 파일이 쌓이는 곳. `.gitattributes` 와 섞이지 않게 한 폴더 아래 모은다. */
|
|
@@ -1343,9 +1349,17 @@ function ensureOrphan(root, { branch, rel, dir, what, seed }) {
|
|
|
1343
1349
|
const remote = r.source === 'ambiguous' ? null : r.name
|
|
1344
1350
|
|
|
1345
1351
|
let base = null
|
|
1346
|
-
if (
|
|
1347
|
-
|
|
1348
|
-
|
|
1352
|
+
if (r.source === 'ambiguous') return fail({ err: '원격이 모호합니다. git config axmap.remote <원격>을 지정하세요.' })
|
|
1353
|
+
if (remote) {
|
|
1354
|
+
const exists = git(['ls-remote', '--exit-code', remote, `refs/heads/${branch}`], { cwd: root })
|
|
1355
|
+
if (exists.code !== 0 && exists.code !== 2) return fail(exists)
|
|
1356
|
+
if (exists.code === 0) {
|
|
1357
|
+
const fetched = git(['fetch', '--quiet', remote, branch], { cwd: root })
|
|
1358
|
+
if (fetched.code !== 0) return fail(fetched)
|
|
1359
|
+
base = git(['rev-parse', 'FETCH_HEAD'], { cwd: root }).out
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
if (!base) {
|
|
1349
1363
|
// 빈 트리 -> 부모 없는 커밋 -> 브랜치. 작업 트리를 건드리지 않는 고아 브랜치.
|
|
1350
1364
|
const tree = git(['mktree'], { cwd: root, input: '' })
|
|
1351
1365
|
if (tree.code !== 0) return fail(tree)
|
|
@@ -1361,6 +1375,8 @@ function ensureOrphan(root, { branch, rel, dir, what, seed }) {
|
|
|
1361
1375
|
const w = git(['worktree', 'add', '--quiet', dir, branch], { cwd: root })
|
|
1362
1376
|
if (w.code !== 0) return fail(w)
|
|
1363
1377
|
|
|
1378
|
+
excludeAxmap(root)
|
|
1379
|
+
|
|
1364
1380
|
seed(dir)
|
|
1365
1381
|
|
|
1366
1382
|
// 🔴 고아 브랜치는 **자기 트리의 `.gitattributes` 만** 본다. 저장소 루트에
|
|
@@ -1386,6 +1402,16 @@ function ensureOrphan(root, { branch, rel, dir, what, seed }) {
|
|
|
1386
1402
|
return 'created'
|
|
1387
1403
|
}
|
|
1388
1404
|
|
|
1405
|
+
function excludeAxmap(root) {
|
|
1406
|
+
const file = path.resolve(root, gitOrDie(['rev-parse', '--git-path', 'info/exclude'], { cwd: root }).out)
|
|
1407
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
1408
|
+
let text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
|
|
1409
|
+
for (const entry of ['.axmap/', '.axmap-bus-seen.json']) {
|
|
1410
|
+
if (!text.split(/\r?\n/).includes(entry)) text += (text && !text.endsWith('\n') ? '\n' : '') + entry + '\n'
|
|
1411
|
+
}
|
|
1412
|
+
fs.writeFileSync(file, text)
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1389
1415
|
function ensureBus(root) {
|
|
1390
1416
|
return ensureOrphan(root, {
|
|
1391
1417
|
branch: BUS_BRANCH, rel: BUS_REL, dir: busDir(root), what: '쪽지함',
|
|
@@ -1424,7 +1450,7 @@ function cmdBusRepair() {
|
|
|
1424
1450
|
const root = repoRoot()
|
|
1425
1451
|
// .axmap 을 손으로 지웠어도 git 쪽에 worktree 등록이 남아 add 가 실패한다.
|
|
1426
1452
|
git(['worktree', 'prune'], { cwd: root })
|
|
1427
|
-
const r = ensureBus(root)
|
|
1453
|
+
const r = withRepoLock(path.join(commonDir(root), 'axmap-init.lock'), () => ensureBus(root))
|
|
1428
1454
|
if (r === 'failed') process.exit(1)
|
|
1429
1455
|
console.log(
|
|
1430
1456
|
r === 'ready'
|
|
@@ -1437,7 +1463,7 @@ function cmdBusRepair() {
|
|
|
1437
1463
|
function cmdUsersRepair() {
|
|
1438
1464
|
const root = repoRoot()
|
|
1439
1465
|
git(['worktree', 'prune'], { cwd: root })
|
|
1440
|
-
const r = ensureUsers(root)
|
|
1466
|
+
const r = withRepoLock(path.join(commonDir(root), 'axmap-init.lock'), () => ensureUsers(root))
|
|
1441
1467
|
if (r === 'failed') process.exit(1)
|
|
1442
1468
|
console.log(
|
|
1443
1469
|
r === 'ready'
|
|
@@ -2778,7 +2804,7 @@ function warnIfStale() {
|
|
|
2778
2804
|
|
|
2779
2805
|
const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
|
|
2780
2806
|
|
|
2781
|
-
axmap setup
|
|
2807
|
+
axmap setup 어느 폴더에서든 이 PC에 전역 설치·등록한다 (첫 한 번)
|
|
2782
2808
|
[--remote <이름|git 주소>] [--name] [--dry-run]
|
|
2783
2809
|
axmap init 장부(고아 브랜치 + worktree)를 준비한다
|
|
2784
2810
|
axmap bus-repair 쪽지함만 준비한다 (장부는 안 건드린다)
|
|
@@ -2898,3 +2924,12 @@ switch (cmd) {
|
|
|
2898
2924
|
console.log(HELP)
|
|
2899
2925
|
process.exit(cmd ? 1 : 0)
|
|
2900
2926
|
}
|
|
2927
|
+
|
|
2928
|
+
// Setup stores a label, never a repository pin. Publish it only after an
|
|
2929
|
+
// actual successful collaboration command in the selected working repository.
|
|
2930
|
+
if (['init', 'claim', 'release', 'renew', 'status'].includes(cmd)) {
|
|
2931
|
+
const warning = syncConfiguredProfile(process.cwd(), path.join(SELF_ROOT, 'tools', 'bus.mjs'), {
|
|
2932
|
+
...process.env, ...(flags.agent ? { AXMAP_AGENT: flags.agent } : {}),
|
|
2933
|
+
})
|
|
2934
|
+
if (warning) console.error(warning)
|
|
2935
|
+
}
|
package/mcp/README.md
CHANGED
|
@@ -11,15 +11,14 @@
|
|
|
11
11
|
|
|
12
12
|
## 설치
|
|
13
13
|
|
|
14
|
-
Node 20 이상과 git이 필요하다.
|
|
14
|
+
Node 20 이상과 git이 필요하다. Git 저장소 밖을 포함해 어느 폴더에서든 실행한다.
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
npm install -g axmap-cli@latest
|
|
18
18
|
axmap setup
|
|
19
|
-
axmap doctor
|
|
20
19
|
```
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
어느 폴더에서든 setup을 실행한 뒤 AI CLI를 다시 시작한다. `setup`은 PC 전역에 MCP와 명령을 설치·등록하며 repo를 초기화하거나 타겟으로 저장하지 않는다. 이후 AI CLI를 켠 repo에서 장부를 자동 준비한다. 소스 개발 환경에서는 `node bin/axmap.mjs setup`을 사용한다. 명시적 repo 준비는 `setup --repo <경로>`를 사용한다.
|
|
23
22
|
|
|
24
23
|
AI에게 설치를 맡기려면 다음 공개 문서를 제공한다.
|
|
25
24
|
|
|
@@ -73,7 +72,7 @@ Claude Code 에서는 `/ax`, `/ax-start`, `/ax-done`, `/ax-tell` 로도 부른
|
|
|
73
72
|
| Claude Code (`claude`) | `~/.claude.json` 의 user 범위 (**이 PC 의 모든 폴더에 적용되는 개인 설정**) | 설치기가 `claude mcp add -s user` 로 | ✅ 이 PC 에서 확인 |
|
|
74
73
|
| Claude Code (`claude`) | 저장소의 `.mcp.json` | 저장소에 들어 있으면 스스로 발견 | ✅ 이 저장소가 그렇게 쓴다 |
|
|
75
74
|
| Antigravity (`agy`) | `~/.gemini/config/mcp_config.json` (전역, `mcpServers`) | `tools/mcp-register.mjs` | ✅ `agy` 1.1.10 으로 확인 |
|
|
76
|
-
| Codex (`codex`) | `~/.codex/config.toml` (전역, `[mcp_servers.axmap]`) | `tools/mcp-register.mjs
|
|
75
|
+
| Codex (`codex`) | `~/.codex/config.toml` (전역, `[mcp_servers.axmap]`) | `tools/mcp-register.mjs`가 Codex CLI로 등록·검증 | 임시 사용자 환경에서 실제 CLI 등록과 repo별 MCP 실행 확인 |
|
|
77
76
|
|
|
78
77
|
`agy` 에서 확인한 것: 전역 파일은 먹고, 저장소의 `.mcp.json` 은 **안 먹는다.**
|
|
79
78
|
공식 문서의 작업 폴더용 `.agents/mcp_config.json` 도 CLI 에서는 안 먹었다(IDE 쪽으로 보인다).
|
|
@@ -163,11 +162,7 @@ clone 하면 장부에는 `claude` 라는 **한 명**만 존재하고, 겹침
|
|
|
163
162
|
3. OS 사용자 이름
|
|
164
163
|
4. 호스트 이름
|
|
165
164
|
|
|
166
|
-
|
|
167
|
-
`user.name` 을, 밖이면 전역 값을 답한다. 그래서 앞으로 일할 저장소 안에서 설치하는
|
|
168
|
-
것이 맞다 — 그래야 MCP 가 쓰는 이름과 같은 저장소에서 손으로 `axmap` 을 칠 때의
|
|
169
|
-
이름이 같아진다. 다르면 한 사람이 장부에서 둘이 되고, **자기가 잡은 것을 자기가
|
|
170
|
-
반납하지 못한다**(`release` 종료 코드 5).
|
|
165
|
+
**기본 전역 setup의 이름은 전역 Git 설정에서 정한다.** setup을 실행한 repo의 로컬 이름을 다른 repo에 가져가지 않는다. 명시적 프로젝트 설정에서는 해당 repo의 이름을 사용할 수 있다. 선점부터 반납까지 같은 이름·세션을 사용해야 한다.
|
|
171
166
|
|
|
172
167
|
이름 검증은 설치기가 다시 짜지 않고 저장소의 `agentNameError()` 를 그대로 부른다.
|
|
173
168
|
설치기가 통과시킨 이름을 서버가 거부하는 일이 없어야 하기 때문이다.
|
|
@@ -207,16 +202,9 @@ clone 하면 장부에는 `claude` 라는 **한 명**만 존재하고, 겹침
|
|
|
207
202
|
|
|
208
203
|
## 안 되는 것 / 아직 확인 못 한 것
|
|
209
204
|
|
|
210
|
-
**Codex
|
|
211
|
-
`~/.codex/config.toml` 형식은 공식 문서를 따른 것이고, `mcp-register` 는
|
|
212
|
-
`codex mcp --help` 를 먼저 찔러 보고 그 하위 명령이 실제로 있을 때만 등록한다.
|
|
213
|
-
TOML 을 직접 파싱하지도 쓰지도 않는다 — 어설픈 파서가 남의 `config.toml` 을 깨뜨리는
|
|
214
|
-
것이 이 작업에서 나올 수 있는 최악의 결과다.
|
|
205
|
+
**Codex CLI 등록은 임시 사용자 환경에서 실제 실행으로 확인했다.** `codex mcp list --json`으로 서버 경로·환경·시작 cwd를 확인하고 `codex mcp add`로 갱신한 뒤 다시 검증한다. TOML을 직접 파싱하거나 덮어쓰지 않는다. 모델 클라이언트별 roots 지원 여부는 별도 확인이 필요하다.
|
|
215
206
|
|
|
216
|
-
|
|
217
|
-
`AXMAP_AGENT` 를 빼고 부르고, 이미 등록돼 있으면 다시 부르지도 않는다. 그래서 설치기는
|
|
218
|
-
codex 에 대해서는 **확인만 하고 이름은 손으로 넣으라고 한 줄 찍는다.** 확인 안 된
|
|
219
|
-
도구의 설정을 추측으로 덮어쓰는 것보다 낫다고 봤다.
|
|
207
|
+
**개인 환경값은 보존한다.** Codex의 기존 `AXMAP_AGENT` 등 개인 환경값은 유지하며, 전역 setup은 예전 서버 경로·고정 `AXMAP_REPO`·시작 cwd를 제거한다. 이름을 따로 지정하지 않았으면 MCP가 작업 repo의 Git 설정에서 정한다.
|
|
220
208
|
|
|
221
209
|
**MCP `roots`는 1.3.0부터 지원한다.** 클라이언트가 지원을 선언하면 `roots/list`로 작업 폴더를 받고 변경 알림도 처리한다. 지원하지 않는 클라이언트는 시작 repo를 사용하고, 실행 중 경로 변경은 도구의 `cwd`로 전달한다. 프로토콜 회귀 테스트로 검증했으며 클라이언트별 실제 지원 여부는 별도 확인이 필요하다.
|
|
222
210
|
|
package/mcp/SETUP-FOR-AI.md
CHANGED
|
@@ -8,15 +8,14 @@ Node 20 이상과 git을 확인한다. npm 패키지 이름은 `axmap-cli`다.
|
|
|
8
8
|
npm install -g axmap-cli@latest
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Git 저장소 밖을 포함해 어느 폴더에서든 다음 명령을 실행한다. setup에는 작업 repo를 지정할 필요가 없다.
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
axmap setup --dry-run
|
|
15
15
|
axmap setup
|
|
16
|
-
axmap doctor
|
|
17
16
|
```
|
|
18
17
|
|
|
19
|
-
`setup`은 AI CLI의 사용자 설정에 MCP를
|
|
18
|
+
`setup`은 프로그램과 전역 명령을 설치하고 AI CLI의 사용자 설정에 MCP를 등록한다. repo를 초기화하거나 `AXMAP_REPO`를 특정 폴더로 고정하지 않는다. repo의 장부와 커밋 훅도 미리 준비하려면 `--repo <경로>`를 명시하고, 다른 원격을 공유하려면 `--remote <git-주소>`도 지정한다.
|
|
20
19
|
|
|
21
20
|
1.3.0부터 다른 repo로 이동할 때 `setup`이나 `init`을 반복하지 않는다. 새 작업 repo에서 AI CLI를 실행하면 첫 조회·선점 시 장부를 자동 준비한다. MCP roots가 지원되면 작업 폴더 변경도 따라간다. 지원되지 않거나 여러 repo가 열려 있으면 도구의 `cwd`에 현재 작업 폴더 절대 경로를 전달한다. 커밋 훅을 새 repo에도 적용하려면 `axmap hook install`을 실행한다.
|
|
22
21
|
|
package/mcp/install.ps1
CHANGED
|
@@ -228,6 +228,7 @@ if (-not (Test-Path -LiteralPath $server)) { Fail "복사가 끝났는데 서버
|
|
|
228
228
|
# 이미 한다. 여기서 다시 짜지 않는다.
|
|
229
229
|
Say ''
|
|
230
230
|
& node (Join-Path $Dest 'tools\mcp-register.mjs')
|
|
231
|
+
if ($LASTEXITCODE -ne 0) { Fail 'AI CLI 전역 등록을 확인하지 못했습니다. 위 오류를 해결하고 setup을 다시 실행하세요.' }
|
|
231
232
|
Say ' (위에 claude 가 ".mcp.json 이 없습니다" 로 나왔다면, 그건 "저장소 안의'
|
|
232
233
|
Say ' .mcp.json" 을 찾는 검사입니다. 전역 설치에는 그 파일이 없는 것이 정상이고,'
|
|
233
234
|
Say ' Claude Code 는 바로 아래에서 따로 등록합니다.)'
|
|
@@ -295,7 +296,7 @@ if (-not (Get-Command codex -ErrorAction SilentlyContinue)) {
|
|
|
295
296
|
$listed = "$(& codex mcp list)"
|
|
296
297
|
if ($listed -match 'axmap') {
|
|
297
298
|
$stCodex = 'ok'
|
|
298
|
-
$whyCodex =
|
|
299
|
+
$whyCodex = '~/.codex/config.toml (codex mcp list로 확인). 작업 repo는 실행 위치에서 결정하고 이름은 git 설정을 사용합니다.'
|
|
299
300
|
} else {
|
|
300
301
|
$stCodex = 'fail'; $whyCodex = 'codex mcp list 에 axmap 이 없습니다 — 위 TOML 조각을 손으로 넣으세요'
|
|
301
302
|
}
|
package/mcp/install.sh
CHANGED
|
@@ -226,7 +226,7 @@ SERVER="$DEST/mcp/server.mjs"
|
|
|
226
226
|
# 이 PC 에 어떤 CLI 가 있는지 찾고 각자의 설정에 넣는 일은 tools/mcp-register.mjs 가
|
|
227
227
|
# 이미 한다. 여기서 다시 짜지 않는다.
|
|
228
228
|
say ''
|
|
229
|
-
node "$DEST/tools/mcp-register.mjs"
|
|
229
|
+
node "$DEST/tools/mcp-register.mjs" || fail 'AI CLI 전역 등록을 확인하지 못했습니다. 위 오류를 해결하고 setup을 다시 실행하세요.'
|
|
230
230
|
say ' (위에 claude 가 ".mcp.json 이 없습니다" 로 나왔다면, 그건 "저장소 안의'
|
|
231
231
|
say ' .mcp.json" 을 찾는 검사입니다. 전역 설치에는 그 파일이 없는 것이 정상이고,'
|
|
232
232
|
say ' Claude Code 는 바로 아래에서 따로 등록합니다.)'
|
|
@@ -293,7 +293,7 @@ if ! command -v codex >/dev/null 2>&1; then
|
|
|
293
293
|
else
|
|
294
294
|
if codex mcp list 2>/dev/null | grep -q axmap; then
|
|
295
295
|
ST_CODEX='ok'
|
|
296
|
-
WHY_CODEX=
|
|
296
|
+
WHY_CODEX='~/.codex/config.toml (codex mcp list로 확인). 작업 repo는 실행 위치에서 결정하고 이름은 git 설정을 사용합니다.'
|
|
297
297
|
else
|
|
298
298
|
ST_CODEX='fail'; WHY_CODEX='codex mcp list 에 axmap 이 없습니다 — 위 TOML 조각을 손으로 넣으세요'
|
|
299
299
|
fi
|
package/mcp/server.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import fs from 'node:fs'
|
|
|
45
45
|
import path from 'node:path'
|
|
46
46
|
import readline from 'node:readline'
|
|
47
47
|
import { fileURLToPath } from 'node:url'
|
|
48
|
+
import { syncConfiguredProfile } from '../src/identity.mjs'
|
|
48
49
|
|
|
49
50
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
50
51
|
|
|
@@ -311,7 +312,7 @@ function unreadBanner(tool, text) {
|
|
|
311
312
|
// 떼는 순간 여기가 조용히 낡는다 — 목록은 언제나 코드보다 먼저 낡는다.
|
|
312
313
|
if (text.includes('안 읽은 쪽지')) return ''
|
|
313
314
|
|
|
314
|
-
const r = bus(['list', '--
|
|
315
|
+
const r = bus(['list', '--mine', '--unread', '--no-mark',
|
|
315
316
|
'--throttle', String(BUS_POLL), '--quiet-if-empty'])
|
|
316
317
|
if (r.code !== 0) return ''
|
|
317
318
|
const rows = busRows(r.out)
|
|
@@ -331,7 +332,7 @@ function unreadBanner(tool, text) {
|
|
|
331
332
|
|
|
332
333
|
// 🔴 배너를 다 만든 뒤에 찍는다. 위에서 죽으면 안 찍혀야 다음에 다시 뜬다.
|
|
333
334
|
const ids = shown.map((s) => s.id).filter(Boolean)
|
|
334
|
-
if (ids.length) bus(['seen', '--
|
|
335
|
+
if (ids.length) bus(['seen', '--mine', ...ids])
|
|
335
336
|
return banner
|
|
336
337
|
}
|
|
337
338
|
|
|
@@ -358,7 +359,7 @@ function cli(args) {
|
|
|
358
359
|
cwd: REPO,
|
|
359
360
|
encoding: 'utf8',
|
|
360
361
|
windowsHide: true,
|
|
361
|
-
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_SESSION: SESSION },
|
|
362
|
+
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_SESSION: SESSION, AXMAP_SKIP_PROFILE_SYNC: '1' },
|
|
362
363
|
})
|
|
363
364
|
const stdout = (r.stdout ?? '').trim()
|
|
364
365
|
const stderr = (r.stderr ?? '').trim()
|
|
@@ -737,7 +738,7 @@ function releaseAllIn(dir) {
|
|
|
737
738
|
cwd: dir,
|
|
738
739
|
encoding: 'utf8',
|
|
739
740
|
windowsHide: true,
|
|
740
|
-
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_SESSION: SESSION },
|
|
741
|
+
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_SESSION: SESSION, AXMAP_SKIP_PROFILE_SYNC: '1' },
|
|
741
742
|
})
|
|
742
743
|
return {
|
|
743
744
|
code: r.status ?? 1,
|
|
@@ -859,7 +860,7 @@ function dispatch(name, args = {}) {
|
|
|
859
860
|
// 겹치지만 `markSeen` 은 뒤로 가지 않으므로 결과가 같다.
|
|
860
861
|
//
|
|
861
862
|
// 전부 보려면 `all: true` 가 이미 있다 — 그쪽은 거르지도 찍지도 않는다.
|
|
862
|
-
const a = args.id ? ['read', String(args.id)] : ['list', ...(args.all ? ['--all'] : ['--
|
|
863
|
+
const a = args.id ? ['read', String(args.id)] : ['list', ...(args.all ? ['--all'] : ['--mine', '--unread'])]
|
|
863
864
|
const r = bus(a)
|
|
864
865
|
|
|
865
866
|
// 🔴 여기도 **낸 것만 찍는다.** 목록을 자르지 않으므로 낸 것 전부다.
|
|
@@ -870,7 +871,7 @@ function dispatch(name, args = {}) {
|
|
|
870
871
|
// id 로 찍으면 **나에게 온 안 읽은 쪽지가 화면에 뜬 적 없이 묻힌다.**
|
|
871
872
|
if (r.code === 0 && !args.id && !args.all) {
|
|
872
873
|
const ids = busRows(r.out).map((s) => s.id).filter(Boolean)
|
|
873
|
-
if (ids.length) bus(['seen', '--
|
|
874
|
+
if (ids.length) bus(['seen', '--mine', ...ids])
|
|
874
875
|
}
|
|
875
876
|
return {
|
|
876
877
|
ok: r.code === 0,
|
|
@@ -1007,7 +1008,8 @@ async function handle(req) {
|
|
|
1007
1008
|
do { task = rootsTask; await task } while (task !== rootsTask)
|
|
1008
1009
|
}
|
|
1009
1010
|
const r = callTool(params?.name, params?.arguments ?? {})
|
|
1010
|
-
|
|
1011
|
+
const warning = r.ok ? syncConfiguredProfile(REPO, BUS, { ...process.env, AXMAP_AGENT: AGENT }) : ''
|
|
1012
|
+
return { content: [{ type: 'text', text: r.text + (warning ? '\n' + warning : '') + (r.ok ? unreadBanner(params?.name, r.text) : '') }], isError: !r.ok }
|
|
1011
1013
|
}
|
|
1012
1014
|
if (method === 'ping') return {}
|
|
1013
1015
|
return null // 알림이거나 지원하지 않는 메서드
|
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.3.
|
|
4
|
+
"version": "1.3.2",
|
|
5
5
|
"//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
|
|
6
6
|
"//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
|
|
7
7
|
"publishConfig": {
|
package/src/identity.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
import { agentNameError } from './protocol.mjs'
|
|
6
|
+
|
|
7
|
+
// Only the display name is global. Email is resolved from the working repo so
|
|
8
|
+
// a work identity never silently becomes the address used in a personal repo.
|
|
9
|
+
export const profilePath = (home = os.homedir()) => path.join(home, '.axmap', 'profile.json')
|
|
10
|
+
|
|
11
|
+
export function displayNameError(name) {
|
|
12
|
+
if (typeof name !== 'string') return '표시 이름은 문자열이어야 합니다.'
|
|
13
|
+
return agentNameError(name) || (/[,;@\u007f]/.test(name)
|
|
14
|
+
? '표시 이름에는 쉼표·세미콜론·@ 문자를 쓸 수 없습니다.'
|
|
15
|
+
: name.toLowerCase() === 'all' ? 'all은 전체 수신자를 뜻하므로 표시 이름으로 쓸 수 없습니다.' : null)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readProfile(home) {
|
|
19
|
+
const file = profilePath(home)
|
|
20
|
+
let data
|
|
21
|
+
try { data = JSON.parse(fs.readFileSync(file, 'utf8')) } catch (e) {
|
|
22
|
+
if (e.code === 'ENOENT') return null
|
|
23
|
+
throw new Error(`표시 이름 설정을 읽지 못했습니다: ${file} (${e.message})`)
|
|
24
|
+
}
|
|
25
|
+
const error = displayNameError(data?.name)
|
|
26
|
+
if (data?.version !== 1 || error || (data.updatedAt && !Number.isFinite(Date.parse(data.updatedAt)))) throw new Error(`표시 이름 설정이 올바르지 않습니다: ${file}${error ? ` (${error})` : ''}`)
|
|
27
|
+
return data
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writeProfile(name, home) {
|
|
31
|
+
const error = displayNameError(name)
|
|
32
|
+
if (error) throw new Error(error)
|
|
33
|
+
const previous = readProfile(home)
|
|
34
|
+
const file = profilePath(home)
|
|
35
|
+
const data = { ...previous, version: 1, name,
|
|
36
|
+
updatedAt: previous?.name === name && previous.updatedAt ? previous.updatedAt : new Date().toISOString() }
|
|
37
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
38
|
+
const tmp = `${file}.tmp-${process.pid}`
|
|
39
|
+
try {
|
|
40
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
|
41
|
+
fs.renameSync(tmp, file)
|
|
42
|
+
} finally { fs.rmSync(tmp, { force: true }) }
|
|
43
|
+
return data
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function emailError(email) {
|
|
47
|
+
// This address also becomes a filename on Windows; reject unsafe Git config
|
|
48
|
+
// values rather than rewriting two different addresses to the same file.
|
|
49
|
+
return typeof email !== 'string' || !/^[^\s@\\/:*?"<>|,;\u0000-\u001f]+@[^\s@\\/:*?"<>|,;\u0000-\u001f]+$/.test(email)
|
|
50
|
+
|| /[.\s]$/.test(email) ? 'Git 이메일이 없거나 사용자 주소로 쓸 수 없습니다. git config user.email을 확인하세요.' : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function syncConfiguredProfile(repo, bus, env = process.env) {
|
|
54
|
+
if (env.AXMAP_SKIP_PROFILE_SYNC === '1') return ''
|
|
55
|
+
try {
|
|
56
|
+
if (!readProfile()) return ''
|
|
57
|
+
const r = spawnSync(process.execPath, [bus, 'profile'], {
|
|
58
|
+
cwd: repo, env, encoding: 'utf8', windowsHide: true,
|
|
59
|
+
})
|
|
60
|
+
return (r.stderr || (r.status !== 0 ? '경고: 쪽지 프로필 공유에 실패했습니다. 다음 사용 때 재시도합니다.' : '')).trim()
|
|
61
|
+
} catch (e) { return `경고: ${e.message}` }
|
|
62
|
+
}
|