Files
AgentHub/server/lib/ai-tools.js
T
nntrivi2001 77917203fd 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.
2026-09-29 08:18:40 +07:00

278 lines
8.3 KiB
JavaScript

/**
* @file The AI Tools catalog: which AI coding CLIs AgentHub can run in a lane
* (`server/data/ai-tools/*.json` plus overrides in `DASHBOARD_AI_TOOLS_DIR`),
* which of them are installed (a walk of the PATH directories — no process per
* tool), and the user's enable/default choice (`<data dir>/ai-tools.json`).
* A catalog file is data only: its args and env reach an argv in pty-run.js,
* never a shell, and a custom file may not claim a hand-written `launcher`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getDataDir } = require("./claude-home");
const BUILTIN_DIR = path.join(__dirname, "..", "data", "ai-tools");
const ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
const CMD_RE = /^[A-Za-z0-9._+-]+$/;
const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const LAUNCHERS = new Set(["claude", "codex"]);
const PROMPT_MODES = new Set(["argv", "flag", "after-start"]);
const ROUTER_CAPABLE = new Set(["claude", "codex"]);
let dirsOverride = null;
let catalog = null; // Map id -> tool, in catalog order
const detected = new Map();
function makeErr(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
const isStrArray = (v, re) =>
Array.isArray(v) && v.every((s) => typeof s === "string" && s && (!re || re.test(s)));
/** A normalised tool, or null when the file breaks any rule. */
function validate(doc, source) {
if (!doc || typeof doc !== "object") return null;
if (typeof doc.id !== "string" || !ID_RE.test(doc.id)) return null;
if (typeof doc.label !== "string" || !doc.label.trim()) return null;
if (!isStrArray(doc.detect, CMD_RE) || !doc.detect.length) return null;
if (doc.requires !== undefined && !isStrArray(doc.requires, CMD_RE)) return null;
for (const k of ["args", "resume", "bypass"]) {
if (doc[k] !== undefined && !isStrArray(doc[k])) return null;
}
if (doc.bypassEnv !== undefined) {
const e = doc.bypassEnv;
if (!e || typeof e !== "object" || Array.isArray(e)) return null;
if (!Object.entries(e).every(([k, v]) => ENV_RE.test(k) && typeof v === "string")) return null;
}
let launcher = null;
if (doc.launcher !== undefined) {
if (source !== "builtin" || !LAUNCHERS.has(doc.launcher) || doc.launcher !== doc.id)
return null;
launcher = doc.launcher;
}
let prompt = null;
if (!launcher) {
const p = doc.prompt;
if (!p || !PROMPT_MODES.has(p.mode)) return null;
if (p.mode === "flag" && (typeof p.flag !== "string" || !p.flag.startsWith("-"))) return null;
if (p.mode === "argv" && p.separator !== undefined && p.separator !== "--") return null;
prompt = { mode: p.mode };
if (p.mode === "flag") prompt.flag = p.flag;
if (p.mode === "argv" && p.separator) prompt.separator = p.separator;
if (p.mode === "after-start") {
const d = Number(p.delayMs);
prompt.delayMs = Number.isFinite(d) && d >= 0 && d <= 60000 ? d : 3000;
}
}
return {
id: doc.id,
label: doc.label.trim(),
detect: doc.detect,
requires: doc.requires || [],
args: doc.args || [],
homepageUrl:
typeof doc.homepageUrl === "string" && /^https:\/\//.test(doc.homepageUrl)
? doc.homepageUrl
: null,
faviconDomain: typeof doc.faviconDomain === "string" ? doc.faviconDomain : null,
prompt,
resume: doc.resume || null,
bypass: doc.bypass || null,
bypassEnv: doc.bypassEnv || null,
launcher,
source,
};
}
function load() {
const dirs = dirsOverride || [BUILTIN_DIR, process.env.DASHBOARD_AI_TOOLS_DIR].filter(Boolean);
const byId = new Map();
dirs.forEach((dir, i) => {
const source = i === 0 ? "builtin" : "custom";
let files = [];
try {
files = fs
.readdirSync(dir)
.filter((f) => f.endsWith(".json"))
.sort();
} catch {
return; // dir absent — fine
}
for (const f of files) {
let tool = null;
try {
tool = validate(JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")), source);
} catch {
/* fall through to the log line */
}
if (!tool) {
console.warn(`ai-tools: skipping invalid catalog file ${path.join(dir, f)}`);
continue;
}
// Custom beats built-in; within one dir the first file wins.
if (!byId.has(tool.id) || (source === "custom" && byId.get(tool.id).source === "builtin")) {
byId.set(tool.id, tool);
}
}
});
const rank = (t) => (t.id === "claude" ? 0 : t.id === "codex" ? 1 : 2);
const sorted = [...byId.values()].sort(
(a, b) => rank(a) - rank(b) || a.label.localeCompare(b.label)
);
return new Map(sorted.map((t) => [t.id, t]));
}
function cat() {
if (!catalog) catalog = load();
return catalog;
}
const list = () => [...cat().values()];
const get = (id) => cat().get(id) || null;
const has = (id) => typeof id === "string" && cat().has(id);
function which(name) {
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
if (!dir) continue;
const p = path.join(dir, name);
try {
if (fs.statSync(p).isFile()) {
fs.accessSync(p, fs.constants.X_OK);
return p;
}
} catch {
/* not here */
}
}
return null;
}
/** `{installed, path, command}` for one tool, cached until refresh() unless `fresh`. */
function detect(id, { fresh = false } = {}) {
if (!fresh && detected.has(id)) return detected.get(id);
const tool = get(id);
let out = { installed: false, path: null, command: tool ? tool.detect[0] : null };
if (tool) {
for (const name of tool.detect) {
const p = which(name);
if (p) {
out = { installed: tool.requires.every((r) => which(r)), path: p, command: name };
break;
}
}
}
detected.set(id, out);
return out;
}
function refresh() {
catalog = null;
detected.clear();
}
const statePath = () => path.join(getDataDir(), "ai-tools.json");
function readState() {
try {
const s = JSON.parse(fs.readFileSync(statePath(), "utf8"));
return {
disabled: Array.isArray(s.disabled) ? s.disabled.filter((x) => typeof x === "string") : [],
default: typeof s.default === "string" ? s.default : null,
};
} catch {
return { disabled: [], default: null };
}
}
function writeState(state) {
fs.mkdirSync(path.dirname(statePath()), { recursive: true });
const tmp = `${statePath()}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n");
fs.renameSync(tmp, statePath());
}
const isEnabled = (id) => !readState().disabled.includes(id);
function requireTool(id) {
if (!has(id)) throw makeErr("ENOTOOL", `unknown AI tool: ${id}`);
}
function setEnabled(id, enabled) {
requireTool(id);
const s = readState();
s.disabled = s.disabled.filter((x) => x !== id);
if (!enabled) {
s.disabled.push(id);
if (s.default === id) s.default = null;
}
writeState(s);
}
function setDefault(id) {
requireTool(id);
if (!detect(id).installed || !isEnabled(id)) {
throw makeErr("EBADDEFAULT", `${id} must be installed and enabled to be the default`);
}
writeState({ ...readState(), default: id });
}
function effectiveDefault() {
const s = readState();
const ok = (id) => has(id) && detect(id).installed && !s.disabled.includes(id);
if (s.default && ok(s.default)) return s.default;
if (ok("claude")) return "claude";
return list().find((t) => ok(t.id))?.id || null;
}
/** The GET /api/ai-tools body — flags only, never the arg arrays. */
function view() {
const disabled = new Set(readState().disabled);
return {
tools: list().map((t) => {
const d = detect(t.id);
return {
id: t.id,
label: t.label,
command: d.command,
homepageUrl: t.homepageUrl,
installed: d.installed,
path: d.path,
enabled: !disabled.has(t.id),
source: t.source,
launcher: t.launcher,
prompt: t.prompt ? { mode: t.prompt.mode } : null,
resume: !!t.resume,
bypass: !!(t.bypass || t.bypassEnv),
routerCapable: ROUTER_CAPABLE.has(t.id),
};
}),
default: effectiveDefault(),
};
}
/** Test seam: `[builtinDir, customDir]`, or null for the real ones. */
function __setCatalogDirs(dirs) {
dirsOverride = dirs;
refresh();
}
module.exports = {
list,
get,
has,
detect,
refresh,
readState,
isEnabled,
setEnabled,
setDefault,
effectiveDefault,
view,
ROUTER_CAPABLE,
__setCatalogDirs,
};