@testchimp/cli 0.1.35 → 0.1.36

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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ChimpHands GitHub Actions bridge: bootstrap → OpenCode → inbound SSE turns.
3
3
  * Relies on TESTCHIMP_API_KEY (+ optional TESTCHIMP_BACKEND_URL; defaults to prod).
4
- * Does not write mcp.json — CLI/skill use process env.
4
+ * Does not write mcp.json — TestChimp MCP is wired via opencode.json for OpenCode.
5
5
  */
6
6
  type RunOptions = {
7
7
  sessionId: string;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ChimpHands GitHub Actions bridge: bootstrap → OpenCode → inbound SSE turns.
3
3
  * Relies on TESTCHIMP_API_KEY (+ optional TESTCHIMP_BACKEND_URL; defaults to prod).
4
- * Does not write mcp.json — CLI/skill use process env.
4
+ * Does not write mcp.json — TestChimp MCP is wired via opencode.json for OpenCode.
5
5
  */
6
6
  import { spawn, execFileSync } from "node:child_process";
7
7
  import { mkdirSync, writeFileSync } from "node:fs";
@@ -16,6 +16,13 @@ const STATUS_RUNNING = "CHIMPHANDS_SESSION_STATUS_RUNNING";
16
16
  const STATUS_WAITING_USER = "CHIMPHANDS_SESSION_STATUS_WAITING_USER";
17
17
  const STATUS_IDLE = "CHIMPHANDS_SESSION_STATUS_IDLE";
18
18
  const STATUS_FAILED = "CHIMPHANDS_SESSION_STATUS_FAILED";
19
+ function ensureTestchimpPrompt(content) {
20
+ const trimmed = content.trim();
21
+ if (!trimmed)
22
+ return trimmed;
23
+ const rest = trimmed.replace(/^\/testchimp\s*/i, "").trim();
24
+ return rest ? `/testchimp ${rest}` : "/testchimp";
25
+ }
19
26
  function apiHeaders(apiKey) {
20
27
  return {
21
28
  "Content-Type": "application/json",
@@ -35,25 +42,72 @@ async function postJson(backend, apiKey, path, body) {
35
42
  return text;
36
43
  }
37
44
  function postJsonFireAndForget(backend, apiKey, path, body) {
38
- void postJson(backend, apiKey, path, body).catch(() => {
39
- /* best-effort agent telemetry */
45
+ void postJson(backend, apiKey, path, body).catch((err) => {
46
+ const detail = err instanceof Error ? err.message : String(err);
47
+ console.error(`ChimpHands API telemetry failed ${path}: ${detail}`);
40
48
  });
41
49
  }
50
+ function resolveOpencodeModel(boot) {
51
+ const raw = (boot.llm_model || "gpt-4o-mini").trim();
52
+ return raw.includes("/") ? raw : `openai/${raw}`;
53
+ }
54
+ function extractOpencodeFatalError(raw) {
55
+ const line = raw.trim();
56
+ if (!line)
57
+ return null;
58
+ try {
59
+ const ev = JSON.parse(line);
60
+ if (ev.type === "error" || ev.name === "UnknownError") {
61
+ const msg = ev.data?.message || ev.message || line;
62
+ const ref = ev.data?.ref ? ` (ref ${ev.data.ref})` : "";
63
+ return `${msg}${ref}`;
64
+ }
65
+ }
66
+ catch {
67
+ /* plain text */
68
+ }
69
+ if (line.includes("Unexpected server error") && line.includes("UnknownError")) {
70
+ return line;
71
+ }
72
+ return null;
73
+ }
42
74
  function writeOpencodeConfig(backend, apiKey, boot) {
43
75
  const llmBase = (boot.llm_base_url || `${backend}/v1`).replace(/\/$/, "");
44
76
  const llmKey = apiKey || boot.llm_api_key || "";
45
- const llmModel = boot.llm_model || "gpt-4o-mini";
77
+ const model = resolveOpencodeModel(boot);
78
+ const mcpEnv = {
79
+ TESTCHIMP_API_KEY: apiKey,
80
+ TESTCHIMP_BACKEND_URL: backend,
81
+ };
82
+ const serviceUserId = boot.chimphands_service_account_user_id?.trim();
83
+ if (serviceUserId) {
84
+ mcpEnv.TESTCHIMP_USER_ID = serviceUserId;
85
+ }
46
86
  writeFileSync("opencode.json", JSON.stringify({
47
- model: llmModel,
87
+ $schema: "https://opencode.ai/config.json",
88
+ model,
89
+ autoupdate: false,
90
+ enabled_providers: ["openai"],
48
91
  provider: {
49
92
  openai: {
50
- apiKey: llmKey,
51
- baseURL: llmBase,
93
+ options: {
94
+ apiKey: llmKey,
95
+ baseURL: llmBase,
96
+ },
97
+ },
98
+ },
99
+ mcp: {
100
+ testchimp: {
101
+ type: "local",
102
+ enabled: true,
103
+ command: ["npx", "-y", "@testchimp/cli@latest", "mcp"],
104
+ environment: mcpEnv,
52
105
  },
53
106
  },
54
107
  }, null, 2));
108
+ return model;
55
109
  }
56
- function runOpencode(prompt, childEnv, postEvent) {
110
+ function runOpencode(prompt, model, childEnv, postEvent) {
57
111
  const help = (() => {
58
112
  try {
59
113
  return execFileSync("opencode", ["run", "--help"], { encoding: "utf8", env: childEnv });
@@ -63,8 +117,9 @@ function runOpencode(prompt, childEnv, postEvent) {
63
117
  }
64
118
  })();
65
119
  const useJson = help.includes("--format");
120
+ const baseArgs = ["run", prompt, "--model", model];
66
121
  if (useJson) {
67
- const child = spawn("opencode", ["run", prompt, "--format", "json"], {
122
+ const child = spawn("opencode", [...baseArgs, "--format", "json"], {
68
123
  stdio: ["ignore", "pipe", "pipe"],
69
124
  env: childEnv,
70
125
  });
@@ -74,6 +129,7 @@ function runOpencode(prompt, childEnv, postEvent) {
74
129
  });
75
130
  return new Promise((resolve) => {
76
131
  let buf = "";
132
+ let fatalError = null;
77
133
  child.stdout.on("data", (chunk) => {
78
134
  buf += chunk.toString();
79
135
  const lines = buf.split("\n");
@@ -81,6 +137,11 @@ function runOpencode(prompt, childEnv, postEvent) {
81
137
  for (const line of lines) {
82
138
  if (!line.trim())
83
139
  continue;
140
+ const fatal = extractOpencodeFatalError(line);
141
+ if (fatal) {
142
+ fatalError = fatal;
143
+ continue;
144
+ }
84
145
  let content = line;
85
146
  let role = ROLE_ASSISTANT;
86
147
  try {
@@ -98,27 +159,48 @@ function runOpencode(prompt, childEnv, postEvent) {
98
159
  }
99
160
  });
100
161
  child.on("close", (code) => {
101
- if (buf.trim())
102
- postEvent(ROLE_ASSISTANT, buf.trim());
162
+ if (buf.trim()) {
163
+ const fatal = extractOpencodeFatalError(buf);
164
+ if (fatal)
165
+ fatalError = fatal;
166
+ else if (!fatalError)
167
+ postEvent(ROLE_ASSISTANT, buf.trim());
168
+ }
169
+ const stderrFatal = extractOpencodeFatalError(err);
170
+ if (stderrFatal)
171
+ fatalError = stderrFatal;
172
+ if (fatalError) {
173
+ resolve({ code: 1, err: fatalError });
174
+ return;
175
+ }
103
176
  resolve({ code: code == null ? 1 : code, err });
104
177
  });
105
178
  });
106
179
  }
107
180
  try {
108
- const out = execFileSync("opencode", ["run", prompt], {
181
+ const out = execFileSync("opencode", baseArgs, {
109
182
  encoding: "utf8",
110
183
  maxBuffer: 20 * 1024 * 1024,
111
184
  stdio: ["ignore", "pipe", "pipe"],
112
185
  env: childEnv,
113
186
  });
187
+ const fatal = extractOpencodeFatalError(out);
188
+ if (fatal)
189
+ return Promise.resolve({ code: 1, err: fatal });
114
190
  if (out)
115
191
  postEvent(ROLE_ASSISTANT, out);
116
192
  return Promise.resolve({ code: 0, err: "" });
117
193
  }
118
194
  catch (e) {
119
195
  const errObj = e;
120
- const err = (errObj.stderr && errObj.stderr.toString()) || errObj.message || "opencode failed";
121
- return Promise.resolve({ code: errObj.status || 1, err });
196
+ const stderr = errObj.stderr?.toString() || "";
197
+ const stdout = errObj.stdout?.toString() || "";
198
+ const fatal = extractOpencodeFatalError(stderr) ||
199
+ extractOpencodeFatalError(stdout) ||
200
+ stderr ||
201
+ errObj.message ||
202
+ "opencode failed";
203
+ return Promise.resolve({ code: errObj.status || 1, err: fatal });
122
204
  }
123
205
  }
124
206
  function connectInbound(backend, apiKey, sessionId, onUserMessage, onIdle, onClosed) {
@@ -184,15 +266,23 @@ export async function runChimphands(opts) {
184
266
  }
185
267
  const promptInput = (opts.prompt ?? process.env.PROMPT ?? "").trim();
186
268
  const bootText = await postJson(backend, apiKey, "/api/chimphands/bootstrap", {
187
- session_id: sessionId,
269
+ sessionId,
188
270
  });
189
271
  const boot = JSON.parse(bootText);
272
+ const githubRunId = (process.env.GITHUB_RUN_ID || "").trim();
273
+ if (githubRunId) {
274
+ postJsonFireAndForget(backend, apiKey, "/api/chimphands/post_agent_event", {
275
+ sessionId,
276
+ githubRunId,
277
+ });
278
+ }
190
279
  const userId = boot.chimphands_service_account_user_id || "";
191
280
  if (userId) {
192
281
  process.env.TESTCHIMP_USER_ID = userId;
193
282
  }
194
283
  mkdirSync(".opencode", { recursive: true });
195
- writeOpencodeConfig(backend, apiKey, boot);
284
+ const opencodeModel = writeOpencodeConfig(backend, apiKey, boot);
285
+ console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
196
286
  const idleMs = (Number(boot.idle_timeout_seconds) || 600) * 1000;
197
287
  const queue = [];
198
288
  let idle = false;
@@ -200,7 +290,7 @@ export async function runChimphands(opts) {
200
290
  let lastUserActivity = Date.now();
201
291
  const postEvent = (role, content, status) => {
202
292
  const body = {
203
- session_id: sessionId,
293
+ sessionId,
204
294
  role,
205
295
  content: String(content || "").slice(0, 20000),
206
296
  };
@@ -209,9 +299,11 @@ export async function runChimphands(opts) {
209
299
  postJsonFireAndForget(backend, apiKey, "/api/chimphands/post_agent_event", body);
210
300
  };
211
301
  const complete = (status, errorMessage) => {
212
- const body = { session_id: sessionId, status };
302
+ const body = { sessionId, status };
213
303
  if (errorMessage)
214
- body.error_message = String(errorMessage).slice(0, 4000);
304
+ body.errorMessage = String(errorMessage).slice(0, 4000);
305
+ if (githubRunId)
306
+ body.githubRunId = githubRunId;
215
307
  postJsonFireAndForget(backend, apiKey, "/api/chimphands/complete_session", body);
216
308
  };
217
309
  const childEnv = {
@@ -222,7 +314,7 @@ export async function runChimphands(opts) {
222
314
  if (userId)
223
315
  childEnv.TESTCHIMP_USER_ID = userId;
224
316
  connectInbound(backend, apiKey, sessionId, (content) => {
225
- queue.push(content);
317
+ queue.push(ensureTestchimpPrompt(content));
226
318
  lastUserActivity = Date.now();
227
319
  }, () => {
228
320
  idle = true;
@@ -230,18 +322,18 @@ export async function runChimphands(opts) {
230
322
  closed = true;
231
323
  });
232
324
  postEvent(ROLE_STATUS, "Agent ready", STATUS_RUNNING);
233
- let prompt = promptInput || boot.initial_prompt || "";
325
+ let prompt = ensureTestchimpPrompt(promptInput || boot.initial_prompt || "");
234
326
  if (boot.conversation_summary) {
235
327
  prompt = `Conversation so far:\n${boot.conversation_summary}\n\nCurrent task:\n${prompt}`;
236
328
  }
237
329
  for (const m of boot.pending_user_messages || []) {
238
330
  if (m?.content)
239
- queue.push(m.content);
331
+ queue.push(ensureTestchimpPrompt(m.content));
240
332
  }
241
333
  const waitForNextPrompt = () => new Promise((resolve) => {
242
334
  const tick = () => {
243
335
  if (queue.length) {
244
- resolve(queue.shift());
336
+ resolve(ensureTestchimpPrompt(queue.shift()));
245
337
  return;
246
338
  }
247
339
  if (idle || closed || Date.now() - lastUserActivity >= idleMs) {
@@ -253,14 +345,20 @@ export async function runChimphands(opts) {
253
345
  tick();
254
346
  });
255
347
  while (prompt) {
256
- const result = await runOpencode(prompt, childEnv, postEvent);
348
+ const result = await runOpencode(ensureTestchimpPrompt(prompt), opencodeModel, childEnv, postEvent);
257
349
  if (result.code !== 0) {
258
- complete(STATUS_FAILED, result.err || "opencode failed");
259
- process.exitCode = result.code || 1;
260
- return;
350
+ const errMsg = result.err || "opencode failed";
351
+ console.error(`ChimpHands OpenCode failed: ${errMsg}`);
352
+ postEvent(ROLE_STATUS, errMsg, STATUS_FAILED);
353
+ complete(STATUS_FAILED, errMsg);
354
+ process.exit(result.code || 1);
261
355
  }
262
356
  postEvent(ROLE_STATUS, "Waiting for user input", STATUS_WAITING_USER);
357
+ // Idle countdown starts when the agent finishes a turn, not at job bootstrap.
358
+ lastUserActivity = Date.now();
359
+ idle = false;
263
360
  prompt = (await waitForNextPrompt()) || "";
264
361
  }
362
+ console.error("ChimpHands session idle — no user input before timeout; completing.");
265
363
  complete(STATUS_IDLE);
266
364
  }
@@ -1578,7 +1578,7 @@ export function buildCliProgram() {
1578
1578
  catch (e) {
1579
1579
  const msg = e instanceof Error ? e.message : String(e);
1580
1580
  console.error(`[testchimp chimphands] ${msg}`);
1581
- process.exitCode = 1;
1581
+ process.exit(1);
1582
1582
  }
1583
1583
  });
1584
1584
  program.on("--help", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "TestChimp CLI and MCP server — coverage, plans, EaaS, TrueCoverage, API operations (calls /api/mcp/*)",
5
5
  "type": "module",
6
6
  "main": "dist/bin/testchimp.js",