ai-cli-mcp 2.15.0 → 2.17.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.
- package/CHANGELOG.md +14 -0
- package/README.ja.md +59 -1
- package/README.md +59 -1
- package/dist/__tests__/app-cli.test.js +56 -1
- package/dist/__tests__/cli-builder.test.js +3 -2
- package/dist/__tests__/cli-process-service.test.js +59 -0
- package/dist/__tests__/e2e.test.js +2 -1
- package/dist/__tests__/mcp-contract.test.js +8 -0
- package/dist/__tests__/parsers.test.js +163 -1
- package/dist/__tests__/peek.test.js +35 -0
- package/dist/__tests__/process-management.test.js +159 -0
- package/dist/__tests__/server.test.js +4 -3
- package/dist/app/cli.js +43 -1
- package/dist/app/mcp.js +45 -0
- package/dist/cli-builder.js +2 -2
- package/dist/cli-process-service.js +104 -2
- package/dist/parsers.js +185 -2
- package/dist/peek.js +56 -0
- package/dist/process-service.js +81 -1
- package/package.json +1 -1
- package/src/__tests__/app-cli.test.ts +71 -0
- package/src/__tests__/cli-builder.test.ts +3 -2
- package/src/__tests__/cli-process-service.test.ts +68 -0
- package/src/__tests__/e2e.test.ts +2 -1
- package/src/__tests__/mcp-contract.test.ts +9 -0
- package/src/__tests__/parsers.test.ts +188 -1
- package/src/__tests__/peek.test.ts +43 -0
- package/src/__tests__/process-management.test.ts +184 -0
- package/src/__tests__/server.test.ts +4 -3
- package/src/app/cli.ts +48 -0
- package/src/app/mcp.ts +46 -0
- package/src/cli-builder.ts +2 -2
- package/src/cli-process-service.ts +134 -1
- package/src/parsers.ts +222 -2
- package/src/peek.ts +88 -0
- package/src/process-service.ts +107 -1
package/dist/parsers.js
CHANGED
|
@@ -1,4 +1,102 @@
|
|
|
1
1
|
import { debugLog } from './cli-utils.js';
|
|
2
|
+
function isGeminiAssistantMessageEvent(parsed) {
|
|
3
|
+
return parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string';
|
|
4
|
+
}
|
|
5
|
+
const GEMINI_STREAM_EVENT_TYPES = new Set([
|
|
6
|
+
'init',
|
|
7
|
+
'message',
|
|
8
|
+
'tool_use',
|
|
9
|
+
'tool_result',
|
|
10
|
+
'result',
|
|
11
|
+
'error',
|
|
12
|
+
'stats',
|
|
13
|
+
]);
|
|
14
|
+
function isGeminiStreamJsonEvent(parsed) {
|
|
15
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && GEMINI_STREAM_EVENT_TYPES.has(parsed.type);
|
|
16
|
+
}
|
|
17
|
+
function extractPeekMessagesFromParsedEvent(agent, parsed, observedAt) {
|
|
18
|
+
if (agent === 'codex') {
|
|
19
|
+
if (parsed.item?.type === 'agent_message' && typeof parsed.item.text === 'string' && parsed.item.text.trim()) {
|
|
20
|
+
return [{ ts: observedAt, text: parsed.item.text }];
|
|
21
|
+
}
|
|
22
|
+
if (parsed.msg?.type === 'agent_message' && typeof parsed.msg.message === 'string' && parsed.msg.message.trim()) {
|
|
23
|
+
return [{ ts: observedAt, text: parsed.msg.message }];
|
|
24
|
+
}
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
if (agent === 'claude' && parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
|
|
28
|
+
return parsed.message.content
|
|
29
|
+
.filter((content) => content?.type === 'text' && typeof content.text === 'string' && content.text.trim())
|
|
30
|
+
.map((content) => ({ ts: observedAt, text: content.text }));
|
|
31
|
+
}
|
|
32
|
+
if (agent === 'opencode' && parsed.type === 'text' && parsed.part?.type === 'text' && typeof parsed.part.text === 'string' && parsed.part.text.trim()) {
|
|
33
|
+
return [{ ts: observedAt, text: parsed.part.text }];
|
|
34
|
+
}
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
export class PeekMessageExtractor {
|
|
38
|
+
agent;
|
|
39
|
+
pending = '';
|
|
40
|
+
geminiAssistantBuffer = '';
|
|
41
|
+
constructor(agent) {
|
|
42
|
+
this.agent = agent;
|
|
43
|
+
}
|
|
44
|
+
push(chunk, observedAt = new Date().toISOString()) {
|
|
45
|
+
if (!chunk) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const lines = `${this.pending}${chunk}`.split(/\r?\n/);
|
|
49
|
+
this.pending = lines.pop() || '';
|
|
50
|
+
return this.extractLines(lines, observedAt);
|
|
51
|
+
}
|
|
52
|
+
flush(observedAt = new Date().toISOString()) {
|
|
53
|
+
const messages = [];
|
|
54
|
+
if (this.pending) {
|
|
55
|
+
const line = this.pending;
|
|
56
|
+
this.pending = '';
|
|
57
|
+
messages.push(...this.extractLines([line], observedAt));
|
|
58
|
+
}
|
|
59
|
+
messages.push(...this.flushGeminiAssistantBuffer(observedAt));
|
|
60
|
+
return messages;
|
|
61
|
+
}
|
|
62
|
+
extractLines(lines, observedAt) {
|
|
63
|
+
const messages = [];
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
if (!line.trim()) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
messages.push(...this.extractParsedEvent(JSON.parse(line), observedAt));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
debugLog(`[Debug] Skipping invalid peek JSON line: ${line}`);
|
|
73
|
+
messages.push(...this.flushGeminiAssistantBuffer(observedAt));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return messages;
|
|
77
|
+
}
|
|
78
|
+
extractParsedEvent(parsed, observedAt) {
|
|
79
|
+
if (this.agent !== 'gemini') {
|
|
80
|
+
return extractPeekMessagesFromParsedEvent(this.agent, parsed, observedAt);
|
|
81
|
+
}
|
|
82
|
+
if (isGeminiAssistantMessageEvent(parsed)) {
|
|
83
|
+
this.geminiAssistantBuffer += parsed.content;
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
return this.flushGeminiAssistantBuffer(observedAt);
|
|
87
|
+
}
|
|
88
|
+
flushGeminiAssistantBuffer(observedAt) {
|
|
89
|
+
if (this.agent !== 'gemini' || !this.geminiAssistantBuffer) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
const text = this.geminiAssistantBuffer;
|
|
93
|
+
this.geminiAssistantBuffer = '';
|
|
94
|
+
if (!text.trim()) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
return [{ ts: observedAt, text }];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
2
100
|
export function parseCodexOutput(stdout) {
|
|
3
101
|
if (!stdout)
|
|
4
102
|
return null;
|
|
@@ -137,12 +235,97 @@ export function parseGeminiOutput(stdout) {
|
|
|
137
235
|
if (!stdout)
|
|
138
236
|
return null;
|
|
139
237
|
try {
|
|
140
|
-
|
|
238
|
+
const parsed = JSON.parse(stdout.trim());
|
|
239
|
+
if (!isGeminiStreamJsonEvent(parsed)) {
|
|
240
|
+
return parsed;
|
|
241
|
+
}
|
|
141
242
|
}
|
|
142
243
|
catch (e) {
|
|
143
244
|
debugLog(`[Debug] Failed to parse Gemini JSON output: ${e}`);
|
|
144
|
-
return null;
|
|
145
245
|
}
|
|
246
|
+
let sessionId = null;
|
|
247
|
+
let assistantBuffer = '';
|
|
248
|
+
let lastMessage = null;
|
|
249
|
+
let stats = null;
|
|
250
|
+
const toolsById = new Map();
|
|
251
|
+
const toolsWithoutId = [];
|
|
252
|
+
const flushAssistantMessage = () => {
|
|
253
|
+
if (assistantBuffer.trim()) {
|
|
254
|
+
lastMessage = assistantBuffer;
|
|
255
|
+
}
|
|
256
|
+
assistantBuffer = '';
|
|
257
|
+
};
|
|
258
|
+
for (const line of stdout.split('\n')) {
|
|
259
|
+
if (!line.trim()) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
let parsed;
|
|
263
|
+
try {
|
|
264
|
+
parsed = JSON.parse(line);
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
debugLog(`[Debug] Skipping invalid Gemini stream-json line: ${line}`);
|
|
268
|
+
flushAssistantMessage();
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (parsed.type === 'init' && typeof parsed.session_id === 'string' && parsed.session_id) {
|
|
272
|
+
sessionId = parsed.session_id;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (isGeminiAssistantMessageEvent(parsed)) {
|
|
276
|
+
assistantBuffer += parsed.content;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
flushAssistantMessage();
|
|
280
|
+
if (parsed.type === 'result') {
|
|
281
|
+
if (parsed.stats) {
|
|
282
|
+
stats = parsed.stats;
|
|
283
|
+
}
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (parsed.type === 'tool_use') {
|
|
287
|
+
const tool = {
|
|
288
|
+
tool: parsed.tool_name || parsed.name || 'tool_use',
|
|
289
|
+
input: parsed.parameters ?? parsed.input ?? null,
|
|
290
|
+
output: null,
|
|
291
|
+
status: null,
|
|
292
|
+
};
|
|
293
|
+
if (typeof parsed.tool_id === 'string' && parsed.tool_id) {
|
|
294
|
+
toolsById.set(parsed.tool_id, tool);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
toolsWithoutId.push(tool);
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (parsed.type === 'tool_result') {
|
|
302
|
+
const toolId = typeof parsed.tool_id === 'string' ? parsed.tool_id : '';
|
|
303
|
+
const tool = toolId ? toolsById.get(toolId) : null;
|
|
304
|
+
if (tool) {
|
|
305
|
+
tool.output = parsed.output ?? parsed.result ?? null;
|
|
306
|
+
tool.status = parsed.status ?? null;
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
toolsWithoutId.push({
|
|
310
|
+
tool: 'tool_result',
|
|
311
|
+
input: null,
|
|
312
|
+
output: parsed.output ?? parsed.result ?? null,
|
|
313
|
+
status: parsed.status ?? null,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
flushAssistantMessage();
|
|
319
|
+
const tools = [...toolsById.values(), ...toolsWithoutId];
|
|
320
|
+
if (lastMessage || sessionId || stats || tools.length > 0) {
|
|
321
|
+
return {
|
|
322
|
+
message: lastMessage,
|
|
323
|
+
session_id: sessionId,
|
|
324
|
+
stats: stats || undefined,
|
|
325
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
146
329
|
}
|
|
147
330
|
export function parseForgeOutput(stdout) {
|
|
148
331
|
if (!stdout)
|
package/dist/peek.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const DEFAULT_PEEK_TIME_SEC = 10;
|
|
2
|
+
export const MAX_PEEK_TIME_SEC = 60;
|
|
3
|
+
export const MAX_PEEK_PIDS = 32;
|
|
4
|
+
export const PEEK_MESSAGE_CAP = 50;
|
|
5
|
+
export function validatePeekPids(value) {
|
|
6
|
+
if (!Array.isArray(value)) {
|
|
7
|
+
throw new Error('Missing or invalid required parameter: pids (must be an array of positive safe integers)');
|
|
8
|
+
}
|
|
9
|
+
const deduped = [];
|
|
10
|
+
const seen = new Set();
|
|
11
|
+
for (const pid of value) {
|
|
12
|
+
if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) {
|
|
13
|
+
throw new Error('All pids must be positive safe integers');
|
|
14
|
+
}
|
|
15
|
+
if (!seen.has(pid)) {
|
|
16
|
+
seen.add(pid);
|
|
17
|
+
deduped.push(pid);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (deduped.length === 0 || deduped.length > MAX_PEEK_PIDS) {
|
|
21
|
+
throw new Error(`pids must contain 1..${MAX_PEEK_PIDS} entries after dedupe`);
|
|
22
|
+
}
|
|
23
|
+
return deduped;
|
|
24
|
+
}
|
|
25
|
+
export function validatePeekTimeSec(value) {
|
|
26
|
+
if (value === undefined || value === null) {
|
|
27
|
+
return DEFAULT_PEEK_TIME_SEC;
|
|
28
|
+
}
|
|
29
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0 || value > MAX_PEEK_TIME_SEC) {
|
|
30
|
+
throw new Error(`peek_time_sec must be a positive integer no greater than ${MAX_PEEK_TIME_SEC}`);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
export function buildNotFoundPeekProcess(pid) {
|
|
35
|
+
return {
|
|
36
|
+
pid,
|
|
37
|
+
agent: null,
|
|
38
|
+
status: 'not_found',
|
|
39
|
+
messages: [],
|
|
40
|
+
truncated: false,
|
|
41
|
+
error: 'process not found',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function appendPeekMessages(target, messages) {
|
|
45
|
+
for (const message of messages) {
|
|
46
|
+
if (target.messages.length < PEEK_MESSAGE_CAP) {
|
|
47
|
+
target.messages.push(message);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
target.truncated = true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function observedDurationSec(startedAtMs, endedAtMs = Date.now()) {
|
|
55
|
+
return Number(((endedAtMs - startedAtMs) / 1000).toFixed(2));
|
|
56
|
+
}
|
package/dist/process-service.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { buildCliCommand } from './cli-builder.js';
|
|
3
|
-
import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput } from './parsers.js';
|
|
3
|
+
import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput, PeekMessageExtractor } from './parsers.js';
|
|
4
|
+
import { appendPeekMessages, buildNotFoundPeekProcess, observedDurationSec, validatePeekPids, validatePeekTimeSec, } from './peek.js';
|
|
4
5
|
import { buildProcessResult } from './process-result.js';
|
|
5
6
|
function parseAgentOutput(agent, stdout, stderr) {
|
|
6
7
|
if (agent === 'codex') {
|
|
@@ -155,6 +156,85 @@ export class ProcessService {
|
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
158
|
}
|
|
159
|
+
async peekProcesses(pids, peekTimeSec = 10) {
|
|
160
|
+
const targetPids = validatePeekPids(pids);
|
|
161
|
+
const targetPeekTimeSec = validatePeekTimeSec(peekTimeSec);
|
|
162
|
+
const processes = [];
|
|
163
|
+
const observers = [];
|
|
164
|
+
for (const pid of targetPids) {
|
|
165
|
+
const entry = this.processManager.get(pid);
|
|
166
|
+
if (!entry) {
|
|
167
|
+
processes.push(buildNotFoundPeekProcess(pid));
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const result = {
|
|
171
|
+
pid,
|
|
172
|
+
agent: entry.toolType,
|
|
173
|
+
status: entry.status,
|
|
174
|
+
messages: [],
|
|
175
|
+
truncated: false,
|
|
176
|
+
error: null,
|
|
177
|
+
};
|
|
178
|
+
processes.push(result);
|
|
179
|
+
const stdoutExtractor = new PeekMessageExtractor(entry.toolType);
|
|
180
|
+
const stderrExtractor = new PeekMessageExtractor(entry.toolType);
|
|
181
|
+
const onStdout = (data) => {
|
|
182
|
+
appendPeekMessages(result, stdoutExtractor.push(data.toString(), new Date().toISOString()));
|
|
183
|
+
};
|
|
184
|
+
const onStderr = (data) => {
|
|
185
|
+
appendPeekMessages(result, stderrExtractor.push(data.toString(), new Date().toISOString()));
|
|
186
|
+
};
|
|
187
|
+
if (entry.status === 'running') {
|
|
188
|
+
entry.process.stdout?.on('data', onStdout);
|
|
189
|
+
entry.process.stderr?.on('data', onStderr);
|
|
190
|
+
}
|
|
191
|
+
observers.push({ entry, result, stdoutExtractor, stderrExtractor, onStdout, onStderr });
|
|
192
|
+
}
|
|
193
|
+
const startedAt = new Date();
|
|
194
|
+
const startedAtMs = Date.now();
|
|
195
|
+
const runningObservers = observers.filter((observer) => observer.entry.status === 'running');
|
|
196
|
+
const terminalPromise = Promise.all(runningObservers.map((observer) => this.waitForProcessTerminal(observer.entry)));
|
|
197
|
+
let timeoutHandle;
|
|
198
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
199
|
+
timeoutHandle = setTimeout(resolve, targetPeekTimeSec * 1000);
|
|
200
|
+
timeoutHandle.unref?.();
|
|
201
|
+
});
|
|
202
|
+
try {
|
|
203
|
+
await Promise.race([terminalPromise, timeoutPromise]);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
if (timeoutHandle) {
|
|
207
|
+
clearTimeout(timeoutHandle);
|
|
208
|
+
}
|
|
209
|
+
const flushTs = new Date().toISOString();
|
|
210
|
+
for (const observer of observers) {
|
|
211
|
+
observer.entry.process.stdout?.off('data', observer.onStdout);
|
|
212
|
+
observer.entry.process.stderr?.off('data', observer.onStderr);
|
|
213
|
+
appendPeekMessages(observer.result, observer.stdoutExtractor.flush(flushTs));
|
|
214
|
+
appendPeekMessages(observer.result, observer.stderrExtractor.flush(flushTs));
|
|
215
|
+
observer.result.status = observer.entry.status;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
peek_started_at: startedAt.toISOString(),
|
|
220
|
+
observed_duration_sec: observedDurationSec(startedAtMs),
|
|
221
|
+
processes,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
waitForProcessTerminal(processEntry) {
|
|
225
|
+
if (processEntry.status !== 'running') {
|
|
226
|
+
return Promise.resolve();
|
|
227
|
+
}
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
const done = () => {
|
|
230
|
+
processEntry.process.off('close', done);
|
|
231
|
+
processEntry.process.off('error', done);
|
|
232
|
+
resolve();
|
|
233
|
+
};
|
|
234
|
+
processEntry.process.once('close', done);
|
|
235
|
+
processEntry.process.once('error', done);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
158
238
|
killProcess(pid) {
|
|
159
239
|
const processEntry = this.processManager.get(pid);
|
|
160
240
|
if (!processEntry) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-cli-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.0",
|
|
4
4
|
"mcpName": "io.github.mkXultra/ai-cli-mcp",
|
|
5
5
|
"description": "MCP server for AI CLI tools (Claude, Codex, Gemini, Forge, and OpenCode) with background process management",
|
|
6
6
|
"author": "mkXultra",
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
CLI_HELP_TEXT,
|
|
4
4
|
DOCTOR_HELP_TEXT,
|
|
5
5
|
MODELS_HELP_TEXT,
|
|
6
|
+
PEEK_HELP_TEXT,
|
|
6
7
|
RESULT_HELP_TEXT,
|
|
7
8
|
RUN_HELP_TEXT,
|
|
8
9
|
WAIT_HELP_TEXT,
|
|
@@ -199,6 +200,64 @@ describe('ai-cli app', () => {
|
|
|
199
200
|
expect(waitForProcesses).not.toHaveBeenCalled();
|
|
200
201
|
});
|
|
201
202
|
|
|
203
|
+
it('dispatches peek with deduped pid arguments and time', async () => {
|
|
204
|
+
const stdout = vi.fn();
|
|
205
|
+
const stderr = vi.fn();
|
|
206
|
+
const peekProcesses = vi.fn().mockResolvedValue({
|
|
207
|
+
peek_started_at: '2026-04-11T12:34:56.789Z',
|
|
208
|
+
observed_duration_sec: 0.01,
|
|
209
|
+
processes: [],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const exitCode = await runCli(
|
|
213
|
+
['peek', '123', '456', '123', '--time', '5'],
|
|
214
|
+
{
|
|
215
|
+
stdout,
|
|
216
|
+
stderr,
|
|
217
|
+
peekProcesses,
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(exitCode).toBe(0);
|
|
222
|
+
expect(peekProcesses).toHaveBeenCalledWith([123, 456], 5);
|
|
223
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"peek_started_at"'));
|
|
224
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('defaults peek time and rejects --follow', async () => {
|
|
228
|
+
const stdout = vi.fn();
|
|
229
|
+
const stderr = vi.fn();
|
|
230
|
+
const peekProcesses = vi.fn().mockResolvedValue({
|
|
231
|
+
peek_started_at: '2026-04-11T12:34:56.789Z',
|
|
232
|
+
observed_duration_sec: 0.01,
|
|
233
|
+
processes: [],
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const defaultExitCode = await runCli(['peek', '123'], { stdout, stderr, peekProcesses });
|
|
237
|
+
expect(defaultExitCode).toBe(0);
|
|
238
|
+
expect(peekProcesses).toHaveBeenCalledWith([123], 10);
|
|
239
|
+
|
|
240
|
+
const followExitCode = await runCli(['peek', '123', '--follow'], { stdout, stderr, peekProcesses });
|
|
241
|
+
expect(followExitCode).toBe(1);
|
|
242
|
+
expect(stderr).toHaveBeenCalledWith('peek does not support --follow in v1\n');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('rejects invalid peek time values', async () => {
|
|
246
|
+
const stdout = vi.fn();
|
|
247
|
+
const stderr = vi.fn();
|
|
248
|
+
const peekProcesses = vi.fn();
|
|
249
|
+
|
|
250
|
+
const exitCode = await runCli(['peek', '123', '--time', '1.5'], {
|
|
251
|
+
stdout,
|
|
252
|
+
stderr,
|
|
253
|
+
peekProcesses,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(exitCode).toBe(1);
|
|
257
|
+
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('peek_time_sec must be a positive integer'));
|
|
258
|
+
expect(peekProcesses).not.toHaveBeenCalled();
|
|
259
|
+
});
|
|
260
|
+
|
|
202
261
|
it('dispatches ps, result, and kill', async () => {
|
|
203
262
|
const stdout = vi.fn();
|
|
204
263
|
const stderr = vi.fn();
|
|
@@ -354,6 +413,18 @@ describe('ai-cli app', () => {
|
|
|
354
413
|
expect(stderr).not.toHaveBeenCalled();
|
|
355
414
|
});
|
|
356
415
|
|
|
416
|
+
it('prints detailed help for peek --help', async () => {
|
|
417
|
+
const stdout = vi.fn();
|
|
418
|
+
const stderr = vi.fn();
|
|
419
|
+
|
|
420
|
+
const exitCode = await runCli(['peek', '--help'], { stdout, stderr });
|
|
421
|
+
|
|
422
|
+
expect(exitCode).toBe(0);
|
|
423
|
+
expect(stdout).toHaveBeenCalledWith(PEEK_HELP_TEXT);
|
|
424
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('No --follow mode'));
|
|
425
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
426
|
+
});
|
|
427
|
+
|
|
357
428
|
it('prints detailed help for models --help', async () => {
|
|
358
429
|
const stdout = vi.fn();
|
|
359
430
|
const stderr = vi.fn();
|
|
@@ -314,7 +314,8 @@ describe('cli-builder', () => {
|
|
|
314
314
|
expect(cmd.agent).toBe('codex');
|
|
315
315
|
expect(cmd.cliPath).toBe('/usr/bin/codex');
|
|
316
316
|
expect(cmd.args).toContain('exec');
|
|
317
|
-
expect(cmd.args).toContain('--
|
|
317
|
+
expect(cmd.args).toContain('--dangerously-bypass-approvals-and-sandbox');
|
|
318
|
+
expect(cmd.args).not.toContain('--full-auto');
|
|
318
319
|
expect(cmd.args).toContain('--json');
|
|
319
320
|
expect(cmd.args).toContain('--model');
|
|
320
321
|
expect(cmd.args).toContain('gpt-5.2-codex');
|
|
@@ -388,7 +389,7 @@ describe('cli-builder', () => {
|
|
|
388
389
|
expect(cmd.cliPath).toBe('/usr/bin/gemini');
|
|
389
390
|
expect(cmd.args).toContain('-y');
|
|
390
391
|
expect(cmd.args).toContain('--output-format');
|
|
391
|
-
expect(cmd.args).toContain('json');
|
|
392
|
+
expect(cmd.args).toContain('stream-json');
|
|
392
393
|
expect(cmd.args).toContain('--model');
|
|
393
394
|
expect(cmd.args).toContain('gemini-2.5-pro');
|
|
394
395
|
});
|
|
@@ -122,6 +122,74 @@ describe('CliProcessService', () => {
|
|
|
122
122
|
expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
it('peeks only appended natural-language messages from detached logs', async () => {
|
|
126
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
127
|
+
tempDirs.push(root);
|
|
128
|
+
const scriptPath = join(root, 'mock-claude-peek');
|
|
129
|
+
writeFileSync(
|
|
130
|
+
scriptPath,
|
|
131
|
+
`#!/bin/bash
|
|
132
|
+
printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"old cli message"}]}}'
|
|
133
|
+
sleep 2
|
|
134
|
+
printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"new cli message"},{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/a"}}]}}'
|
|
135
|
+
printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}'
|
|
136
|
+
`
|
|
137
|
+
);
|
|
138
|
+
chmodSync(scriptPath, 0o755);
|
|
139
|
+
const stateDir = join(root, 'state');
|
|
140
|
+
const workFolder = join(root, 'work');
|
|
141
|
+
mkdirSync(workFolder, { recursive: true });
|
|
142
|
+
|
|
143
|
+
const service = new CliProcessService({
|
|
144
|
+
stateDir,
|
|
145
|
+
cliPaths: {
|
|
146
|
+
claude: scriptPath,
|
|
147
|
+
codex: scriptPath,
|
|
148
|
+
gemini: scriptPath,
|
|
149
|
+
forge: scriptPath,
|
|
150
|
+
opencode: scriptPath,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const runResult = await service.startProcess({
|
|
155
|
+
prompt: 'hello peek',
|
|
156
|
+
cwd: workFolder,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(runResult.pid));
|
|
160
|
+
const stdoutPath = join(processDir, 'stdout.log');
|
|
161
|
+
const startedAt = Date.now();
|
|
162
|
+
while (Date.now() - startedAt < 5000 && !readFileSync(stdoutPath, 'utf-8').includes('old cli message')) {
|
|
163
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
164
|
+
}
|
|
165
|
+
expect(readFileSync(stdoutPath, 'utf-8')).toContain('old cli message');
|
|
166
|
+
|
|
167
|
+
const peekResult = await service.peekProcesses([runResult.pid, runResult.pid, 999999], 3);
|
|
168
|
+
|
|
169
|
+
expect(peekResult.processes).toHaveLength(2);
|
|
170
|
+
expect(peekResult.processes[0]).toMatchObject({
|
|
171
|
+
pid: runResult.pid,
|
|
172
|
+
agent: 'claude',
|
|
173
|
+
status: 'completed',
|
|
174
|
+
messages: [
|
|
175
|
+
{
|
|
176
|
+
ts: expect.any(String),
|
|
177
|
+
text: 'new cli message',
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
truncated: false,
|
|
181
|
+
error: null,
|
|
182
|
+
});
|
|
183
|
+
expect(peekResult.processes[1]).toEqual({
|
|
184
|
+
pid: 999999,
|
|
185
|
+
agent: null,
|
|
186
|
+
status: 'not_found',
|
|
187
|
+
messages: [],
|
|
188
|
+
truncated: false,
|
|
189
|
+
error: 'process not found',
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
125
193
|
it('returns compact results by default and full results when verbose is true', async () => {
|
|
126
194
|
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
127
195
|
tempDirs.push(root);
|
|
@@ -39,7 +39,7 @@ describe('Claude Code MCP E2E Tests', () => {
|
|
|
39
39
|
it('should register run tool', async () => {
|
|
40
40
|
const tools = await client.listTools();
|
|
41
41
|
|
|
42
|
-
expect(tools).toHaveLength(
|
|
42
|
+
expect(tools).toHaveLength(7);
|
|
43
43
|
const claudeCodeTool = tools.find((t: any) => t.name === 'run');
|
|
44
44
|
expect(claudeCodeTool.inputSchema.properties.model.description).toContain('sonnet');
|
|
45
45
|
expect(claudeCodeTool.inputSchema.properties.model.description).toContain('opencode');
|
|
@@ -49,6 +49,7 @@ describe('Claude Code MCP E2E Tests', () => {
|
|
|
49
49
|
// Verify other tools exist
|
|
50
50
|
expect(tools.some((t: any) => t.name === 'list_processes')).toBe(true);
|
|
51
51
|
expect(tools.some((t: any) => t.name === 'get_result')).toBe(true);
|
|
52
|
+
expect(tools.some((t: any) => t.name === 'peek')).toBe(true);
|
|
52
53
|
expect(tools.some((t: any) => t.name === 'kill_process')).toBe(true);
|
|
53
54
|
});
|
|
54
55
|
});
|
|
@@ -96,6 +96,7 @@ describe('MCP Contract Tests', () => {
|
|
|
96
96
|
'get_result',
|
|
97
97
|
'kill_process',
|
|
98
98
|
'list_processes',
|
|
99
|
+
'peek',
|
|
99
100
|
'run',
|
|
100
101
|
'wait',
|
|
101
102
|
]);
|
|
@@ -132,6 +133,14 @@ describe('MCP Contract Tests', () => {
|
|
|
132
133
|
'timeout',
|
|
133
134
|
'verbose',
|
|
134
135
|
]);
|
|
136
|
+
|
|
137
|
+
const peekTool = tools.find((tool: any) => tool.name === 'peek');
|
|
138
|
+
expect(peekTool.inputSchema.required).toEqual(['pids']);
|
|
139
|
+
expect(Object.keys(peekTool.inputSchema.properties).sort()).toEqual([
|
|
140
|
+
'peek_time_sec',
|
|
141
|
+
'pids',
|
|
142
|
+
]);
|
|
143
|
+
expect(peekTool.description).toContain('One-shot');
|
|
135
144
|
});
|
|
136
145
|
|
|
137
146
|
it('preserves the stdio MCP smoke flow and response shapes', async () => {
|