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
+50
View File
@@ -0,0 +1,50 @@
/**
* @file HTTP surface for the AI Tools catalog (server/lib/ai-tools.js): list
* what is known and installed, re-detect after hydrating PATH from the login
* shell, and enable/disable a tool or make it the default. Nothing here takes
* a command or an argument from the request — the catalog files are the only
* source of what a lane may run. Writes sit behind the same-origin guard.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const express = require("express");
const aiTools = require("../lib/ai-tools");
const { hydrateShellPath } = require("../lib/shell-path");
const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
router.get("/", (_req, res) => {
res.json(aiTools.view());
});
router.post("/refresh", sameOriginGuard, async (_req, res) => {
await hydrateShellPath();
aiTools.refresh();
res.json(aiTools.view());
});
router.put("/:id", sameOriginGuard, (req, res) => {
const { enabled, default: makeDefault } = req.body || {};
try {
if (enabled !== undefined) {
if (typeof enabled !== "boolean") {
return res
.status(400)
.json({ error: { code: "EBADFIELD", message: "enabled must be a boolean" } });
}
aiTools.setEnabled(req.params.id, enabled);
}
if (makeDefault === true) aiTools.setDefault(req.params.id);
else if (enabled === undefined && !aiTools.has(req.params.id)) {
return res.status(404).json({ error: { code: "ENOTOOL", message: "unknown AI tool" } });
}
res.json(aiTools.view());
} catch (e) {
const status = e.code === "ENOTOOL" ? 404 : e.code === "EBADDEFAULT" ? 400 : 500;
res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
});
module.exports = router;
+18 -5
View File
@@ -14,6 +14,7 @@ const os = require("node:os");
const path = require("node:path");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const aiTools = require("../lib/ai-tools");
const {
listPipelines,
getPipeline,
@@ -333,7 +334,7 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir()
);
if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) {
if (body.tool !== undefined && !aiTools.has(body.tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
}
@@ -370,7 +371,12 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
broadcastLane(lane.id);
return res.status(201).json({ lane: payload(lane), created: true });
} catch (err) {
if (err.code === "EBADCWD" || err.code === "EBADPIPELINE" || err.code === "EBADTOOL") {
if (
err.code === "EBADCWD" ||
err.code === "EBADPIPELINE" ||
err.code === "EBADTOOL" ||
err.code === "EDISABLED"
) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
// The cwd UNIQUE constraint is the arbiter: someone else won the race, so
@@ -457,7 +463,9 @@ router.post("/", sameOriginGuard, (req, res) => {
err.code === "EBADCWD" ||
err.code === "EBADPIPELINE" ||
err.code === "EBADKIND" ||
err.code === "EBADFIELD"
err.code === "EBADFIELD" ||
err.code === "EBADTOOL" ||
err.code === "EDISABLED"
) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
@@ -1044,8 +1052,12 @@ router.get("/:id/ci/status", async (req, res) => {
router.post("/worktree", sameOriginGuard, async (req, res) => {
const body = req.body || {};
if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
// Checked before any git work: a worktree for a tool that cannot get a lane
// would be left behind with no row pointing at it.
try {
lanesLib.validateTool(body.tool === undefined ? "claude" : body.tool);
} catch (err) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
const sourceRepo = body.sourceRepo;
@@ -1709,6 +1721,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
sandbox: body.sandbox,
approval: body.approval,
resumeLast: body.resumeLast === true,
bypass: body.bypass === true,
resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
});
startedRunId = handle.id;
+15 -12
View File
@@ -29,6 +29,7 @@ const runs = require("../lib/pty-run");
const tmux = require("../lib/tmux");
const lanesLib = require("../lib/lanes");
const { sameOriginGuard } = require("../lib/same-origin");
const aiTools = require("../lib/ai-tools");
const router = Router();
@@ -286,21 +287,16 @@ router.get("/files", (req, res) => {
router.get("/binary", (req, res) => {
// Surface whether the lane's CLI is on PATH so the UI can show a helpful
// error before the user clicks Run. We don't actually invoke it. The name is
// taken from a fixed allowlist, never from the query string directly — this
// string reaches `which`.
// error before the user clicks Run. We don't actually invoke it. The tool id
// is looked up in the AI Tools catalog; only a catalog file names a command.
const tool = req.query.tool === undefined ? "claude" : String(req.query.tool);
const bins = { claude: "claude", codex: "codex", antigravity: "agy" };
if (!Object.hasOwn(bins, tool)) {
if (!aiTools.has(tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
}
const which = require("node:child_process").spawnSync(
process.platform === "win32" ? "where" : "which",
[bins[tool]],
{ encoding: "utf8" }
);
const stdout = (which.stdout || "").trim();
res.json({ found: which.status === 0 && stdout.length > 0, path: stdout || null });
// Fresh, not the catalog's cache: a CLI installed while the dashboard is up
// should clear the missing-binary banner on the next look.
const d = aiTools.detect(tool, { fresh: true });
res.json({ found: d.installed, path: d.path });
});
router.get("/tmux", (_req, res) => {
@@ -333,6 +329,13 @@ router.post("/", (req, res) => {
? body.permissionMode
: "auto",
effort: typeof body.effort === "string" && body.effort ? body.effort : null,
// Codex's own fields, checked against pty-run's allowlists there.
sandbox: typeof body.sandbox === "string" && body.sandbox ? body.sandbox : null,
approval: typeof body.approval === "string" && body.approval ? body.approval : null,
resumeLast: body.resumeLast === true,
// Only for this run, only when the user ticked it; the args come from
// the lane tool's catalog file, never from here.
bypass: body.bypass === true,
resumeSessionId:
typeof body.resumeSessionId === "string" && body.resumeSessionId
? body.resumeSessionId