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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user