@danwahl/antigravity-mcp 0.2.0 → 0.3.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/README.md +26 -9
- package/dist/index.js +43 -6
- package/dist/lib.js +107 -36
- package/package.json +5 -4
- package/dist/lib.test.js +0 -145
package/README.md
CHANGED
|
@@ -47,13 +47,18 @@ Verify with `claude mcp list`.
|
|
|
47
47
|
|
|
48
48
|
## Tool: `cli`
|
|
49
49
|
|
|
50
|
-
| Parameter | Type
|
|
51
|
-
|
|
52
|
-
| `prompt` | string
|
|
53
|
-
| `cwd` | string
|
|
54
|
-
| `model` | string
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
50
|
+
| Parameter | Type | Required | Description |
|
|
51
|
+
|------------------|---------|----------|-------------|
|
|
52
|
+
| `prompt` | string | yes | Task or question to send to Antigravity |
|
|
53
|
+
| `cwd` | string | yes | Absolute path to the working directory (agy's workspace) |
|
|
54
|
+
| `model` | string | no | Model name, e.g. `gemini-3.1-pro-high`, `gemini-3.8-flash-medium`, `claude-sonnet-4-6`. Omit for agy's default. `agy models` lists the options. |
|
|
55
|
+
| `effort` | string | no | Reasoning effort: `low`, `medium`, or `high`. Omit for agy's default. |
|
|
56
|
+
| `sandbox` | boolean | no | Default `true`. Shell commands can read anywhere but write only under `/tmp`; agy's file edit tools are unaffected. Set `false` for builds, installs, git commits, or tests that write to the workspace. |
|
|
57
|
+
| `mode` | string | no | `plan` or `accept-edits`. In headless mode plan review is auto-approved, so `plan` shapes the workflow but does not block edits. |
|
|
58
|
+
| `agent` | string | no | Custom agent name, defined at `.agents/agents/<name>.md` in the workspace or `~/.gemini/config/agents/`. |
|
|
59
|
+
| `jsonSchema` | object | no | JSON Schema enforced on the final answer. The parsed object comes back in `structuredOutput`. |
|
|
60
|
+
| `conversationId` | string | no | Resume a previous conversation. Returned in the structured output of each call. Pass the same `cwd`, since conversations are workspace-scoped. |
|
|
61
|
+
| `timeout` | number | no | Seconds before the run is stopped. Default 120. On expiry the call returns an error; any partial response is in the structured output. |
|
|
57
62
|
|
|
58
63
|
### Structured output
|
|
59
64
|
|
|
@@ -70,7 +75,11 @@ Each call returns structured content alongside the text response:
|
|
|
70
75
|
"thinkingTokens": 121,
|
|
71
76
|
"cacheReadTokens": 0,
|
|
72
77
|
"totalTokens": 12569
|
|
73
|
-
}
|
|
78
|
+
},
|
|
79
|
+
"structuredOutput": null,
|
|
80
|
+
"durationSeconds": 2.6,
|
|
81
|
+
"numTurns": 1,
|
|
82
|
+
"deniedActions": []
|
|
74
83
|
}
|
|
75
84
|
```
|
|
76
85
|
|
|
@@ -78,7 +87,15 @@ Each call returns structured content alongside the text response:
|
|
|
78
87
|
|
|
79
88
|
### What Antigravity can do
|
|
80
89
|
|
|
81
|
-
`agy` runs with `--dangerously-skip-permissions`, giving it full tool access: read/write files, run shell commands, web search, and more.
|
|
90
|
+
`agy` runs with `--dangerously-skip-permissions`, giving it full tool access: read/write files, run shell commands, web search, and more. Headless agy cannot prompt for permission, so without this flag any tool needing approval is silently denied and the turn ends early. Deny rules in agy's own `settings.json` still apply; when one fires the call returns an error listing `deniedActions`.
|
|
91
|
+
|
|
92
|
+
The `cwd` you specify is passed as `--add-dir` so it becomes the agent's workspace; the spawn working directory alone is not enough in headless mode.
|
|
93
|
+
|
|
94
|
+
`--sandbox` is on by default as a check against the permission bypass. It restricts shell commands only: they can read anywhere but write only under `/tmp`. agy's file edit tools can still modify the workspace.
|
|
95
|
+
|
|
96
|
+
### Timeouts and cancellation
|
|
97
|
+
|
|
98
|
+
The `timeout` is passed to agy as `--print-timeout`, so agy stops its own turn at the deadline and returns whatever it has. The server reports that as an error with the partial response in the structured output. If agy fails to exit within a few seconds after that, its process group is killed. Cancelling the MCP call (for example, interrupting Claude Code) kills the agy process group immediately.
|
|
82
99
|
|
|
83
100
|
### Errors
|
|
84
101
|
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,9 @@ server.registerTool("cli", {
|
|
|
26
26
|
"- Research and brainstorming: Antigravity has web search and a large context window, useful for exploring options or summarizing docs\n" +
|
|
27
27
|
"- Large file analysis: processing files that would be expensive to handle directly\n" +
|
|
28
28
|
"- Parallel workstreams: offloading independent subtasks while you continue other work\n\n" +
|
|
29
|
+
"Sandbox: on by default. Antigravity's file tools can still edit the workspace, but shell commands " +
|
|
30
|
+
"can only write under /tmp. Set `sandbox: false` for tasks whose shell commands must write to the " +
|
|
31
|
+
"workspace (builds, installs, git commits, test runs that produce files).\n\n" +
|
|
29
32
|
"Conversation resumption: each response includes a `conversationId`. " +
|
|
30
33
|
"Pass it back via the `conversationId` parameter (with the same `cwd`) to continue a conversation " +
|
|
31
34
|
"without re-sending context — useful for multi-step tasks or follow-up questions.\n\n" +
|
|
@@ -42,7 +45,32 @@ server.registerTool("cli", {
|
|
|
42
45
|
.string()
|
|
43
46
|
.optional()
|
|
44
47
|
.describe("Model to use. Omit to use Antigravity's default. " +
|
|
45
|
-
"
|
|
48
|
+
"Examples: \"gemini-3.1-pro-high\", \"gemini-3.1-pro-low\", \"gemini-3.8-flash-medium\", " +
|
|
49
|
+
"\"claude-sonnet-4-6\", \"claude-opus-4-6-thinking\". Run `agy models` for the full list."),
|
|
50
|
+
effort: z
|
|
51
|
+
.enum(["low", "medium", "high"])
|
|
52
|
+
.optional()
|
|
53
|
+
.describe("Reasoning effort for the session. Omit to use Antigravity's default."),
|
|
54
|
+
sandbox: z
|
|
55
|
+
.boolean()
|
|
56
|
+
.optional()
|
|
57
|
+
.default(true)
|
|
58
|
+
.describe("Run shell commands in a sandbox where they can read anywhere but write only under /tmp. " +
|
|
59
|
+
"File edit tools are unaffected. Default: true."),
|
|
60
|
+
mode: z
|
|
61
|
+
.enum(["accept-edits", "plan"])
|
|
62
|
+
.optional()
|
|
63
|
+
.describe("Agent execution mode. \"plan\" drafts an implementation plan before acting; in headless mode " +
|
|
64
|
+
"the plan is auto-approved, so this shapes the workflow but does not prevent edits."),
|
|
65
|
+
agent: z
|
|
66
|
+
.string()
|
|
67
|
+
.optional()
|
|
68
|
+
.describe("Name of a custom agent to run, defined at .agents/agents/<name>.md in the workspace " +
|
|
69
|
+
"or ~/.gemini/config/agents/. Omit for the default agent."),
|
|
70
|
+
jsonSchema: z
|
|
71
|
+
.record(z.string(), z.unknown())
|
|
72
|
+
.optional()
|
|
73
|
+
.describe("JSON Schema to enforce on the final answer. The parsed object is returned in `structuredOutput`."),
|
|
46
74
|
conversationId: z
|
|
47
75
|
.string()
|
|
48
76
|
.optional()
|
|
@@ -52,31 +80,40 @@ server.registerTool("cli", {
|
|
|
52
80
|
.number()
|
|
53
81
|
.optional()
|
|
54
82
|
.default(120)
|
|
55
|
-
.describe("Timeout in seconds. Default: 120. Increase for complex multi-step tasks."
|
|
83
|
+
.describe("Timeout in seconds. Default: 120. Increase for complex multi-step tasks. " +
|
|
84
|
+
"On expiry the call returns an error with any partial response in the structured output."),
|
|
56
85
|
},
|
|
57
86
|
outputSchema: {
|
|
58
87
|
conversationId: z.string().nullable().describe("Antigravity conversation ID"),
|
|
59
88
|
response: z.string().describe("Antigravity's text response"),
|
|
60
89
|
status: z.string().nullable().describe("Run status reported by agy (e.g. SUCCESS)"),
|
|
61
90
|
usage: usageSchema.nullable().describe("Token usage for this turn"),
|
|
91
|
+
structuredOutput: z
|
|
92
|
+
.record(z.string(), z.unknown())
|
|
93
|
+
.nullable()
|
|
94
|
+
.describe("Parsed answer when `jsonSchema` was given"),
|
|
95
|
+
durationSeconds: z.number().nullable().describe("Wall-clock time of the run"),
|
|
96
|
+
numTurns: z.number().nullable().describe("Number of agent turns in the conversation"),
|
|
97
|
+
deniedActions: z.array(z.string()).describe("Tools agy was denied permission to use"),
|
|
62
98
|
},
|
|
63
99
|
annotations: {
|
|
64
100
|
readOnlyHint: false,
|
|
65
101
|
openWorldHint: true,
|
|
66
102
|
},
|
|
67
|
-
}, async ({ prompt, cwd, model, conversationId, timeout }) => {
|
|
103
|
+
}, async ({ prompt, cwd, model, effort, sandbox, mode, agent, jsonSchema, conversationId, timeout }, extra) => {
|
|
68
104
|
const timeoutMs = (timeout ?? 120) * 1000;
|
|
69
|
-
const result = await runAgy(prompt, cwd, model,
|
|
105
|
+
const result = await runAgy(prompt, cwd, timeoutMs, { model, effort, sandbox, mode, agent, jsonSchema, conversationId }, extra.signal);
|
|
106
|
+
const structuredContent = result.output;
|
|
70
107
|
if (result.isError) {
|
|
71
108
|
return {
|
|
72
109
|
isError: true,
|
|
73
110
|
content: [{ type: "text", text: result.errorMessage ?? "Unknown error" }],
|
|
74
|
-
structuredContent
|
|
111
|
+
structuredContent,
|
|
75
112
|
};
|
|
76
113
|
}
|
|
77
114
|
return {
|
|
78
115
|
content: [{ type: "text", text: result.output.response }],
|
|
79
|
-
structuredContent
|
|
116
|
+
structuredContent,
|
|
80
117
|
};
|
|
81
118
|
});
|
|
82
119
|
async function runServer() {
|
package/dist/lib.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
export function buildAgyArgs(prompt, cwd,
|
|
3
|
+
export function buildAgyArgs(prompt, cwd, opts = {}) {
|
|
4
4
|
// The spawn cwd alone does not register a workspace in headless mode; without
|
|
5
5
|
// --add-dir the agent is told it has no active workspace and works in its
|
|
6
6
|
// own scratch directory.
|
|
@@ -10,34 +10,75 @@ export function buildAgyArgs(prompt, cwd, model, conversationId) {
|
|
|
10
10
|
"--dangerously-skip-permissions",
|
|
11
11
|
"--add-dir", cwd,
|
|
12
12
|
];
|
|
13
|
-
if (
|
|
14
|
-
args.push("--
|
|
13
|
+
if (opts.sandbox ?? true) {
|
|
14
|
+
args.push("--sandbox");
|
|
15
15
|
}
|
|
16
|
-
if (
|
|
17
|
-
args.push("--
|
|
16
|
+
if (opts.model) {
|
|
17
|
+
args.push("--model", opts.model);
|
|
18
|
+
}
|
|
19
|
+
if (opts.effort) {
|
|
20
|
+
args.push("--effort", opts.effort);
|
|
21
|
+
}
|
|
22
|
+
if (opts.mode) {
|
|
23
|
+
args.push("--mode", opts.mode);
|
|
24
|
+
}
|
|
25
|
+
if (opts.agent) {
|
|
26
|
+
args.push("--agent", opts.agent);
|
|
27
|
+
}
|
|
28
|
+
if (opts.jsonSchema) {
|
|
29
|
+
args.push("--json-schema", JSON.stringify(opts.jsonSchema));
|
|
30
|
+
}
|
|
31
|
+
if (opts.conversationId) {
|
|
32
|
+
args.push("--conversation", opts.conversationId);
|
|
33
|
+
}
|
|
34
|
+
if (opts.timeoutMs) {
|
|
35
|
+
// Let agy wind down on its own at the deadline and return partial output;
|
|
36
|
+
// the caller's kill timer is only a backstop.
|
|
37
|
+
args.push("--print-timeout", `${Math.ceil(opts.timeoutMs / 1000)}s`);
|
|
18
38
|
}
|
|
19
39
|
return args;
|
|
20
40
|
}
|
|
21
|
-
const EMPTY_OUTPUT = {
|
|
41
|
+
export const EMPTY_OUTPUT = {
|
|
42
|
+
conversationId: null,
|
|
43
|
+
response: "",
|
|
44
|
+
status: null,
|
|
45
|
+
usage: null,
|
|
46
|
+
structuredOutput: null,
|
|
47
|
+
durationSeconds: null,
|
|
48
|
+
numTurns: null,
|
|
49
|
+
deniedActions: [],
|
|
50
|
+
};
|
|
22
51
|
function numberOr(value, fallback) {
|
|
23
52
|
return typeof value === "number" ? value : fallback;
|
|
24
53
|
}
|
|
54
|
+
function numberOrNull(value) {
|
|
55
|
+
return typeof value === "number" ? value : null;
|
|
56
|
+
}
|
|
57
|
+
function isObject(value) {
|
|
58
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
59
|
+
}
|
|
25
60
|
function parseUsage(value) {
|
|
26
|
-
if (value
|
|
61
|
+
if (!isObject(value))
|
|
27
62
|
return null;
|
|
28
|
-
const u = value;
|
|
29
63
|
return {
|
|
30
|
-
inputTokens: numberOr(
|
|
31
|
-
outputTokens: numberOr(
|
|
32
|
-
thinkingTokens: numberOr(
|
|
33
|
-
cacheReadTokens: numberOr(
|
|
34
|
-
totalTokens: numberOr(
|
|
64
|
+
inputTokens: numberOr(value.input_tokens, 0),
|
|
65
|
+
outputTokens: numberOr(value.output_tokens, 0),
|
|
66
|
+
thinkingTokens: numberOr(value.thinking_tokens, 0),
|
|
67
|
+
cacheReadTokens: numberOr(value.cache_read_tokens, 0),
|
|
68
|
+
totalTokens: numberOr(value.total_tokens, 0),
|
|
35
69
|
};
|
|
36
70
|
}
|
|
71
|
+
function parseDeniedActions(value) {
|
|
72
|
+
if (!Array.isArray(value))
|
|
73
|
+
return [];
|
|
74
|
+
return value
|
|
75
|
+
.map((d) => (isObject(d) && typeof d.action === "string" ? d.action : null))
|
|
76
|
+
.filter((a) => a !== null);
|
|
77
|
+
}
|
|
37
78
|
export function parseAgyOutput(stdout) {
|
|
38
79
|
const trimmed = stdout.trim();
|
|
39
80
|
if (!trimmed) {
|
|
40
|
-
return EMPTY_OUTPUT;
|
|
81
|
+
return { ...EMPTY_OUTPUT };
|
|
41
82
|
}
|
|
42
83
|
let parsed;
|
|
43
84
|
try {
|
|
@@ -46,18 +87,18 @@ export function parseAgyOutput(stdout) {
|
|
|
46
87
|
catch {
|
|
47
88
|
return { ...EMPTY_OUTPUT, response: trimmed };
|
|
48
89
|
}
|
|
49
|
-
if (parsed
|
|
50
|
-
return { ...EMPTY_OUTPUT, response: trimmed };
|
|
51
|
-
}
|
|
52
|
-
const obj = parsed;
|
|
53
|
-
if (typeof obj.response !== "string") {
|
|
90
|
+
if (!isObject(parsed) || typeof parsed.response !== "string") {
|
|
54
91
|
return { ...EMPTY_OUTPUT, response: trimmed };
|
|
55
92
|
}
|
|
56
93
|
return {
|
|
57
|
-
conversationId: typeof
|
|
58
|
-
response:
|
|
59
|
-
status: typeof
|
|
60
|
-
usage: parseUsage(
|
|
94
|
+
conversationId: typeof parsed.conversation_id === "string" ? parsed.conversation_id : null,
|
|
95
|
+
response: parsed.response,
|
|
96
|
+
status: typeof parsed.status === "string" ? parsed.status : null,
|
|
97
|
+
usage: parseUsage(parsed.usage),
|
|
98
|
+
structuredOutput: isObject(parsed.structured_output) ? parsed.structured_output : null,
|
|
99
|
+
durationSeconds: numberOrNull(parsed.duration_seconds),
|
|
100
|
+
numTurns: numberOrNull(parsed.num_turns),
|
|
101
|
+
deniedActions: parseDeniedActions(parsed.denied_actions),
|
|
61
102
|
};
|
|
62
103
|
}
|
|
63
104
|
// agy prints a structured `AGY_ERROR: {...}` line on stderr for model/API failures.
|
|
@@ -76,15 +117,31 @@ export function extractAgyError(stderr) {
|
|
|
76
117
|
return json;
|
|
77
118
|
}
|
|
78
119
|
}
|
|
79
|
-
|
|
80
|
-
|
|
120
|
+
// On --print-timeout expiry agy exits 0 with status SUCCESS and a stderr notice.
|
|
121
|
+
export function hitPrintTimeout(stderr) {
|
|
122
|
+
return /print timeout after .* with turn in progress/.test(stderr);
|
|
81
123
|
}
|
|
82
|
-
|
|
124
|
+
function errorResult(errorMessage, output = { ...EMPTY_OUTPUT }) {
|
|
125
|
+
return { output, isError: true, errorMessage };
|
|
126
|
+
}
|
|
127
|
+
// Classify a completed (exit 0) run. Exposed for testing.
|
|
128
|
+
export function classifyRun(output, stderr, timeoutMs) {
|
|
129
|
+
if (hitPrintTimeout(stderr)) {
|
|
130
|
+
return errorResult(`agy timed out after ${timeoutMs / 1000}s; partial response returned in structured output`, output);
|
|
131
|
+
}
|
|
132
|
+
if (output.deniedActions.length > 0) {
|
|
133
|
+
return errorResult(`agy was denied permission for: ${output.deniedActions.join(", ")}. ` +
|
|
134
|
+
"Check permissions.deny rules in agy's settings.json.", output);
|
|
135
|
+
}
|
|
136
|
+
return { output, isError: false };
|
|
137
|
+
}
|
|
138
|
+
const KILL_GRACE_MS = 5000;
|
|
139
|
+
export function runAgy(prompt, cwd, timeoutMs, opts = {}, signal) {
|
|
83
140
|
if (!existsSync(cwd)) {
|
|
84
141
|
return Promise.resolve(errorResult(`Working directory does not exist: ${cwd}`));
|
|
85
142
|
}
|
|
86
143
|
return new Promise((resolve) => {
|
|
87
|
-
const args = buildAgyArgs(prompt, cwd,
|
|
144
|
+
const args = buildAgyArgs(prompt, cwd, { ...opts, timeoutMs });
|
|
88
145
|
let child;
|
|
89
146
|
try {
|
|
90
147
|
child = spawn("agy", args, { cwd, env: process.env, detached: true });
|
|
@@ -111,20 +168,34 @@ export function runAgy(prompt, cwd, model, timeoutMs, conversationId) {
|
|
|
111
168
|
// group already gone
|
|
112
169
|
}
|
|
113
170
|
};
|
|
171
|
+
const terminate = () => {
|
|
172
|
+
killGroup("SIGTERM");
|
|
173
|
+
setTimeout(() => killGroup("SIGKILL"), KILL_GRACE_MS).unref();
|
|
174
|
+
};
|
|
114
175
|
const finish = (r) => {
|
|
115
176
|
if (settled)
|
|
116
177
|
return;
|
|
117
178
|
settled = true;
|
|
118
179
|
clearTimeout(timer);
|
|
180
|
+
signal?.removeEventListener("abort", onAbort);
|
|
119
181
|
resolve(r);
|
|
120
182
|
};
|
|
183
|
+
// Backstop: agy should exit on its own via --print-timeout, but if it doesn't
|
|
184
|
+
// (or grandchildren keep pipes open), kill the group and resolve without
|
|
185
|
+
// waiting for `close`.
|
|
121
186
|
const timer = setTimeout(() => {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
187
|
+
terminate();
|
|
188
|
+
finish(errorResult(`agy timed out after ${timeoutMs / 1000}s and was killed`));
|
|
189
|
+
}, timeoutMs + KILL_GRACE_MS);
|
|
190
|
+
const onAbort = () => {
|
|
191
|
+
terminate();
|
|
192
|
+
finish(errorResult("agy call was cancelled"));
|
|
193
|
+
};
|
|
194
|
+
if (signal?.aborted) {
|
|
195
|
+
onAbort();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
128
199
|
child.stdout?.on("data", (chunk) => {
|
|
129
200
|
stdout += chunk;
|
|
130
201
|
});
|
|
@@ -138,14 +209,14 @@ export function runAgy(prompt, cwd, model, timeoutMs, conversationId) {
|
|
|
138
209
|
? "agy binary not found. Install Antigravity CLI and make sure `agy` is on PATH."
|
|
139
210
|
: `Failed to spawn agy: ${err.message}`));
|
|
140
211
|
});
|
|
141
|
-
child.on("close", (code,
|
|
212
|
+
child.on("close", (code, sig) => {
|
|
142
213
|
if (code !== 0) {
|
|
143
|
-
const how = code === null ? `signal ${
|
|
214
|
+
const how = code === null ? `signal ${sig}` : `code ${code}`;
|
|
144
215
|
const detail = extractAgyError(stderr) || stderr.trim() || stdout.trim() || how;
|
|
145
216
|
finish(errorResult(`agy exited with ${how}: ${detail}`));
|
|
146
217
|
return;
|
|
147
218
|
}
|
|
148
|
-
finish(
|
|
219
|
+
finish(classifyRun(parseAgyOutput(stdout), stderr, timeoutMs));
|
|
149
220
|
});
|
|
150
221
|
});
|
|
151
222
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danwahl/antigravity-mcp",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"description": "MCP server that exposes Antigravity CLI (agy) as a single tool for Claude Code",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/danwahl/antigravity-mcp.git"
|
|
9
|
+
"url": "git+https://github.com/danwahl/antigravity-mcp.git"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
12
12
|
"mcp",
|
|
@@ -24,12 +24,13 @@
|
|
|
24
24
|
"antigravity-mcp": "dist/index.js"
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
|
-
"build": "tsc && shx chmod +x dist/*.js",
|
|
27
|
+
"build": "rm -rf dist && tsc && shx chmod +x dist/*.js",
|
|
28
28
|
"test": "node --import tsx/esm --test src/lib.test.ts",
|
|
29
29
|
"prepublishOnly": "npm run build"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
|
-
"dist"
|
|
32
|
+
"dist",
|
|
33
|
+
"!dist/*.test.js"
|
|
33
34
|
],
|
|
34
35
|
"dependencies": {
|
|
35
36
|
"@modelcontextprotocol/sdk": "^1.21.0",
|
package/dist/lib.test.js
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import { describe, it } from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { buildGeminiArgs, parseGeminiOutput, extractStructuredOutput, } from "./lib.ts";
|
|
4
|
-
describe("buildGeminiArgs", () => {
|
|
5
|
-
it("omits --model when not provided", () => {
|
|
6
|
-
const args = buildGeminiArgs("hello", undefined);
|
|
7
|
-
assert.ok(!args.includes("--model"));
|
|
8
|
-
});
|
|
9
|
-
it("includes --model when provided", () => {
|
|
10
|
-
const args = buildGeminiArgs("hello", "gemini-2.5-pro");
|
|
11
|
-
const idx = args.indexOf("--model");
|
|
12
|
-
assert.ok(idx !== -1);
|
|
13
|
-
assert.equal(args[idx + 1], "gemini-2.5-pro");
|
|
14
|
-
});
|
|
15
|
-
it("passes model string through unchanged", () => {
|
|
16
|
-
const args = buildGeminiArgs("hello", "gemini-1.5-flash-001");
|
|
17
|
-
assert.equal(args[args.indexOf("--model") + 1], "gemini-1.5-flash-001");
|
|
18
|
-
});
|
|
19
|
-
it("includes --resume when sessionId provided", () => {
|
|
20
|
-
const args = buildGeminiArgs("x", undefined, "my-session-id");
|
|
21
|
-
const idx = args.indexOf("--resume");
|
|
22
|
-
assert.ok(idx !== -1);
|
|
23
|
-
assert.equal(args[idx + 1], "my-session-id");
|
|
24
|
-
});
|
|
25
|
-
it("omits --resume when sessionId not provided", () => {
|
|
26
|
-
const args = buildGeminiArgs("x", undefined);
|
|
27
|
-
assert.ok(!args.includes("--resume"));
|
|
28
|
-
});
|
|
29
|
-
it("includes --approval-mode yolo", () => {
|
|
30
|
-
const args = buildGeminiArgs("x", undefined);
|
|
31
|
-
const idx = args.indexOf("--approval-mode");
|
|
32
|
-
assert.ok(idx !== -1);
|
|
33
|
-
assert.equal(args[idx + 1], "yolo");
|
|
34
|
-
});
|
|
35
|
-
it("includes --output-format json", () => {
|
|
36
|
-
const args = buildGeminiArgs("x", undefined);
|
|
37
|
-
const idx = args.indexOf("--output-format");
|
|
38
|
-
assert.ok(idx !== -1);
|
|
39
|
-
assert.equal(args[idx + 1], "json");
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
describe("parseGeminiOutput", () => {
|
|
43
|
-
it("parses valid JSON response", () => {
|
|
44
|
-
const result = parseGeminiOutput(JSON.stringify({ response: "Hello, world!" }));
|
|
45
|
-
assert.equal(result.response, "Hello, world!");
|
|
46
|
-
assert.equal(result.sessionId, null);
|
|
47
|
-
assert.equal(result.stats, undefined);
|
|
48
|
-
});
|
|
49
|
-
it("extracts session_id", () => {
|
|
50
|
-
const result = parseGeminiOutput(JSON.stringify({ session_id: "abc-123", response: "ok" }));
|
|
51
|
-
assert.equal(result.sessionId, "abc-123");
|
|
52
|
-
});
|
|
53
|
-
it("extracts stats field", () => {
|
|
54
|
-
const stats = { models: {}, tools: {} };
|
|
55
|
-
const result = parseGeminiOutput(JSON.stringify({ response: "ok", stats }));
|
|
56
|
-
assert.deepEqual(result.stats, stats);
|
|
57
|
-
});
|
|
58
|
-
it("handles missing stats gracefully", () => {
|
|
59
|
-
const result = parseGeminiOutput(JSON.stringify({ response: "ok" }));
|
|
60
|
-
assert.equal(result.stats, undefined);
|
|
61
|
-
});
|
|
62
|
-
it("returns raw stdout when JSON parsing fails", () => {
|
|
63
|
-
const raw = "this is not json";
|
|
64
|
-
const result = parseGeminiOutput(raw);
|
|
65
|
-
assert.equal(result.response, raw);
|
|
66
|
-
assert.equal(result.sessionId, null);
|
|
67
|
-
});
|
|
68
|
-
it("returns raw stdout for JSON without response field", () => {
|
|
69
|
-
const raw = JSON.stringify({ message: "unexpected shape" });
|
|
70
|
-
const result = parseGeminiOutput(raw);
|
|
71
|
-
assert.equal(result.response, raw);
|
|
72
|
-
});
|
|
73
|
-
it("handles empty stdout", () => {
|
|
74
|
-
const result = parseGeminiOutput("");
|
|
75
|
-
assert.equal(result.response, "");
|
|
76
|
-
});
|
|
77
|
-
it("trims surrounding whitespace before parsing", () => {
|
|
78
|
-
const result = parseGeminiOutput(" " + JSON.stringify({ response: "trimmed" }) + "\n");
|
|
79
|
-
assert.equal(result.response, "trimmed");
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
describe("extractStructuredOutput", () => {
|
|
83
|
-
it("passes through sessionId and response", () => {
|
|
84
|
-
const output = { sessionId: "abc", response: "hello" };
|
|
85
|
-
const result = extractStructuredOutput(output);
|
|
86
|
-
assert.equal(result.sessionId, "abc");
|
|
87
|
-
assert.equal(result.response, "hello");
|
|
88
|
-
});
|
|
89
|
-
it("returns empty records when no stats", () => {
|
|
90
|
-
const output = { sessionId: null, response: "hi" };
|
|
91
|
-
const result = extractStructuredOutput(output);
|
|
92
|
-
assert.deepEqual(result.models, {});
|
|
93
|
-
assert.deepEqual(result.tools, {});
|
|
94
|
-
});
|
|
95
|
-
it("extracts model token totals", () => {
|
|
96
|
-
const output = {
|
|
97
|
-
sessionId: null,
|
|
98
|
-
response: "hi",
|
|
99
|
-
stats: {
|
|
100
|
-
models: {
|
|
101
|
-
"gemini-2.5-pro": { tokens: { total: 100 } },
|
|
102
|
-
"gemini-2.0-flash": { tokens: { total: 50 } },
|
|
103
|
-
},
|
|
104
|
-
},
|
|
105
|
-
};
|
|
106
|
-
assert.deepEqual(extractStructuredOutput(output).models, {
|
|
107
|
-
"gemini-2.5-pro": 100,
|
|
108
|
-
"gemini-2.0-flash": 50,
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
it("omits models with no token data", () => {
|
|
112
|
-
const output = {
|
|
113
|
-
sessionId: null,
|
|
114
|
-
response: "hi",
|
|
115
|
-
stats: { models: { "gemini-2.5-pro": {} } },
|
|
116
|
-
};
|
|
117
|
-
assert.deepEqual(extractStructuredOutput(output).models, {});
|
|
118
|
-
});
|
|
119
|
-
it("extracts tool call counts from byName", () => {
|
|
120
|
-
const output = {
|
|
121
|
-
sessionId: null,
|
|
122
|
-
response: "hi",
|
|
123
|
-
stats: {
|
|
124
|
-
tools: {
|
|
125
|
-
byName: {
|
|
126
|
-
list_directory: { count: 2 },
|
|
127
|
-
web_fetch: { count: 1 },
|
|
128
|
-
},
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
assert.deepEqual(extractStructuredOutput(output).tools, {
|
|
133
|
-
list_directory: 2,
|
|
134
|
-
web_fetch: 1,
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
it("returns empty tools when no tool calls", () => {
|
|
138
|
-
const output = {
|
|
139
|
-
sessionId: null,
|
|
140
|
-
response: "hi",
|
|
141
|
-
stats: { tools: { totalCalls: 0, byName: {} } },
|
|
142
|
-
};
|
|
143
|
-
assert.deepEqual(extractStructuredOutput(output).tools, {});
|
|
144
|
-
});
|
|
145
|
-
});
|