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:
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @file Tests for server/lib/ai-tools.js — catalog validation and override,
|
||||
* PATH detection over fake executables, and the enable/default state rules.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tools-"));
|
||||
const builtin = path.join(root, "builtin");
|
||||
const custom = path.join(root, "custom");
|
||||
const bin = path.join(root, "bin");
|
||||
const data = path.join(root, "data");
|
||||
for (const d of [builtin, custom, bin, data]) fs.mkdirSync(d);
|
||||
|
||||
const origPath = process.env.PATH;
|
||||
const origData = process.env.DASHBOARD_DATA_DIR;
|
||||
process.env.DASHBOARD_DATA_DIR = data;
|
||||
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
|
||||
const write = (dir, name, doc) =>
|
||||
fs.writeFileSync(path.join(dir, name), typeof doc === "string" ? doc : JSON.stringify(doc));
|
||||
const exe = (name, mode = 0o755) => {
|
||||
const p = path.join(bin, name);
|
||||
fs.writeFileSync(p, "#!/bin/sh\n");
|
||||
fs.chmodSync(p, mode);
|
||||
return p;
|
||||
};
|
||||
|
||||
before(() => {
|
||||
write(builtin, "claude.json", {
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
detect: ["claude"],
|
||||
launcher: "claude",
|
||||
});
|
||||
write(builtin, "codex.json", {
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
detect: ["codex"],
|
||||
launcher: "codex",
|
||||
});
|
||||
write(builtin, "zed.json", {
|
||||
id: "zed",
|
||||
label: "Aaa Zed",
|
||||
detect: ["zed"],
|
||||
prompt: { mode: "argv" },
|
||||
});
|
||||
write(builtin, "pi.json", {
|
||||
id: "pi",
|
||||
label: "Pi",
|
||||
detect: ["pi"],
|
||||
prompt: { mode: "argv" },
|
||||
resume: ["--continue"],
|
||||
});
|
||||
write(builtin, "needs.json", {
|
||||
id: "needs",
|
||||
label: "Needs",
|
||||
detect: ["needs"],
|
||||
requires: ["helper"],
|
||||
prompt: { mode: "argv" },
|
||||
});
|
||||
write(builtin, "bad-id.json", {
|
||||
id: "Bad_Id",
|
||||
label: "x",
|
||||
detect: ["x"],
|
||||
prompt: { mode: "argv" },
|
||||
});
|
||||
write(builtin, "slash.json", {
|
||||
id: "slash",
|
||||
label: "x",
|
||||
detect: ["/bin/sh"],
|
||||
prompt: { mode: "argv" },
|
||||
});
|
||||
write(builtin, "args.json", {
|
||||
id: "args",
|
||||
label: "x",
|
||||
detect: ["x"],
|
||||
args: "--oops",
|
||||
prompt: { mode: "argv" },
|
||||
});
|
||||
write(builtin, "mode.json", { id: "mode", label: "x", detect: ["x"], prompt: { mode: "shell" } });
|
||||
write(builtin, "broken.json", "{not json");
|
||||
write(custom, "pi.json", {
|
||||
id: "pi",
|
||||
label: "Pi Custom",
|
||||
detect: ["pi"],
|
||||
prompt: { mode: "flag", flag: "-p" },
|
||||
});
|
||||
write(custom, "evil.json", { id: "evil", label: "x", detect: ["x"], launcher: "claude" });
|
||||
exe("claude");
|
||||
exe("pi");
|
||||
exe("needs");
|
||||
exe("zed", 0o644); // present but not executable
|
||||
process.env.PATH = bin;
|
||||
aiTools.__setCatalogDirs([builtin, custom]);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
process.env.PATH = origPath;
|
||||
if (origData === undefined) delete process.env.DASHBOARD_DATA_DIR;
|
||||
else process.env.DASHBOARD_DATA_DIR = origData;
|
||||
aiTools.__setCatalogDirs(null);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(path.join(data, "ai-tools.json"), { force: true });
|
||||
aiTools.refresh();
|
||||
});
|
||||
|
||||
describe("catalog", () => {
|
||||
it("skips invalid files and keeps valid ones in catalog order", () => {
|
||||
assert.deepEqual(
|
||||
aiTools.list().map((t) => t.id),
|
||||
["claude", "codex", "zed", "needs", "pi"]
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a custom file replace a built-in one, but never set launcher", () => {
|
||||
assert.equal(aiTools.get("pi").label, "Pi Custom");
|
||||
assert.equal(aiTools.get("pi").source, "custom");
|
||||
assert.equal(aiTools.get("pi").resume, null);
|
||||
assert.equal(aiTools.has("evil"), false);
|
||||
assert.equal(aiTools.get("claude").source, "builtin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detection", () => {
|
||||
it("finds executables on PATH and ignores non-executable files", () => {
|
||||
assert.deepEqual(aiTools.detect("claude"), {
|
||||
installed: true,
|
||||
path: path.join(bin, "claude"),
|
||||
command: "claude",
|
||||
});
|
||||
assert.equal(aiTools.detect("zed").installed, false);
|
||||
assert.equal(aiTools.detect("codex").installed, false);
|
||||
});
|
||||
|
||||
it("needs every `requires` command too", () => {
|
||||
assert.equal(aiTools.detect("needs").installed, false);
|
||||
exe("helper");
|
||||
aiTools.refresh();
|
||||
assert.equal(aiTools.detect("needs").installed, true);
|
||||
fs.rmSync(path.join(bin, "helper"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("enable / default", () => {
|
||||
it("defaults to claude, then the first installed enabled tool", () => {
|
||||
assert.equal(aiTools.effectiveDefault(), "claude");
|
||||
aiTools.setEnabled("claude", false);
|
||||
assert.equal(aiTools.effectiveDefault(), "pi");
|
||||
});
|
||||
|
||||
it("stores a default and clears it when that tool is disabled", () => {
|
||||
aiTools.setDefault("pi");
|
||||
assert.equal(aiTools.readState().default, "pi");
|
||||
assert.equal(aiTools.effectiveDefault(), "pi");
|
||||
aiTools.setEnabled("pi", false);
|
||||
assert.equal(aiTools.readState().default, null);
|
||||
assert.equal(aiTools.isEnabled("pi"), false);
|
||||
aiTools.setEnabled("pi", true);
|
||||
assert.equal(aiTools.isEnabled("pi"), true);
|
||||
});
|
||||
|
||||
it("refuses a default that is unknown, not installed or disabled", () => {
|
||||
assert.throws(() => aiTools.setDefault("nope"), { code: "ENOTOOL" });
|
||||
assert.throws(() => aiTools.setDefault("codex"), { code: "EBADDEFAULT" });
|
||||
aiTools.setEnabled("pi", false);
|
||||
assert.throws(() => aiTools.setDefault("pi"), { code: "EBADDEFAULT" });
|
||||
assert.throws(() => aiTools.setEnabled("nope", false), { code: "ENOTOOL" });
|
||||
});
|
||||
|
||||
it("view() carries flags, never arg arrays", () => {
|
||||
const v = aiTools.view();
|
||||
assert.equal(v.default, "claude");
|
||||
const pi = v.tools.find((t) => t.id === "pi");
|
||||
assert.deepEqual(pi, {
|
||||
id: "pi",
|
||||
label: "Pi Custom",
|
||||
command: "pi",
|
||||
homepageUrl: null,
|
||||
installed: true,
|
||||
path: path.join(bin, "pi"),
|
||||
enabled: true,
|
||||
source: "custom",
|
||||
launcher: null,
|
||||
prompt: { mode: "flag" },
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
});
|
||||
assert.equal(v.tools.find((t) => t.id === "codex").routerCapable, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("built-in catalog", () => {
|
||||
it("loads every shipped file", () => {
|
||||
aiTools.__setCatalogDirs(null);
|
||||
const ids = aiTools.list().map((t) => t.id);
|
||||
const files = fs
|
||||
.readdirSync(path.join(__dirname, "..", "data", "ai-tools"))
|
||||
.filter((f) => f.endsWith(".json"));
|
||||
assert.equal(ids.length, files.length);
|
||||
assert.deepEqual(ids.slice(0, 2), ["claude", "codex"]);
|
||||
assert.equal(aiTools.get("antigravity").prompt.flag, "-i");
|
||||
aiTools.__setCatalogDirs([builtin, custom]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user