feat(ai-tools): catalog of AI coding CLIs, detection, enable/default, lanes for any tool (#40 A1)

server/lib/ai-tools.js loads server/data/ai-tools/*.json (38 tools, data from
Orca, MIT) plus DASHBOARD_AI_TOOLS_DIR, detects each CLI by walking PATH, and
keeps enable/default in <data dir>/ai-tools.json. shell-path.js merges the
login shell's PATH at startup and on Refresh. /api/ai-tools lists, refreshes
and toggles; /api/run/binary resolves through the catalog.

Lanes accept any catalog id (EDISABLED for a disabled one on creation, ENOTOOL
at start once a tool left the catalog). pty-run keeps the Claude/Codex
builders and builds every other tool's argv from its catalog file, pasting an
after-start prompt through a tmux buffer; every pane gets the hydrated PATH.
A per-run bypass switch appends the catalog's bypass args (Codex included).
POST /api/run now forwards sandbox/approval/resumeLast, which it dropped.
agenthub lanes add gains --tool.
This commit is contained in:
2026-09-28 10:57:10 +07:00
parent 0aa7fc232e
commit 77917203fd
59 changed files with 1804 additions and 84 deletions
+155 -18
View File
@@ -398,25 +398,162 @@ describe("pty-run", () => {
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
});
it("spawnRun runs `agy` for an antigravity lane, dropping flags it has no use for", () => {
const call = captureNewSession(() =>
pty.spawnRun({
laneId: 13,
cwd: "/tmp/repo",
tool: "antigravity",
model: "opus",
effort: "high",
permissionMode: "bypassPermissions",
initialPrompt: "hello",
})
);
assert.deepEqual(paneArgv(call), ["agy", "-i", "hello"]);
describe("catalog tools", () => {
const aiTools = require("../lib/ai-tools");
const BIN = path.join(TMP, "bin");
let savedPath;
before(() => {
fs.mkdirSync(BIN, { recursive: true });
for (const name of ["agy", "gemini", "grok", "goose", "command-code"]) {
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, name), 0o755);
}
savedPath = process.env.PATH;
process.env.PATH = `${BIN}:/usr/bin:/bin`;
aiTools.refresh();
});
after(() => {
process.env.PATH = savedPath;
aiTools.refresh();
});
const at = (name) => path.join(BIN, name);
const envOf = (call, key) => {
const i = call.findIndex((a, n) => call[n - 1] === "-e" && a.startsWith(`${key}=`));
return i < 0 ? undefined : call[i].slice(key.length + 1);
};
tmux.__reset();
const last = captureNewSession(() =>
pty.spawnRun({ laneId: 14, cwd: "/tmp/repo", tool: "antigravity", resumeLast: true })
);
assert.deepEqual(paneArgv(last), ["agy", "--continue"]);
it("starts antigravity by absolute path, dropping flags it has no use for", () => {
const call = captureNewSession(() =>
pty.spawnRun({
laneId: 13,
cwd: "/tmp/repo",
tool: "antigravity",
model: "opus",
effort: "high",
permissionMode: "bypassPermissions",
initialPrompt: "hello",
})
);
assert.deepEqual(paneArgv(call), [at("agy"), "-i", "hello"]);
assert.equal(envOf(call, "PATH"), process.env.PATH);
tmux.__reset();
const last = captureNewSession(() =>
pty.spawnRun({
laneId: 14,
cwd: "/tmp/repo",
tool: "antigravity",
resumeLast: true,
bypass: true,
})
);
assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]);
});
it("builds each prompt mode, with fixed args first", () => {
const argvFor = (tool, extra = {}) =>
paneArgv(
captureNewSession(() =>
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool, initialPrompt: "-x y", ...extra })
)
);
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", "-x y"]);
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", "-x y"]);
assert.deepEqual(argvFor("gemini", { bypass: false }), [
at("gemini"),
"--prompt-interactive",
"-x y",
]);
});
it("pastes an after-start prompt later and adds bypassEnv only on bypass", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const calls = [];
let live = false;
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "has-session" && !live) {
const e = new Error("no such session");
e.status = 1;
throw e;
}
return "";
});
pty.spawnRun({
laneId: 16,
cwd: "/tmp/repo",
tool: "goose",
initialPrompt: "fix it",
bypass: true,
});
const ns = calls.find((c) => c[0] === "new-session");
assert.deepEqual(paneArgv(ns), [at("goose")]);
assert.equal(envOf(ns, "GOOSE_MODE"), "auto");
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
live = true;
t.mock.timers.tick(3000);
assert.deepEqual(
calls.find((c) => c[0] === "set-buffer"),
["set-buffer", "-b", "agenthub-paste-agenthub-lane-16", "--", "fix it"]
);
assert.ok(calls.some((c) => c[0] === "paste-buffer"));
calls.length = 0;
live = false;
pty.spawnRun({ laneId: 17, cwd: "/tmp/repo", tool: "goose" });
assert.equal(
envOf(
calls.find((c) => c[0] === "new-session"),
"GOOSE_MODE"
),
undefined
);
});
it("skips the paste when the pane is gone", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "has-session") {
const e = new Error("no such session");
e.status = 1;
throw e;
}
return "";
});
pty.spawnRun({ laneId: 18, cwd: "/tmp/repo", tool: "goose", initialPrompt: "hi" });
t.mock.timers.tick(3000);
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
});
it("codex bypass replaces sandbox/approval; claude ignores bypass", () => {
const codex = captureNewSession(() =>
pty.spawnRun({
laneId: 19,
cwd: "/tmp/repo",
tool: "codex",
sandbox: "read-only",
approval: "never",
bypass: true,
})
);
assert.ok(paneArgv(codex).includes("--dangerously-bypass-approvals-and-sandbox"));
assert.ok(!paneArgv(codex).includes("-s"));
tmux.__reset();
const claude = captureNewSession(() =>
pty.spawnRun({ laneId: 20, cwd: "/tmp/repo", bypass: true })
);
assert.ok(!paneArgv(claude).includes("--dangerously-skip-permissions"));
});
it("refuses a tool the catalog does not know", () => {
tmux.__setExecImpl(() => "");
assert.throws(() => pty.spawnRun({ laneId: 21, cwd: "/tmp/repo", tool: "nope" }), {
code: "ENOTOOL",
});
});
});
// `dashboard_runs.mode` is NOT NULL and recordRun swallows insert errors, so a