axmap-cli 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.ko.md +20 -0
  2. package/README.md +20 -0
  3. package/bin/axmap.mjs +6 -0
  4. package/mcp/README.md +6 -0
  5. package/mcp/pipeline.html +157 -0
  6. package/mcp/server.mjs +128 -18
  7. package/package.json +11 -3
  8. package/src/agentcli.mjs +17 -14
  9. package/src/agentcli.mts +19 -0
  10. package/src/bridge.mjs +296 -219
  11. package/src/bridge.mts +281 -0
  12. package/src/external-console.mjs +140 -97
  13. package/src/external-console.mts +128 -0
  14. package/src/managed-session.mjs +263 -169
  15. package/src/managed-session.mts +198 -0
  16. package/src/monitor-cli.mjs +87 -0
  17. package/src/monitor-cli.mts +64 -0
  18. package/src/monitor.mjs +240 -0
  19. package/src/monitor.mts +195 -0
  20. package/src/node-pty.d.ts +13 -0
  21. package/src/pipeline-mcp.mjs +109 -0
  22. package/src/pipeline-mcp.mts +103 -0
  23. package/src/pipeline-statusline.mjs +135 -0
  24. package/src/pipeline-statusline.mts +106 -0
  25. package/src/session-dialogue.mjs +168 -116
  26. package/src/session-dialogue.mts +136 -0
  27. package/src/session-status.mjs +47 -41
  28. package/src/session-status.mts +50 -0
  29. package/src/session-types.mjs +7 -0
  30. package/src/session-types.mts +118 -0
  31. package/src/session-usage.mjs +33 -28
  32. package/src/session-usage.mts +39 -0
  33. package/src/terminal-bridge.mjs +293 -209
  34. package/src/terminal-bridge.mts +235 -0
  35. package/src/windows-console.mjs +42 -0
  36. package/src/windows-console.mts +29 -0
  37. package/tools/bench-console.mjs +70 -0
  38. package/tools/bridge.mjs +9 -6
  39. package/tools/build.mjs +39 -0
  40. package/tools/monitor.mjs +5 -0
  41. package/tools/pipeline.mjs +26 -0
  42. package/tools/run-tests.mjs +3 -2
  43. package/tools/windows-console.cs +116 -0
@@ -1,225 +1,309 @@
1
+ // Generated from src/terminal-bridge.mts. Edit the .mts source and run npm run build.
1
2
  /** The user launches an interactive CLI. axMap owns its PTY and a local,
2
3
  * authenticated pipe; remote requests never launch/resume another model. */
3
- import fs from 'node:fs'
4
- import os from 'node:os'
5
- import path from 'node:path'
6
- import net from 'node:net'
7
- import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto'
8
- import { fileURLToPath } from 'node:url'
9
- import { createRequire } from 'node:module'
10
- import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs'
11
-
12
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
13
- const validID = id => /^[0-9a-f-]{36}$/i.test(id || '')
14
- const sessionFile = id => {
15
- if (!validID(id)) throw new Error('잘못된 터미널 세션 ID입니다.')
16
- return path.join(bridgeHome(), 'sessions', `${id}.json`)
17
- }
18
- const pipeFor = id => process.platform === 'win32'
19
- ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`)
20
-
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import net from 'node:net';
8
+ import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { createRequire } from 'node:module';
11
+ import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs';
12
+ import { errorCode, errorMessage } from './session-types.mjs';
13
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
+ const validID = (id) => /^[0-9a-f-]{36}$/i.test(id || '');
15
+ const sessionFile = (id) => {
16
+ if (!validID(id))
17
+ throw new Error('잘못된 터미널 세션 ID입니다.');
18
+ return path.join(bridgeHome(), 'sessions', `${id}.json`);
19
+ };
20
+ const pipeFor = (id) => process.platform === 'win32'
21
+ ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`);
21
22
  export function sendTerminal(id, message, token = null, { timeoutMs = 10000 } = {}) {
22
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('양의 응답 시간 한도가 필요합니다.')
23
- const s = readJSON(sessionFile(id))
24
- if (!['pty', 'stdio'].includes(s.transport)) throw new Error('로컬 관리형 세션이 아닙니다.')
25
- return new Promise((resolve, reject) => {
26
- const socket = net.createConnection(pipeFor(id))
27
- socket.setEncoding('utf8')
28
- let buffer = ''
29
- const fail = e => { socket.destroy(); reject(e) }
30
- socket.setTimeout(timeoutMs, () => fail(new Error('로컬 세션 응답 시간 초과. 자동 재전송하지 마세요.')))
31
- socket.on('error', fail)
32
- socket.on('connect', () => socket.write(JSON.stringify({ ...message, token: token || s.token }) + '\n'))
33
- socket.on('data', chunk => {
34
- buffer += chunk
35
- if (Buffer.byteLength(buffer) > 1024 * 1024) return fail(new Error('로컬 응답 크기 제한 초과'))
36
- const end = buffer.indexOf('\n')
37
- if (end < 0) return
38
- socket.end()
39
- try {
40
- const response = JSON.parse(buffer.slice(0, end))
41
- if (response.error) reject(new Error(response.error)); else resolve(response.result)
42
- } catch (e) { reject(e) }
43
- })
44
- socket.on('end', () => { if (!buffer.includes('\n')) reject(new Error('세션 연결이 응답 없이 종료됐습니다.')) })
45
- })
23
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
24
+ throw new Error('양의 응답 시간 한도가 필요합니다.');
25
+ const s = readJSON(sessionFile(id));
26
+ if (!['pty', 'stdio'].includes(s.transport || ''))
27
+ throw new Error('로컬 관리형 세션이 아닙니다.');
28
+ return new Promise((resolve, reject) => {
29
+ const socket = net.createConnection(pipeFor(id));
30
+ socket.setEncoding('utf8');
31
+ let buffer = '';
32
+ const fail = (e) => { socket.destroy(); reject(e); };
33
+ socket.setTimeout(timeoutMs, () => fail(new Error('로컬 세션 응답 시간 초과. 자동 재전송하지 마세요.')));
34
+ socket.on('error', fail);
35
+ socket.on('connect', () => socket.write(JSON.stringify({ ...message, token: token || s.token }) + '\n'));
36
+ socket.on('data', chunk => {
37
+ buffer += chunk;
38
+ if (Buffer.byteLength(buffer) > 1024 * 1024)
39
+ return fail(new Error('로컬 응답 크기 제한 초과'));
40
+ const end = buffer.indexOf('\n');
41
+ if (end < 0)
42
+ return;
43
+ socket.end();
44
+ try {
45
+ const response = JSON.parse(buffer.slice(0, end));
46
+ if (response.error)
47
+ reject(new Error(response.error));
48
+ else
49
+ resolve(response.result);
50
+ }
51
+ catch (e) {
52
+ reject(e);
53
+ }
54
+ });
55
+ socket.on('end', () => { if (!buffer.includes('\n'))
56
+ reject(new Error('세션 연결이 응답 없이 종료됐습니다.')); });
57
+ });
46
58
  }
47
-
48
- export async function startTerminal({ executable, args = [], provider, cwd = process.cwd(), onData = () => {}, cols = 100, rows = 30 }) {
49
- const { spawn } = await import('node-pty')
50
- const id = randomUUID(), token = randomBytes(32).toString('hex')
51
- const s = { id, provider, transport: 'pty', token, workspaces: [cwd], repos: [repoKey(cwd)],
52
- state: 'starting', fullyIdle: false, registeredAt: new Date().toISOString(), event: 'launch' }
53
- const jobs = new Map()
54
- let pending = null, draft = false, term, closed = false, stopTimer
55
- const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionFile(id), s) }
56
- const finish = (state, result, error) => {
57
- if (!pending) return
58
- Object.assign(pending, { state, result, error, completedAt: new Date().toISOString() })
59
- jobs.set(pending.id, { ...pending }); pending = null
60
- }
61
- const handle = message => {
62
- const offered = Buffer.from(typeof message.token === 'string' ? message.token : '')
63
- const expected = Buffer.from(token)
64
- if (offered.length !== expected.length || !timingSafeEqual(offered, expected)) throw new Error('로컬 세션 인증 실패')
65
- if (message.action === 'status') return { state: s.state, id, provider }
66
- if (message.action === 'result') {
67
- const job = jobs.get(message.id)
68
- if (!job) throw new Error('이 터미널에 해당 요청이 없습니다.')
69
- return job
70
- }
71
- if (message.action === 'hook') {
72
- clearTimeout(stopTimer)
73
- const { input, event } = message
74
- s.event = event; s.model = input.modelName || input.model || s.model
75
- s.conversationId = input.conversationId || input.session_id || s.conversationId
76
- if (event !== 'Stop') {
77
- s.state = 'running'; s.fullyIdle = false
78
- if (pending && event === 'UserPromptSubmit' && typeof input.prompt === 'string') {
79
- pending.observed = input.prompt.includes(`[axMap request ${pending.id}]`)
59
+ export async function startTerminal({ executable, args = [], provider, cwd = process.cwd(), onData = () => { }, cols = 100, rows = 30 }) {
60
+ const { spawn } = await import('node-pty');
61
+ const id = randomUUID(), token = randomBytes(32).toString('hex');
62
+ const s = { id, provider, transport: 'pty', token, workspaces: [cwd], repos: [repoKey(cwd)],
63
+ state: 'starting', fullyIdle: false, registeredAt: new Date().toISOString(), event: 'launch' };
64
+ const jobs = new Map();
65
+ let pending = null, draft = false, term, closed = false, stopTimer;
66
+ const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionFile(id), s); };
67
+ const finish = (state, result, error) => {
68
+ if (!pending)
69
+ return;
70
+ Object.assign(pending, { state, result, error, completedAt: new Date().toISOString() });
71
+ jobs.set(pending.id, { ...pending });
72
+ pending = null;
73
+ };
74
+ const handle = (message) => {
75
+ const offered = Buffer.from(typeof message.token === 'string' ? message.token : '');
76
+ const expected = Buffer.from(token);
77
+ if (offered.length !== expected.length || !timingSafeEqual(offered, expected))
78
+ throw new Error('로컬 세션 인증 실패');
79
+ if (message.action === 'status')
80
+ return { state: s.state, id, provider };
81
+ if (message.action === 'result') {
82
+ const job = jobs.get(message.id);
83
+ if (!job)
84
+ throw new Error('이 터미널에 해당 요청이 없습니다.');
85
+ return job;
86
+ }
87
+ if (message.action === 'hook') {
88
+ clearTimeout(stopTimer);
89
+ const { input, event } = message;
90
+ s.event = event;
91
+ s.model = input.modelName || input.model || s.model;
92
+ s.conversationId = input.conversationId || input.session_id || s.conversationId;
93
+ if (event !== 'Stop') {
94
+ s.state = 'running';
95
+ s.fullyIdle = false;
96
+ if (pending && event === 'UserPromptSubmit' && typeof input.prompt === 'string') {
97
+ pending.observed = input.prompt.includes(`[axMap request ${pending.id}]`);
98
+ }
99
+ save();
100
+ return {};
101
+ }
102
+ if (provider === 'agy' && input.fullyIdle !== true) {
103
+ save();
104
+ return {};
105
+ }
106
+ let answer = input.last_assistant_message;
107
+ if (provider === 'agy') {
108
+ const log = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain', s.conversationId || '', '.system_generated', 'logs', 'transcript.jsonl');
109
+ if (path.resolve(input.transcriptPath || '') !== log)
110
+ throw new Error('종료 훅의 대화 기록 경로가 다릅니다.');
111
+ const lines = fs.readFileSync(log, 'utf8').trimEnd().split('\n').filter(Boolean).map(line => JSON.parse(line));
112
+ const lastUser = lines.findLast(e => e.type === 'USER_INPUT');
113
+ if (pending)
114
+ pending.observed = String(lastUser?.content).includes(`[axMap request ${pending.id}]`);
115
+ answer = lines.findLast(e => e.type === 'PLANNER_RESPONSE' && e.source === 'MODEL' && e.status === 'DONE')?.content;
116
+ }
117
+ if (pending)
118
+ finish(pending.observed && !pending.interrupted && typeof answer === 'string' ? 'completed' : 'superseded', pending.observed && !pending.interrupted ? answer : undefined, pending.observed && !pending.interrupted ? undefined : '다른 입력이 섞였거나 이 요청의 응답을 확인하지 못했습니다.');
119
+ // Stop runs before the provider restores its input UI. Allow that handoff
120
+ // to finish before accepting another injected prompt.
121
+ s.state = 'settling';
122
+ s.fullyIdle = false;
123
+ save();
124
+ stopTimer = setTimeout(() => { if (!closed && s.state === 'settling') {
125
+ s.state = 'idle';
126
+ s.fullyIdle = true;
127
+ draft = false;
128
+ save();
129
+ } }, 500);
130
+ return {};
80
131
  }
81
- save(); return {}
82
- }
83
- if (provider === 'agy' && input.fullyIdle !== true) { save(); return {} }
84
- let answer = input.last_assistant_message
85
- if (provider === 'agy') {
86
- const log = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'),
87
- 'brain', s.conversationId || '', '.system_generated', 'logs', 'transcript.jsonl')
88
- if (path.resolve(input.transcriptPath || '') !== log) throw new Error('종료 훅의 대화 기록 경로가 다릅니다.')
89
- const lines = fs.readFileSync(log, 'utf8').trimEnd().split('\n').filter(Boolean).map(line => JSON.parse(line))
90
- const lastUser = lines.findLast(e => e.type === 'USER_INPUT')
91
- if (pending) pending.observed = String(lastUser?.content).includes(`[axMap request ${pending.id}]`)
92
- answer = lines.findLast(e => e.type === 'PLANNER_RESPONSE' && e.source === 'MODEL' && e.status === 'DONE')?.content
93
- }
94
- if (pending) finish(pending.observed && !pending.interrupted && typeof answer === 'string' ? 'completed' : 'superseded',
95
- pending.observed && !pending.interrupted ? answer : undefined,
96
- pending.observed && !pending.interrupted ? undefined : '다른 입력이 섞였거나 이 요청의 응답을 확인하지 못했습니다.')
97
- // Stop runs before the provider restores its input UI. Allow that handoff
98
- // to finish before accepting another injected prompt.
99
- s.state = 'settling'; s.fullyIdle = false; save()
100
- stopTimer = setTimeout(() => { if (!closed && s.state === 'settling') { s.state = 'idle'; s.fullyIdle = true; draft = false; save() } }, 500)
101
- return {}
132
+ if (message.action === 'submit') {
133
+ const request = message.request;
134
+ if (closed || s.state !== 'idle' || draft || pending)
135
+ throw new Error('상대가 대기 중이 아니거나 입력 중입니다. 작업을 전달하지 않았습니다.');
136
+ if (!validID(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768)
137
+ throw new Error('요청 형식이 올바르지 않습니다.');
138
+ const prompt = `[axMap request ${request.id}]\n요청자: ${String(request.from || 'axmap')}\n${request.prompt}`;
139
+ if (/[\x00-\x08\x0b-\x1f\x7f]/.test(prompt))
140
+ throw new Error('터미널 제어 문자가 포함된 요청은 보낼 수 없습니다.');
141
+ pending = { id: request.id, to: id, state: 'submitted' };
142
+ jobs.set(request.id, pending);
143
+ s.state = 'running';
144
+ s.fullyIdle = false;
145
+ save();
146
+ term.write(`\x1b[200~${prompt}\x1b[201~`);
147
+ setTimeout(() => { if (!closed)
148
+ term.write('\r'); }, 100);
149
+ return { accepted: true, session: id };
150
+ }
151
+ throw new Error('지원하지 않는 로컬 세션 요청입니다.');
152
+ };
153
+ fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
154
+ const server = net.createServer(socket => {
155
+ socket.setEncoding('utf8');
156
+ let buffer = '', handled = false;
157
+ socket.setTimeout(15000, () => socket.destroy());
158
+ socket.on('error', () => { });
159
+ socket.on('data', chunk => {
160
+ if (handled)
161
+ return;
162
+ buffer += chunk;
163
+ if (Buffer.byteLength(buffer) > 65536)
164
+ return socket.destroy();
165
+ const end = buffer.indexOf('\n');
166
+ if (end < 0)
167
+ return;
168
+ handled = true;
169
+ try {
170
+ socket.end(JSON.stringify({ result: handle(JSON.parse(buffer.slice(0, end))) }) + '\n');
171
+ }
172
+ catch (e) {
173
+ socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n');
174
+ }
175
+ });
176
+ });
177
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(pipeFor(id), resolve); });
178
+ if (process.platform !== 'win32')
179
+ fs.chmodSync(pipeFor(id), 0o600);
180
+ save();
181
+ try {
182
+ term = spawn(executable, args, { name: 'xterm-256color', cols, rows, cwd, env: { ...process.env,
183
+ AXMAP_TERMINAL_SESSION: id, AXMAP_TERMINAL_TOKEN: token,
184
+ AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id,
185
+ }, useConpty: true, useConptyDll: process.platform === 'win32' });
102
186
  }
103
- if (message.action === 'submit') {
104
- const request = message.request
105
- if (closed || s.state !== 'idle' || draft || pending) throw new Error('상대가 대기 중이 아니거나 입력 중입니다. 작업을 전달하지 않았습니다.')
106
- if (!validID(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768) throw new Error('요청 형식이 올바르지 않습니다.')
107
- const prompt = `[axMap request ${request.id}]\n요청자: ${String(request.from || 'axmap')}\n${request.prompt}`
108
- if (/[\x00-\x08\x0b-\x1f\x7f]/.test(prompt)) throw new Error('터미널 제어 문자가 포함된 요청은 보낼 수 없습니다.')
109
- pending = { id: request.id, to: id, state: 'submitted' }; jobs.set(request.id, pending)
110
- s.state = 'running'; s.fullyIdle = false; save()
111
- term.write(`\x1b[200~${prompt}\x1b[201~`)
112
- setTimeout(() => { if (!closed) term.write('\r') }, 100)
113
- return { accepted: true, session: id }
187
+ catch (e) {
188
+ server.close();
189
+ s.state = 'closed';
190
+ save();
191
+ throw e;
114
192
  }
115
- throw new Error('지원하지 않는 로컬 세션 요청입니다.')
116
- }
117
- fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 })
118
- const server = net.createServer(socket => {
119
- socket.setEncoding('utf8')
120
- let buffer = '', handled = false
121
- socket.setTimeout(15000, () => socket.destroy())
122
- socket.on('error', () => {})
123
- socket.on('data', chunk => {
124
- if (handled) return
125
- buffer += chunk
126
- if (Buffer.byteLength(buffer) > 65536) return socket.destroy()
127
- const end = buffer.indexOf('\n'); if (end < 0) return
128
- handled = true
129
- try { socket.end(JSON.stringify({ result: handle(JSON.parse(buffer.slice(0, end))) }) + '\n') }
130
- catch (e) { socket.end(JSON.stringify({ error: e.message }) + '\n') }
131
- })
132
- })
133
- await new Promise((resolve, reject) => { server.once('error', reject); server.listen(pipeFor(id), resolve) })
134
- if (process.platform !== 'win32') fs.chmodSync(pipeFor(id), 0o600)
135
- save()
136
- try {
137
- term = spawn(executable, args, { name: 'xterm-256color', cols, rows, cwd, env: { ...process.env,
138
- AXMAP_TERMINAL_SESSION: id, AXMAP_TERMINAL_TOKEN: token,
139
- AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id,
140
- }, useConpty: true, useConptyDll: process.platform === 'win32' })
141
- } catch (e) { server.close(); s.state = 'closed'; save(); throw e }
142
- term.onData(onData)
143
- const exited = new Promise(resolve => term.onExit(ev => {
144
- if (closed) return
145
- closed = true; clearTimeout(stopTimer)
146
- // ConPTY may keep its input handle alive after a normal child exit.
147
- if (process.platform === 'win32') { try { term.kill() } catch { /* already closed */ } }
148
- finish('unknown', undefined, '요청의 완료를 확인하기 전에 세션이 종료됐습니다.')
149
- s.state = 'closed'; s.fullyIdle = false; save(); server.close(); resolve(ev)
150
- }))
151
- return { id, pid: term.pid, exited,
152
- input(data) {
153
- const terminalReply = /^\x1b\[(?:\d+;\d+R|\?[^a-zA-Z]*[a-zA-Z])$/.test(String(data))
154
- if (terminalReply) { term.write(data); return }
155
- if (pending) pending.interrupted = true
156
- draft = !String(data).includes('\r')
157
- if (!draft) { s.state = 'running'; s.fullyIdle = false; save() }
158
- term.write(data)
159
- },
160
- resize: (c, r) => term.resize(c, r),
161
- close: () => term.kill(),
162
- }
193
+ term.onData(onData);
194
+ const exited = new Promise(resolve => term.onExit(ev => {
195
+ if (closed)
196
+ return;
197
+ closed = true;
198
+ clearTimeout(stopTimer);
199
+ // ConPTY may keep its input handle alive after a normal child exit.
200
+ if (process.platform === 'win32') {
201
+ try {
202
+ term.kill();
203
+ }
204
+ catch { /* already closed */ }
205
+ }
206
+ finish('unknown', undefined, '요청의 완료를 확인하기 전에 세션이 종료됐습니다.');
207
+ s.state = 'closed';
208
+ s.fullyIdle = false;
209
+ save();
210
+ server.close();
211
+ resolve(ev);
212
+ }));
213
+ return { id, pid: term.pid, exited,
214
+ input(data) {
215
+ const terminalReply = /^\x1b\[(?:\d+;\d+R|\?[^a-zA-Z]*[a-zA-Z])$/.test(String(data));
216
+ if (terminalReply) {
217
+ term.write(data);
218
+ return;
219
+ }
220
+ if (pending)
221
+ pending.interrupted = true;
222
+ draft = !String(data).includes('\r');
223
+ if (!draft) {
224
+ s.state = 'running';
225
+ s.fullyIdle = false;
226
+ save();
227
+ }
228
+ term.write(data);
229
+ },
230
+ resize: (c, r) => term.resize(c, r),
231
+ close: () => term.kill(), };
163
232
  }
164
-
165
233
  /** Copy only production dependencies from the npm package, not a developer's
166
234
  * entire node_modules. This also keeps the setup copy independent of npm cache. */
167
235
  export function installTerminalRuntime(sourceRoot, destRoot = ROOT) {
168
- const source = path.resolve(sourceRoot), dest = path.resolve(destRoot)
169
- const require = createRequire(path.join(source, 'package.json'))
170
- const copied = new Set()
171
- function copy(name) {
172
- if (copied.has(name)) return
173
- const descriptor = require.resolve(`${name}/package.json`)
174
- const pkg = readJSON(descriptor)
175
- const target = path.join(dest, 'node_modules', ...name.split('/'))
176
- if (path.resolve(path.dirname(descriptor)) !== path.resolve(target)) {
177
- fs.mkdirSync(path.dirname(target), { recursive: true })
178
- fs.cpSync(path.dirname(descriptor), target, { recursive: true, force: true })
179
- }
180
- copied.add(name)
181
- for (const dependency of Object.keys(pkg.dependencies || {})) copy(dependency)
182
- for (const dependency of Object.keys(pkg.optionalDependencies || {})) {
183
- try { require.resolve(`${dependency}/package.json`) }
184
- catch (e) { if (e.code === 'MODULE_NOT_FOUND') continue; throw e }
185
- copy(dependency)
236
+ const source = path.resolve(sourceRoot), dest = path.resolve(destRoot);
237
+ const require = createRequire(path.join(source, 'package.json'));
238
+ const copied = new Set();
239
+ function copy(name) {
240
+ if (copied.has(name))
241
+ return;
242
+ const descriptor = require.resolve(`${name}/package.json`);
243
+ const pkg = readJSON(descriptor);
244
+ const target = path.join(dest, 'node_modules', ...name.split('/'));
245
+ if (path.resolve(path.dirname(descriptor)) !== path.resolve(target)) {
246
+ fs.mkdirSync(path.dirname(target), { recursive: true });
247
+ fs.cpSync(path.dirname(descriptor), target, { recursive: true, force: true });
248
+ }
249
+ copied.add(name);
250
+ for (const dependency of Object.keys(pkg.dependencies || {}))
251
+ copy(dependency);
252
+ for (const dependency of Object.keys(pkg.optionalDependencies || {})) {
253
+ try {
254
+ require.resolve(`${dependency}/package.json`);
255
+ }
256
+ catch (e) {
257
+ if (errorCode(e) === 'MODULE_NOT_FOUND')
258
+ continue;
259
+ throw e;
260
+ }
261
+ copy(dependency);
262
+ }
186
263
  }
187
- }
188
- copy('node-pty')
189
- const installed = createRequire(path.join(dest, 'package.json'))('node-pty')
190
- if (typeof installed.spawn !== 'function') throw new Error('설치된 터미널 라이브러리를 불러오지 못했습니다.')
191
- return { packages: [...copied], destination: dest }
264
+ copy('node-pty');
265
+ const installed = createRequire(path.join(dest, 'package.json'))('node-pty');
266
+ if (typeof installed.spawn !== 'function')
267
+ throw new Error('설치된 터미널 라이브러리를 불러오지 못했습니다.');
268
+ return { packages: [...copied], destination: dest };
192
269
  }
193
-
194
270
  export function installTerminalHooks(home = os.homedir(), provider = 'agy') {
195
- if (!['agy', 'codex'].includes(provider)) throw new Error('지원하지 않는 CLI 훅입니다.')
196
- const target = provider === 'agy' ? path.join(home, '.gemini', 'config', 'hooks.json')
197
- : path.join(home, '.codex', 'hooks.json')
198
- const config = fs.existsSync(target) ? readJSON(target) : {}
199
- if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('기존 hooks.json을 읽지 못했습니다.')
200
- if (provider === 'codex' && config.hooks !== undefined && (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks))) throw new Error('기존 Codex hooks 설정이 객체가 아닙니다.')
201
- const definition = {}
202
- const script = path.join(ROOT, 'tools', 'run.mjs')
203
- if ([process.execPath, script].some(p => /["\r\n%&|<>^]/.test(p))) throw new Error('훅 경로에 지원하지 않는 셸 문자가 있습니다.')
204
- for (const event of provider === 'agy' ? ['SessionStart', 'PreInvocation', 'Stop'] : ['SessionStart', 'UserPromptSubmit', 'Stop']) {
205
- let command = `"${process.execPath}" "${script}" hook ${event}`
206
- if (process.platform === 'win32') {
207
- const wrapper = path.join(home, '.axmap', 'bridge', `terminal-${provider}-${event}.cmd`)
208
- if (/[\s"%&|<>^]/.test(wrapper)) throw new Error('이 Windows 훅 경로에는 공백/셸 문자를 사용할 수 없습니다.')
209
- fs.mkdirSync(path.dirname(wrapper), { recursive: true })
210
- fs.writeFileSync(wrapper, `@echo off\r\n${command}\r\n`)
211
- command = wrapper
212
- }
213
- if (provider === 'agy') definition[event] = [{ type: 'command', command, timeout: 10 }]
214
- else {
215
- config.hooks ||= {}
216
- const existing = config.hooks[event] || []
217
- if (!Array.isArray(existing)) throw new Error(`기존 Codex ${event} 설정이 배열이 아닙니다.`)
218
- config.hooks[event] = existing.filter(group => !(Array.isArray(group.hooks) && group.hooks.some(h => h.statusMessage === 'axMap terminal bridge')))
219
- config.hooks[event].push({ hooks: [{ type: 'command', command, timeout: 10, statusMessage: 'axMap terminal bridge' }] })
271
+ if (!['agy', 'codex'].includes(provider))
272
+ throw new Error('지원하지 않는 CLI 훅입니다.');
273
+ const target = provider === 'agy' ? path.join(home, '.gemini', 'config', 'hooks.json')
274
+ : path.join(home, '.codex', 'hooks.json');
275
+ const config = fs.existsSync(target) ? readJSON(target) : {};
276
+ if (!config || typeof config !== 'object' || Array.isArray(config))
277
+ throw new Error('기존 hooks.json을 읽지 못했습니다.');
278
+ if (provider === 'codex' && config.hooks !== undefined && (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)))
279
+ throw new Error('기존 Codex hooks 설정이 객체가 아닙니다.');
280
+ const definition = {};
281
+ const script = path.join(ROOT, 'tools', 'run.mjs');
282
+ if ([process.execPath, script].some(p => /["\r\n%&|<>^]/.test(p)))
283
+ throw new Error('훅 경로에 지원하지 않는 셸 문자가 있습니다.');
284
+ for (const event of provider === 'agy' ? ['SessionStart', 'PreInvocation', 'Stop'] : ['SessionStart', 'UserPromptSubmit', 'Stop']) {
285
+ let command = `"${process.execPath}" "${script}" hook ${event}`;
286
+ if (process.platform === 'win32') {
287
+ const wrapper = path.join(home, '.axmap', 'bridge', `terminal-${provider}-${event}.cmd`);
288
+ if (/[\s"%&|<>^]/.test(wrapper))
289
+ throw new Error('이 Windows 훅 경로에는 공백/셸 문자를 사용할 수 없습니다.');
290
+ fs.mkdirSync(path.dirname(wrapper), { recursive: true });
291
+ fs.writeFileSync(wrapper, `@echo off\r\n${command}\r\n`);
292
+ command = wrapper;
293
+ }
294
+ if (provider === 'agy')
295
+ definition[event] = [{ type: 'command', command, timeout: 10 }];
296
+ else {
297
+ config.hooks ||= {};
298
+ const existing = config.hooks[event] || [];
299
+ if (!Array.isArray(existing))
300
+ throw new Error(`기존 Codex ${event} 설정이 배열이 아닙니다.`);
301
+ config.hooks[event] = existing.filter(group => !(Array.isArray(group.hooks) && group.hooks.some(h => h.statusMessage === 'axMap terminal bridge')));
302
+ config.hooks[event].push({ hooks: [{ type: 'command', command, timeout: 10, statusMessage: 'axMap terminal bridge' }] });
303
+ }
220
304
  }
221
- }
222
- if (provider === 'agy') config['axmap-terminal'] = definition
223
- writeJSON(target, config)
224
- return target
305
+ if (provider === 'agy')
306
+ config['axmap-terminal'] = definition;
307
+ writeJSON(target, config);
308
+ return target;
225
309
  }