@cleocode/adapters 2026.4.67 → 2026.4.68

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.
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Gemini CLI Spawn Provider
3
+ *
4
+ * Implements `AdapterSpawnProvider` for the Google Gemini CLI (`gemini` binary).
5
+ *
6
+ * The `gemini` binary is the Google Gemini CLI agent, available at:
7
+ * https://github.com/google-gemini/gemini-cli
8
+ *
9
+ * Invocation: `gemini --yolo < <prompt-file>`
10
+ *
11
+ * The provider pipes the prompt via stdin using the `--yolo` flag, which
12
+ * enables non-interactive mode (auto-approve all actions). Processes run
13
+ * detached and are tracked by PID for listing and termination.
14
+ *
15
+ * If the `gemini` binary is not found, `canSpawn()` returns `false` with a
16
+ * graceful error — no crash.
17
+ *
18
+ * @remarks
19
+ * The Gemini CLI supports a `--model` flag to select the model family and a
20
+ * `--yolo` flag for non-interactive headless execution (equivalent to Claude
21
+ * Code's `--dangerously-skip-permissions`). Prompts are supplied via stdin
22
+ * when run in `--yolo` mode. Install: `npm install -g @google/gemini-cli`
23
+ * or see the GitHub repo above.
24
+ *
25
+ * @task T648
26
+ */
27
+
28
+ import { exec, spawn as nodeSpawn } from 'node:child_process';
29
+ import { promisify } from 'node:util';
30
+ import type { AdapterSpawnProvider, SpawnContext, SpawnResult } from '@cleocode/contracts';
31
+ import { getErrorMessage } from '@cleocode/contracts';
32
+
33
+ const execAsync = promisify(exec);
34
+
35
+ /** Default Gemini model for subagent spawns. */
36
+ const DEFAULT_MODEL = 'gemini-2.5-pro';
37
+
38
+ /** Internal tracking entry for a spawned process. */
39
+ interface TrackedProcess {
40
+ pid: number;
41
+ taskId: string;
42
+ startTime: string;
43
+ }
44
+
45
+ /**
46
+ * Spawn provider for the Google Gemini CLI.
47
+ *
48
+ * Spawns detached Gemini CLI processes for subagent execution. Each spawn
49
+ * pipes its prompt via stdin, then runs
50
+ * `gemini --yolo --model <model>` as a detached, unref'd child process.
51
+ *
52
+ * @remarks
53
+ * `canSpawn()` returns `false` (with no crash) when the `gemini` binary is
54
+ * not found in PATH. Install instructions are emitted via `console.warn`
55
+ * once to help operators discover the binary is missing.
56
+ *
57
+ * Processes are tracked by instance ID in an in-memory map and verified
58
+ * via `kill(pid, 0)` liveness checks.
59
+ *
60
+ * @task T648
61
+ */
62
+ export class GeminiCliSpawnProvider implements AdapterSpawnProvider {
63
+ /** Map of instance IDs to tracked process info. */
64
+ private processMap = new Map<string, TrackedProcess>();
65
+
66
+ /**
67
+ * Check if the Gemini CLI is available in PATH.
68
+ *
69
+ * @returns `true` if `gemini` is found via `which`
70
+ */
71
+ async canSpawn(): Promise<boolean> {
72
+ try {
73
+ await execAsync('which gemini');
74
+ return true;
75
+ } catch {
76
+ console.warn(
77
+ '[GeminiCliSpawnProvider] gemini CLI not found. ' +
78
+ 'Install: npm install -g @google/gemini-cli ' +
79
+ 'Docs: https://github.com/google-gemini/gemini-cli',
80
+ );
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Spawn a subagent via the Gemini CLI.
87
+ *
88
+ * Pipes the enriched prompt to stdin and spawns a detached Gemini
89
+ * process. The process runs independently of the parent.
90
+ *
91
+ * @param context - Spawn context with taskId, prompt, and options
92
+ * @returns Spawn result with instance ID and status
93
+ */
94
+ async spawn(context: SpawnContext): Promise<SpawnResult> {
95
+ const instanceId = `gemini-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
96
+ const startTime = new Date().toISOString();
97
+
98
+ try {
99
+ // Enrich prompt with CANT bundle, memory bridge, and mental model.
100
+ // Best-effort: if CANT context is unavailable, the raw prompt is used.
101
+ let enrichedPrompt = context.prompt;
102
+ try {
103
+ const { buildCantEnrichedPrompt } = await import('../../cant-context.js');
104
+ enrichedPrompt = await buildCantEnrichedPrompt({
105
+ projectDir: context.workingDirectory ?? process.cwd(),
106
+ basePrompt: context.prompt,
107
+ agentName: (context.options?.agentName as string) ?? undefined,
108
+ });
109
+ } catch {
110
+ // CANT enrichment unavailable — use raw prompt
111
+ }
112
+
113
+ const model = (context.options?.model as string) ?? DEFAULT_MODEL;
114
+
115
+ // --yolo: non-interactive batch mode (auto-approve all actions)
116
+ // --model: select the Gemini model variant
117
+ // Prompt is supplied via stdin (pipe)
118
+ const args = ['--yolo', '--model', model];
119
+ const spawnOpts: Parameters<typeof nodeSpawn>[2] = {
120
+ detached: true,
121
+ stdio: ['pipe', 'ignore', 'ignore'],
122
+ };
123
+
124
+ if (context.workingDirectory) {
125
+ spawnOpts.cwd = context.workingDirectory;
126
+ }
127
+
128
+ const child = nodeSpawn('gemini', args, spawnOpts);
129
+
130
+ // Write the prompt to stdin then close so the CLI receives it.
131
+ if (child.stdin) {
132
+ child.stdin.write(enrichedPrompt, 'utf-8');
133
+ child.stdin.end();
134
+ }
135
+
136
+ child.unref();
137
+
138
+ if (child.pid) {
139
+ this.processMap.set(instanceId, {
140
+ pid: child.pid,
141
+ taskId: context.taskId,
142
+ startTime,
143
+ });
144
+ }
145
+
146
+ child.on('exit', () => {
147
+ this.processMap.delete(instanceId);
148
+ });
149
+
150
+ return {
151
+ instanceId,
152
+ taskId: context.taskId,
153
+ providerId: 'gemini-cli',
154
+ status: 'running',
155
+ startTime,
156
+ };
157
+ } catch (error) {
158
+ console.error(`[GeminiCliSpawnProvider] Failed to spawn: ${getErrorMessage(error)}`);
159
+
160
+ return {
161
+ instanceId,
162
+ taskId: context.taskId,
163
+ providerId: 'gemini-cli',
164
+ status: 'failed',
165
+ startTime,
166
+ endTime: new Date().toISOString(),
167
+ error: getErrorMessage(error),
168
+ };
169
+ }
170
+ }
171
+
172
+ /**
173
+ * List currently running Gemini CLI subagent processes.
174
+ *
175
+ * Checks each tracked process via kill(pid, 0) to verify it is still alive.
176
+ * Dead processes are automatically cleaned from the tracking map.
177
+ *
178
+ * @returns Array of spawn results for running processes
179
+ */
180
+ async listRunning(): Promise<SpawnResult[]> {
181
+ const running: SpawnResult[] = [];
182
+
183
+ for (const [instanceId, tracked] of this.processMap.entries()) {
184
+ try {
185
+ process.kill(tracked.pid, 0);
186
+ running.push({
187
+ instanceId,
188
+ taskId: tracked.taskId,
189
+ providerId: 'gemini-cli',
190
+ status: 'running',
191
+ startTime: tracked.startTime,
192
+ });
193
+ } catch {
194
+ this.processMap.delete(instanceId);
195
+ }
196
+ }
197
+
198
+ return running;
199
+ }
200
+
201
+ /**
202
+ * Terminate a running spawn by instance ID.
203
+ *
204
+ * Sends SIGTERM to the tracked process. If the process is not found
205
+ * or has already exited, this is a no-op.
206
+ *
207
+ * @param instanceId - ID of the spawn instance to terminate
208
+ */
209
+ async terminate(instanceId: string): Promise<void> {
210
+ const tracked = this.processMap.get(instanceId);
211
+ if (!tracked) return;
212
+
213
+ try {
214
+ process.kill(tracked.pid, 'SIGTERM');
215
+ } catch {
216
+ // Process may have already exited
217
+ }
218
+ this.processMap.delete(instanceId);
219
+ }
220
+ }
@@ -13,6 +13,7 @@ import { KimiAdapter } from './adapter.js';
13
13
  export { KimiAdapter } from './adapter.js';
14
14
  export { KimiHookProvider } from './hooks.js';
15
15
  export { KimiInstallProvider } from './install.js';
16
+ export { KimiSpawnProvider } from './spawn.js';
16
17
 
17
18
  export default KimiAdapter;
18
19
 
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Kimi (Moonshot AI) Spawn Provider
3
+ *
4
+ * Implements `AdapterSpawnProvider` for Moonshot AI's Kimi models.
5
+ *
6
+ * There is no widely-distributed standalone Kimi CLI binary. This provider
7
+ * uses the Moonshot AI Chat Completions API directly (REST, no extra SDK
8
+ * dependency) when `MOONSHOT_API_KEY` is present in the environment.
9
+ *
10
+ * API documentation: https://platform.moonshot.cn/docs/api/chat
11
+ * Endpoint: https://api.moonshot.cn/v1/chat/completions
12
+ *
13
+ * `canSpawn()` returns `true` only when:
14
+ * 1. `MOONSHOT_API_KEY` is set in the environment, OR
15
+ * 2. A `kimi` binary is found in PATH (future CLI support)
16
+ *
17
+ * If neither condition holds, `canSpawn()` returns `false` with a clear
18
+ * message — no crash.
19
+ *
20
+ * @remarks
21
+ * Unlike the CLI-based providers (codex, gemini-cli), Kimi spawn runs the
22
+ * API call to completion before returning (`status: 'completed'` or
23
+ * `status: 'failed'`). This mirrors the claude-sdk and openai-sdk providers.
24
+ * The API call uses Node's built-in `fetch` (Node 18+) with no extra
25
+ * dependencies.
26
+ *
27
+ * @task T648
28
+ */
29
+
30
+ import { exec } from 'node:child_process';
31
+ import { promisify } from 'node:util';
32
+ import type { AdapterSpawnProvider, SpawnContext, SpawnResult } from '@cleocode/contracts';
33
+ import { getErrorMessage } from '@cleocode/contracts';
34
+
35
+ const execAsync = promisify(exec);
36
+
37
+ /** Moonshot AI API base URL. */
38
+ const MOONSHOT_API_BASE = 'https://api.moonshot.cn/v1';
39
+
40
+ /** Default model when none is specified in spawn options. */
41
+ const DEFAULT_MODEL = 'moonshot-v1-8k';
42
+
43
+ /**
44
+ * Shape of a Moonshot chat completion response (subset we care about).
45
+ *
46
+ * @internal
47
+ */
48
+ interface MoonshotChatResponse {
49
+ choices: Array<{
50
+ message: {
51
+ content: string;
52
+ };
53
+ finish_reason: string;
54
+ }>;
55
+ error?: {
56
+ message: string;
57
+ type: string;
58
+ };
59
+ }
60
+
61
+ /** Internal tracking entry for an in-flight API call. */
62
+ interface TrackedRun {
63
+ instanceId: string;
64
+ taskId: string;
65
+ startTime: string;
66
+ }
67
+
68
+ /**
69
+ * Resolve the Moonshot API key from the environment.
70
+ *
71
+ * @returns The key string if set, or `null` if absent/empty.
72
+ */
73
+ function resolveMoonshotApiKey(): string | null {
74
+ const key = process.env.MOONSHOT_API_KEY;
75
+ return key?.trim() ? key : null;
76
+ }
77
+
78
+ /**
79
+ * Check whether a `kimi` CLI binary is available in PATH.
80
+ *
81
+ * This is a forward-compatibility hook for any future official Kimi CLI.
82
+ *
83
+ * @returns `true` if `kimi` is found via `which`
84
+ */
85
+ async function kimiCliBinaryAvailable(): Promise<boolean> {
86
+ try {
87
+ await execAsync('which kimi');
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Spawn provider for Moonshot AI Kimi.
96
+ *
97
+ * Uses the Moonshot AI Chat Completions REST API to run subagent prompts.
98
+ * Each `spawn()` call completes synchronously (awaits the API response) and
99
+ * returns `status: 'completed'` or `status: 'failed'`.
100
+ *
101
+ * In-flight runs are tracked by instance ID so `listRunning()` reflects
102
+ * concurrent spawns correctly.
103
+ *
104
+ * @remarks
105
+ * `canSpawn()` checks for `MOONSHOT_API_KEY` first (API mode), then falls
106
+ * back to checking for a `kimi` CLI binary (CLI mode, future). If neither is
107
+ * available, `canSpawn()` returns `false` and `spawn()` throws a descriptive
108
+ * error rather than crashing silently.
109
+ *
110
+ * @task T648
111
+ */
112
+ export class KimiSpawnProvider implements AdapterSpawnProvider {
113
+ /** In-flight run tracking set. */
114
+ private readonly runningInstances = new Map<string, TrackedRun>();
115
+
116
+ /**
117
+ * Check whether Kimi spawning is available in the current environment.
118
+ *
119
+ * Returns `true` when either:
120
+ * - `MOONSHOT_API_KEY` is set (API mode), or
121
+ * - A `kimi` binary is found in PATH (CLI mode — future)
122
+ *
123
+ * @returns `true` when any Kimi access method is available
124
+ */
125
+ async canSpawn(): Promise<boolean> {
126
+ if (resolveMoonshotApiKey()) return true;
127
+ if (await kimiCliBinaryAvailable()) return true;
128
+
129
+ console.warn(
130
+ '[KimiSpawnProvider] No Kimi access method found. ' +
131
+ 'Set MOONSHOT_API_KEY to enable API-based spawning. ' +
132
+ 'Get a key at: https://platform.moonshot.cn/',
133
+ );
134
+ return false;
135
+ }
136
+
137
+ /**
138
+ * Spawn a subagent via the Moonshot AI Kimi API.
139
+ *
140
+ * Enriches the prompt with CANT context (best-effort), then calls
141
+ * the Moonshot Chat Completions API. The call is awaited to completion.
142
+ *
143
+ * @param context - Spawn context with taskId, prompt, and options
144
+ * @returns Resolved spawn result with `status: 'completed'` or `'failed'`
145
+ */
146
+ async spawn(context: SpawnContext): Promise<SpawnResult> {
147
+ const instanceId = `kimi-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
148
+ const startTime = new Date().toISOString();
149
+
150
+ this.runningInstances.set(instanceId, {
151
+ instanceId,
152
+ taskId: context.taskId,
153
+ startTime,
154
+ });
155
+
156
+ try {
157
+ const apiKey = resolveMoonshotApiKey();
158
+ if (!apiKey) {
159
+ throw new Error(
160
+ 'MOONSHOT_API_KEY is not set. ' +
161
+ 'Set the environment variable to enable Kimi spawning. ' +
162
+ 'Get a key at: https://platform.moonshot.cn/',
163
+ );
164
+ }
165
+
166
+ // Enrich prompt with CANT bundle, memory bridge, and mental model.
167
+ // Best-effort: if CANT context is unavailable, the raw prompt is used.
168
+ let enrichedPrompt = context.prompt;
169
+ try {
170
+ const { buildCantEnrichedPrompt } = await import('../../cant-context.js');
171
+ enrichedPrompt = await buildCantEnrichedPrompt({
172
+ projectDir: context.workingDirectory ?? process.cwd(),
173
+ basePrompt: context.prompt,
174
+ agentName: (context.options?.agentName as string) ?? undefined,
175
+ });
176
+ } catch {
177
+ // CANT enrichment unavailable — use raw prompt
178
+ }
179
+
180
+ const model = (context.options?.model as string) ?? DEFAULT_MODEL;
181
+
182
+ const response = await fetch(`${MOONSHOT_API_BASE}/chat/completions`, {
183
+ method: 'POST',
184
+ headers: {
185
+ 'Content-Type': 'application/json',
186
+ Authorization: `Bearer ${apiKey}`,
187
+ },
188
+ body: JSON.stringify({
189
+ model,
190
+ messages: [
191
+ {
192
+ role: 'user',
193
+ content: enrichedPrompt,
194
+ },
195
+ ],
196
+ stream: false,
197
+ }),
198
+ });
199
+
200
+ if (!response.ok) {
201
+ const bodyText = await response.text();
202
+ throw new Error(
203
+ `Moonshot API error ${response.status} ${response.statusText}: ${bodyText}`,
204
+ );
205
+ }
206
+
207
+ const data = (await response.json()) as MoonshotChatResponse;
208
+
209
+ if (data.error) {
210
+ throw new Error(`Moonshot API returned error: ${data.error.message} (${data.error.type})`);
211
+ }
212
+
213
+ const output = data.choices[0]?.message?.content ?? '';
214
+ const endTime = new Date().toISOString();
215
+ this.runningInstances.delete(instanceId);
216
+
217
+ return {
218
+ instanceId,
219
+ taskId: context.taskId,
220
+ providerId: 'kimi',
221
+ status: 'completed',
222
+ output,
223
+ exitCode: 0,
224
+ startTime,
225
+ endTime,
226
+ };
227
+ } catch (error) {
228
+ const endTime = new Date().toISOString();
229
+ this.runningInstances.delete(instanceId);
230
+
231
+ return {
232
+ instanceId,
233
+ taskId: context.taskId,
234
+ providerId: 'kimi',
235
+ status: 'failed',
236
+ exitCode: 1,
237
+ startTime,
238
+ endTime,
239
+ error: getErrorMessage(error),
240
+ };
241
+ }
242
+ }
243
+
244
+ /**
245
+ * List currently in-flight Kimi API calls.
246
+ *
247
+ * Because each `spawn()` call awaits the API response, this list is
248
+ * typically empty unless concurrent spawns are in flight.
249
+ *
250
+ * @returns Array of in-progress spawn results
251
+ */
252
+ async listRunning(): Promise<SpawnResult[]> {
253
+ return [...this.runningInstances.values()].map((entry) => ({
254
+ instanceId: entry.instanceId,
255
+ taskId: entry.taskId,
256
+ providerId: 'kimi',
257
+ status: 'running' as const,
258
+ startTime: entry.startTime,
259
+ }));
260
+ }
261
+
262
+ /**
263
+ * Remove an instance from the running-instances tracking map.
264
+ *
265
+ * The underlying fetch call cannot be cancelled externally once started.
266
+ * This method removes the entry so it will no longer appear in
267
+ * `listRunning()`, but does not abort the in-progress HTTP request.
268
+ *
269
+ * @param instanceId - ID of the spawn instance to terminate
270
+ */
271
+ async terminate(instanceId: string): Promise<void> {
272
+ this.runningInstances.delete(instanceId);
273
+ }
274
+ }