@testchimp/cli 0.1.64 → 0.1.65

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,22 @@
1
+ /**
2
+ * Remint a short-lived GitHub App installation token and apply it for git/gh.
3
+ * Never prints the token. Safe for ChimpHands agents and GHA runners.
4
+ */
5
+ export type RefreshGitAuthResult = {
6
+ ok: true;
7
+ repositoryFullName: string;
8
+ expiresAtMillis: number;
9
+ };
10
+ /**
11
+ * Mint a ~1h GitHub App installation token and apply it for subsequent git/gh
12
+ * in this process (and this GHA job when GITHUB_ENV is set).
13
+ *
14
+ * Does **not** print the token. Applies via:
15
+ * - process.env GH_TOKEN / GITHUB_TOKEN
16
+ * - $GITHUB_ENV when present (GitHub Actions)
17
+ * - origin remote URL rewrite (https github.com) when possible
18
+ * - `gh auth login --with-token` + `gh auth setup-git` when `gh` exists
19
+ */
20
+ export declare function refreshGitAuth(): Promise<RefreshGitAuthResult>;
21
+ /** True when stderr/stdout looks like expired / rejected GitHub credentials. */
22
+ export declare function looksLikeGitAuthFailure(text: string): boolean;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Remint a short-lived GitHub App installation token and apply it for git/gh.
3
+ * Never prints the token. Safe for ChimpHands agents and GHA runners.
4
+ */
5
+ import { spawn } from "node:child_process";
6
+ import { appendFileSync } from "node:fs";
7
+ import { getBackendUrl, requireApiKey } from "../core/client.js";
8
+ function run(cmd, args, env) {
9
+ return new Promise((resolve) => {
10
+ const child = spawn(cmd, args, {
11
+ stdio: ["ignore", "pipe", "pipe"],
12
+ env: env ? { ...process.env, ...env } : process.env,
13
+ });
14
+ let out = "";
15
+ let err = "";
16
+ child.stdout.on("data", (d) => {
17
+ out += d.toString();
18
+ });
19
+ child.stderr.on("data", (d) => {
20
+ err += d.toString();
21
+ });
22
+ child.on("close", (code) => resolve({ code: code ?? 1, out, err }));
23
+ child.on("error", (e) => resolve({ code: 1, out: "", err: e instanceof Error ? e.message : String(e) }));
24
+ });
25
+ }
26
+ function pipeTokenToGh(token) {
27
+ return new Promise((resolve) => {
28
+ const child = spawn("gh", ["auth", "login", "--with-token"], {
29
+ stdio: ["pipe", "ignore", "pipe"],
30
+ env: process.env,
31
+ });
32
+ let err = "";
33
+ child.stderr.on("data", (d) => {
34
+ err += d.toString();
35
+ });
36
+ child.on("close", (code) => resolve({ code: code ?? 1, err }));
37
+ child.on("error", (e) => resolve({ code: 1, err: e instanceof Error ? e.message : String(e) }));
38
+ child.stdin.write(token);
39
+ child.stdin.end();
40
+ });
41
+ }
42
+ function injectTokenIntoGithubHttpsUrl(remoteUrl, token) {
43
+ // https://github.com/owner/repo.git or https://x-access-token:OLD@github.com/owner/repo.git
44
+ const m = remoteUrl.match(/^https:\/\/(?:[^@]+@)?github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?\s*$/i);
45
+ if (!m)
46
+ return null;
47
+ const owner = m[1];
48
+ const repo = m[2].replace(/\.git$/i, "");
49
+ return `https://x-access-token:${token}@github.com/${owner}/${repo}.git`;
50
+ }
51
+ async function mintGithubWriteToken() {
52
+ const apiKey = requireApiKey();
53
+ const backend = getBackendUrl();
54
+ const res = await fetch(`${backend}/api/chimphands/github_write_token`, {
55
+ method: "POST",
56
+ headers: {
57
+ "Content-Type": "application/json",
58
+ "TestChimp-Api-Key": apiKey,
59
+ },
60
+ body: "{}",
61
+ });
62
+ const text = await res.text();
63
+ if (!res.ok) {
64
+ throw new Error(`github_write_token failed HTTP ${res.status}: ${text}`);
65
+ }
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(text);
69
+ }
70
+ catch {
71
+ throw new Error("github_write_token returned non-JSON");
72
+ }
73
+ const token = (parsed.token || "").trim();
74
+ if (!token) {
75
+ throw new Error("github_write_token returned empty token");
76
+ }
77
+ const repositoryFullName = (parsed.repositoryFullName ||
78
+ parsed.repository_full_name ||
79
+ "").trim();
80
+ const expiresAtMillis = Number(parsed.expiresAtMillis ?? parsed.expires_at_millis) ||
81
+ Date.now() + 60 * 60 * 1000;
82
+ return { token, repositoryFullName, expiresAtMillis };
83
+ }
84
+ /**
85
+ * Mint a ~1h GitHub App installation token and apply it for subsequent git/gh
86
+ * in this process (and this GHA job when GITHUB_ENV is set).
87
+ *
88
+ * Does **not** print the token. Applies via:
89
+ * - process.env GH_TOKEN / GITHUB_TOKEN
90
+ * - $GITHUB_ENV when present (GitHub Actions)
91
+ * - origin remote URL rewrite (https github.com) when possible
92
+ * - `gh auth login --with-token` + `gh auth setup-git` when `gh` exists
93
+ */
94
+ export async function refreshGitAuth() {
95
+ const { token, repositoryFullName, expiresAtMillis } = await mintGithubWriteToken();
96
+ process.env.GH_TOKEN = token;
97
+ process.env.GITHUB_TOKEN = token;
98
+ const githubEnvPath = process.env.GITHUB_ENV?.trim();
99
+ if (githubEnvPath) {
100
+ try {
101
+ appendFileSync(githubEnvPath, `GH_TOKEN=${token}\nGITHUB_TOKEN=${token}\n`, "utf8");
102
+ }
103
+ catch (e) {
104
+ console.error(`ChimpHands refresh-git-auth: could not write GITHUB_ENV: ${e instanceof Error ? e.message : String(e)}`);
105
+ }
106
+ }
107
+ const remote = await run("git", ["remote", "get-url", "origin"]);
108
+ if (remote.code === 0) {
109
+ const current = remote.out.trim();
110
+ const next = injectTokenIntoGithubHttpsUrl(current, token);
111
+ if (next) {
112
+ const set = await run("git", ["remote", "set-url", "origin", next]);
113
+ if (set.code !== 0) {
114
+ console.error(`ChimpHands refresh-git-auth: git remote set-url failed: ${set.err || set.out}`);
115
+ }
116
+ }
117
+ }
118
+ const ghLogin = await pipeTokenToGh(token);
119
+ if (ghLogin.code !== 0) {
120
+ // setup-git alone still helps when login is already present / unavailable
121
+ console.error(`ChimpHands refresh-git-auth: gh auth login skipped/failed: ${ghLogin.err || "non-zero"}`);
122
+ }
123
+ const setup = await run("gh", ["auth", "setup-git"], {
124
+ GH_TOKEN: token,
125
+ GITHUB_TOKEN: token,
126
+ });
127
+ if (setup.code !== 0) {
128
+ console.error(`ChimpHands refresh-git-auth: gh auth setup-git: ${setup.err || setup.out}`);
129
+ }
130
+ console.error(`ChimpHands refresh-git-auth: reminted GitHub App installation token` +
131
+ (repositoryFullName ? ` for ${repositoryFullName}` : "") +
132
+ ` (expires ~${new Date(expiresAtMillis).toISOString()})`);
133
+ return { ok: true, repositoryFullName, expiresAtMillis };
134
+ }
135
+ /** True when stderr/stdout looks like expired / rejected GitHub credentials. */
136
+ export function looksLikeGitAuthFailure(text) {
137
+ const t = text.toLowerCase();
138
+ return (/authentication failed|could not read username|invalid username or password|bad credentials|401\b|403\b|permission.*denied|remote:.*denied|write access not granted|repository not found/.test(t) || /gh:\s*to get started|gh auth login|HTTP\s*401|HTTP\s*403/.test(t));
139
+ }
@@ -75,6 +75,7 @@ const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. Y
75
75
  - After the branch is on the remote (and after opening a PR), IMMEDIATELY run:
76
76
  \`testchimp chimphands report-branch --branch <name> [--pr-url <url>]\`
77
77
  - Tell the user which branch you are on and include the PR URL when available.
78
+ - **GitHub auth expiry (self-fix):** Job-start App installation tokens expire after ~1 hour. On \`git push\` / \`gh\` auth failures (401/403 / Authentication failed / write access not granted), run \`testchimp chimphands refresh-git-auth\` then retry — **never** ask the user to paste or reconnect a GitHub token in chat. See skill \`references/chimphands-faq.md\`.
78
79
 
79
80
  ## TestChimp workflows (/testchimp …)
80
81
  - Load and follow the \`testchimp\` skill under \`.agents/skills/testchimp/SKILL.md\`.
@@ -124,17 +125,26 @@ function apiHeaders(apiKey) {
124
125
  "TestChimp-Api-Key": apiKey,
125
126
  };
126
127
  }
128
+ const POST_JSON_TIMEOUT_MS = 30_000;
127
129
  async function postJson(backend, apiKey, path, body) {
128
- const res = await fetch(`${backend}${path}`, {
129
- method: "POST",
130
- headers: apiHeaders(apiKey),
131
- body: JSON.stringify(body ?? {}),
132
- });
133
- const text = await res.text();
134
- if (!res.ok) {
135
- throw new Error(`ChimpHands API ${res.status} ${path}: ${text}`);
130
+ const ac = new AbortController();
131
+ const timer = setTimeout(() => ac.abort(), POST_JSON_TIMEOUT_MS);
132
+ try {
133
+ const res = await fetch(`${backend}${path}`, {
134
+ method: "POST",
135
+ headers: apiHeaders(apiKey),
136
+ body: JSON.stringify(body ?? {}),
137
+ signal: ac.signal,
138
+ });
139
+ const text = await res.text();
140
+ if (!res.ok) {
141
+ throw new Error(`ChimpHands API ${res.status} ${path}: ${text}`);
142
+ }
143
+ return text;
144
+ }
145
+ finally {
146
+ clearTimeout(timer);
136
147
  }
137
- return text;
138
148
  }
139
149
  /** Serializes agent event posts so streaming chunks fan out in order. */
140
150
  class AgentEventPoster {
@@ -319,12 +329,29 @@ function mapToolExecutionStatus(status) {
319
329
  return TOOL_STATUS_ERROR;
320
330
  return undefined;
321
331
  }
332
+ /** First observed start time per OpenCode tool part — progress updates must not reset the UI countdown. */
333
+ const toolStartedAtByPartId = new Map();
322
334
  function toolExecutionFromOpencodePart(part) {
323
335
  const status = mapToolExecutionStatus(part.state?.status);
324
336
  if (!status)
325
337
  return undefined;
326
- const startedAtMillis = normalizeEpochMillis(part.state?.time?.start) ??
327
- (status === TOOL_STATUS_RUNNING ? Date.now() : undefined);
338
+ const partKey = String(part.id || part.messageID || "").trim();
339
+ const fromOpencode = normalizeEpochMillis(part.state?.time?.start);
340
+ const cached = partKey ? toolStartedAtByPartId.get(partKey) : undefined;
341
+ const isActive = status === TOOL_STATUS_PENDING || status === TOOL_STATUS_RUNNING;
342
+ let startedAtMillis = cached ?? fromOpencode;
343
+ if (startedAtMillis == null && isActive) {
344
+ startedAtMillis = Date.now();
345
+ }
346
+ if (partKey) {
347
+ if (isActive && startedAtMillis != null) {
348
+ toolStartedAtByPartId.set(partKey, startedAtMillis);
349
+ }
350
+ else if (status === TOOL_STATUS_COMPLETED ||
351
+ status === TOOL_STATUS_ERROR) {
352
+ toolStartedAtByPartId.delete(partKey);
353
+ }
354
+ }
328
355
  const endedAtMillis = normalizeEpochMillis(part.state?.time?.end);
329
356
  const toolName = String(part.state?.title || part.tool || "").trim() || undefined;
330
357
  const timeoutMillis = coerceTimeoutMillis(part.state?.input);
@@ -926,8 +953,8 @@ function detectWorkingBranchFromToolOutput(output) {
926
953
  const prMatch = text.match(/https:\/\/github\.com\/[^\s)\]]+\/pull\/\d+/);
927
954
  // Only auto-detect after a successful push to origin — local checkout -b alone would
928
955
  // report a branch URL that 404s until the remote ref exists.
929
- const pushMatch = text.match(/push\s+(?:--set-upstream\s+|-u\s+)?origin\s+((?:testchimp-|chimphands-)[^\s'"]+)/i);
930
- const branch = pushMatch?.[1]?.replace(/[`'"]/g, "");
956
+ const pushMatch = text.match(/push\s+(?:--set-upstream\s+|-u\s+)?origin\s+((?:testchimp-|chimphands-)[^\s'"\[\]]+)/i);
957
+ const branch = pushMatch?.[1]?.replace(/[`'"\[\]]/g, "");
931
958
  return {
932
959
  branch,
933
960
  pullRequestUrl: prMatch?.[0],
@@ -1449,6 +1476,7 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
1449
1476
  let stopped = false;
1450
1477
  let reconnectTimer = null;
1451
1478
  let reconnectDelayMs = 1000;
1479
+ let activeReq = null;
1452
1480
  const scheduleReconnect = () => {
1453
1481
  if (stopped || !handlers.shouldRun())
1454
1482
  return;
@@ -1463,6 +1491,15 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
1463
1491
  const connect = () => {
1464
1492
  if (stopped || !handlers.shouldRun())
1465
1493
  return;
1494
+ if (activeReq) {
1495
+ try {
1496
+ activeReq.destroy();
1497
+ }
1498
+ catch {
1499
+ /* ignore */
1500
+ }
1501
+ activeReq = null;
1502
+ }
1466
1503
  const req = lib.request({
1467
1504
  hostname: url.hostname,
1468
1505
  port: url.port || (url.protocol === "https:" ? 443 : 80),
@@ -1541,16 +1578,36 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
1541
1578
  }
1542
1579
  }
1543
1580
  });
1544
- res.on("end", () => scheduleReconnect());
1581
+ res.on("end", () => {
1582
+ if (activeReq === req)
1583
+ activeReq = null;
1584
+ scheduleReconnect();
1585
+ });
1586
+ });
1587
+ activeReq = req;
1588
+ req.on("error", () => {
1589
+ if (activeReq === req)
1590
+ activeReq = null;
1591
+ scheduleReconnect();
1545
1592
  });
1546
- req.on("error", () => scheduleReconnect());
1547
1593
  req.end();
1548
1594
  };
1549
1595
  connect();
1550
1596
  return () => {
1551
1597
  stopped = true;
1552
- if (reconnectTimer)
1598
+ if (reconnectTimer) {
1553
1599
  clearTimeout(reconnectTimer);
1600
+ reconnectTimer = null;
1601
+ }
1602
+ if (activeReq) {
1603
+ try {
1604
+ activeReq.destroy();
1605
+ }
1606
+ catch {
1607
+ /* ignore */
1608
+ }
1609
+ activeReq = null;
1610
+ }
1554
1611
  };
1555
1612
  }
1556
1613
  export async function runChimphands(opts) {
@@ -1718,6 +1775,13 @@ export async function runChimphands(opts) {
1718
1775
  let cancelTurnRequested = false;
1719
1776
  let activeOpencodeChild = null;
1720
1777
  let agentTurnInProgress = false;
1778
+ /** Wakes waitForNextPrompt when idle is signaled (SSE) or a user message arrives. */
1779
+ let wakeWaitForPrompt = null;
1780
+ const wakePromptWaiter = () => {
1781
+ const wake = wakeWaitForPrompt;
1782
+ wakeWaitForPrompt = null;
1783
+ wake?.();
1784
+ };
1721
1785
  const turnControl = {
1722
1786
  getCancelRequested: () => cancelTurnRequested,
1723
1787
  onActiveChild: (child) => {
@@ -1737,6 +1801,7 @@ export async function runChimphands(opts) {
1737
1801
  queue.push(content);
1738
1802
  lastUserActivity = Date.now();
1739
1803
  idle = false;
1804
+ wakePromptWaiter();
1740
1805
  const preview = content.length > 120 ? `${content.slice(0, 117)}...` : content;
1741
1806
  console.error(`ChimpHands queued user message (turnActive=${agentTurnInProgress}, depth=${queue.length}): ${JSON.stringify(preview)}`);
1742
1807
  };
@@ -1781,15 +1846,18 @@ export async function runChimphands(opts) {
1781
1846
  }
1782
1847
  postEvent(role, content, opts);
1783
1848
  };
1784
- const complete = (status, errorMessage) => {
1849
+ const complete = async (status, errorMessage) => {
1785
1850
  const body = { sessionId, status };
1786
1851
  if (errorMessage)
1787
1852
  body.errorMessage = String(errorMessage).slice(0, 4000);
1788
1853
  if (githubRunId)
1789
1854
  body.githubRunId = githubRunId;
1790
- void postJson(backend, apiKey, "/api/chimphands/complete_session", body).catch((err) => {
1855
+ try {
1856
+ await postJson(backend, apiKey, "/api/chimphands/complete_session", body);
1857
+ }
1858
+ catch (err) {
1791
1859
  console.error(`ChimpHands complete_session failed: ${err instanceof Error ? err.message : String(err)}`);
1792
- });
1860
+ }
1793
1861
  };
1794
1862
  const childEnv = {
1795
1863
  ...process.env,
@@ -1829,6 +1897,7 @@ export async function runChimphands(opts) {
1829
1897
  onUserMessage: enqueueUserMessage,
1830
1898
  onIdle: () => {
1831
1899
  idle = true;
1900
+ wakePromptWaiter();
1832
1901
  },
1833
1902
  onCancelTurn: handleCancelTurn,
1834
1903
  onUserFileEdit: handleUserFileEdit,
@@ -1856,6 +1925,7 @@ export async function runChimphands(opts) {
1856
1925
  stopLiveSse();
1857
1926
  stopLiveSse = null;
1858
1927
  }
1928
+ wakePromptWaiter();
1859
1929
  await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
1860
1930
  await poster.flush();
1861
1931
  if (runtimeId) {
@@ -1887,30 +1957,48 @@ export async function runChimphands(opts) {
1887
1957
  }
1888
1958
  const waitForNextPrompt = () => new Promise((resolve) => {
1889
1959
  let lastPollAt = 0;
1960
+ let settled = false;
1961
+ const finish = (value) => {
1962
+ if (settled)
1963
+ return;
1964
+ settled = true;
1965
+ wakeWaitForPrompt = null;
1966
+ resolve(value);
1967
+ };
1968
+ wakeWaitForPrompt = () => {
1969
+ if (settled)
1970
+ return;
1971
+ if (queue.length) {
1972
+ finish(normalizeUserMessage(queue.shift()));
1973
+ return;
1974
+ }
1975
+ if (idle || Date.now() - lastUserActivity >= idleMs) {
1976
+ finish(null);
1977
+ }
1978
+ };
1890
1979
  const tick = () => {
1980
+ if (settled)
1981
+ return;
1891
1982
  if (queue.length) {
1892
- resolve(normalizeUserMessage(queue.shift()));
1983
+ finish(normalizeUserMessage(queue.shift()));
1893
1984
  return;
1894
1985
  }
1895
1986
  const now = Date.now();
1987
+ if (idle || now - lastUserActivity >= idleMs) {
1988
+ finish(null);
1989
+ return;
1990
+ }
1991
+ // Never gate the idle clock on consume_pending — a hung poll used to
1992
+ // block waitForNextPrompt forever and keep the Actions job alive.
1896
1993
  if (now - lastPollAt >= 1500) {
1897
1994
  lastPollAt = now;
1898
1995
  void pollPendingUserMessages().then(() => {
1899
- if (queue.length) {
1900
- resolve(normalizeUserMessage(queue.shift()));
1901
- return;
1902
- }
1903
- if (idle || now - lastUserActivity >= idleMs) {
1904
- resolve(null);
1996
+ if (settled)
1905
1997
  return;
1998
+ if (queue.length) {
1999
+ finish(normalizeUserMessage(queue.shift()));
1906
2000
  }
1907
- setTimeout(tick, 500);
1908
2001
  });
1909
- return;
1910
- }
1911
- if (idle || now - lastUserActivity >= idleMs) {
1912
- resolve(null);
1913
- return;
1914
2002
  }
1915
2003
  setTimeout(tick, 500);
1916
2004
  };
@@ -2005,7 +2093,7 @@ export async function runChimphands(opts) {
2005
2093
  const detail = reportErr instanceof Error ? reportErr.message : String(reportErr);
2006
2094
  console.error(`ChimpHands failed to report OpenCode error to backend: ${detail}`);
2007
2095
  postEvent(ROLE_STATUS, errMsg, { status: STATUS_FAILED });
2008
- complete(STATUS_FAILED, errMsg);
2096
+ await complete(STATUS_FAILED, errMsg);
2009
2097
  }
2010
2098
  exitCode = result.code || 1;
2011
2099
  break;
@@ -2016,21 +2104,22 @@ export async function runChimphands(opts) {
2016
2104
  else {
2017
2105
  postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
2018
2106
  }
2107
+ await poster.flush();
2019
2108
  lastUserActivity = Date.now();
2020
2109
  idle = false;
2021
2110
  prompt = (await waitForNextPrompt()) || "";
2022
2111
  }
2023
2112
  if (exitCode == null) {
2024
2113
  console.error("ChimpHands session idle — no user input before timeout; completing.");
2025
- complete(STATUS_IDLE);
2114
+ await complete(STATUS_IDLE);
2026
2115
  }
2027
2116
  }
2028
2117
  finally {
2029
2118
  await shutdownRuntime();
2030
2119
  }
2031
- if (exitCode != null) {
2032
- process.exit(exitCode);
2033
- }
2120
+ // Always exit: tunnel WS / inbound SSE / heartbeat timers otherwise keep the
2121
+ // Actions step alive after a successful idle teardown.
2122
+ process.exit(exitCode ?? 0);
2034
2123
  }
2035
2124
  function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
2036
2125
  let stopped = false;
@@ -2120,10 +2209,29 @@ async function commitAndPushDirtyWorktree(message) {
2120
2209
  console.error(`ChimpHands git commit: ${commit.err || commit.out}`);
2121
2210
  return;
2122
2211
  }
2123
- const push = await run(["push", "-u", "origin", "HEAD"]);
2212
+ let push = await run(["push", "-u", "origin", "HEAD"]);
2124
2213
  if (push.code !== 0) {
2214
+ const detail = `${push.err || ""}\n${push.out || ""}`;
2125
2215
  console.error(`ChimpHands git push failed: ${push.err || push.out}`);
2126
- return;
2216
+ try {
2217
+ const { looksLikeGitAuthFailure, refreshGitAuth } = await import("./refreshGitAuth.js");
2218
+ if (looksLikeGitAuthFailure(detail)) {
2219
+ console.error("ChimpHands reminting GitHub write token after push auth failure…");
2220
+ await refreshGitAuth();
2221
+ push = await run(["push", "-u", "origin", "HEAD"]);
2222
+ if (push.code !== 0) {
2223
+ console.error(`ChimpHands git push failed after refresh: ${push.err || push.out}`);
2224
+ return;
2225
+ }
2226
+ }
2227
+ else {
2228
+ return;
2229
+ }
2230
+ }
2231
+ catch (refreshErr) {
2232
+ console.error(`ChimpHands refresh-git-auth after push failure: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
2233
+ return;
2234
+ }
2127
2235
  }
2128
2236
  console.error(`ChimpHands committed and pushed dirty worktree on ${current} before shutdown`);
2129
2237
  }
@@ -1584,6 +1584,21 @@ export function buildCliProgram() {
1584
1584
  process.exit(1);
1585
1585
  }
1586
1586
  });
1587
+ chimphands
1588
+ .command("refresh-git-auth")
1589
+ .description("Remint a short-lived GitHub App installation token and apply it for git/gh (never prints the token)")
1590
+ .action(async () => {
1591
+ const { refreshGitAuth } = await import("../chimphands/refreshGitAuth.js");
1592
+ try {
1593
+ const result = await refreshGitAuth();
1594
+ console.log(JSON.stringify(result));
1595
+ }
1596
+ catch (e) {
1597
+ const msg = e instanceof Error ? e.message : String(e);
1598
+ console.error(`[testchimp chimphands refresh-git-auth] ${msg}`);
1599
+ process.exit(1);
1600
+ }
1601
+ });
1587
1602
  chimphands
1588
1603
  .command("run")
1589
1604
  .description("Bootstrap session, configure OpenCode, and run the interactive bridge")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
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",