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