huddle-cli 0.0.0-stage → 0.1.1

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/QUICKSTART.md +102 -0
  4. package/README.md +376 -2
  5. package/dist/adapters/amp.js +40 -0
  6. package/dist/adapters/antigravity.js +54 -0
  7. package/dist/adapters/claude.js +86 -0
  8. package/dist/adapters/codex.js +64 -0
  9. package/dist/adapters/copilot.js +69 -0
  10. package/dist/adapters/cursor.js +92 -0
  11. package/dist/adapters/custom.js +342 -0
  12. package/dist/adapters/droid.js +58 -0
  13. package/dist/adapters/gemini.js +61 -0
  14. package/dist/adapters/goose.js +57 -0
  15. package/dist/adapters/index.js +161 -0
  16. package/dist/adapters/kiro.js +41 -0
  17. package/dist/adapters/opencode.js +63 -0
  18. package/dist/adapters/qwen.js +67 -0
  19. package/dist/adapters/types.js +6 -0
  20. package/dist/adapters/usage.js +141 -0
  21. package/dist/attach/connect.js +412 -0
  22. package/dist/attach/guard.js +83 -0
  23. package/dist/attach/live.js +226 -0
  24. package/dist/attach/plug.js +185 -0
  25. package/dist/attach/screen.js +150 -0
  26. package/dist/attach/session.js +177 -0
  27. package/dist/cli.js +244 -0
  28. package/dist/mcp/context-tools.js +79 -0
  29. package/dist/mcp/server.js +267 -0
  30. package/dist/orchestrator/ask.js +54 -0
  31. package/dist/orchestrator/brain.js +174 -0
  32. package/dist/orchestrator/council.js +615 -0
  33. package/dist/orchestrator/goal.js +196 -0
  34. package/dist/orchestrator/handoff.js +117 -0
  35. package/dist/orchestrator/launcher.js +171 -0
  36. package/dist/orchestrator/learn.js +105 -0
  37. package/dist/orchestrator/planner.js +212 -0
  38. package/dist/orchestrator/qa.js +398 -0
  39. package/dist/orchestrator/radar.js +719 -0
  40. package/dist/orchestrator/scheduler.js +1207 -0
  41. package/dist/orchestrator/takeover.js +240 -0
  42. package/dist/platform.js +273 -0
  43. package/dist/preflight.js +31 -0
  44. package/dist/relay/server.js +113 -0
  45. package/dist/room/context.js +1285 -0
  46. package/dist/room/contract.js +132 -0
  47. package/dist/room/db.js +680 -0
  48. package/dist/room/followup.js +233 -0
  49. package/dist/room/identity.js +122 -0
  50. package/dist/room/lessons.js +275 -0
  51. package/dist/room/live.js +348 -0
  52. package/dist/room/mail.js +212 -0
  53. package/dist/room/progress.js +149 -0
  54. package/dist/room/relaylink.js +85 -0
  55. package/dist/room/server.js +882 -0
  56. package/dist/room/sqlite.js +156 -0
  57. package/dist/room/transport.js +107 -0
  58. package/dist/room/tunnel.js +86 -0
  59. package/dist/sandbox/sync.js +104 -0
  60. package/dist/sandbox/worktree.js +392 -0
  61. package/dist/types.js +4 -0
  62. package/dist/ui/app.js +1955 -0
  63. package/dist/ui/catalog.js +113 -0
  64. package/dist/ui/dashboard.js +335 -0
  65. package/dist/ui/doctor.js +58 -0
  66. package/dist/ui/lessons.js +81 -0
  67. package/dist/ui/logo.js +40 -0
  68. package/dist/ui/paste.js +515 -0
  69. package/dist/ui/remote.js +338 -0
  70. package/dist/ui/setup.js +152 -0
  71. package/dist/ui/story.js +1529 -0
  72. package/dist/ui/term.js +249 -0
  73. package/dist/ui/watch.js +271 -0
  74. package/install.sh +44 -0
  75. package/package.json +63 -4
@@ -0,0 +1,86 @@
1
+ import { claudeUsage } from "./usage.js";
2
+ // Claude Code — launched headless with `-p`. The Plug is handed over with
3
+ // --mcp-config pointing at a file in .huddle/run/, so nothing lands in the
4
+ // agent's worktree. Auth is Claude's own (OAuth or ANTHROPIC_API_KEY); Huddle
5
+ // never touches it.
6
+ const PERMISSIONS = {
7
+ safe: ["--permission-mode", "acceptEdits", "--allowedTools", "mcp__huddle"],
8
+ // Claude's own auto mode: a safety check approves routine actions.
9
+ auto: ["--permission-mode", "auto", "--allowedTools", "mcp__huddle"],
10
+ yolo: ["--dangerously-skip-permissions"],
11
+ };
12
+ export const claudeAdapter = {
13
+ id: "claude",
14
+ vendor: "anthropic",
15
+ bin: "claude",
16
+ roles: ["orchestrator", "worker", "reviewer"],
17
+ prefers: ["frontend", "design", "product", "refactor"],
18
+ // CLI aliases, not dated ids — Claude Code resolves them to the latest model.
19
+ models: { orchestrator: "opus", worker: "opus", fast: "haiku", reviewer: "opus" },
20
+ accountNote: "personal Anthropic",
21
+ // https://code.claude.com/docs/en/setup · sign-in: https://code.claude.com/docs/en/cli-reference
22
+ install: {
23
+ cmd: "curl -fsSL https://claude.ai/install.sh | bash",
24
+ win: "irm https://claude.ai/install.ps1 | iex",
25
+ login: ["claude", "auth", "login"],
26
+ },
27
+ authProbe: {
28
+ // `claude auth status` (JSON by default, verified on 2.1.300): exit 0 +
29
+ // "loggedIn": true when signed in (OAuth or ANTHROPIC_API_KEY), exit 1 +
30
+ // "loggedIn": false when not. `--version` used to be the probe, which showed
31
+ // a signed-out Claude as ready.
32
+ cmd: ["claude", "auth", "status"],
33
+ ok: (r) => r.code === 0 && !/"loggedIn"\s*:\s*false/.test(r.stdout),
34
+ fixHint: "run `claude auth login` (or set ANTHROPIC_API_KEY)",
35
+ },
36
+ plug: {
37
+ file: { path: "claude-mcp.json", in: "run-dir" },
38
+ entry: (plug) => ({ mcpServers: { huddle: { type: "stdio", ...plug } } }),
39
+ },
40
+ // Read-only questions never need your personal MCP servers (Gmail, Drive, …).
41
+ // Self-contained prompts go further: safe mode + no tools ≈ 15k tokens of
42
+ // context instead of ~38k (or 200k+ on Haiku, which loads every tool eagerly).
43
+ ask: ({ prompt, cwd, model, noTools }) => ({
44
+ cmd: [
45
+ "claude",
46
+ "-p",
47
+ prompt,
48
+ "--model",
49
+ model || "opus",
50
+ ...(noTools ? ["--safe-mode", "--tools", ""] : ["--permission-mode", "plan", "--strict-mcp-config"]),
51
+ ],
52
+ cwd,
53
+ }),
54
+ // `claude -p` with no prompt argument reads it from stdin.
55
+ stdinPrompt: "",
56
+ usage: claudeUsage,
57
+ // A PostToolUse hook, loaded with --settings (a file in .huddle/run/, never
58
+ // the user's settings): after any tool call, `huddle mail --hook` prints
59
+ // {hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext}} when
60
+ // a teammate wrote, and nothing otherwise. Schema per
61
+ // https://code.claude.com/docs/en/hooks; --settings per `claude --help` (2.1.300).
62
+ mailHook: (command) => ({ hooks: { PostToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: 5 }] }] } }),
63
+ launch: ({ worktree, prompt, model, mcpConfigPath, approvals, context, hookSettingsPath }) => ({
64
+ cmd: [
65
+ "claude",
66
+ "-p",
67
+ prompt,
68
+ // The shared screen lives outside the worktree; Read opens its images and PDFs.
69
+ ...(context ? ["--add-dir", context.dir] : []),
70
+ ...(model ? ["--model", model] : []),
71
+ ...PERMISSIONS[approvals],
72
+ "--mcp-config",
73
+ mcpConfigPath,
74
+ // Only Huddle's Plug — not your personal MCP servers. Smaller context
75
+ // every turn, and a headless agent can't reach your Gmail/Drive/etc.
76
+ "--strict-mcp-config",
77
+ ...(hookSettingsPath ? ["--settings", hookSettingsPath] : []),
78
+ // JSON events so Huddle can count tokens live (logs stay in .huddle/logs)
79
+ "--output-format",
80
+ "stream-json",
81
+ "--verbose",
82
+ ],
83
+ cwd: worktree,
84
+ }),
85
+ };
86
+ //# sourceMappingURL=claude.js.map
@@ -0,0 +1,64 @@
1
+ import { codexUsage } from "./usage.js";
2
+ // OpenAI Codex CLI — runs non-interactively via `codex exec`. The Plug is
3
+ // passed as `-c mcp_servers.huddle.*` overrides, so neither ~/.codex/config.toml
4
+ // nor the worktree is touched. Auth is `codex login` (ChatGPT) or OPENAI_API_KEY.
5
+ const toml = (v) => JSON.stringify(v); // strings/arrays are valid TOML as JSON
6
+ const tomlTable = (o) => `{${Object.entries(o).map(([k, v]) => `${k}=${toml(v)}`).join(",")}}`;
7
+ export const codexAdapter = {
8
+ id: "codex",
9
+ vendor: "openai",
10
+ bin: "codex",
11
+ roles: ["worker", "reviewer"],
12
+ prefers: ["systems", "algorithms", "refactor", "backend"],
13
+ models: { orchestrator: "", worker: "", fast: "", reviewer: "" },
14
+ accountNote: "personal OpenAI",
15
+ install: { cmd: "npm install -g @openai/codex", login: ["codex", "login"] },
16
+ authProbe: {
17
+ // `codex login status` exits non-zero when not authenticated.
18
+ cmd: ["codex", "login", "status"],
19
+ ok: (r) => r.code === 0,
20
+ fixHint: "run `codex login` (or set OPENAI_API_KEY)",
21
+ },
22
+ plug: {},
23
+ // exec streams its work to stdout; -o captures just the final answer.
24
+ ask: ({ prompt, cwd, outFile, model }) => ({
25
+ cmd: ["codex", "exec", "--sandbox", "read-only", ...(model ? ["-m", model] : []), "-o", outFile, prompt],
26
+ cwd,
27
+ usesOutFile: true,
28
+ }),
29
+ // `codex exec -`: "instructions are read from stdin".
30
+ stdinPrompt: "-",
31
+ usage: codexUsage,
32
+ launch: ({ worktree, prompt, model, plug, approvals, context }) => ({
33
+ cmd: [
34
+ "codex",
35
+ "exec",
36
+ // `codex exec -i/--image <FILE>`: screenshots the planner linked to this task, attached to the prompt.
37
+ // One flag per file, before the other flags, so the list never swallows the prompt.
38
+ ...(context?.images ?? []).flatMap((f) => ["--image", f]),
39
+ "--json", // JSON events so Huddle can count tokens live
40
+ ...(model ? ["--model", model] : []),
41
+ "-c",
42
+ `mcp_servers.huddle.command=${toml(plug.command)}`,
43
+ "-c",
44
+ `mcp_servers.huddle.args=${toml(plug.args)}`,
45
+ "-c",
46
+ `mcp_servers.huddle.env=${tomlTable(plug.env)}`,
47
+ // Headless: nobody can click "allow", and Codex refuses unapproved MCP
48
+ // calls under exec. The Plug is Huddle's own server, so pre-approve it.
49
+ "-c",
50
+ `mcp_servers.huddle.default_tools_approval_mode="approve"`,
51
+ // `codex exec` never stops to ask; its sandbox confines commands to the
52
+ // worktree. Without network, the sandbox also refuses to listen on
53
+ // localhost: seen live on the capstone, Codex couldn't run its own HTTP
54
+ // API tests ("26 HTTP tests blocked by sandbox listen EPERM"). `auto`
55
+ // runs commands freely, so it gets network; `safe` stays offline.
56
+ ...(approvals === "yolo"
57
+ ? ["--dangerously-bypass-approvals-and-sandbox"]
58
+ : ["--sandbox", "workspace-write", ...(approvals === "auto" ? ["-c", "sandbox_workspace_write.network_access=true"] : [])]),
59
+ prompt,
60
+ ],
61
+ cwd: worktree,
62
+ }),
63
+ };
64
+ //# sourceMappingURL=codex.js.map
@@ -0,0 +1,69 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ // GitHub Copilot CLI — headless via `copilot -p`. The Plug goes in a file in
5
+ // .huddle/run/ handed over with --additional-mcp-config=@<file>, which adds a
6
+ // server "for this session only": ~/.copilot/mcp-config.json is never touched.
7
+ // Auth is Copilot's own (`copilot login`, or COPILOT_GITHUB_TOKEN / GH_TOKEN).
8
+ //
9
+ // Sources (checked 2026-10):
10
+ // https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli
11
+ // npm install -g @github/copilot · brew install --cask copilot-cli · curl -fsSL https://gh.io/copilot-install | bash
12
+ // https://docs.github.com/en/copilot/reference/cli-command-reference
13
+ // -p/--prompt, --model=MODEL, --allow-tool=TOOL (comma list), --deny-tool, --allow-all-tools,
14
+ // --allow-all, --no-ask-user, --disable-builtin-mcps, -s/--silent,
15
+ // --additional-mcp-config=JSON or @file, local server fields { type, command, args, env, tools },
16
+ // permission patterns: SERVER-NAME (all of that server's tools), write, read, shell(git push)
17
+ // Not yet run live on this machine. The reference says --allow-all-tools is
18
+ // "required when using the CLI programmatically"; safe mode instead pre-allows
19
+ // just the Plug + file edits, so if Copilot refuses it, use auto.
20
+ const PERMISSIONS = {
21
+ safe: ["--allow-tool=huddle,write,read"],
22
+ auto: ["--allow-all-tools", "--deny-tool=shell(git push)"],
23
+ yolo: ["--allow-all"],
24
+ };
25
+ const authed = () => ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"].some((k) => !!process.env[k]) ||
26
+ fs.existsSync(process.env.COPILOT_HOME ?? path.join(os.homedir(), ".copilot"));
27
+ export const copilotAdapter = {
28
+ id: "copilot",
29
+ vendor: "github",
30
+ bin: "copilot",
31
+ roles: ["worker", "reviewer"],
32
+ prefers: ["frontend", "backend", "quickfix"],
33
+ models: { orchestrator: "", worker: "", fast: "", reviewer: "" },
34
+ accountNote: "your GitHub Copilot plan",
35
+ homepage: "https://docs.github.com/en/copilot/how-tos/copilot-cli",
36
+ optional: true,
37
+ install: { cmd: "npm install -g @github/copilot", login: ["copilot", "login"] },
38
+ authProbe: {
39
+ cmd: ["copilot", "--version"],
40
+ ok: (r) => r.code === 0,
41
+ // Copilot has no "login status" command; a token or its config dir is the best cheap signal.
42
+ check: authed,
43
+ fixHint: "run `copilot login` (or set COPILOT_GITHUB_TOKEN)",
44
+ },
45
+ plug: {
46
+ file: { path: "copilot-mcp.json", in: "run-dir" },
47
+ entry: (plug) => ({ mcpServers: { huddle: { type: "local", ...plug, tools: ["*"] } } }),
48
+ },
49
+ ask: ({ prompt, cwd, model }) => ({
50
+ cmd: ["copilot", "-p", prompt, "-s", "--no-ask-user", "--deny-tool=write", "--deny-tool=shell", ...(model ? [`--model=${model}`] : [])],
51
+ cwd,
52
+ }),
53
+ launch: ({ worktree, prompt, model, mcpConfigPath, approvals }) => ({
54
+ cmd: [
55
+ "copilot",
56
+ "-p",
57
+ prompt,
58
+ ...(model ? [`--model=${model}`] : []),
59
+ `--additional-mcp-config=@${mcpConfigPath}`,
60
+ // Only Huddle's Plug on top of yours — not the built-in GitHub server,
61
+ // so a headless agent can't open issues/PRs on your account.
62
+ "--disable-builtin-mcps",
63
+ "--no-ask-user", // nobody is there to answer
64
+ ...PERMISSIONS[approvals],
65
+ ],
66
+ cwd: worktree,
67
+ }),
68
+ };
69
+ //# sourceMappingURL=copilot.js.map
@@ -0,0 +1,92 @@
1
+ import { spawnToolSync } from "../platform.js";
2
+ import { cursorUsage } from "./usage.js";
3
+ /**
4
+ * The model for quick prompt-only answers (council turns, cross-reviews), or
5
+ * undefined = cursor-agent's default (Auto). Found live (capstone, both
6
+ * rounds): Cursor's council critique timed out at 2 min. Probed with the same
7
+ * prompt and no tools used: Auto spent 126s thinking (12k chars) before a
8
+ * 120-word answer; composer-2.5 answered as well in 21s. Model ids depend on
9
+ * the account, so this picks only from what `cursor-agent --list-models`
10
+ * lists here (a "composer-<version>" id, Cursor's own fast model), once per
11
+ * process. HUDDLE_CURSOR_FAST_MODEL=<id> chooses one; =auto keeps the default.
12
+ */
13
+ let fastPick;
14
+ export function cursorFastModel(list = listCursorModels) {
15
+ const env = process.env.HUDDLE_CURSOR_FAST_MODEL?.trim();
16
+ if (env)
17
+ return env === "auto" ? undefined : env;
18
+ if (fastPick)
19
+ return fastPick.model;
20
+ let out = "";
21
+ try {
22
+ out = list();
23
+ }
24
+ catch {
25
+ /* not installed / signed out: keep the default */
26
+ }
27
+ const ids = out.split(/\r?\n/).map((l) => /^\s*([\w.-]+)\s+-\s/.exec(l)?.[1]).filter((x) => !!x);
28
+ fastPick = { model: ids.find((id) => /^composer-[\d.]+$/.test(id)) };
29
+ return fastPick.model;
30
+ }
31
+ /** For tests: forget the cached pick. */
32
+ export function resetCursorFastModel() {
33
+ fastPick = undefined;
34
+ }
35
+ function listCursorModels() {
36
+ const r = spawnToolSync(["cursor-agent", "--list-models"], { encoding: "utf8", timeout: 15_000 });
37
+ return r.status === 0 ? r.stdout : "";
38
+ }
39
+ // Cursor CLI (cursor-agent) — headless coding agent. Personal, team or SSO
40
+ // account: Huddle doesn't care. `cursor-agent` owns its own login (SSO
41
+ // included); Huddle only checks that the login succeeded, then shells out to it.
42
+ export const cursorAdapter = {
43
+ id: "cursor",
44
+ vendor: "cursor",
45
+ bin: "cursor-agent",
46
+ roles: ["worker"],
47
+ prefers: ["in-editor-edits", "frontend", "quickfix"],
48
+ models: { orchestrator: "", worker: "", fast: "", reviewer: "" },
49
+ accountNote: "your Cursor account (SSO handled by cursor-agent itself)",
50
+ // https://cursor.com/docs/cli/installation (native Windows: PowerShell)
51
+ install: {
52
+ cmd: "curl https://cursor.com/install -fsS | bash",
53
+ win: "irm 'https://cursor.com/install?win32=true' | iex",
54
+ login: ["cursor-agent", "login"],
55
+ },
56
+ authProbe: {
57
+ cmd: ["cursor-agent", "status"],
58
+ ok: (r) => r.code === 0 && !/not logged in/i.test(r.stdout + r.stderr),
59
+ fixHint: "run `cursor-agent login` (SSO works too — Huddle never sees it)",
60
+ },
61
+ plug: {
62
+ // cursor-agent reads project MCP from .cursor/mcp.json in its cwd.
63
+ file: { path: ".cursor/mcp.json", in: "worktree" },
64
+ entry: (plug) => ({ mcpServers: { huddle: plug } }),
65
+ },
66
+ ask: ({ prompt, cwd, model, quick }) => {
67
+ // Quick asks (council turns, reviews) get the fast model when the account has one; the brain keeps its own.
68
+ const m = model || (quick ? cursorFastModel() : undefined);
69
+ return { cmd: ["cursor-agent", "-p", prompt, "--mode", "ask", "--trust", ...(m ? ["--model", m] : [])], cwd };
70
+ },
71
+ usage: cursorUsage,
72
+ launch: ({ worktree, prompt, model, approvals, context }) => ({
73
+ cmd: [
74
+ "cursor-agent",
75
+ "-p",
76
+ prompt,
77
+ // No image flag on cursor-agent: the shared screen's folder, so its tools can open items by path.
78
+ ...(context ? ["--add-dir", context.dir] : []),
79
+ ...(model ? ["--model", model] : []),
80
+ // headless: trust the worktree and the Plug without prompting
81
+ "--trust",
82
+ "--approve-mcps",
83
+ "--output-format",
84
+ "stream-json", // so Huddle can count tokens
85
+ ...(approvals === "safe" ? ["--sandbox", "enabled"] : []),
86
+ ...(approvals === "auto" ? ["--force", "--sandbox", "enabled"] : []),
87
+ ...(approvals === "yolo" ? ["--force", "--sandbox", "disabled"] : []),
88
+ ],
89
+ cwd: worktree,
90
+ }),
91
+ };
92
+ //# sourceMappingURL=cursor.js.map
@@ -0,0 +1,342 @@
1
+ // User-defined connectors: any agent CLI that can run headless and speak MCP
2
+ // can join Huddle by being DECLARED, no code and no release needed.
3
+ //
4
+ // ~/.huddle/agents.json yours, on this machine (HUDDLE_HOME moves it)
5
+ // <repo>/.huddle/agents.json this repo only (must not be committed — see below)
6
+ //
7
+ // A file is { "agents": [ <spec>, … ] } (a bare array works too). Each spec
8
+ // becomes a normal Adapter; the same id as a built-in replaces it, which is
9
+ // how you work around a vendor renaming a flag before Huddle ships a fix.
10
+ // Templates use {placeholders}; see docs/ADAPTERS.md for the full reference
11
+ // and a worked example.
12
+ //
13
+ // Safety: a spec names a program Huddle will run, so a repo's own file is
14
+ // only honored while it is untracked. A cloned repo can't smuggle in an agent.
15
+ import { execFileSync } from "node:child_process";
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { agyUsage, ampUsage, claudeUsage, codexUsage, cursorUsage, qwenUsage } from "./usage.js";
20
+ /** Token parsers a spec can reuse by name (`"usage": "claude"`). */
21
+ export const USAGE_PARSERS = {
22
+ claude: claudeUsage, // also any CLI that emits Claude-style stream-json
23
+ codex: codexUsage,
24
+ cursor: cursorUsage,
25
+ agy: agyUsage,
26
+ gemini: agyUsage,
27
+ amp: ampUsage,
28
+ qwen: qwenUsage,
29
+ };
30
+ const SPEC_KEYS = new Set([
31
+ "id", "bin", "launch", "vendor", "approvals", "ask", "askOutFile", "plug", "env", "install", "installWin", "login", "loginNote",
32
+ "auth", "usage", "roles", "prefers", "models", "accountNote", "homepage", "optional", "$comment",
33
+ ]);
34
+ const ROLES = new Set(["orchestrator", "worker", "reviewer", "fast"]);
35
+ /** Placeholders that expand to several tokens when they are a whole token. */
36
+ const SPREAD = new Set(["approvals", "plug.args", "plug.argv"]);
37
+ const LAUNCH_VARS = new Set(["prompt", "worktree", "model", "mcpConfigPath", "plug.command", "plug.json", "plug.server", ...SPREAD]);
38
+ const ASK_VARS = new Set(["prompt", "cwd", "model", "outFile"]);
39
+ /** Placeholders that hand over the Plug (so the agent actually gets huddle_* tools). */
40
+ const PLUG_VARS = ["mcpConfigPath", "plug.command", "plug.json", "plug.server", "plug.args", "plug.argv"];
41
+ // {name}; {{name}} is left alone, so another tool's own templates pass through.
42
+ const PH = /(?<!\{)\{([a-zA-Z][\w.]*)\}(?!\})/g;
43
+ const placeholders = (s) => [...s.matchAll(PH)].map((m) => m[1]);
44
+ const flatStrings = (t) => t.flatMap((a) => (Array.isArray(a) ? a : [a]));
45
+ const isStrArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
46
+ /** Check one spec; returns problems (empty = valid). */
47
+ export function validateSpec(raw) {
48
+ const errs = [];
49
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
50
+ return ["must be an object"];
51
+ const s = raw;
52
+ for (const k of Object.keys(s))
53
+ if (!SPEC_KEYS.has(k))
54
+ errs.push(`unknown field "${k}"`);
55
+ if (typeof s.id !== "string" || !/^[a-z0-9][a-z0-9_-]*$/.test(s.id))
56
+ errs.push(`"id" must be lowercase letters, digits, - or _`);
57
+ if (typeof s.bin !== "string" || !s.bin.trim())
58
+ errs.push(`"bin" must name the executable`);
59
+ const checkTemplate = (field, t, allowed) => {
60
+ if (!Array.isArray(t) || !t.length || !t.every((a) => typeof a === "string" || isStrArray(a))) {
61
+ errs.push(`"${field}" must be a non-empty array of strings (or [string, …] groups)`);
62
+ return;
63
+ }
64
+ const strs = flatStrings(t);
65
+ for (const v of strs.flatMap(placeholders))
66
+ if (!allowed.has(v))
67
+ errs.push(`"${field}" uses unknown placeholder {${v}}`);
68
+ for (const str of strs)
69
+ for (const v of placeholders(str))
70
+ if (SPREAD.has(v) && str !== `{${v}}`)
71
+ errs.push(`{${v}} must be a whole token in "${field}"`);
72
+ if (!strs.some((x) => placeholders(x).includes("prompt")))
73
+ errs.push(`"${field}" must include {prompt}`);
74
+ };
75
+ checkTemplate("launch", s.launch, LAUNCH_VARS);
76
+ if (s.ask !== undefined)
77
+ checkTemplate("ask", s.ask, ASK_VARS);
78
+ if (s.approvals !== undefined) {
79
+ const a = s.approvals;
80
+ if (!a || typeof a !== "object" || Array.isArray(a))
81
+ errs.push(`"approvals" must be {safe?, auto?, yolo?}`);
82
+ else
83
+ for (const [k, v] of Object.entries(a)) {
84
+ if (!["safe", "auto", "yolo"].includes(k))
85
+ errs.push(`"approvals.${k}": modes are safe, auto, yolo`);
86
+ else if (!isStrArray(v))
87
+ errs.push(`"approvals.${k}" must be an array of strings`);
88
+ }
89
+ }
90
+ let hasFile = false;
91
+ if (s.plug !== undefined) {
92
+ const p = s.plug;
93
+ if (!p || typeof p !== "object" || typeof p.file !== "string" || !p.file)
94
+ errs.push(`"plug.file" must be a path`);
95
+ else {
96
+ hasFile = true;
97
+ if (path.isAbsolute(p.file) || p.file.split(/[\\/]/).includes(".."))
98
+ errs.push(`"plug.file" must be relative, inside the worktree or run dir`);
99
+ if (p.in !== undefined && p.in !== "worktree" && p.in !== "run-dir")
100
+ errs.push(`"plug.in" is "worktree" or "run-dir"`);
101
+ for (const k of Object.keys(p))
102
+ if (!["file", "in", "entry"].includes(k))
103
+ errs.push(`unknown field "plug.${k}"`);
104
+ }
105
+ }
106
+ if (s.env !== undefined) {
107
+ const e = s.env;
108
+ if (!e || typeof e !== "object" || Array.isArray(e) || !Object.values(e).every((v) => typeof v === "string"))
109
+ errs.push(`"env" must map names to strings`);
110
+ else
111
+ for (const v of Object.values(e).flatMap(placeholders))
112
+ if (!LAUNCH_VARS.has(v) || SPREAD.has(v))
113
+ errs.push(`"env" uses unknown placeholder {${v}}`);
114
+ }
115
+ // The whole point is the Plug: refuse a spec that would join without huddle_* tools.
116
+ const used = [...(Array.isArray(s.launch) ? flatStrings(s.launch) : []), ...Object.values(s.env ?? {})]
117
+ .filter((x) => typeof x === "string")
118
+ .flatMap(placeholders);
119
+ if (!hasFile && used.includes("mcpConfigPath"))
120
+ errs.push(`{mcpConfigPath} needs "plug.file"`);
121
+ if (!hasFile && !used.some((v) => PLUG_VARS.includes(v)))
122
+ errs.push(`no way to hand it the Plug: set "plug.file", or use {plug.json} / {plug.command} {plug.args} in "launch"`);
123
+ if (s.login !== undefined && !isStrArray(s.login))
124
+ errs.push(`"login" must be an array of strings`);
125
+ if (s.install !== undefined && typeof s.install !== "string")
126
+ errs.push(`"install" must be a shell command string`);
127
+ if (s.installWin !== undefined && typeof s.installWin !== "string")
128
+ errs.push(`"installWin" must be a PowerShell command string`);
129
+ if (s.auth !== undefined) {
130
+ const a = s.auth;
131
+ if (!a || typeof a !== "object")
132
+ errs.push(`"auth" must be an object`);
133
+ else {
134
+ if (a.cmd !== undefined && (!isStrArray(a.cmd) || !a.cmd.length))
135
+ errs.push(`"auth.cmd" must be a non-empty array of strings`);
136
+ if (a.failPattern !== undefined) {
137
+ try {
138
+ new RegExp(String(a.failPattern), "i");
139
+ }
140
+ catch {
141
+ errs.push(`"auth.failPattern" is not a valid regex`);
142
+ }
143
+ }
144
+ }
145
+ }
146
+ if (s.usage !== undefined && !(typeof s.usage === "string" && s.usage in USAGE_PARSERS))
147
+ errs.push(`"usage" is one of: ${Object.keys(USAGE_PARSERS).join(", ")}`);
148
+ if (s.roles !== undefined && !(isStrArray(s.roles) && s.roles.every((r) => ROLES.has(r))))
149
+ errs.push(`"roles" are orchestrator, worker, reviewer, fast`);
150
+ if (s.prefers !== undefined && !isStrArray(s.prefers))
151
+ errs.push(`"prefers" must be an array of strings`);
152
+ if (s.models !== undefined) {
153
+ const m = s.models;
154
+ if (!m || typeof m !== "object" || !Object.entries(m).every(([k, v]) => ROLES.has(k) && typeof v === "string"))
155
+ errs.push(`"models" maps orchestrator/worker/fast/reviewer to model names`);
156
+ }
157
+ return errs;
158
+ }
159
+ /**
160
+ * Fill a command template. A whole-token {approvals}/{plug.args}/{plug.argv}
161
+ * spreads into several tokens; a token or [group] whose placeholders are all
162
+ * empty (e.g. no model configured) is dropped instead of passing "".
163
+ */
164
+ export function renderTemplate(t, vars) {
165
+ // `empty`: some placeholder in the token had no value — "--model={model}" with no model.
166
+ const one = (s) => {
167
+ const whole = s.match(/^\{([\w.]+)\}$/);
168
+ if (whole && Array.isArray(vars[whole[1]])) {
169
+ const arr = vars[whole[1]];
170
+ return { out: arr, empty: !arr.length };
171
+ }
172
+ let empty = false;
173
+ const out = s.replace(PH, (_, k) => {
174
+ const v = vars[k];
175
+ const str = Array.isArray(v) ? v.join(" ") : (v ?? "");
176
+ if (!str)
177
+ empty = true;
178
+ return str;
179
+ });
180
+ return { out: [out], empty };
181
+ };
182
+ const cmd = [];
183
+ for (const a of t) {
184
+ const parts = (Array.isArray(a) ? a : [a]).map(one);
185
+ if (parts.some((p) => p.empty))
186
+ continue;
187
+ cmd.push(...parts.flatMap((p) => p.out));
188
+ }
189
+ return cmd;
190
+ }
191
+ /** Fill a JSON template: "{plug.args}"/"{plug.argv}"/"{plug.env}" as whole strings become real arrays/objects. */
192
+ function renderJson(v, plug) {
193
+ if (typeof v === "string") {
194
+ if (v === "{plug.args}")
195
+ return plug.args;
196
+ if (v === "{plug.argv}")
197
+ return [plug.command, ...plug.args];
198
+ if (v === "{plug.env}")
199
+ return plug.env;
200
+ return v.replace(/\{plug\.command\}/g, plug.command);
201
+ }
202
+ if (Array.isArray(v))
203
+ return v.map((x) => renderJson(x, plug));
204
+ if (v && typeof v === "object")
205
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, renderJson(x, plug)]));
206
+ return v;
207
+ }
208
+ /** Turn a validated spec into an Adapter. */
209
+ export function specToAdapter(spec, source) {
210
+ const authCmd = spec.auth?.cmd ?? [spec.bin, "--version"];
211
+ const fail = spec.auth?.failPattern ? new RegExp(spec.auth.failPattern, "i") : null;
212
+ const plugFile = spec.plug?.file;
213
+ const server = (plug) => ({ command: plug.command, args: plug.args, env: plug.env });
214
+ return {
215
+ id: spec.id,
216
+ vendor: spec.vendor ?? spec.id,
217
+ bin: spec.bin,
218
+ roles: spec.roles ?? (spec.ask ? ["worker", "reviewer"] : ["worker"]),
219
+ prefers: spec.prefers ?? [],
220
+ models: { orchestrator: "", worker: "", fast: "", reviewer: "", ...spec.models },
221
+ install: { cmd: spec.install ?? "", ...(spec.installWin !== undefined ? { win: spec.installWin } : {}), login: spec.login, loginNote: spec.loginNote },
222
+ authProbe: {
223
+ cmd: authCmd,
224
+ ok: (r) => r.code === 0 && !(fail && fail.test(r.stdout + r.stderr)),
225
+ fixHint: spec.auth?.fixHint ?? `sign in to ${spec.bin} the way its docs say`,
226
+ },
227
+ plug: plugFile
228
+ ? {
229
+ file: { path: plugFile, in: spec.plug.in ?? "run-dir" },
230
+ entry: (plug) => (spec.plug.entry !== undefined ? renderJson(spec.plug.entry, plug) : { mcpServers: { huddle: server(plug) } }),
231
+ }
232
+ : {},
233
+ launch: (ctx) => {
234
+ const vars = {
235
+ prompt: ctx.prompt,
236
+ worktree: ctx.worktree,
237
+ model: ctx.model,
238
+ mcpConfigPath: ctx.mcpConfigPath ?? "",
239
+ approvals: spec.approvals?.[ctx.approvals] ?? [],
240
+ "plug.command": ctx.plug.command,
241
+ "plug.args": ctx.plug.args,
242
+ "plug.argv": [ctx.plug.command, ...ctx.plug.args],
243
+ "plug.json": JSON.stringify({ huddle: server(ctx.plug) }),
244
+ "plug.server": JSON.stringify(server(ctx.plug)),
245
+ };
246
+ const env = spec.env
247
+ ? Object.fromEntries(Object.entries(spec.env).map(([k, v]) => [k, renderTemplate([v], vars)[0] ?? ""]))
248
+ : undefined;
249
+ return { cmd: [spec.bin, ...renderTemplate(spec.launch, vars)], cwd: ctx.worktree, env };
250
+ },
251
+ ask: spec.ask
252
+ ? ({ prompt, cwd, outFile, model }) => ({
253
+ cmd: [spec.bin, ...renderTemplate(spec.ask, { prompt, cwd, outFile, model: model ?? "" })],
254
+ cwd,
255
+ usesOutFile: spec.askOutFile,
256
+ })
257
+ : undefined,
258
+ usage: spec.usage ? USAGE_PARSERS[spec.usage] : undefined,
259
+ accountNote: spec.accountNote ?? (source ? `from ${tildify(source)}` : undefined),
260
+ homepage: spec.homepage,
261
+ optional: spec.optional,
262
+ source,
263
+ };
264
+ }
265
+ /** Parse one agents.json's text. Invalid specs are skipped (and reported); valid ones still load. */
266
+ export function parseAgentsJson(text, source) {
267
+ const where = tildify(source);
268
+ let doc;
269
+ try {
270
+ doc = JSON.parse(text);
271
+ }
272
+ catch (e) {
273
+ return { adapters: [], errors: [`${where}: not valid JSON (${e?.message ?? e})`] };
274
+ }
275
+ const list = Array.isArray(doc) ? doc : doc?.agents;
276
+ if (!Array.isArray(list))
277
+ return { adapters: [], errors: [`${where}: expected { "agents": [ … ] }`] };
278
+ const out = { adapters: [], errors: [] };
279
+ const seen = new Set();
280
+ list.forEach((raw, i) => {
281
+ const name = typeof raw?.id === "string" ? raw.id : `#${i + 1}`;
282
+ const errs = validateSpec(raw);
283
+ if (!errs.length && seen.has(name))
284
+ errs.push(`id "${name}" is declared twice`);
285
+ if (errs.length)
286
+ return void out.errors.push(...errs.map((e) => `${where} · ${name}: ${e}`));
287
+ seen.add(name);
288
+ out.adapters.push(specToAdapter(raw, source));
289
+ });
290
+ return out;
291
+ }
292
+ /** The two places agents.json lives, for this machine and (optionally) this repo. */
293
+ export function agentsFiles(repoRoot) {
294
+ const home = process.env.HUDDLE_HOME ?? path.join(os.homedir(), ".huddle");
295
+ return { user: path.join(home, "agents.json"), repo: repoRoot ? path.join(repoRoot, ".huddle", "agents.json") : null };
296
+ }
297
+ function tracked(repoRoot, file) {
298
+ try {
299
+ execFileSync("git", ["ls-files", "--error-unmatch", path.relative(repoRoot, file)], { cwd: repoRoot, stdio: "pipe" });
300
+ return true;
301
+ }
302
+ catch {
303
+ return false;
304
+ }
305
+ }
306
+ function gitRoot(cwd) {
307
+ try {
308
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: "pipe" }).trim() || null;
309
+ }
310
+ catch {
311
+ return null;
312
+ }
313
+ }
314
+ /** Load user then repo agents.json (repo wins on the same id). Missing files are fine. */
315
+ export function loadAgentsFiles(opts = {}) {
316
+ const repoRoot = opts.repoRoot !== undefined ? opts.repoRoot : gitRoot(opts.cwd ?? process.cwd());
317
+ const files = agentsFiles(repoRoot);
318
+ const out = { adapters: [], errors: [] };
319
+ for (const file of [files.user, files.repo]) {
320
+ if (!file || !fs.existsSync(file))
321
+ continue;
322
+ if (file === files.repo && repoRoot && tracked(repoRoot, file)) {
323
+ out.errors.push(`${tildify(file)}: ignored — it is committed to git, and a repo can't add agents to your machine. Move it to ${tildify(files.user)}.`);
324
+ continue;
325
+ }
326
+ const r = parseAgentsJson(fs.readFileSync(file, "utf8"), file);
327
+ for (const a of r.adapters) {
328
+ const i = out.adapters.findIndex((x) => x.id === a.id);
329
+ if (i >= 0)
330
+ out.adapters[i] = a;
331
+ else
332
+ out.adapters.push(a);
333
+ }
334
+ out.errors.push(...r.errors);
335
+ }
336
+ return out;
337
+ }
338
+ function tildify(p) {
339
+ const home = os.homedir();
340
+ return p.startsWith(home + path.sep) ? "~" + p.slice(home.length) : p;
341
+ }
342
+ //# sourceMappingURL=custom.js.map