@agentplate/cli 1.0.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 +54 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/agents/architect.md +108 -0
- package/agents/builder.md +97 -0
- package/agents/coordinator.md +113 -0
- package/agents/deployer.md +117 -0
- package/agents/devops.md +114 -0
- package/agents/lead.md +107 -0
- package/agents/merger.md +103 -0
- package/agents/reviewer.md +90 -0
- package/agents/scout.md +95 -0
- package/agents/verifier.md +106 -0
- package/package.json +64 -0
- package/src/agents/guard-rules.ts +55 -0
- package/src/agents/identity.test.ts +161 -0
- package/src/agents/identity.ts +229 -0
- package/src/agents/manifest.test.ts +260 -0
- package/src/agents/manifest.ts +286 -0
- package/src/agents/overlay.test.ts +190 -0
- package/src/agents/overlay.ts +212 -0
- package/src/agents/system-prompt.test.ts +53 -0
- package/src/agents/system-prompt.ts +95 -0
- package/src/agents/turn-runner.ts +79 -0
- package/src/commands/coordinator.test.ts +75 -0
- package/src/commands/coordinator.ts +259 -0
- package/src/commands/deploy.test.ts +504 -0
- package/src/commands/deploy.ts +874 -0
- package/src/commands/doctor.test.ts +106 -0
- package/src/commands/doctor.ts +208 -0
- package/src/commands/init.ts +71 -0
- package/src/commands/log.ts +51 -0
- package/src/commands/mail.ts +197 -0
- package/src/commands/merge.ts +127 -0
- package/src/commands/model.ts +58 -0
- package/src/commands/prime.ts +61 -0
- package/src/commands/reap.ts +87 -0
- package/src/commands/serve.ts +61 -0
- package/src/commands/setup.ts +48 -0
- package/src/commands/ship.test.ts +106 -0
- package/src/commands/ship.ts +202 -0
- package/src/commands/skill.test.ts +458 -0
- package/src/commands/skill.ts +730 -0
- package/src/commands/sling.ts +365 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/stop.ts +56 -0
- package/src/commands/tui.ts +199 -0
- package/src/commands/worktree.ts +77 -0
- package/src/config.test.ts +92 -0
- package/src/config.ts +202 -0
- package/src/db/sqlite.test.ts +77 -0
- package/src/db/sqlite.ts +102 -0
- package/src/deploy/audit.test.ts +233 -0
- package/src/deploy/audit.ts +245 -0
- package/src/deploy/context.test.ts +243 -0
- package/src/deploy/context.ts +72 -0
- package/src/deploy/registry.test.ts +101 -0
- package/src/deploy/registry.ts +86 -0
- package/src/deploy/secrets.test.ts +129 -0
- package/src/deploy/secrets.ts +69 -0
- package/src/deploy/targets/docker-gha.test.ts +323 -0
- package/src/deploy/targets/docker-gha.ts +841 -0
- package/src/deploy/types.ts +153 -0
- package/src/errors.test.ts +42 -0
- package/src/errors.ts +69 -0
- package/src/events/store.test.ts +183 -0
- package/src/events/store.ts +201 -0
- package/src/index.ts +137 -0
- package/src/insights/quality-gates.ts +73 -0
- package/src/json.test.ts +28 -0
- package/src/json.ts +50 -0
- package/src/logging/color.ts +62 -0
- package/src/logging/logger.ts +60 -0
- package/src/logging/sanitizer.test.ts +36 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/client.test.ts +192 -0
- package/src/mail/client.ts +188 -0
- package/src/mail/store.test.ts +279 -0
- package/src/mail/store.ts +311 -0
- package/src/merge/lock.test.ts +88 -0
- package/src/merge/lock.ts +84 -0
- package/src/merge/queue.test.ts +136 -0
- package/src/merge/queue.ts +177 -0
- package/src/merge/resolver.test.ts +219 -0
- package/src/merge/resolver.ts +274 -0
- package/src/paths.ts +36 -0
- package/src/providers/apply.test.ts +90 -0
- package/src/providers/apply.ts +66 -0
- package/src/providers/registry.test.ts +74 -0
- package/src/providers/registry.ts +254 -0
- package/src/runtimes/claude.ts +313 -0
- package/src/runtimes/codex.ts +280 -0
- package/src/runtimes/cursor.ts +247 -0
- package/src/runtimes/gemini.ts +173 -0
- package/src/runtimes/mock.ts +71 -0
- package/src/runtimes/opencode.ts +259 -0
- package/src/runtimes/registry.test.ts +924 -0
- package/src/runtimes/registry.ts +63 -0
- package/src/runtimes/resolve.ts +45 -0
- package/src/runtimes/types.ts +97 -0
- package/src/scaffold.ts +68 -0
- package/src/secrets.test.ts +51 -0
- package/src/secrets.ts +78 -0
- package/src/serve/api.ts +667 -0
- package/src/serve/server.test.ts +433 -0
- package/src/serve/server.ts +271 -0
- package/src/serve/system.ts +90 -0
- package/src/serve/weather.ts +140 -0
- package/src/sessions/reaper.test.ts +162 -0
- package/src/sessions/reaper.ts +149 -0
- package/src/sessions/store.test.ts +351 -0
- package/src/sessions/store.ts +350 -0
- package/src/skills/distiller.test.ts +498 -0
- package/src/skills/distiller.ts +426 -0
- package/src/skills/feedback.test.ts +300 -0
- package/src/skills/feedback.ts +168 -0
- package/src/skills/lifecycle.ts +169 -0
- package/src/skills/retrieval.test.ts +421 -0
- package/src/skills/retrieval.ts +365 -0
- package/src/skills/safety.test.ts +335 -0
- package/src/skills/safety.ts +216 -0
- package/src/skills/store.test.ts +425 -0
- package/src/skills/store.ts +684 -0
- package/src/skills/types.ts +107 -0
- package/src/types.ts +442 -0
- package/src/utils/detect.test.ts +35 -0
- package/src/utils/detect.ts +82 -0
- package/src/version.test.ts +19 -0
- package/src/version.ts +7 -0
- package/src/wizard/setup.ts +254 -0
- package/src/worktree/manager.test.ts +181 -0
- package/src/worktree/manager.ts +229 -0
- package/templates/overlay.md.tmpl +102 -0
- package/ui/dist/assets/index-C7rXIMER.css +1 -0
- package/ui/dist/assets/index-W4kbr4by.js +4526 -0
- package/ui/dist/favicon.svg +21 -0
- package/ui/dist/index.html +16 -0
- package/ui/dist/logo-clay.svg +21 -0
- package/ui/dist/logo.svg +18 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor runtime adapter (`cursor-agent` CLI).
|
|
3
|
+
*
|
|
4
|
+
* Drives Cursor's `cursor-agent` CLI in headless, spawn-per-turn mode. Like Claude
|
|
5
|
+
* Code, Cursor authenticates with its OWN login: a Cursor OAuth session created by
|
|
6
|
+
* `cursor-agent login` (stored under `~/.cursor`). When the active provider uses
|
|
7
|
+
* `authMode: "subscription"`, the provider layer injects no key (see
|
|
8
|
+
* `src/runtimes/resolve.ts`) and Cursor falls back to that OAuth login — the same
|
|
9
|
+
* pattern the Anthropic provider uses with the `claude` login. An `api-key` / `env`
|
|
10
|
+
* provider instead flows `CURSOR_API_KEY` through {@link CursorRuntime.buildEnv};
|
|
11
|
+
* auth is never hardcoded here.
|
|
12
|
+
*
|
|
13
|
+
* NOTE the id/binary split: the registry id is `cursor` (matching
|
|
14
|
+
* `src/utils/detect.ts`), but the CLI binary is `cursor-agent`.
|
|
15
|
+
*
|
|
16
|
+
* A headless turn is `cursor-agent -p … --output-format stream-json`, whose stdout
|
|
17
|
+
* is a stream of NDJSON events; {@link CursorRuntime.parseEvents} normalizes those
|
|
18
|
+
* into {@link AgentEvent}s. Session continuity across turns is carried by the chat
|
|
19
|
+
* id captured from the event stream and threaded back via `--resume`
|
|
20
|
+
* ({@link DirectSpawnOpts.resumeSessionId}).
|
|
21
|
+
*
|
|
22
|
+
* Validated against cursor-agent 2026.05.28: flags (`-p`, `--output-format
|
|
23
|
+
* stream-json`, `--model`, `--force`, `--resume`), the auth-failure path (surfaced
|
|
24
|
+
* via stderr), and a real authenticated multi-agent run — `system`/`user`/
|
|
25
|
+
* `assistant`/`tool_call`/`result` events parse, with tool names, token usage, and
|
|
26
|
+
* errors extracted.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { ResolvedModel } from "../types.ts";
|
|
30
|
+
import type { AgentEvent, AgentRuntime, DirectSpawnOpts, InteractiveSpawnOpts } from "./types.ts";
|
|
31
|
+
|
|
32
|
+
export class CursorRuntime implements AgentRuntime {
|
|
33
|
+
/** Registry id; also the value users pass to `--runtime cursor`. */
|
|
34
|
+
readonly id = "cursor";
|
|
35
|
+
|
|
36
|
+
/** Beta: validated against cursor-agent 2026.05.28 (flags + JSON-event shapes). */
|
|
37
|
+
readonly stability = "beta" as const;
|
|
38
|
+
|
|
39
|
+
/** Cursor reads `AGENTS.md` from the working directory at startup. */
|
|
40
|
+
readonly instructionPath = "AGENTS.md";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build argv for one headless streaming turn (`cursor-agent -p`).
|
|
44
|
+
*
|
|
45
|
+
* Flag choices:
|
|
46
|
+
* - `-p <prompt>` runs the prompt non-interactively and exits on completion.
|
|
47
|
+
* - `--output-format stream-json` emits the per-event NDJSON that
|
|
48
|
+
* {@link parseEvents} consumes (including the chat-id event used for resume).
|
|
49
|
+
* - `--model <model>` pins the concrete model resolved upstream.
|
|
50
|
+
* - `--force` allows all tool actions without prompting — the analog of Claude
|
|
51
|
+
* Code's `bypassPermissions`, since workers run unattended in an isolated
|
|
52
|
+
* worktree where prompts would deadlock a headless process.
|
|
53
|
+
* - `--resume <id>` is emitted ONLY on follow-up turns; the first turn omits it
|
|
54
|
+
* so Cursor starts a fresh chat.
|
|
55
|
+
*
|
|
56
|
+
* Returned as an argv array (never a shell string) so no value is subject to
|
|
57
|
+
* shell interpolation.
|
|
58
|
+
*/
|
|
59
|
+
buildDirectSpawn(opts: DirectSpawnOpts): string[] {
|
|
60
|
+
return [
|
|
61
|
+
"cursor-agent",
|
|
62
|
+
"-p",
|
|
63
|
+
opts.prompt ?? "",
|
|
64
|
+
"--output-format",
|
|
65
|
+
"stream-json",
|
|
66
|
+
"--model",
|
|
67
|
+
opts.model,
|
|
68
|
+
"--force",
|
|
69
|
+
// Only resume when a prior turn handed us a real chat id. An empty string
|
|
70
|
+
// is treated as "no resume" so the first turn opens a new chat.
|
|
71
|
+
...(opts.resumeSessionId ? ["--resume", opts.resumeSessionId] : []),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build argv for an ATTENDED interactive Cursor session.
|
|
77
|
+
*
|
|
78
|
+
* Run in the foreground with inherited stdio so the operator chats directly
|
|
79
|
+
* (`coordinator start`). `cursor-agent` has no `--append-system-prompt` flag, so
|
|
80
|
+
* the agent's role must be supplied via the `AGENTS.md` overlay;
|
|
81
|
+
* `systemPrompt`/`permissionMode` are accepted for interface parity but not
|
|
82
|
+
* passed as flags. A seed message becomes the initial prompt while the TUI
|
|
83
|
+
* stays interactive.
|
|
84
|
+
*/
|
|
85
|
+
buildInteractiveSpawn(opts: InteractiveSpawnOpts): string[] {
|
|
86
|
+
const argv = ["cursor-agent", "--model", opts.model];
|
|
87
|
+
if (opts.initialMessage && opts.initialMessage.length > 0) {
|
|
88
|
+
argv.push(opts.initialMessage);
|
|
89
|
+
}
|
|
90
|
+
return argv;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Provider env vars for the resolved model (API keys, base URLs).
|
|
95
|
+
*
|
|
96
|
+
* Auth is never hardcoded here — it is whatever the provider layer resolved
|
|
97
|
+
* onto the model (empty for subscription/OAuth login, `CURSOR_API_KEY` for an
|
|
98
|
+
* api-key/env provider). A fresh object is returned so a caller mutating the
|
|
99
|
+
* result cannot leak back into shared config.
|
|
100
|
+
*/
|
|
101
|
+
buildEnv(model: ResolvedModel): Record<string, string> {
|
|
102
|
+
return { ...(model.env ?? {}) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build argv for a one-shot, non-streaming call (`cursor-agent -p
|
|
107
|
+
* --output-format text`). Used by AI-assisted merge resolution and skill
|
|
108
|
+
* distillation, where we want only the final text answer. The model is appended
|
|
109
|
+
* only when provided so the caller can defer to Cursor's own default.
|
|
110
|
+
*/
|
|
111
|
+
buildPrintCommand(prompt: string, model?: string): string[] {
|
|
112
|
+
const argv = ["cursor-agent", "-p", prompt, "--output-format", "text"];
|
|
113
|
+
if (model !== undefined) {
|
|
114
|
+
argv.push("--model", model);
|
|
115
|
+
}
|
|
116
|
+
return argv;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse Cursor's `--output-format stream-json` stdout into normalized
|
|
121
|
+
* {@link AgentEvent}s.
|
|
122
|
+
*
|
|
123
|
+
* The stream is NDJSON: one JSON object per line, but pipe chunk boundaries do
|
|
124
|
+
* NOT align to newlines, so we keep a `buffer` of the trailing partial line
|
|
125
|
+
* across reads and only parse once a `\n` completes it. Malformed lines are
|
|
126
|
+
* skipped (a partial flush or diagnostic line must not abort the whole turn).
|
|
127
|
+
*/
|
|
128
|
+
async *parseEvents(stream: ReadableStream<Uint8Array>): AsyncIterable<AgentEvent> {
|
|
129
|
+
const reader = stream.getReader();
|
|
130
|
+
const decoder = new TextDecoder();
|
|
131
|
+
let buffer = "";
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
while (true) {
|
|
135
|
+
const { done, value } = await reader.read();
|
|
136
|
+
if (done) break;
|
|
137
|
+
buffer += decoder.decode(value, { stream: true });
|
|
138
|
+
|
|
139
|
+
const lines = buffer.split("\n");
|
|
140
|
+
buffer = lines.pop() ?? "";
|
|
141
|
+
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
const event = parseCursorLine(line);
|
|
144
|
+
if (event) yield event;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const tail = parseCursorLine(buffer);
|
|
149
|
+
if (tail) yield tail;
|
|
150
|
+
} finally {
|
|
151
|
+
reader.releaseLock();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse a single `cursor-agent` stream-json line into an {@link AgentEvent}, or
|
|
158
|
+
* `null` for a blank or unparseable line. We accept any of the common chat-id key
|
|
159
|
+
* spellings so resume keeps working across CLI versions, mirroring the resilience
|
|
160
|
+
* of the Claude parser.
|
|
161
|
+
*/
|
|
162
|
+
function parseCursorLine(line: string): AgentEvent | null {
|
|
163
|
+
const trimmed = line.trim();
|
|
164
|
+
if (!trimmed) return null;
|
|
165
|
+
|
|
166
|
+
let raw: unknown;
|
|
167
|
+
try {
|
|
168
|
+
raw = JSON.parse(trimmed);
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
174
|
+
const msg = raw as Record<string, unknown>;
|
|
175
|
+
|
|
176
|
+
const event: AgentEvent = {
|
|
177
|
+
type: typeof msg.type === "string" ? msg.type : "unknown",
|
|
178
|
+
raw,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
for (const candidate of [msg.chat_id, msg.chatId, msg.session_id, msg.sessionId]) {
|
|
182
|
+
if (typeof candidate === "string" && candidate.length > 0) {
|
|
183
|
+
event.sessionId = candidate;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const tool = extractCursorTool(msg);
|
|
189
|
+
if (tool !== undefined) event.tool = tool;
|
|
190
|
+
|
|
191
|
+
const usage = extractCursorUsage(msg);
|
|
192
|
+
if (usage !== undefined) event.usage = usage;
|
|
193
|
+
|
|
194
|
+
// A `result` event with `is_error` carries the failure message in `result`.
|
|
195
|
+
if (msg.type === "result" && msg.is_error === true && typeof msg.result === "string") {
|
|
196
|
+
event.error = msg.result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return event;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Pull a tool name from a Cursor event. Cursor emits dedicated `tool_call` events
|
|
204
|
+
* whose `tool_call` object is keyed by the tool (`shellToolCall`, `editToolCall`,
|
|
205
|
+
* `readToolCall`, …) — we map that key to a short name (`shell`, `edit`, …). It
|
|
206
|
+
* also nests `{ type: "tool_use", name }` blocks in assistant `message.content`
|
|
207
|
+
* (the Claude shape), handled as a fallback. Returns `undefined` if no tool.
|
|
208
|
+
*/
|
|
209
|
+
function extractCursorTool(msg: Record<string, unknown>): string | undefined {
|
|
210
|
+
if (msg.type === "tool_call" && typeof msg.tool_call === "object" && msg.tool_call !== null) {
|
|
211
|
+
const key = Object.keys(msg.tool_call as Record<string, unknown>)[0];
|
|
212
|
+
if (key) return key.replace(/ToolCall$/, "").toLowerCase() || key;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const message = msg.message;
|
|
216
|
+
if (typeof message !== "object" || message === null) return undefined;
|
|
217
|
+
const content = (message as Record<string, unknown>).content;
|
|
218
|
+
if (!Array.isArray(content)) return undefined;
|
|
219
|
+
for (const block of content) {
|
|
220
|
+
if (typeof block !== "object" || block === null) continue;
|
|
221
|
+
const b = block as Record<string, unknown>;
|
|
222
|
+
if (b.type === "tool_use" && typeof b.name === "string") return b.name;
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Pull token usage from a Cursor `result` event's `usage`
|
|
229
|
+
* (`{ inputTokens, outputTokens, cache… }`). Tokens are input + output (cache
|
|
230
|
+
* counts are reported separately); Cursor gives no USD figure, so cost is 0.
|
|
231
|
+
*/
|
|
232
|
+
function extractCursorUsage(
|
|
233
|
+
msg: Record<string, unknown>,
|
|
234
|
+
): { tokens: number; costUsd: number } | undefined {
|
|
235
|
+
if (msg.type !== "result") return undefined;
|
|
236
|
+
const usage = msg.usage;
|
|
237
|
+
if (typeof usage !== "object" || usage === null) return undefined;
|
|
238
|
+
const u = usage as Record<string, unknown>;
|
|
239
|
+
const input = typeof u.inputTokens === "number" ? u.inputTokens : 0;
|
|
240
|
+
const output = typeof u.outputTokens === "number" ? u.outputTokens : 0;
|
|
241
|
+
const tokens = input + output;
|
|
242
|
+
if (tokens === 0) return undefined;
|
|
243
|
+
return { tokens, costUsd: 0 };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Singleton for callers that do not need dependency injection. */
|
|
247
|
+
export const cursorRuntime = new CursorRuntime();
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Gemini runtime adapter (`gemini` CLI).
|
|
3
|
+
*
|
|
4
|
+
* Drives Google's `gemini` CLI in headless mode. Like Claude Code and Codex,
|
|
5
|
+
* Gemini authenticates with its OWN login: a "Login with Google" OAuth session
|
|
6
|
+
* under `~/.gemini/`. When the active provider uses `authMode: "subscription"`,
|
|
7
|
+
* the provider layer injects no key and Gemini uses that login; an `api-key`/`env`
|
|
8
|
+
* provider flows `GEMINI_API_KEY` through {@link GeminiRuntime.buildEnv}.
|
|
9
|
+
*
|
|
10
|
+
* A headless turn is `gemini -p … --output-format stream-json`, whose stdout is a
|
|
11
|
+
* stream of JSONL events (`init` / `message` / `result`); {@link parseEvents}
|
|
12
|
+
* normalizes them. `--yolo` auto-approves tool actions, and `--skip-trust` is
|
|
13
|
+
* REQUIRED: in an untrusted folder (e.g. a fresh worktree) Gemini silently
|
|
14
|
+
* downgrades the approval mode to "default" and then deadlocks/aborts a headless
|
|
15
|
+
* turn it can't prompt in. The session id comes from the `init` event; Gemini's
|
|
16
|
+
* `--resume` is index-based (not a session uuid), so turns run fresh.
|
|
17
|
+
*
|
|
18
|
+
* Validated against gemini-cli 0.44.1 flag + JSON-event shapes.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ResolvedModel } from "../types.ts";
|
|
22
|
+
import type { AgentEvent, AgentRuntime, DirectSpawnOpts, InteractiveSpawnOpts } from "./types.ts";
|
|
23
|
+
|
|
24
|
+
export class GeminiRuntime implements AgentRuntime {
|
|
25
|
+
/** Registry id; also the value users pass to `--runtime gemini`. */
|
|
26
|
+
readonly id = "gemini";
|
|
27
|
+
|
|
28
|
+
/** Beta: validated against gemini-cli 0.44.1 flag + JSON-event shapes. */
|
|
29
|
+
readonly stability = "beta" as const;
|
|
30
|
+
|
|
31
|
+
/** Gemini reads `GEMINI.md` from the working directory at startup. */
|
|
32
|
+
readonly instructionPath = "GEMINI.md";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build argv for a single headless turn (`gemini -p … -o stream-json`).
|
|
36
|
+
*
|
|
37
|
+
* `--prompt <text>` runs non-interactively and exits. `--model` pins the model;
|
|
38
|
+
* `--output-format stream-json` emits the per-event JSONL {@link parseEvents}
|
|
39
|
+
* consumes. `--yolo` auto-approves tools and `--skip-trust` keeps that mode in
|
|
40
|
+
* an untrusted worktree (without it Gemini reverts to prompting and the headless
|
|
41
|
+
* turn fails). Gemini has no uuid-based resume, so `resumeSessionId` is ignored.
|
|
42
|
+
*/
|
|
43
|
+
buildDirectSpawn(opts: DirectSpawnOpts): string[] {
|
|
44
|
+
return [
|
|
45
|
+
"gemini",
|
|
46
|
+
"--skip-trust",
|
|
47
|
+
"--yolo",
|
|
48
|
+
"--model",
|
|
49
|
+
opts.model,
|
|
50
|
+
"--output-format",
|
|
51
|
+
"stream-json",
|
|
52
|
+
"--prompt",
|
|
53
|
+
opts.prompt ?? "",
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build argv for an ATTENDED interactive Gemini session.
|
|
59
|
+
*
|
|
60
|
+
* Foreground with inherited stdio so the operator chats directly. `--skip-trust`
|
|
61
|
+
* avoids the trust prompt; in auto/bypass mode `--yolo` auto-approves actions,
|
|
62
|
+
* while `--safe` (`"default"`) leaves Gemini's in-TUI approval prompts. Gemini
|
|
63
|
+
* has no system-prompt flag, so the role is supplied via the `GEMINI.md` overlay.
|
|
64
|
+
* A seed message uses `--prompt-interactive` so the TUI stays interactive.
|
|
65
|
+
*/
|
|
66
|
+
buildInteractiveSpawn(opts: InteractiveSpawnOpts): string[] {
|
|
67
|
+
const argv = ["gemini", "--skip-trust"];
|
|
68
|
+
if (opts.permissionMode === "bypass") argv.push("--yolo");
|
|
69
|
+
argv.push("--model", opts.model);
|
|
70
|
+
if (opts.initialMessage && opts.initialMessage.length > 0) {
|
|
71
|
+
argv.push("--prompt-interactive", opts.initialMessage);
|
|
72
|
+
}
|
|
73
|
+
return argv;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Provider env vars for the resolved model (fresh copy; empty for OAuth login). */
|
|
77
|
+
buildEnv(model: ResolvedModel): Record<string, string> {
|
|
78
|
+
return { ...(model.env ?? {}) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Build argv for a one-shot, non-streaming call (`gemini -p -o text`). */
|
|
82
|
+
buildPrintCommand(prompt: string, model?: string): string[] {
|
|
83
|
+
const argv = ["gemini", "--skip-trust", "--output-format", "text"];
|
|
84
|
+
if (model !== undefined) {
|
|
85
|
+
argv.push("--model", model);
|
|
86
|
+
}
|
|
87
|
+
argv.push("--prompt", prompt);
|
|
88
|
+
return argv;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse Gemini's `--output-format stream-json` stdout into normalized
|
|
93
|
+
* {@link AgentEvent}s. The stream is JSONL; pipe chunk boundaries don't align to
|
|
94
|
+
* newlines, so a partial trailing line is buffered across reads. Malformed lines
|
|
95
|
+
* are skipped.
|
|
96
|
+
*/
|
|
97
|
+
async *parseEvents(stream: ReadableStream<Uint8Array>): AsyncIterable<AgentEvent> {
|
|
98
|
+
const reader = stream.getReader();
|
|
99
|
+
const decoder = new TextDecoder();
|
|
100
|
+
let buffer = "";
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
while (true) {
|
|
104
|
+
const { done, value } = await reader.read();
|
|
105
|
+
if (done) break;
|
|
106
|
+
buffer += decoder.decode(value, { stream: true });
|
|
107
|
+
const lines = buffer.split("\n");
|
|
108
|
+
buffer = lines.pop() ?? "";
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const event = parseGeminiLine(line);
|
|
111
|
+
if (event) yield event;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const tail = parseGeminiLine(buffer);
|
|
115
|
+
if (tail) yield tail;
|
|
116
|
+
} finally {
|
|
117
|
+
reader.releaseLock();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Parse a single `gemini -o stream-json` line into an {@link AgentEvent}, or `null`
|
|
124
|
+
* for a blank/unparseable line. Gemini emits `init` ({ session_id, model }),
|
|
125
|
+
* `message` ({ role, content }), and `result` ({ status, stats }).
|
|
126
|
+
*/
|
|
127
|
+
function parseGeminiLine(line: string): AgentEvent | null {
|
|
128
|
+
const trimmed = line.trim();
|
|
129
|
+
if (!trimmed) return null;
|
|
130
|
+
|
|
131
|
+
let raw: unknown;
|
|
132
|
+
try {
|
|
133
|
+
raw = JSON.parse(trimmed);
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
138
|
+
const msg = raw as Record<string, unknown>;
|
|
139
|
+
|
|
140
|
+
const event: AgentEvent = {
|
|
141
|
+
type: typeof msg.type === "string" ? msg.type : "unknown",
|
|
142
|
+
raw,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// The opening `init` event carries the session id.
|
|
146
|
+
if (typeof msg.session_id === "string" && msg.session_id.length > 0) {
|
|
147
|
+
event.sessionId = msg.session_id;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Tool activity: a tool-call event/message names the tool.
|
|
151
|
+
if (msg.type === "tool_call" || msg.type === "tool") {
|
|
152
|
+
const name = msg.name ?? msg.tool;
|
|
153
|
+
if (typeof name === "string") event.tool = name;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The `result` event carries token usage and success/error status.
|
|
157
|
+
if (msg.type === "result") {
|
|
158
|
+
const stats = msg.stats;
|
|
159
|
+
if (typeof stats === "object" && stats !== null) {
|
|
160
|
+
const total = (stats as Record<string, unknown>).total_tokens;
|
|
161
|
+
if (typeof total === "number" && total > 0) event.usage = { tokens: total, costUsd: 0 };
|
|
162
|
+
}
|
|
163
|
+
if (typeof msg.status === "string" && msg.status !== "success") {
|
|
164
|
+
const detail = typeof msg.error === "string" ? msg.error : msg.status;
|
|
165
|
+
event.error = `gemini turn ${msg.status}: ${detail}`;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return event;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Singleton for callers that do not need dependency injection. */
|
|
173
|
+
export const geminiRuntime = new GeminiRuntime();
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic test runtime.
|
|
3
|
+
*
|
|
4
|
+
* The mock runtime exists so orchestration code (sling, turn-runner, merge) can
|
|
5
|
+
* be exercised end-to-end without a real coding-agent CLL or any LLM cost. A
|
|
6
|
+
* "turn" is just a scripted bash command: tests set `AGENTPLATE_MOCK_CMD` to make
|
|
7
|
+
* the worker do something concrete inside its worktree (e.g. write a file and
|
|
8
|
+
* `git commit`), then assert on the resulting branch. Because the script runs
|
|
9
|
+
* via `bash -lc`, the env var can be an arbitrary shell snippet.
|
|
10
|
+
*
|
|
11
|
+
* It implements the same {@link AgentRuntime} contract as real adapters but is
|
|
12
|
+
* marked `experimental` and is never selected by default — only when a caller
|
|
13
|
+
* explicitly resolves `"mock"`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ResolvedModel } from "../types.ts";
|
|
17
|
+
import type { AgentRuntime, DirectSpawnOpts, InteractiveSpawnOpts } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
export class MockRuntime implements AgentRuntime {
|
|
20
|
+
/** Registry id; tests resolve this explicitly via `getRuntime("mock")`. */
|
|
21
|
+
readonly id = "mock";
|
|
22
|
+
|
|
23
|
+
/** Test-only — never a production default. */
|
|
24
|
+
readonly stability = "experimental" as const;
|
|
25
|
+
|
|
26
|
+
/** Plain `CLAUDE.md` at the worktree root; the mock does not nest under `.claude/`. */
|
|
27
|
+
readonly instructionPath = "CLAUDE.md";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build argv for one "turn" as a scripted bash command.
|
|
31
|
+
*
|
|
32
|
+
* The command body comes from `AGENTPLATE_MOCK_CMD` (default `true`, a no-op that
|
|
33
|
+
* exits 0), letting a test drive the worker's exact behavior — including
|
|
34
|
+
* writing files and committing inside the worktree the orchestrator created.
|
|
35
|
+
* `bash -lc` is used so the snippet runs as a normal login shell command with
|
|
36
|
+
* full access to PATH (git, etc.). The argv form keeps the snippet a single
|
|
37
|
+
* opaque argument — no second layer of shell interpolation by us.
|
|
38
|
+
*
|
|
39
|
+
* `opts` is part of the interface but unused: the mock's behavior is fully
|
|
40
|
+
* determined by the env var, which is what makes it deterministic.
|
|
41
|
+
*/
|
|
42
|
+
buildDirectSpawn(_opts: DirectSpawnOpts): string[] {
|
|
43
|
+
return ["bash", "-lc", process.env.AGENTPLATE_MOCK_CMD ?? "true"];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Interactive session, scripted via `AGENTPLATE_MOCK_INTERACTIVE` (default
|
|
48
|
+
* `true`, an instant clean exit) so `coordinator start` tests never fork a real
|
|
49
|
+
* `claude`. `opts` is accepted for interface parity but ignored.
|
|
50
|
+
*/
|
|
51
|
+
buildInteractiveSpawn(_opts: InteractiveSpawnOpts): string[] {
|
|
52
|
+
return ["bash", "-lc", process.env.AGENTPLATE_MOCK_INTERACTIVE ?? "true"];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Pass provider env through unchanged (a fresh copy), mirroring real adapters. */
|
|
56
|
+
buildEnv(model: ResolvedModel): Record<string, string> {
|
|
57
|
+
return { ...(model.env ?? {}) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One-shot print command, scripted via `AGENTPLATE_MOCK_PRINT` (default
|
|
62
|
+
* `echo mock`). `prompt`/`model` are accepted for interface parity but ignored
|
|
63
|
+
* so the output stays deterministic for assertions.
|
|
64
|
+
*/
|
|
65
|
+
buildPrintCommand(_prompt: string, _model?: string): string[] {
|
|
66
|
+
return ["bash", "-lc", process.env.AGENTPLATE_MOCK_PRINT ?? "echo mock"];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Singleton for callers that do not need dependency injection. */
|
|
71
|
+
export const mockRuntime = new MockRuntime();
|