huddle-cli 0.1.3 → 0.1.5

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.
@@ -8,10 +8,16 @@
8
8
  // Local only: it listens on 127.0.0.1, every API call needs the token from
9
9
  // the URL it prints, and a Host other than localhost is refused (so a web
10
10
  // page you visit can't reach it through DNS rebinding).
11
+ //
12
+ // `--phone` opens it to your Wi-Fi too, to run things from your phone: it
13
+ // also listens on this machine's network addresses, and accepts those as the
14
+ // Host (plus the tunnel's name with `--anywhere`). The token is still needed
15
+ // for everything, and it's longer then, since the network can see the door.
11
16
  import { spawn } from "node:child_process";
12
17
  import crypto from "node:crypto";
13
18
  import fs from "node:fs";
14
19
  import http from "node:http";
20
+ import os from "node:os";
15
21
  import path from "node:path";
16
22
  import { fileURLToPath } from "node:url";
17
23
  import { ADAPTERS, LEGACY, applySkips, probeAsync } from "../adapters/index.js";
@@ -19,9 +25,32 @@ import { RoomDB } from "../room/db.js";
19
25
  import { splitRuns } from "../ui/story.js";
20
26
  import { buildExport, findRun, renderExport, runRows, EXPORT_FORMATS, EXPORT_LEVELS } from "../ui/export.js";
21
27
  import { packageInfo } from "../preflight.js";
28
+ import { readConfig, githubReady } from "../connectors/hub.js";
29
+ import { runAsync } from "../connectors/github.js";
30
+ import { undoPlan, undoRun } from "../ui/undo.js";
22
31
  import { clearAppInfo, postCommand, readLive, readReport, writeAppInfo } from "./live.js";
23
32
  import { appPage } from "./page.js";
33
+ import { qrLines } from "./qr.js";
34
+ import { C } from "../ui/term.js";
24
35
  const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
36
+ /**
37
+ * This machine's IPv4 addresses on its networks, the likeliest one for a
38
+ * phone first: Wi-Fi and Ethernet before VPNs and virtual machines' bridges
39
+ * (a Mac running a VM has a 192.168.x bridge no phone can reach).
40
+ */
41
+ export function lanAddresses() {
42
+ const out = [];
43
+ for (const [name, list] of Object.entries(os.networkInterfaces())) {
44
+ const rank = /^(en|eth|wl|wlan|wi-?fi|ethernet)/i.test(name) ? 0 : /^(bridge|vmnet|vboxnet|virbr|docker|br-|veth|utun|tun|tap|tailscale|zt|ham|vethernet)/i.test(name) ? 2 : 1;
45
+ for (const a of list ?? []) {
46
+ if ((a.family === "IPv4" || a.family === 4) && !a.internal && !a.address.startsWith("169.254."))
47
+ out.push({ ip: a.address, rank });
48
+ }
49
+ }
50
+ const home = (ip) => /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip);
51
+ out.sort((a, b) => a.rank - b.rank || Number(home(b.ip)) - Number(home(a.ip)));
52
+ return [...new Set(out.map((a) => a.ip))];
53
+ }
25
54
  const json = (res, status, body) => {
26
55
  res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
27
56
  res.end(JSON.stringify(body));
@@ -71,7 +100,12 @@ export async function startApp(opts) {
71
100
  const dbFile = path.join(root, ".huddle", "huddle.db");
72
101
  fs.mkdirSync(path.dirname(dbFile), { recursive: true });
73
102
  const db = new RoomDB(dbFile);
74
- const token = crypto.randomBytes(18).toString("base64url");
103
+ // On the network, a longer key: 192 bits, still short enough for a small QR code.
104
+ const token = crypto.randomBytes(opts.phone ? 24 : 18).toString("base64url");
105
+ const tokenBuf = Buffer.from(token);
106
+ const keyOk = (given) => typeof given === "string" && given.length === token.length && crypto.timingSafeEqual(Buffer.from(given), tokenBuf);
107
+ const addresses = opts.addresses ?? lanAddresses;
108
+ const extraHosts = new Set();
75
109
  const startRun = opts.startRun ?? ((goal) => spawnRun(root, goal));
76
110
  let agents = opts.agents ?? null;
77
111
  let probing = null;
@@ -107,8 +141,51 @@ export async function startApp(opts) {
107
141
  probing: !agents,
108
142
  live,
109
143
  runs: runRows(runs).reverse(),
144
+ // Changes when a run is undone (from here or `huddle undo`), so its view refetches.
145
+ undone: runs.reduce((n, r) => n + r.events.filter((e) => e.type === "git.undone").length, 0),
146
+ connectors: connectorsNow(),
147
+ };
148
+ };
149
+ /** What's configured, from ~/.huddle/connectors.json: shown, not started. */
150
+ const connectorsNow = () => {
151
+ const cfg = readConfig();
152
+ return {
153
+ github: cfg.github?.off ? "off" : cfg.github?.write ? "write" : "read",
154
+ others: Object.entries(cfg.connectors ?? {})
155
+ .filter(([, c]) => !c?.off)
156
+ .map(([name, c]) => ({ name, write: !!c?.write })),
110
157
  };
111
158
  };
159
+ // Open issues for the start screen. gh is slow (a network call), so the
160
+ // answer is kept a minute, and every page asking at once shares one call.
161
+ let github = null;
162
+ const githubView = () => {
163
+ if (github && Date.now() - github.at < 60_000)
164
+ return github.view;
165
+ const view = (async () => {
166
+ if (readConfig().github?.off)
167
+ return { ready: false, note: "GitHub is off (huddle connectors github on)", issues: [] };
168
+ const g = await githubReady(root);
169
+ if (!g.ok)
170
+ return { ready: false, note: g.note, issues: [] };
171
+ const r = await runAsync(["gh", "issue", "list", "--state", "open", "--limit", "20", "--json", "number,title"], { cwd: root, timeoutMs: 20_000 });
172
+ if (r.code !== 0)
173
+ return { ready: false, note: (r.stderr || r.stdout).trim().split("\n")[0].slice(0, 160) || "gh failed", issues: [] };
174
+ try {
175
+ const list = JSON.parse(r.stdout);
176
+ const issues = list
177
+ .map((i) => ({ number: Number(i.number), title: String(i.title ?? "").slice(0, 200) }))
178
+ .filter((i) => Number.isInteger(i.number) && i.number > 0)
179
+ .slice(0, 20);
180
+ return { ready: true, note: g.note, issues };
181
+ }
182
+ catch {
183
+ return { ready: false, note: "couldn't read gh's answer", issues: [] };
184
+ }
185
+ })();
186
+ github = { at: Date.now(), view };
187
+ return view;
188
+ };
112
189
  const runDoc = (which) => {
113
190
  const runs = runsNow();
114
191
  const run = findRun(runs, which ?? undefined);
@@ -117,9 +194,18 @@ export async function startApp(opts) {
117
194
  const doc = buildExport(run, runs.length, { level: "chat", logsDir: path.join(root, ".huddle", "logs"), repoRoot: root });
118
195
  const made = run.events.find((e) => e.type === "plan.made");
119
196
  const plain = made?.p?.plain && typeof made.p.plain === "object" ? made.p.plain : null;
120
- return { ...doc, plain, report: readReport(root, run.id) };
197
+ // Whether this run can be taken back, and what that would revert (ui/undo.ts).
198
+ const undo = undoPlan(root, db.allEvents(), String(run.index));
199
+ return { ...doc, plain, report: readReport(root, run.id), undo: { ok: undo.ok, why: undo.why ?? "", commits: undo.commits ?? [], end: undo.end ?? "" } };
200
+ };
201
+ const allowedHost = (h, port) => {
202
+ if (h === `127.0.0.1:${port}` || h === `localhost:${port}`)
203
+ return true;
204
+ if (!opts.phone || !h)
205
+ return false;
206
+ // Checked on every request: the Wi-Fi can change while the dashboard is open.
207
+ return extraHosts.has(h.toLowerCase()) || addresses().some((ip) => h === `${ip}:${port}`);
121
208
  };
122
- const allowedHost = (h, port) => h === `127.0.0.1:${port}` || h === `localhost:${port}`;
123
209
  const server = http.createServer(async (req, res) => {
124
210
  const url = new URL(req.url ?? "/", "http://local");
125
211
  const port = server.address().port;
@@ -127,7 +213,7 @@ export async function startApp(opts) {
127
213
  return json(res, 403, { error: "local only" });
128
214
  try {
129
215
  if (req.method === "GET" && url.pathname === "/") {
130
- if (url.searchParams.get("t") !== token) {
216
+ if (!keyOk(url.searchParams.get("t"))) {
131
217
  res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
132
218
  return res.end("Open the link `huddle app` printed (it carries a key for this session).");
133
219
  }
@@ -138,7 +224,7 @@ export async function startApp(opts) {
138
224
  return json(res, 404, { error: "not found" });
139
225
  // The token: a header for fetches, ?t= for the event stream and downloads.
140
226
  const given = req.headers["x-huddle-token"] ?? url.searchParams.get("t");
141
- if (given !== token)
227
+ if (!keyOk(given))
142
228
  return json(res, 401, { error: "no key" });
143
229
  const route = `${req.method} ${url.pathname}`;
144
230
  if (route === "GET /api/state")
@@ -177,6 +263,22 @@ export async function startApp(opts) {
177
263
  req.on("close", () => clearInterval(t));
178
264
  return;
179
265
  }
266
+ if (route === "GET /api/github")
267
+ return json(res, 200, await githubView());
268
+ if (route === "POST /api/undo") {
269
+ const body = await readBody(req);
270
+ // Never while a run is going: it's merging onto the branch this would revert on.
271
+ if (readLive(root))
272
+ return json(res, 409, { error: "a run is going — undo once it's finished" });
273
+ const plan = undoPlan(root, db.allEvents(), String(body.n ?? ""));
274
+ if (!plan.ok)
275
+ return json(res, 409, { error: plan.why });
276
+ // The commits you were shown are the ones reverted, or nothing is.
277
+ if (body.end && body.end !== plan.end)
278
+ return json(res, 409, { error: "this run changed since you looked — look again" });
279
+ const done = undoRun(root, db, plan);
280
+ return done.ok ? json(res, 200, done) : json(res, 409, { error: done.why });
281
+ }
180
282
  if (route === "POST /api/agents") {
181
283
  agents = null;
182
284
  await probe();
@@ -214,7 +316,7 @@ export async function startApp(opts) {
214
316
  });
215
317
  const port = await new Promise((resolve, reject) => {
216
318
  server.once("error", reject);
217
- server.listen(opts.port ?? 0, "127.0.0.1", () => resolve(server.address().port));
319
+ server.listen(opts.port ?? 0, opts.phone ? "0.0.0.0" : "127.0.0.1", () => resolve(server.address().port));
218
320
  });
219
321
  const url = `http://127.0.0.1:${port}/?t=${token}`;
220
322
  writeAppInfo(root, { pid: process.pid, url });
@@ -224,11 +326,26 @@ export async function startApp(opts) {
224
326
  clearAppInfo(root);
225
327
  db.close();
226
328
  };
227
- return { url, port, token, close };
329
+ const lanUrls = () => (opts.phone ? addresses().map((ip) => `http://${ip}:${port}/?t=${token}`) : []);
330
+ const allowHost = (h) => void extraHosts.add(h.toLowerCase());
331
+ return { url, port, token, lanUrls, allowHost, close };
228
332
  }
229
333
  /** `huddle app [--port n] [--no-open]` */
230
334
  export async function runApp(args) {
231
- const { C } = await import("../ui/term.js");
335
+ // Found live (0.1.5): `huddle app --help` opened a dashboard. A flag it doesn't know prints this instead.
336
+ const known = new Set(["--port", "--no-open", "--phone", "--anywhere"]);
337
+ const odd = args.find((a, k) => a.startsWith("-") && !known.has(a) && args[k - 1] !== "--port");
338
+ if (odd) {
339
+ console.log(`${C.bold("huddle app")} the dashboard in your browser, for the repo you're in
340
+ --phone open it on your phone too (same Wi-Fi): scan the QR code
341
+ --anywhere on your phone from any network (needs cloudflared)
342
+ --no-open don't open the browser --port <n> pick the port`);
343
+ if (odd !== "--help" && odd !== "-h") {
344
+ console.log(C.red(`✗ unknown: ${odd}`));
345
+ process.exitCode = 1;
346
+ }
347
+ return;
348
+ }
232
349
  const { isGitRepo } = await import("../sandbox/worktree.js");
233
350
  const { ensureInit, repoRoot } = await import("../ui/app.js");
234
351
  if (!isGitRepo(process.cwd()) || !ensureInit()) {
@@ -238,18 +355,72 @@ export async function runApp(args) {
238
355
  }
239
356
  const i = args.indexOf("--port");
240
357
  const port = i >= 0 ? Number(args[i + 1]) : undefined;
241
- const app = await startApp({ root: repoRoot(), port });
358
+ const anywhere = args.includes("--anywhere");
359
+ const phone = anywhere || args.includes("--phone");
360
+ const app = await startApp({ root: repoRoot(), port, phone });
242
361
  console.log(`${C.green("✓")} Huddle is open at ${C.bold(app.url)}`);
243
362
  console.log(C.faint(" runs keep going if you close the page · ctrl-c closes the dashboard"));
363
+ let tunnel = null;
364
+ if (phone) {
365
+ let link = app.lanUrls()[0] ?? null;
366
+ let how = "on the same Wi-Fi as this computer";
367
+ if (anywhere) {
368
+ const { hasCloudflared, openTunnel } = await import("../room/tunnel.js");
369
+ if (!hasCloudflared()) {
370
+ console.log(` ${C.yellow("!")} --anywhere needs cloudflared (free, no account): ${cloudflaredHint()}`);
371
+ console.log(C.faint(" using your Wi-Fi for now"));
372
+ }
373
+ else {
374
+ const t = await openTunnel(app.port, (l) => console.log(C.faint(l)));
375
+ if (t) {
376
+ tunnel = t;
377
+ app.allowHost(new URL(t.url).host);
378
+ link = `${t.url}/?t=${app.token}`;
379
+ how = "from anywhere";
380
+ }
381
+ }
382
+ }
383
+ console.log("");
384
+ const also = link === app.lanUrls()[0] ? app.lanUrls().slice(1).map((u) => new URL(u).hostname) : [];
385
+ for (const l of phoneLines(link, how, process.stdout.isTTY === true && !process.env.NO_COLOR, also))
386
+ console.log(l);
387
+ }
244
388
  if (!args.includes("--no-open"))
245
389
  openBrowser(app.url);
246
390
  const bye = () => {
391
+ tunnel?.close();
247
392
  app.close();
248
393
  process.exit(0);
249
394
  };
250
395
  process.on("SIGINT", bye);
251
396
  process.on("SIGTERM", bye);
252
397
  }
398
+ /**
399
+ * What `huddle app --phone` prints: the code to scan, the link under it, and
400
+ * what sharing it means. `link` is null when this machine has no network.
401
+ */
402
+ export function phoneLines(link, how, color, also = []) {
403
+ if (!link)
404
+ return [` ${C.yellow("!")} This computer is on no network, so a phone can't reach it. Join Wi-Fi, try again.`];
405
+ return [
406
+ ` ${C.bold("On your phone")} ${C.faint(`(${how})`)}: point the camera at this code`,
407
+ "",
408
+ ...qrLines(link, { color }).map((l) => " " + l),
409
+ "",
410
+ ` or open ${C.bold(link)}`,
411
+ ...(also.length ? [C.faint(` not working? try the same link with ${also.slice(0, 3).join(" or ")} in it`)] : []),
412
+ ` ${C.yellow("!")} Anyone with this link can control this run. Ctrl-C stops sharing.`,
413
+ ...(link.startsWith("http:") ? [C.faint(" Plain http on your Wi-Fi: fine on a network you trust. --anywhere is https.")] : []),
414
+ ];
415
+ }
416
+ /** How to get cloudflared here, in one line. */
417
+ function cloudflaredHint() {
418
+ if (process.platform === "darwin")
419
+ return "brew install cloudflared";
420
+ if (process.platform === "win32")
421
+ return "winget install --id Cloudflare.cloudflared";
422
+ return "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
423
+ }
253
424
  function openBrowser(url) {
254
425
  const [cmd, ...a] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
255
426
  try {
package/dist/args.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // What `huddle <words>` means.
2
- const COMMANDS = new Set("app dashboard plug init setup agents doctor join board view watch story brief runs export lessons up down peers add ctx context attach land connect disconnect guard mail relay version help".split(" "));
2
+ const COMMANDS = new Set("app dashboard plug init setup agents doctor join board view watch story brief runs export lessons up down peers add ctx context attach land connect disconnect guard mail relay version update upgrade connectors connector undo help".split(" "));
3
3
  /**
4
4
  * `huddle build a temperature converter` / `huddle "fix the login bug"`: a goal
5
5
  * in plain words, without `join` or quotes. Found live (0.1.2 test): "as a
@@ -11,7 +11,7 @@ export function asGoal(args) {
11
11
  if (!cmd || cmd.startsWith("-") || COMMANDS.has(cmd))
12
12
  return null;
13
13
  const words = [cmd, ...rest.filter((a) => !a.startsWith("--"))].join(" ").trim().split(/\s+/);
14
- if (cmd === "build" || cmd === "make" || words.length >= 3 || /\s/.test(cmd))
14
+ if (cmd === "build" || cmd === "make" || (cmd === "fix" && rest.length > 0) || words.length >= 3 || /\s/.test(cmd))
15
15
  return ["join", ...(cmd === "build" ? rest : args)];
16
16
  return null;
17
17
  }
package/dist/cli.js CHANGED
@@ -15,7 +15,8 @@
15
15
  // huddle agents your agents: ready / not signed in / not installed, used or skipped
16
16
  // huddle agents use|skip <ids> choose who works with you (remembered)
17
17
  // huddle --version print the version
18
- // huddle agents --all every connector (built in + agents.json) and how to get it
18
+ // huddle update install the newest huddle-cli from npm (--update too)
19
+ // huddle agents --all every agent (built in + agents.json) and how to get it
19
20
  // huddle agents install <id> install one connector's CLI, after asking
20
21
  // huddle board print the board + shared memory
21
22
  // huddle view [repo] the live view (ctrl-t) from another terminal, read-only
@@ -57,7 +58,10 @@ const USAGE = `${C.violet("huddle")} — your agents, in the same room
57
58
  ${B("--budget=5M")} tokens it may use ${F("(stops new work at 90%)")}
58
59
  ${B("--with <file>")} put a file, folder or glob on the shared screen
59
60
  ${F("(repeatable; or pipe: cat log | huddle join …)")}
61
+ ${B("huddle fix issue 12")} GitHub issue 12 is the goal ${F("(also: fix #12)")}
60
62
  ${B("huddle app")} the dashboard: live run, history, talk to agents
63
+ ${B("--phone")} on your phone too: scan the QR code it prints
64
+ ${F("(--anywhere: off your Wi-Fi, needs cloudflared)")}
61
65
  ${B("huddle join <address> <CODE>")} join someone's room from an invite
62
66
 
63
67
  ${F("watch and replay")}
@@ -70,6 +74,7 @@ const USAGE = `${C.violet("huddle")} — your agents, in the same room
70
74
  ${B("huddle export")} ${F("[run]")} a run as a chat you can read or share
71
75
  ${F("(--level summary|chat|full, --out <path>,")}
72
76
  ${F(" --format md|html|json, --no-redact)")}
77
+ ${B("huddle undo")} ${F("[--run n]")} take back what a run merged ${F("(one revert commit)")}
73
78
  ${B("huddle board")} print the board
74
79
  ${B("huddle lessons")} ${F("[--forget id]")} what earlier runs taught your agents ${F("(--clear)")}
75
80
 
@@ -78,10 +83,12 @@ const USAGE = `${C.violet("huddle")} — your agents, in the same room
78
83
  ${B("huddle doctor")} check this machine: node, git, your agents
79
84
  ${B("huddle agents")} your agents: ready or not, used or skipped
80
85
  ${B("huddle agents use|skip <ids>")} choose who works with you ${F("(e.g. skip cursor)")}
81
- ${B("huddle agents --all")} every connector Huddle knows, installed or not
86
+ ${B("huddle agents --all")} every agent Huddle knows, installed or not
82
87
  ${B("huddle agents install <id>")} install one ${F("(asks first)")}
88
+ ${B("huddle connectors")} GitHub, Jira, Linear… for every agent
83
89
  ${B("huddle relay")} ${F("[--port 8080]")} run a relay so rooms work across networks
84
90
  ${B("huddle --version")} print the version
91
+ ${B("huddle update")} get the newest Huddle from npm
85
92
 
86
93
  ${F("run claude, codex and cursor yourself — in one repo, safely")}
87
94
  ${B("huddle connect")} wire your agents into this repo's live room
@@ -241,6 +248,17 @@ async function main() {
241
248
  console.log(`${C.violet("huddle relay")} listening on :${r.port} ${C.faint("· people use it with: relay ws://<this-host>:" + r.port)}`);
242
249
  return;
243
250
  }
251
+ case "undo":
252
+ return void (process.exitCode = await (await import("./ui/undo.js")).runUndo(rest));
253
+ case "connectors":
254
+ case "connector": {
255
+ const { repoRoot } = await app();
256
+ return void (process.exitCode = await (await import("./connectors/cli.js")).runConnectors(rest, repoRoot()));
257
+ }
258
+ case "update":
259
+ case "upgrade":
260
+ case "--update":
261
+ return void process.exit((await import("./update.js")).runUpdate());
244
262
  case "--version":
245
263
  case "-v":
246
264
  case "-V":
@@ -0,0 +1,121 @@
1
+ // `huddle connectors`: which outside tools your agents get, and adding more.
2
+ //
3
+ // huddle connectors what's on, with each one's tool count
4
+ // huddle connectors add atlassian Jira + Confluence (signs in in your browser, once)
5
+ // huddle connectors add <name> <https-url> any remote MCP server
6
+ // huddle connectors add <name> -- <command…> any local MCP server
7
+ // huddle connectors add … --write let agents use the tools that post or change things
8
+ // huddle connectors remove <name>
9
+ // huddle connectors github on|off|write|read
10
+ import { C } from "../ui/term.js";
11
+ import { configFile, PRESETS, presetName, readConfig, remoteCommand, startHub, statusLine, writeConfig } from "./hub.js";
12
+ const USAGE = `${C.bold("huddle connectors")} what your agents can reach, and how many tools each gives
13
+ ${C.bold("huddle connectors add")} ${C.faint("<service>")} a service your team uses:
14
+ ${Object.entries(PRESETS)
15
+ .map(([n, p]) => ` ${n.padEnd(34)}${C.faint(p.what)}`)
16
+ .join("\n")}
17
+ ${C.bold("huddle connectors add")} ${C.faint("<name> <url>")} any remote MCP server (signs in through mcp-remote)
18
+ ${C.bold("huddle connectors add")} ${C.faint("<name> -- <cmd…>")} any local MCP server
19
+ ${C.faint("add --write to let agents use the tools that post or change things")}
20
+ ${C.bold("huddle connectors remove")} ${C.faint("<name>")}
21
+ ${C.bold("huddle connectors github")} ${C.faint("on|off|write|read")}
22
+ ${C.faint("GitHub (through the gh CLI) is on by default, read-only")}`;
23
+ async function check(root, cfg, only) {
24
+ const one = only
25
+ ? only === "github"
26
+ ? { github: cfg.github }
27
+ : { github: { off: true }, connectors: { [only]: cfg.connectors[only] } }
28
+ : cfg;
29
+ if (only && only !== "github")
30
+ console.log(C.faint(` connecting to ${only}… (the first time, it may open your browser to sign in)`));
31
+ const hub = startHub({ root, config: one, connectMs: 180_000 });
32
+ await hub.ready;
33
+ let ok = true;
34
+ for (const s of hub.status()) {
35
+ ok &&= s.ok;
36
+ console.log(` ${s.ok ? C.green("✓") : C.red("✗")} ${statusLine(s)}`);
37
+ }
38
+ if (!hub.status().length)
39
+ console.log(C.faint(" (none on)"));
40
+ hub.close();
41
+ return ok;
42
+ }
43
+ export async function runConnectors(args, root) {
44
+ const [sub, ...rest] = args;
45
+ const cfg = readConfig();
46
+ cfg.connectors ??= {};
47
+ // --write is Huddle's flag only before `--`; after it, everything is the server's command.
48
+ const cut = rest.indexOf("--");
49
+ const own = cut < 0 ? rest : rest.slice(0, cut);
50
+ const write = own.includes("--write");
51
+ const words = [...own.filter((a) => a !== "--write"), ...(cut < 0 ? [] : rest.slice(cut))];
52
+ if (!sub || sub === "list" || sub === "status") {
53
+ console.log(C.bold("Connectors") + C.faint(` · every agent in a run gets these tools · ${configFile()}`));
54
+ await check(root, cfg);
55
+ console.log();
56
+ console.log(USAGE);
57
+ return 0;
58
+ }
59
+ if (sub === "help" || sub === "--help")
60
+ return void console.log(USAGE), 0;
61
+ if (sub === "github") {
62
+ const how = words[0];
63
+ if (!how)
64
+ return (await check(root, cfg, "github")) ? 0 : 1;
65
+ if (!["on", "off", "write", "read"].includes(how)) {
66
+ console.log(C.red("✗ ") + "huddle connectors github on|off|write|read");
67
+ return 1;
68
+ }
69
+ cfg.github = how === "off" ? { ...cfg.github, off: true } : { off: false, write: how === "write" ? true : how === "read" ? false : !!cfg.github?.write };
70
+ writeConfig(cfg);
71
+ console.log(`${C.green("✓")} github ${how === "off" ? "off" : cfg.github.write ? "on — agents may comment and open issues, as you" : "on, read-only"}`);
72
+ return how === "off" ? 0 : (await check(root, cfg, "github")) ? 0 : 1;
73
+ }
74
+ if (sub === "add") {
75
+ const dash = words.indexOf("--");
76
+ const name = presetName(words[0] ?? "");
77
+ if (!/^[a-z][a-z0-9-]{0,30}$/.test(name) || name === "github") {
78
+ console.log(C.red("✗ ") + (name === "github" ? "GitHub is built in: huddle connectors github on" : "give it a short name (letters, digits, -): huddle connectors add jira"));
79
+ return 1;
80
+ }
81
+ let command;
82
+ if (dash >= 0)
83
+ command = words.slice(dash + 1);
84
+ else if (words[1] && /^https?:\/\//.test(words[1]))
85
+ command = remoteCommand(words[1]);
86
+ else if (PRESETS[name])
87
+ command = remoteCommand(PRESETS[name].url);
88
+ else {
89
+ console.log(C.red(`✗ no known service called "${name}".`) + ` Give its MCP URL (huddle connectors add ${name} https://…) or a command (huddle connectors add ${name} -- npx -y some-mcp-server).`);
90
+ return 1;
91
+ }
92
+ if (!command.length) {
93
+ console.log(C.red("✗ ") + "nothing after --");
94
+ return 1;
95
+ }
96
+ cfg.connectors[name] = { command, ...(write ? { write: true } : {}) };
97
+ writeConfig(cfg);
98
+ console.log(`${C.green("✓")} added ${C.bold(name)}${PRESETS[name] ? C.faint(` (${PRESETS[name].what})`) : ""}: ${C.faint(command.join(" "))}`);
99
+ const ok = await check(root, cfg, name);
100
+ if (!ok)
101
+ console.log(C.faint(` it stays saved; fix it and check with: huddle connectors · or: huddle connectors remove ${name}`));
102
+ else if (!write)
103
+ console.log(C.faint(` read-only: agents see the tools that read. To let them post or change things: huddle connectors add --write ${words.join(" ")}`));
104
+ return ok ? 0 : 1;
105
+ }
106
+ if (sub === "remove" || sub === "rm") {
107
+ const name = presetName(words[0] ?? "");
108
+ if (!cfg.connectors[name]) {
109
+ console.log(C.red(`✗ no connector "${name}".`) + ` On: ${Object.keys(cfg.connectors).join(", ") || "(none added)"}`);
110
+ return 1;
111
+ }
112
+ delete cfg.connectors[name];
113
+ writeConfig(cfg);
114
+ console.log(`${C.green("✓")} removed ${name}`);
115
+ return 0;
116
+ }
117
+ console.log(C.red(`✗ unknown: connectors ${sub}`));
118
+ console.log(USAGE);
119
+ return 1;
120
+ }
121
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,162 @@
1
+ // The GitHub connector: issues, pull requests and CI for the repo you're in,
2
+ // through the `gh` CLI you already signed in to. Huddle holds no GitHub token;
3
+ // `gh` does, the same way each agent CLI holds its own login.
4
+ //
5
+ // Read tools are on whenever `gh` is signed in and the repo has a GitHub
6
+ // remote. The two that write (a comment, a new issue) post as you, so they
7
+ // show only after `huddle connectors github write`.
8
+ import { spawnTool } from "../platform.js";
9
+ const MAX_OUT = 60_000;
10
+ /** Runs a command to completion, without blocking the Room's event loop. */
11
+ export function runAsync(cmd, opts) {
12
+ return new Promise((resolve) => {
13
+ let stdout = "";
14
+ let stderr = "";
15
+ let done = false;
16
+ const finish = (r) => {
17
+ if (done)
18
+ return;
19
+ done = true;
20
+ clearTimeout(timer);
21
+ resolve(r);
22
+ };
23
+ let child;
24
+ try {
25
+ child = spawnTool(cmd, { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, GH_PROMPT_DISABLED: "1", NO_COLOR: "1" } });
26
+ }
27
+ catch (e) {
28
+ return resolve({ code: 127, stdout: "", stderr: String(e?.message ?? e) });
29
+ }
30
+ const timer = setTimeout(() => {
31
+ child.kill();
32
+ finish({ code: 124, stdout, stderr: stderr + `\n(timed out after ${Math.round((opts.timeoutMs ?? 30_000) / 1000)}s)` });
33
+ }, opts.timeoutMs ?? 30_000);
34
+ child.stdout.on("data", (d) => (stdout += d));
35
+ child.stderr.on("data", (d) => (stderr += d));
36
+ child.on("error", (e) => finish({ code: 127, stdout, stderr: String(e.message) }));
37
+ child.on("close", (code) => finish({ code: code ?? 1, stdout, stderr }));
38
+ child.stdin.on("error", () => { }); // it may exit before reading it all
39
+ child.stdin.end(opts.input ?? "");
40
+ });
41
+ }
42
+ export const clipOut = (s, n = MAX_OUT) => (s.length > n ? s.slice(0, n) + `\n… (cut at ${n} characters)` : s);
43
+ const num = (v) => {
44
+ const n = Number(String(v ?? "").replace(/^#/, ""));
45
+ if (!Number.isInteger(n) || n <= 0)
46
+ throw new Error(`not an issue or PR number: ${JSON.stringify(v)}`);
47
+ return String(n);
48
+ };
49
+ const limit = (v, def = 20) => String(Math.min(100, Math.max(1, Number(v) || def)));
50
+ const state = (v) => (["open", "closed", "merged", "all"].includes(String(v)) ? String(v) : "open");
51
+ /** One tool's result: gh's JSON (or text) when it worked, its error when not. */
52
+ async function out(r) {
53
+ const x = await r;
54
+ if (x.code !== 0)
55
+ return { error: (x.stderr || x.stdout).trim().slice(0, 2000) || `gh exited ${x.code}` };
56
+ const t = x.stdout.trim();
57
+ try {
58
+ return JSON.parse(t);
59
+ }
60
+ catch {
61
+ return clipOut(t);
62
+ }
63
+ }
64
+ export function githubTools(gh, opts) {
65
+ const tools = [
66
+ {
67
+ name: "github_issues",
68
+ description: "List this repo's GitHub issues (number, title, state, labels, author). Filter with state, label or a search query.",
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {
72
+ state: { type: "string", enum: ["open", "closed", "all"] },
73
+ label: { type: "string" },
74
+ search: { type: "string", description: "GitHub search syntax, e.g. 'login in:title'" },
75
+ limit: { type: "number" },
76
+ },
77
+ },
78
+ call: (a) => out(gh([
79
+ "issue", "list", "--state", state(a.state), "--limit", limit(a.limit),
80
+ ...(a.label ? ["--label", String(a.label)] : []),
81
+ ...(a.search ? ["--search", String(a.search)] : []),
82
+ "--json", "number,title,state,labels,author,updatedAt",
83
+ ])),
84
+ },
85
+ {
86
+ name: "github_issue",
87
+ description: "Read one GitHub issue in full: its body, labels and every comment.",
88
+ inputSchema: { type: "object", properties: { number: { type: "number" } }, required: ["number"] },
89
+ call: (a) => out(gh(["issue", "view", num(a.number), "--json", "number,title,body,state,labels,author,comments,url"])),
90
+ },
91
+ {
92
+ name: "github_prs",
93
+ description: "List this repo's pull requests (number, title, branch, author, state).",
94
+ inputSchema: { type: "object", properties: { state: { type: "string", enum: ["open", "closed", "merged", "all"] }, limit: { type: "number" } } },
95
+ call: (a) => out(gh(["pr", "list", "--state", state(a.state), "--limit", limit(a.limit), "--json", "number,title,state,headRefName,author,url"])),
96
+ },
97
+ {
98
+ name: "github_pr",
99
+ description: "Read one pull request: description, changed files, comments and reviews. Pass diff: true for the diff too (cut at 60k characters).",
100
+ inputSchema: { type: "object", properties: { number: { type: "number" }, diff: { type: "boolean" } }, required: ["number"] },
101
+ call: async (a) => {
102
+ const n = num(a.number);
103
+ const view = await out(gh(["pr", "view", n, "--json", "number,title,body,state,headRefName,baseRefName,files,comments,reviews,url"]));
104
+ if (!a.diff)
105
+ return view;
106
+ const d = await gh(["pr", "diff", n]);
107
+ return { pr: view, diff: d.code === 0 ? clipOut(d.stdout) : `(no diff: ${d.stderr.trim().slice(0, 300)})` };
108
+ },
109
+ },
110
+ {
111
+ name: "github_ci",
112
+ description: "The latest GitHub Actions runs (workflow, branch, status, conclusion), so you can see whether CI is green or what broke.",
113
+ inputSchema: { type: "object", properties: { branch: { type: "string" }, limit: { type: "number" } } },
114
+ call: (a) => out(gh(["run", "list", "--limit", limit(a.limit, 8), ...(a.branch ? ["--branch", String(a.branch)] : []), "--json", "databaseId,workflowName,headBranch,status,conclusion,createdAt,url"])),
115
+ },
116
+ ];
117
+ const writes = !opts.write ? [] : [
118
+ {
119
+ name: "github_comment",
120
+ description: "Post a comment on a GitHub issue or pull request, as the person running Huddle. Use it for what they'd want posted (a fix summary, a question), not chatter.",
121
+ inputSchema: { type: "object", properties: { number: { type: "number" }, body: { type: "string" } }, required: ["number", "body"] },
122
+ write: true,
123
+ call: (a, agent) => out(gh(["issue", "comment", num(a.number), "--body-file", "-"], `${String(a.body ?? "").trim()}\n\n${opts.signature(agent)}`)),
124
+ },
125
+ {
126
+ name: "github_new_issue",
127
+ description: "Open a new GitHub issue, as the person running Huddle: for a real bug or follow-up found while working that this run won't fix.",
128
+ inputSchema: { type: "object", properties: { title: { type: "string" }, body: { type: "string" } }, required: ["title", "body"] },
129
+ write: true,
130
+ call: (a, agent) => out(gh(["issue", "create", "--title", String(a.title ?? "").slice(0, 250), "--body-file", "-"], `${String(a.body ?? "").trim()}\n\n${opts.signature(agent)}`)),
131
+ },
132
+ ];
133
+ // A bad argument rejects (never throws mid-call), like every other tool.
134
+ return [...tools, ...writes].map((t) => ({ ...t, call: async (a, agent) => t.call(a, agent) }));
135
+ }
136
+ /** "#12", "issue 12", "fix #12", "github issue #12": the issue number, else null. */
137
+ export function issueRef(goal) {
138
+ const m = /^\s*(?:(?:fix|close|solve|do|work on|build)\s+)?(?:(?:github|gh)\s+)?(?:issue\s*)?#(\d+)\s*$/i.exec(goal) ?? /^\s*(?:(?:fix|close|solve)\s+)?(?:github\s+)?issue\s+(\d+)\s*$/i.exec(goal);
139
+ return m ? Number(m[1]) : null;
140
+ }
141
+ /** The goal a run gets for a GitHub issue: its title, body and comments, verbatim. */
142
+ export function issueGoal(issue) {
143
+ const comments = (issue.comments ?? [])
144
+ .slice(-8)
145
+ .map((c) => `- ${c.author?.login ?? "someone"}: ${String(c.body ?? "").trim().slice(0, 800)}`)
146
+ .join("\n");
147
+ return [
148
+ `Resolve GitHub issue #${issue.number}: ${issue.title}`,
149
+ issue.url ? `(${issue.url})` : "",
150
+ "",
151
+ String(issue.body ?? "").trim().slice(0, 6000) || "(the issue has no description)",
152
+ comments ? `\nComments on the issue:\n${comments}` : "",
153
+ ]
154
+ .filter((l, i) => l || i === 2)
155
+ .join("\n")
156
+ .trim();
157
+ }
158
+ /** Issues a longer goal mentions ("fix #12 and add a test"): up to 3 numbers. */
159
+ export function issueMentions(goal) {
160
+ return [...new Set([...goal.matchAll(/(?:^|[\s(])#(\d{1,6})\b/g)].map((m) => Number(m[1])))].slice(0, 3);
161
+ }
162
+ //# sourceMappingURL=github.js.map