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.
- package/CHANGELOG.md +28 -0
- package/QUICKSTART.md +29 -1
- package/README.md +12 -0
- package/dist/app/page.js +86 -12
- package/dist/app/qr.js +47 -0
- package/dist/app/server.js +180 -9
- package/dist/args.js +2 -2
- package/dist/cli.js +20 -2
- package/dist/connectors/cli.js +121 -0
- package/dist/connectors/github.js +162 -0
- package/dist/connectors/hub.js +234 -0
- package/dist/connectors/types.js +3 -0
- package/dist/mcp/context-tools.js +2 -0
- package/dist/mcp/server.js +23 -3
- package/dist/orchestrator/council.js +1 -1
- package/dist/orchestrator/planner.js +1 -1
- package/dist/orchestrator/scheduler.js +23 -3
- package/dist/room/server.js +29 -0
- package/dist/ui/app.js +61 -2
- package/dist/ui/catalog.js +1 -1
- package/dist/ui/undo.js +131 -0
- package/dist/update.js +84 -0
- package/package.json +3 -1
package/dist/app/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|