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:
+15
-6
@@ -1608,10 +1608,11 @@ async function cmdLanesAdd(args) {
|
|||||||
const pipeline = flag("pipeline");
|
const pipeline = flag("pipeline");
|
||||||
const base = flag("base");
|
const base = flag("base");
|
||||||
const slug = flag("slug");
|
const slug = flag("slug");
|
||||||
|
const tool = flag("tool");
|
||||||
if (repo) {
|
if (repo) {
|
||||||
if (cwd || pipeline) {
|
if (cwd || pipeline) {
|
||||||
console.error(
|
console.error(
|
||||||
"usage: agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]"
|
"usage: agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]"
|
||||||
);
|
);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
@@ -1619,6 +1620,7 @@ async function cmdLanesAdd(args) {
|
|||||||
const body = { sourceRepo: repo, title: title || path.basename(repo) };
|
const body = { sourceRepo: repo, title: title || path.basename(repo) };
|
||||||
if (base) body.base = base;
|
if (base) body.base = base;
|
||||||
if (slug) body.slug = slug;
|
if (slug) body.slug = slug;
|
||||||
|
if (tool) body.tool = tool;
|
||||||
const { lane: provisioning } = await post("/api/lanes/worktree", body);
|
const { lane: provisioning } = await post("/api/lanes/worktree", body);
|
||||||
const deadline = Date.now() + 30_000;
|
const deadline = Date.now() + 30_000;
|
||||||
let lane = provisioning;
|
let lane = provisioning;
|
||||||
@@ -1644,12 +1646,15 @@ async function cmdLanesAdd(args) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!cwd || !title) {
|
if (!cwd || !title) {
|
||||||
console.error("usage: agenthub lanes add --cwd <path> --title <text> [--pipeline <id>]");
|
console.error(
|
||||||
|
"usage: agenthub lanes add --cwd <path> --title <text> [--pipeline <id>] [--tool <id>]"
|
||||||
|
);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = { cwd, title };
|
const body = { cwd, title };
|
||||||
if (pipeline) body.pipeline = pipeline;
|
if (pipeline) body.pipeline = pipeline;
|
||||||
|
if (tool) body.tool = tool;
|
||||||
const { lane } = await post("/api/lanes", body);
|
const { lane } = await post("/api/lanes", body);
|
||||||
console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
|
console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
|
||||||
}
|
}
|
||||||
@@ -2666,7 +2671,7 @@ async function cmdStage(args) {
|
|||||||
const stage = args[0];
|
const stage = args[0];
|
||||||
if (!stage || stage.startsWith("--")) {
|
if (!stage || stage.startsWith("--")) {
|
||||||
console.error(
|
console.error(
|
||||||
"usage: agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
|
"usage: agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
|
||||||
);
|
);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
@@ -2789,10 +2794,14 @@ const COMMAND_GROUPS = [
|
|||||||
"Lanes",
|
"Lanes",
|
||||||
[
|
[
|
||||||
["lanes", "", "List lanes with stage, liveness and progress"],
|
["lanes", "", "List lanes with stage, liveness and progress"],
|
||||||
["lanes add", "--cwd <path> --title <text>", "Create a new lane bound to a directory"],
|
|
||||||
[
|
[
|
||||||
"lanes add",
|
"lanes add",
|
||||||
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>]",
|
"--cwd <path> --title <text> [--tool <id>]",
|
||||||
|
"Create a new lane bound to a directory (--tool: an AI Tools id, default claude)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes add",
|
||||||
|
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]",
|
||||||
"Provision a managed worktree lane",
|
"Provision a managed worktree lane",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -2871,7 +2880,7 @@ const COMMAND_GROUPS = [
|
|||||||
[
|
[
|
||||||
"stage <stage> [flags]",
|
"stage <stage> [flags]",
|
||||||
"",
|
"",
|
||||||
"Report the current pipeline stage for a lane (--tool claude|codex when one cwd has both)",
|
"Report the current pipeline stage for a lane (--tool <id> when one cwd has lanes for two tools)",
|
||||||
],
|
],
|
||||||
["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
|
["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# AI Tools A1 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** AgentHub knows ~38 AI coding CLIs from a JSON catalog, detects which are installed, lets the user enable/disable them and pick a default, and runs a lane on any of them.
|
||||||
|
|
||||||
|
**Architecture:** One server module `server/lib/ai-tools.js` owns catalog load/validation, PATH detection and the enable/default state file; `server/lib/shell-path.js` hydrates `process.env.PATH` from the login shell. `pty-run.js` keeps its Claude/Codex builders (`launcher`) and gains one generic catalog builder. The client reads everything through one hook, `useAiTools`.
|
||||||
|
|
||||||
|
**Tech Stack:** Node/Express + better-sqlite3 (server, `node:test`), React + Vite + vitest (client), tmux.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-09-28-ai-tools-a1-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Catalog id: `^[a-z0-9][a-z0-9-]{0,39}$`; `detect`/`requires` entries are bare names, no `/`.
|
||||||
|
- `launcher` is accepted only in built-in files (`"claude"|"codex"`).
|
||||||
|
- Args and env only ever come from the catalog; the prompt is ONE argv element or a tmux paste buffer; never a shell string.
|
||||||
|
- Bypass args are emitted only when the run request carries `bypass: true`; never remembered.
|
||||||
|
- State file: `<getDataDir()>/ai-tools.json` = `{ "disabled": [], "default": null }`.
|
||||||
|
- PATH hydration: `$SHELL` (fallback `/bin/bash`) `-ilc`, delimiters, 5 s timeout, stdin ignored, after listen, off with `AGENTHUB_SHELL_PATH=0`.
|
||||||
|
- `routerCapable` = `claude`, `codex` only.
|
||||||
|
- Every new source file starts with the header ending `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`.
|
||||||
|
- Server tests: `npm run test:server`; client: `cd client && NODE_ENV=test npx vitest run`; commits: `NODE_ENV=test git commit` (hook runs both suites, long timeout).
|
||||||
|
- Never push without an explicit ask.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Catalog, detection, state (`server/lib/ai-tools.js`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server/lib/ai-tools.js`, `server/data/ai-tools/*.json` (38 files), `server/data/ai-tools/README.md`
|
||||||
|
- Test: `server/__tests__/ai-tools.test.js`
|
||||||
|
|
||||||
|
**Interfaces (Produces):**
|
||||||
|
- `list(): Tool[]` — catalog order: `claude`, `codex`, then by label.
|
||||||
|
- `get(id): Tool|null`, `has(id): boolean`
|
||||||
|
- `detect(id): {installed, path, command}` (cached), `refresh()` clears the cache and re-reads the catalog.
|
||||||
|
- `readState(): {disabled: string[], default: string|null}`, `isEnabled(id)`, `setEnabled(id, bool)`, `setDefault(id)` (throws `ENOTOOL` / `EBADDEFAULT`), `effectiveDefault(): string|null`
|
||||||
|
- `view(): {tools: ToolView[], default}` — the GET body.
|
||||||
|
- `ROUTER_CAPABLE = new Set(["claude","codex"])`
|
||||||
|
- `__setCatalogDirs([...])` test seam (built-in dir, custom dir).
|
||||||
|
- `Tool = {id,label,detect,requires,args,homepageUrl,faviconDomain,prompt,resume,bypass,bypassEnv,launcher,source}`.
|
||||||
|
|
||||||
|
- [ ] Write failing tests: bad id skipped; `/` in detect skipped; non-array args skipped; `launcher` in custom file skipped; custom id replaces built-in; detection over a temp PATH with an executable and a non-executable file; `requires` missing ⇒ not installed; enable/default rules (default falls back to claude, then first installed enabled; disabling the default clears it; `EBADDEFAULT` for not-installed/disabled; `ENOTOOL` unknown).
|
||||||
|
- [ ] Implement; write the 38 JSON files from the Orca table (spec §1 mapping); README attribution (Orca MIT).
|
||||||
|
- [ ] `npm run test:server` green.
|
||||||
|
|
||||||
|
### Task 2: PATH hydration (`server/lib/shell-path.js`)
|
||||||
|
|
||||||
|
**Interfaces:** `hydrateShellPath({spawn?, shell?, timeoutMs?}) : Promise<boolean>` — merges new segments into `process.env.PATH`, returns whether it ran.
|
||||||
|
|
||||||
|
- [ ] Tests with an injected `spawn` (execFile-like): delimiters with noisy rc output, timeout, spawn error, `AGENTHUB_SHELL_PATH=0`, no duplicate segments.
|
||||||
|
- [ ] Implement with `child_process.execFile(shell, ["-ilc", "printf '__AGENTHUB_PATH__%s__AGENTHUB_PATH__' \"$PATH\""], {timeout:5000, stdio ignore stdin})`.
|
||||||
|
|
||||||
|
### Task 3: Routes `/api/ai-tools`
|
||||||
|
|
||||||
|
**Files:** Create `server/routes/ai-tools.js`, `server/openapi-extra/ai-tools.js`; modify `server/index.js` (mount + hydrate after listen), `server/openapi-extra.js`, `server/routes/run.js` (binary probe via catalog).
|
||||||
|
- `GET /` → `view()`; `POST /refresh` → hydrate, `refresh()`, `view()`; `PUT /:id {enabled?, default?:true}`. Mutations behind `sameOriginGuard`.
|
||||||
|
- [ ] Route test (pattern: `git-credentials-route.test.js`): GET shape, PUT enable/disable, default errors, `/api/run/binary?tool=nope` 400, `?tool=antigravity` resolves through catalog.
|
||||||
|
|
||||||
|
### Task 4: Lanes and spawn on any tool
|
||||||
|
|
||||||
|
**Files:** `server/lib/lanes.js`, `server/routes/lanes.js`, `server/lib/pty-run.js`, `server/lib/tmux.js` (`pasteText`), `server/lib/dashboard-runs.js`, `server/lib/router-apply.js`, `server/lib/ai-routers.js` (builtin label "Native"), `server/routes/run.js` (forward sandbox/approval/resumeLast/bypass), `bin/agenthub.js` (`lanes add --tool`).
|
||||||
|
- `VALID_TOOLS` ⇒ `aiTools.has`; lane creation on a disabled tool ⇒ 400 `EDISABLED`.
|
||||||
|
- `buildCatalogArgv(tool, {resumeLast, bypass, initialPrompt}, bin)` → `[bin, ...args, ...resume?, ...bypass?, ...prompt]`; `after-start` ⇒ `setTimeout(delayMs)` then `tmux.pasteText(id, prompt)` if the session still exists.
|
||||||
|
- Every spawn env carries `PATH: process.env.PATH`; catalog tools start by `detect().path` (fallback first `detect` name); `bypassEnv` merged when `bypass`.
|
||||||
|
- Codex: `bypass` ⇒ catalog codex bypass args, and `-s`/`-a` dropped.
|
||||||
|
- Unknown tool ⇒ `ENOTOOL` at spawn.
|
||||||
|
- router-apply: non-`routerCapable` accepts only native/builtin.
|
||||||
|
- [ ] Tests: argv per prompt mode, resume, bypass on/off, Codex bypass, Claude ignores bypass, after-start paste scheduling (fake timers), PATH in env, EDISABLED/EBADTOOL/not-installed allowed, router-apply generic, dashboard-runs any id.
|
||||||
|
|
||||||
|
### Task 5: Client data layer
|
||||||
|
|
||||||
|
**Files:** `client/src/lib/api.ts` (`aiTools.list/refresh/update`, `AiTool` type, `RunStartArgs.bypass`), `client/src/lib/types.ts` (`LaneTool = string`, drop `LANE_TOOL_LABEL`), create `client/src/hooks/useAiTools.ts` (`useAiTools(): {tools, default, byId, label(id), reload, loading}` — module-level shared cache + subscribers, refetch on focus), replace all `LANE_TOOL_LABEL` uses and the Workspace per-tool binary probes.
|
||||||
|
|
||||||
|
### Task 6: Settings → AI Tools section
|
||||||
|
|
||||||
|
**Files:** Create `client/src/components/settings/AiToolsSettings.tsx`; modify `client/src/pages/Settings.tsx` (TOC + section), i18n `settings.json` en/vi; hide Claude/Codex tabs while disabled (settings hub nav).
|
||||||
|
- [ ] Test `client/src/components/settings/__tests__/AiToolsSettings.test.tsx`: rows, toggle calls `update(id,{enabled})`, set default, refresh, available rows show only link.
|
||||||
|
|
||||||
|
### Task 7: Add lane + router row + run form
|
||||||
|
|
||||||
|
**Files:** `AddLaneModal.tsx` (Default / Other… select, empty state link), `LaneCard.tsx` `LaneRouterRow` ("Native (<label>)", router options disabled for non-routerCapable), `Workspace.tsx`, `RunSetup.tsx` (catalog form: resume, red bypass checkbox; Codex bypass), `LaneConsolePane.tsx`, i18n.
|
||||||
|
- [ ] Tests updated/added for Default/Other, disabled router option, resume + bypass on the catalog form, Codex bypass.
|
||||||
|
|
||||||
|
### Task 8: Docs + full verification
|
||||||
|
|
||||||
|
- CLAUDE.md (tool rule, bypass rule, router label, marker), AGENTS.md, docs/LANES.md, docs/API.md, docs/CLI.md, server/README.md, `npm run openapi:yaml`.
|
||||||
|
- `npm run test:server`, `cd client && NODE_ENV=test npx vitest run` (review snapshot diffs), `npx tsc --noEmit`, `bash skills/file-headers/scripts/check-headers.sh`.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* @file Route tests for /api/ai-tools: list shape, enable/disable, default
|
||||||
|
* errors, the same-origin guard, and /api/run/binary resolving through the
|
||||||
|
* catalog.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, 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 http = require("node:http");
|
||||||
|
|
||||||
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tools-route-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||||
|
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||||
|
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||||
|
process.env.AGENTHUB_SHELL_PATH = "0";
|
||||||
|
const BIN = path.join(ROOT, "bin");
|
||||||
|
fs.mkdirSync(BIN);
|
||||||
|
for (const name of ["claude", "agy"]) {
|
||||||
|
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
|
||||||
|
fs.chmodSync(path.join(BIN, name), 0o755);
|
||||||
|
}
|
||||||
|
const origPath = process.env.PATH;
|
||||||
|
process.env.PATH = BIN;
|
||||||
|
|
||||||
|
const { createApp } = require("../index");
|
||||||
|
require("../lib/ai-tools").refresh();
|
||||||
|
|
||||||
|
let app;
|
||||||
|
let base;
|
||||||
|
const request = (method, p, body, headers = {}) =>
|
||||||
|
fetch(`${base}${p}`, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json", ...headers },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
app = http.createServer(createApp());
|
||||||
|
await new Promise((r) => app.listen(0, "127.0.0.1", r));
|
||||||
|
base = `http://127.0.0.1:${app.address().port}`;
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
process.env.PATH = origPath;
|
||||||
|
await new Promise((r) => app.close(r));
|
||||||
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/api/ai-tools", () => {
|
||||||
|
it("lists the catalog with detection and the default", async () => {
|
||||||
|
const { status, body } = await request("GET", "/api/ai-tools");
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.equal(body.default, "claude");
|
||||||
|
const agy = body.tools.find((t) => t.id === "antigravity");
|
||||||
|
assert.equal(agy.installed, true);
|
||||||
|
assert.equal(agy.path, path.join(BIN, "agy"));
|
||||||
|
assert.equal(agy.resume, true);
|
||||||
|
assert.equal(agy.bypass, true);
|
||||||
|
assert.equal(body.tools.find((t) => t.id === "gemini").installed, false);
|
||||||
|
assert.equal("args" in agy, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables and re-enables a tool, and sets the default", async () => {
|
||||||
|
let r = await request("PUT", "/api/ai-tools/claude", { enabled: false });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.tools.find((t) => t.id === "claude").enabled, false);
|
||||||
|
assert.equal(r.body.default, "antigravity");
|
||||||
|
r = await request("PUT", "/api/ai-tools/claude", { enabled: true });
|
||||||
|
assert.equal(r.body.default, "claude");
|
||||||
|
r = await request("PUT", "/api/ai-tools/antigravity", { default: true });
|
||||||
|
assert.equal(r.body.default, "antigravity");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => {
|
||||||
|
assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404);
|
||||||
|
const bad = await request("PUT", "/api/ai-tools/gemini", { default: true });
|
||||||
|
assert.equal(bad.status, 400);
|
||||||
|
assert.equal(bad.body.error.code, "EBADDEFAULT");
|
||||||
|
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a cross-origin write", async () => {
|
||||||
|
const r = await request(
|
||||||
|
"PUT",
|
||||||
|
"/api/ai-tools/claude",
|
||||||
|
{ enabled: false },
|
||||||
|
{ Origin: "https://evil.test" }
|
||||||
|
);
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refresh re-detects", async () => {
|
||||||
|
fs.writeFileSync(path.join(BIN, "gemini"), "#!/bin/sh\n");
|
||||||
|
fs.chmodSync(path.join(BIN, "gemini"), 0o755);
|
||||||
|
const r = await request("POST", "/api/ai-tools/refresh");
|
||||||
|
assert.equal(r.body.tools.find((t) => t.id === "gemini").installed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/run/binary resolves through the catalog", async () => {
|
||||||
|
const r = await request("GET", "/api/run/binary?tool=antigravity");
|
||||||
|
assert.deepEqual(r.body, { found: true, path: path.join(BIN, "agy") });
|
||||||
|
assert.equal((await request("GET", "/api/run/binary?tool=rm")).status, 400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lanes on catalog tools", () => {
|
||||||
|
it("creates a lane for an installed or not-installed tool, refuses unknown/disabled", async () => {
|
||||||
|
const dir = (n) => {
|
||||||
|
const d = path.join(ROOT, n);
|
||||||
|
fs.mkdirSync(d, { recursive: true });
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "gemini" });
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
assert.equal(r.body.lane.tool, "gemini");
|
||||||
|
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "aider" });
|
||||||
|
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
|
||||||
|
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
|
||||||
|
assert.equal(r.body.error.code, "EBADTOOL");
|
||||||
|
await request("PUT", "/api/ai-tools/aider", { enabled: false });
|
||||||
|
r = await request("POST", "/api/lanes", { cwd: dir("d"), title: "d", tool: "aider" });
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.equal(r.body.error.code, "EDISABLED");
|
||||||
|
r = await request("POST", "/api/lanes/ensure", { cwd: dir("e"), tool: "aider" });
|
||||||
|
assert.equal(r.body.error.code, "EDISABLED");
|
||||||
|
r = await request("POST", "/api/lanes/ensure", { cwd: dir("b"), tool: "aider" });
|
||||||
|
assert.equal(r.status, 200, "an existing lane on a disabled tool is still found");
|
||||||
|
r = await request("POST", "/api/lanes/worktree", { sourceRepo: dir("f"), tool: "aider" });
|
||||||
|
assert.equal(r.body.error.code, "EDISABLED");
|
||||||
|
await request("PUT", "/api/ai-tools/aider", { enabled: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1441,7 +1441,7 @@ describe("/api/lanes tool tests", () => {
|
|||||||
it("POST /api/lanes/ensure rejects an unknown tool", async () => {
|
it("POST /api/lanes/ensure rejects an unknown tool", async () => {
|
||||||
const res = await request("POST", "/api/lanes/ensure", {
|
const res = await request("POST", "/api/lanes/ensure", {
|
||||||
cwd: "/tmp/bad-tool",
|
cwd: "/tmp/bad-tool",
|
||||||
tool: "cursor",
|
tool: "not-a-tool",
|
||||||
});
|
});
|
||||||
assert.equal(res.status, 400);
|
assert.equal(res.status, 400);
|
||||||
assert.equal(res.body.error.code, "EBADTOOL");
|
assert.equal(res.body.error.code, "EBADTOOL");
|
||||||
@@ -1595,7 +1595,7 @@ describe("/api/lanes tool tests", () => {
|
|||||||
|
|
||||||
const badRes = await request("POST", "/api/lanes/ensure", {
|
const badRes = await request("POST", "/api/lanes/ensure", {
|
||||||
cwd: "/tmp/dual-invalid-tool-cwd",
|
cwd: "/tmp/dual-invalid-tool-cwd",
|
||||||
tool: "cursor", // invalid
|
tool: "not-a-tool", // invalid
|
||||||
});
|
});
|
||||||
assert.equal(badRes.status, 400);
|
assert.equal(badRes.status, 400);
|
||||||
assert.equal(badRes.body.error.code, "EBADTOOL");
|
assert.equal(badRes.body.error.code, "EBADTOOL");
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ describe("lanes lib", () => {
|
|||||||
|
|
||||||
it("createLane rejects an unknown tool", () => {
|
it("createLane rejects an unknown tool", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => lanes.createLane({ cwd: "/tmp/tool-bad", tool: "cursor" }),
|
() => lanes.createLane({ cwd: "/tmp/tool-bad", tool: "not-a-tool" }),
|
||||||
(err) => err.code === "EBADTOOL"
|
(err) => err.code === "EBADTOOL"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -398,7 +398,31 @@ describe("pty-run", () => {
|
|||||||
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
|
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", () => {
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("starts antigravity by absolute path, dropping flags it has no use for", () => {
|
||||||
const call = captureNewSession(() =>
|
const call = captureNewSession(() =>
|
||||||
pty.spawnRun({
|
pty.spawnRun({
|
||||||
laneId: 13,
|
laneId: 13,
|
||||||
@@ -410,13 +434,126 @@ describe("pty-run", () => {
|
|||||||
initialPrompt: "hello",
|
initialPrompt: "hello",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert.deepEqual(paneArgv(call), ["agy", "-i", "hello"]);
|
assert.deepEqual(paneArgv(call), [at("agy"), "-i", "hello"]);
|
||||||
|
assert.equal(envOf(call, "PATH"), process.env.PATH);
|
||||||
|
|
||||||
tmux.__reset();
|
tmux.__reset();
|
||||||
const last = captureNewSession(() =>
|
const last = captureNewSession(() =>
|
||||||
pty.spawnRun({ laneId: 14, cwd: "/tmp/repo", tool: "antigravity", resumeLast: true })
|
pty.spawnRun({
|
||||||
|
laneId: 14,
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
tool: "antigravity",
|
||||||
|
resumeLast: true,
|
||||||
|
bypass: true,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
assert.deepEqual(paneArgv(last), ["agy", "--continue"]);
|
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
|
// `dashboard_runs.mode` is NOT NULL and recordRun swallows insert errors, so a
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for server/lib/shell-path.js — login-shell PATH hydration with a
|
||||||
|
* stubbed spawner (noisy rc output, errors, opt-out, dedupe) plus one real
|
||||||
|
* `/bin/sh` run to prove the argv works end to end.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { hydrateShellPath } = require("../lib/shell-path");
|
||||||
|
|
||||||
|
const M = "__AGENTHUB_PATH__";
|
||||||
|
const stub =
|
||||||
|
(stdout, err = null) =>
|
||||||
|
(_cmd, _args, _opts, cb) => {
|
||||||
|
setImmediate(() => cb(err, stdout));
|
||||||
|
return { stdin: { end() {} } };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("hydrateShellPath", () => {
|
||||||
|
let saved;
|
||||||
|
beforeEach(() => {
|
||||||
|
saved = { PATH: process.env.PATH, OPT: process.env.AGENTHUB_SHELL_PATH };
|
||||||
|
process.env.PATH = "/usr/bin:/bin";
|
||||||
|
delete process.env.AGENTHUB_SHELL_PATH;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.PATH = saved.PATH;
|
||||||
|
if (saved.OPT === undefined) delete process.env.AGENTHUB_SHELL_PATH;
|
||||||
|
else process.env.AGENTHUB_SHELL_PATH = saved.OPT;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends new segments from between the delimiters, ignoring rc noise", async () => {
|
||||||
|
const ok = await hydrateShellPath({
|
||||||
|
spawn: stub(`welcome!\n${M}/home/u/.local/bin:/usr/bin:/opt/x${M}bye`),
|
||||||
|
});
|
||||||
|
assert.equal(ok, true);
|
||||||
|
assert.equal(process.env.PATH, "/usr/bin:/bin:/home/u/.local/bin:/opt/x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps PATH on error or missing delimiters", async () => {
|
||||||
|
assert.equal(await hydrateShellPath({ spawn: stub("", new Error("timeout")) }), false);
|
||||||
|
assert.equal(await hydrateShellPath({ spawn: stub("no markers") }), false);
|
||||||
|
assert.equal(
|
||||||
|
await hydrateShellPath({
|
||||||
|
spawn: () => {
|
||||||
|
throw new Error("ENOENT");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(process.env.PATH, "/usr/bin:/bin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing with AGENTHUB_SHELL_PATH=0", async () => {
|
||||||
|
process.env.AGENTHUB_SHELL_PATH = "0";
|
||||||
|
assert.equal(await hydrateShellPath({ spawn: stub(`${M}/x${M}`) }), false);
|
||||||
|
assert.equal(process.env.PATH, "/usr/bin:/bin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a real shell's PATH", async () => {
|
||||||
|
const ok = await hydrateShellPath({ shell: "/bin/sh" });
|
||||||
|
assert.equal(ok, true);
|
||||||
|
assert.ok(process.env.PATH.startsWith("/usr/bin:/bin"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# AI Tools catalog
|
||||||
|
|
||||||
|
One JSON file per AI coding CLI AgentHub can run in a lane. Loaded by
|
||||||
|
`server/lib/ai-tools.js`; the fields are described in
|
||||||
|
`docs/superpowers/specs/2026-09-28-ai-tools-a1-design.md` § 1. A file in
|
||||||
|
`DASHBOARD_AI_TOOLS_DIR` with the same `id` replaces the built-in one (it may
|
||||||
|
not set `launcher`).
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
The commands, prompt modes, permission-bypass flags and homepages of the tools
|
||||||
|
other than Claude Code, Codex and Antigravity come from Orca
|
||||||
|
(<https://github.com/stablyai/orca>, MIT License, Copyright (c) Stably AI):
|
||||||
|
`src/renderer/src/lib/agent-catalog.tsx`, `src/shared/tui-agent-config.ts` and
|
||||||
|
`src/shared/tui-agent-permissions.ts`.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "aider",
|
||||||
|
"label": "Aider",
|
||||||
|
"detect": [
|
||||||
|
"aider"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--yes-always"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://aider.chat/docs/"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"id": "amp",
|
||||||
|
"label": "Amp",
|
||||||
|
"detect": [
|
||||||
|
"amp"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--dangerously-allow-all"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://ampcode.com/manual#install",
|
||||||
|
"faviconDomain": "ampcode.com"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"id": "ante",
|
||||||
|
"label": "Ante",
|
||||||
|
"detect": [
|
||||||
|
"ante"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--yolo"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://github.com/AntigmaLabs/ante-preview",
|
||||||
|
"faviconDomain": "antigma.ai"
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"id": "antigravity",
|
||||||
|
"label": "Antigravity",
|
||||||
|
"detect": [
|
||||||
|
"agy"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "flag",
|
||||||
|
"flag": "-i"
|
||||||
|
},
|
||||||
|
"resume": [
|
||||||
|
"--continue"
|
||||||
|
],
|
||||||
|
"bypass": [
|
||||||
|
"--dangerously-skip-permissions"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://antigravity.google/docs/cli/overview",
|
||||||
|
"faviconDomain": "antigravity.google"
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "aug",
|
||||||
|
"label": "Auggie",
|
||||||
|
"detect": [
|
||||||
|
"auggie"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"homepageUrl": "https://docs.augmentcode.com/cli/overview",
|
||||||
|
"faviconDomain": "augmentcode.com"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"id": "autohand",
|
||||||
|
"label": "Autohand Code",
|
||||||
|
"detect": [
|
||||||
|
"autohand"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--unrestricted"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://github.com/autohandai/code-cli",
|
||||||
|
"faviconDomain": "autohand.ai"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "claude",
|
||||||
|
"label": "Claude Code",
|
||||||
|
"detect": [
|
||||||
|
"claude"
|
||||||
|
],
|
||||||
|
"launcher": "claude",
|
||||||
|
"homepageUrl": "https://code.claude.com/docs"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"id": "cline",
|
||||||
|
"label": "Cline",
|
||||||
|
"detect": [
|
||||||
|
"cline"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--auto-approve",
|
||||||
|
"true"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://docs.cline.bot/cline-cli/overview",
|
||||||
|
"faviconDomain": "cline.bot"
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "codebuff",
|
||||||
|
"label": "Codebuff",
|
||||||
|
"detect": [
|
||||||
|
"codebuff"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"homepageUrl": "https://www.codebuff.com/docs/help/quick-start",
|
||||||
|
"faviconDomain": "codebuff.com"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"id": "codex",
|
||||||
|
"label": "Codex",
|
||||||
|
"detect": [
|
||||||
|
"codex"
|
||||||
|
],
|
||||||
|
"launcher": "codex",
|
||||||
|
"bypass": [
|
||||||
|
"--dangerously-bypass-approvals-and-sandbox"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://github.com/openai/codex"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"id": "command-code",
|
||||||
|
"label": "Command Code",
|
||||||
|
"detect": [
|
||||||
|
"command-code"
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
"--trust"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "argv"
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--yolo"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://commandcode.ai/docs/quickstart"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"id": "continue",
|
||||||
|
"label": "Continue",
|
||||||
|
"detect": [
|
||||||
|
"cn"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--allow",
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://docs.continue.dev/guides/cli",
|
||||||
|
"faviconDomain": "continue.dev"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "copilot",
|
||||||
|
"label": "GitHub Copilot",
|
||||||
|
"detect": [
|
||||||
|
"copilot"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "flag",
|
||||||
|
"flag": "-i"
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--yolo"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"id": "crush",
|
||||||
|
"label": "Charm Crush",
|
||||||
|
"detect": [
|
||||||
|
"crush"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--yolo"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://github.com/charmbracelet/crush",
|
||||||
|
"faviconDomain": "charm.sh"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "cursor",
|
||||||
|
"label": "Cursor",
|
||||||
|
"detect": [
|
||||||
|
"cursor-agent"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "argv"
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--yolo"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://cursor.com/cli",
|
||||||
|
"faviconDomain": "cursor.com"
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"id": "devin",
|
||||||
|
"label": "Devin",
|
||||||
|
"detect": [
|
||||||
|
"devin"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--permission-mode",
|
||||||
|
"bypass",
|
||||||
|
"--respect-workspace-trust",
|
||||||
|
"false"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://devin.ai/cli",
|
||||||
|
"faviconDomain": "devin.ai"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "droid",
|
||||||
|
"label": "Droid",
|
||||||
|
"detect": [
|
||||||
|
"droid"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "argv"
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--auto",
|
||||||
|
"high"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://docs.factory.ai/cli/getting-started/quickstart"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"id": "gemini",
|
||||||
|
"label": "Gemini",
|
||||||
|
"detect": [
|
||||||
|
"gemini"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "flag",
|
||||||
|
"flag": "--prompt-interactive"
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--yolo"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://github.com/google-gemini/gemini-cli",
|
||||||
|
"faviconDomain": "gemini.google.com"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"id": "goose",
|
||||||
|
"label": "Goose",
|
||||||
|
"detect": [
|
||||||
|
"goose"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypassEnv": {
|
||||||
|
"GOOSE_MODE": "auto"
|
||||||
|
},
|
||||||
|
"homepageUrl": "https://block.github.io/goose/docs/quickstart/",
|
||||||
|
"faviconDomain": "goose-docs.ai"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"id": "grok",
|
||||||
|
"label": "Grok",
|
||||||
|
"detect": [
|
||||||
|
"grok"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "argv",
|
||||||
|
"separator": "--"
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--permission-mode",
|
||||||
|
"bypassPermissions"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://x.ai/cli",
|
||||||
|
"faviconDomain": "x.ai"
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"id": "hermes",
|
||||||
|
"label": "Hermes",
|
||||||
|
"detect": [
|
||||||
|
"hermes"
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
"--tui"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--yolo"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://hermes-agent.nousresearch.com/docs/",
|
||||||
|
"faviconDomain": "nousresearch.com"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"id": "kilo",
|
||||||
|
"label": "Kilocode",
|
||||||
|
"detect": [
|
||||||
|
"kilo"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"homepageUrl": "https://kilo.ai/docs/cli"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"id": "kimi",
|
||||||
|
"label": "Kimi",
|
||||||
|
"detect": [
|
||||||
|
"kimi",
|
||||||
|
"kimi-code"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--yolo"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html",
|
||||||
|
"faviconDomain": "moonshot.cn"
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"id": "kiro",
|
||||||
|
"label": "Kiro",
|
||||||
|
"detect": [
|
||||||
|
"kiro-cli"
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
"chat",
|
||||||
|
"--tui"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--trust-all-tools"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://kiro.dev/docs/cli/"
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "mimo-code",
|
||||||
|
"label": "MiMo Code",
|
||||||
|
"detect": [
|
||||||
|
"mimo"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "flag",
|
||||||
|
"flag": "--prompt"
|
||||||
|
},
|
||||||
|
"homepageUrl": "https://mimo.xiaomi.com/coder",
|
||||||
|
"faviconDomain": "mimo.xiaomi.com"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "mistral-vibe",
|
||||||
|
"label": "Mistral Vibe",
|
||||||
|
"detect": [
|
||||||
|
"vibe",
|
||||||
|
"mistral-vibe"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--agent",
|
||||||
|
"auto-approve"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://github.com/mistralai/mistral-vibe",
|
||||||
|
"faviconDomain": "mistral.ai"
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"id": "muse",
|
||||||
|
"label": "Muse",
|
||||||
|
"detect": [
|
||||||
|
"muse"
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
"--trust-workspace"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--yolo"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://dev.meta.ai/docs/muse-code",
|
||||||
|
"faviconDomain": "dev.meta.ai"
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "omp",
|
||||||
|
"label": "Oh My Pi (OMP)",
|
||||||
|
"detect": [
|
||||||
|
"omp"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "argv"
|
||||||
|
},
|
||||||
|
"homepageUrl": "https://omp.sh"
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"id": "openclaude",
|
||||||
|
"label": "OpenClaude",
|
||||||
|
"detect": [
|
||||||
|
"openclaude"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "argv"
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--dangerously-skip-permissions"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://openclaude.gitlawb.com/"
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "openclaw",
|
||||||
|
"label": "OpenClaw",
|
||||||
|
"detect": [
|
||||||
|
"openclaw"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"homepageUrl": "https://github.com/openclaw/openclaw",
|
||||||
|
"faviconDomain": "openclaw.ai"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"id": "opencode",
|
||||||
|
"label": "OpenCode",
|
||||||
|
"detect": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "flag",
|
||||||
|
"flag": "--prompt"
|
||||||
|
},
|
||||||
|
"homepageUrl": "https://opencode.ai/docs/cli/"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "opencode2",
|
||||||
|
"label": "OpenCode 2",
|
||||||
|
"detect": [
|
||||||
|
"opencode2"
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
"--standalone"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "flag",
|
||||||
|
"flag": "--prompt"
|
||||||
|
},
|
||||||
|
"homepageUrl": "https://opencode.ai/v2/docs/"
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "pi",
|
||||||
|
"label": "Pi",
|
||||||
|
"detect": [
|
||||||
|
"pi"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "argv"
|
||||||
|
},
|
||||||
|
"homepageUrl": "https://pi.dev"
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "prime-agent",
|
||||||
|
"label": "Prime Agent",
|
||||||
|
"detect": [
|
||||||
|
"prime-agent"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "argv",
|
||||||
|
"separator": "--"
|
||||||
|
},
|
||||||
|
"homepageUrl": "https://github.com/PrimeIntellect-ai/prime-agent",
|
||||||
|
"faviconDomain": "primeintellect.ai"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"id": "qwen-code",
|
||||||
|
"label": "Qwen Code",
|
||||||
|
"detect": [
|
||||||
|
"qwen"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--approval-mode",
|
||||||
|
"yolo"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://github.com/QwenLM/qwen-code",
|
||||||
|
"faviconDomain": "qwenlm.github.io"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"id": "rovo",
|
||||||
|
"label": "Rovo Dev",
|
||||||
|
"detect": [
|
||||||
|
"rovo"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--yolo"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/",
|
||||||
|
"faviconDomain": "atlassian.com"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"id": "trae",
|
||||||
|
"label": "Trae",
|
||||||
|
"detect": [
|
||||||
|
"traecli"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "argv",
|
||||||
|
"separator": "--"
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--yolo"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://docs.trae.cn/cli_get-started-with-trae-cli",
|
||||||
|
"faviconDomain": "www.trae.cn"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"id": "zcode",
|
||||||
|
"label": "ZCode",
|
||||||
|
"detect": [
|
||||||
|
"zcode"
|
||||||
|
],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "after-start",
|
||||||
|
"delayMs": 3000
|
||||||
|
},
|
||||||
|
"bypass": [
|
||||||
|
"--mode",
|
||||||
|
"yolo"
|
||||||
|
],
|
||||||
|
"homepageUrl": "https://zcode.z.ai/en/docs",
|
||||||
|
"faviconDomain": "zcode.z.ai"
|
||||||
|
}
|
||||||
@@ -90,6 +90,7 @@ const ccConfigRouter = require("./routes/cc-config");
|
|||||||
const codexConfigRouter = require("./routes/codex-config");
|
const codexConfigRouter = require("./routes/codex-config");
|
||||||
const aiRoutersRouter = require("./routes/ai-routers");
|
const aiRoutersRouter = require("./routes/ai-routers");
|
||||||
const gitCredentialsRouter = require("./routes/git-credentials");
|
const gitCredentialsRouter = require("./routes/git-credentials");
|
||||||
|
const aiToolsRouter = require("./routes/ai-tools");
|
||||||
const runRouter = require("./routes/run");
|
const runRouter = require("./routes/run");
|
||||||
const alertsRouter = require("./routes/alerts");
|
const alertsRouter = require("./routes/alerts");
|
||||||
const webhooksRouter = require("./routes/webhooks");
|
const webhooksRouter = require("./routes/webhooks");
|
||||||
@@ -133,6 +134,7 @@ function createApp() {
|
|||||||
app.use("/api/codex-config", codexConfigRouter);
|
app.use("/api/codex-config", codexConfigRouter);
|
||||||
app.use("/api/ai-routers", aiRoutersRouter);
|
app.use("/api/ai-routers", aiRoutersRouter);
|
||||||
app.use("/api/git-credentials", gitCredentialsRouter);
|
app.use("/api/git-credentials", gitCredentialsRouter);
|
||||||
|
app.use("/api/ai-tools", aiToolsRouter);
|
||||||
app.use("/api/run", runRouter);
|
app.use("/api/run", runRouter);
|
||||||
app.use("/api/lanes", lanesRouter);
|
app.use("/api/lanes", lanesRouter);
|
||||||
app.use("/api/locks", locksRouter);
|
app.use("/api/locks", locksRouter);
|
||||||
@@ -340,6 +342,13 @@ function startBackgroundServices() {
|
|||||||
// One-time legacy-session backfill (a no-op once its marker file exists).
|
// One-time legacy-session backfill (a no-op once its marker file exists).
|
||||||
autoImportLegacySessions();
|
autoImportLegacySessions();
|
||||||
|
|
||||||
|
// The service's PATH is whatever its unit captured at install time; read the
|
||||||
|
// login shell's so a CLI installed since is detected and reaches lane panes.
|
||||||
|
require("./lib/shell-path")
|
||||||
|
.hydrateShellPath()
|
||||||
|
.then(() => require("./lib/ai-tools").refresh())
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
// Rates for models seen in token_usage with no pricing row of their own.
|
// Rates for models seen in token_usage with no pricing row of their own.
|
||||||
require("./lib/pricing-sync").startPricingSync(require("./db").db);
|
require("./lib/pricing-sync").startPricingSync(require("./db").db);
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const BUILTIN_NAME = "native";
|
|||||||
*/
|
*/
|
||||||
const NATIVE = Object.freeze({
|
const NATIVE = Object.freeze({
|
||||||
name: BUILTIN_NAME,
|
name: BUILTIN_NAME,
|
||||||
label: "Native (Claude / Codex)",
|
label: "Native",
|
||||||
builtin: true,
|
builtin: true,
|
||||||
baseUrl: "",
|
baseUrl: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* @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,
|
||||||
|
};
|
||||||
@@ -89,7 +89,7 @@ function recordRun(handle) {
|
|||||||
started_at: startedAt,
|
started_at: startedAt,
|
||||||
ended_at: endedAt,
|
ended_at: endedAt,
|
||||||
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
||||||
tool: handle.tool === "codex" || handle.tool === "antigravity" ? handle.tool : "claude",
|
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Best-effort by design (a live run must never fail because sqlite did),
|
// Best-effort by design (a live run must never fail because sqlite did),
|
||||||
|
|||||||
+13
-4
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { db } = require("../db");
|
const { db } = require("../db");
|
||||||
|
const aiTools = require("./ai-tools");
|
||||||
const {
|
const {
|
||||||
DEFAULT_PIPELINE_ID,
|
DEFAULT_PIPELINE_ID,
|
||||||
listPipelines,
|
listPipelines,
|
||||||
@@ -97,12 +98,20 @@ function validateKind(kind) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID_TOOLS = new Set(["claude", "codex", "antigravity"]);
|
/**
|
||||||
|
* A lane may be created for any AI Tools catalog id, but not for one the user
|
||||||
|
* disabled. Only creation asks — an existing lane on a tool disabled since
|
||||||
|
* keeps working.
|
||||||
|
*/
|
||||||
function validateTool(tool) {
|
function validateTool(tool) {
|
||||||
if (!VALID_TOOLS.has(tool)) {
|
if (!aiTools.has(tool)) {
|
||||||
throw Object.assign(new Error(`unknown tool: ${tool}`), { code: "EBADTOOL" });
|
throw Object.assign(new Error(`unknown tool: ${tool}`), { code: "EBADTOOL" });
|
||||||
}
|
}
|
||||||
|
if (!aiTools.isEnabled(tool)) {
|
||||||
|
throw Object.assign(new Error(`${tool} is disabled in Settings → AI Tools`), {
|
||||||
|
code: "EDISABLED",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -888,7 +897,7 @@ function clearCodexPipelines() {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
DEAD_SEC,
|
DEAD_SEC,
|
||||||
VALID_TOOLS,
|
validateTool,
|
||||||
detectionIsStale,
|
detectionIsStale,
|
||||||
canonicalCwd,
|
canonicalCwd,
|
||||||
createLane,
|
createLane,
|
||||||
|
|||||||
+62
-18
@@ -32,6 +32,7 @@
|
|||||||
const tmux = require("./tmux");
|
const tmux = require("./tmux");
|
||||||
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
|
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
|
||||||
const { desktopEnv } = require("./desktop-env");
|
const { desktopEnv } = require("./desktop-env");
|
||||||
|
const aiTools = require("./ai-tools");
|
||||||
|
|
||||||
let dashboardRuns = null;
|
let dashboardRuns = null;
|
||||||
try {
|
try {
|
||||||
@@ -56,7 +57,6 @@ const ALLOWED_PERMISSION_MODES = new Set([
|
|||||||
]);
|
]);
|
||||||
// Pane commands that mean "idle shell prompt, safe to type a command into".
|
// Pane commands that mean "idle shell prompt, safe to type a command into".
|
||||||
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||||
const VALID_TOOLS = new Set(["claude", "codex", "antigravity"]);
|
|
||||||
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
||||||
const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
||||||
// Codex has no `--effort` flag; reasoning effort is a config override.
|
// Codex has no `--effort` flag; reasoning effort is a config override.
|
||||||
@@ -120,6 +120,7 @@ function buildCodexArgv({
|
|||||||
approval,
|
approval,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
resumeLast,
|
resumeLast,
|
||||||
|
bypass,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
routerProfile,
|
routerProfile,
|
||||||
}) {
|
}) {
|
||||||
@@ -131,30 +132,42 @@ function buildCodexArgv({
|
|||||||
if (resumeSessionId) argv.push("resume", resumeSessionId);
|
if (resumeSessionId) argv.push("resume", resumeSessionId);
|
||||||
else if (resumeLast) argv.push("resume", "--last");
|
else if (resumeLast) argv.push("resume", "--last");
|
||||||
if (model) argv.push("-m", model);
|
if (model) argv.push("-m", model);
|
||||||
|
// The bypass flag turns off both sandbox and approvals, so the two flags it
|
||||||
|
// replaces are not sent beside it.
|
||||||
|
if (bypass) argv.push(...(aiTools.get("codex")?.bypass || []));
|
||||||
|
else {
|
||||||
if (sandbox) argv.push("-s", sandbox);
|
if (sandbox) argv.push("-s", sandbox);
|
||||||
if (approval) argv.push("-a", approval);
|
if (approval) argv.push("-a", approval);
|
||||||
|
}
|
||||||
if (effort && CODEX_EFFORTS.has(effort)) argv.push("-c", `model_reasoning_effort=${effort}`);
|
if (effort && CODEX_EFFORTS.has(effort)) argv.push("-c", `model_reasoning_effort=${effort}`);
|
||||||
if (initialPrompt) argv.push(initialPrompt);
|
if (initialPrompt) argv.push(initialPrompt);
|
||||||
return argv;
|
return argv;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Antigravity's `agy` has no model, effort or permission-mode flag, so only
|
* Any catalog tool without a hand-written launcher: `[bin, ...args, ...resume?,
|
||||||
* resume and the first prompt reach it. `-i` keeps the session interactive
|
* ...bypass?, ...prompt]`. Every piece but the prompt comes from the catalog
|
||||||
* after that prompt (`-p` would print and exit). `--dangerously-skip-permissions`
|
* file, and the prompt is one argv element — or, for `after-start`, nothing
|
||||||
* is never emitted, same rule as Codex's bypass flags.
|
* here: spawnRun pastes it into the pane once the CLI is up.
|
||||||
*/
|
*/
|
||||||
function buildAntigravityArgv({ resumeLast, initialPrompt }) {
|
function buildCatalogArgv(entry, bin, { resumeLast, bypass, initialPrompt }) {
|
||||||
const argv = ["agy"];
|
const argv = [bin, ...entry.args];
|
||||||
if (resumeLast) argv.push("--continue");
|
if (resumeLast && entry.resume) argv.push(...entry.resume);
|
||||||
if (initialPrompt) argv.push("-i", initialPrompt);
|
if (bypass && entry.bypass) argv.push(...entry.bypass);
|
||||||
|
const p = entry.prompt;
|
||||||
|
if (initialPrompt && p.mode === "flag") argv.push(p.flag, initialPrompt);
|
||||||
|
if (initialPrompt && p.mode === "argv")
|
||||||
|
argv.push(...(p.separator ? [p.separator] : []), initialPrompt);
|
||||||
return argv;
|
return argv;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildArgv(args) {
|
function buildArgv(args, entry) {
|
||||||
if (args.tool === "codex") return buildCodexArgv(args);
|
if (entry.launcher === "codex") return buildCodexArgv(args);
|
||||||
if (args.tool === "antigravity") return buildAntigravityArgv(args);
|
if (entry.launcher === "claude") return buildClaudeArgv(args);
|
||||||
return buildClaudeArgv(args);
|
const d = aiTools.detect(entry.id);
|
||||||
|
// By absolute path when found; the bare name otherwise, so a CLI missing
|
||||||
|
// from PATH dies in the pane with the shell's own "not found".
|
||||||
|
return buildCatalogArgv(entry, d.path || entry.detect[0], args);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,6 +182,8 @@ function buildArgv(args) {
|
|||||||
* @param {string} [args.approval]
|
* @param {string} [args.approval]
|
||||||
* @param {string} [args.resumeSessionId]
|
* @param {string} [args.resumeSessionId]
|
||||||
* @param {boolean} [args.resumeLast]
|
* @param {boolean} [args.resumeLast]
|
||||||
|
* @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
|
||||||
|
* run; appends the catalog's bypass args/env. Claude has no catalog bypass.
|
||||||
* @param {string} [args.initialPrompt]
|
* @param {string} [args.initialPrompt]
|
||||||
* @param {string} [args.routerProfile] AI Router profile the LANE runs against
|
* @param {string} [args.routerProfile] AI Router profile the LANE runs against
|
||||||
* (`lanes.router_profile`), never a request field.
|
* (`lanes.router_profile`), never a request field.
|
||||||
@@ -186,6 +201,7 @@ function spawnRun(args) {
|
|||||||
approval,
|
approval,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
resumeLast,
|
resumeLast,
|
||||||
|
bypass,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
routerProfile,
|
routerProfile,
|
||||||
} = args || {};
|
} = args || {};
|
||||||
@@ -198,9 +214,10 @@ function spawnRun(args) {
|
|||||||
if (typeof cwd !== "string" || !cwd) {
|
if (typeof cwd !== "string" || !cwd) {
|
||||||
throw makeErr("EBADCWD", "cwd is required");
|
throw makeErr("EBADCWD", "cwd is required");
|
||||||
}
|
}
|
||||||
if (!VALID_TOOLS.has(tool)) {
|
// A lane whose tool has since left the catalog keeps its row; it just
|
||||||
throw makeErr("EBADTOOL", `tool must be one of: ${Array.from(VALID_TOOLS).join(", ")}`);
|
// cannot start until the catalog knows the tool again.
|
||||||
}
|
const entry = aiTools.get(tool);
|
||||||
|
if (!entry) throw makeErr("ENOTOOL", `unknown AI tool: ${tool}`);
|
||||||
if (tool === "codex") {
|
if (tool === "codex") {
|
||||||
if (sandbox != null && sandbox !== "" && !CODEX_SANDBOXES.has(sandbox)) {
|
if (sandbox != null && sandbox !== "" && !CODEX_SANDBOXES.has(sandbox)) {
|
||||||
throw makeErr(
|
throw makeErr(
|
||||||
@@ -248,7 +265,8 @@ function spawnRun(args) {
|
|||||||
|
|
||||||
const id = runIdForLane(laneId, slot);
|
const id = runIdForLane(laneId, slot);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const argv = buildArgv({
|
const argv = buildArgv(
|
||||||
|
{
|
||||||
tool,
|
tool,
|
||||||
model,
|
model,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
@@ -257,9 +275,13 @@ function spawnRun(args) {
|
|||||||
approval,
|
approval,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
resumeLast,
|
resumeLast,
|
||||||
|
bypass: bypass === true,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
routerProfile,
|
routerProfile,
|
||||||
});
|
},
|
||||||
|
entry
|
||||||
|
);
|
||||||
|
const pasteLater = entry.prompt?.mode === "after-start" && initialPrompt ? initialPrompt : null;
|
||||||
|
|
||||||
const record = () => {
|
const record = () => {
|
||||||
if (!dashboardRuns) return;
|
if (!dashboardRuns) return;
|
||||||
@@ -300,15 +322,36 @@ function spawnRun(args) {
|
|||||||
// starts has no clipboard at all — pasting an image into `claude` there
|
// starts has no clipboard at all — pasting an image into `claude` there
|
||||||
// answers "no image found in clipboard" while the same CLI pastes fine in a
|
// answers "no image found in clipboard" while the same CLI pastes fine in a
|
||||||
// terminal. Never overrides what this spawn already needs.
|
// terminal. Never overrides what this spawn already needs.
|
||||||
|
// PATH is the dashboard's own, hydrated from the login shell: a pane's env
|
||||||
|
// comes from the tmux server, and an npm-installed CLI (`#!/usr/bin/env
|
||||||
|
// node`) dies there without the `node` the user's shell would have found.
|
||||||
const env = {
|
const env = {
|
||||||
AGENTHUB_LANE_ID: String(laneId),
|
AGENTHUB_LANE_ID: String(laneId),
|
||||||
|
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||||
...desktopEnv(),
|
...desktopEnv(),
|
||||||
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
|
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
|
||||||
|
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// `after-start` tools take no prompt on their command line: paste it once
|
||||||
|
// the CLI has had `delayMs` to draw its input box. ponytail: fixed delay,
|
||||||
|
// add readiness detection (Orca's) if a tool drops prompts.
|
||||||
|
const schedulePaste = () => {
|
||||||
|
if (!pasteLater) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (tmux.hasSession(id)) tmux.pasteText(id, pasteLater);
|
||||||
|
} catch {
|
||||||
|
/* pane gone mid-paste — nothing to deliver to */
|
||||||
|
}
|
||||||
|
}, entry.prompt.delayMs);
|
||||||
|
if (timer.unref) timer.unref();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!tmux.hasSession(id)) {
|
if (!tmux.hasSession(id)) {
|
||||||
tmux.newSession({ name: id, cwd, argv, env });
|
tmux.newSession({ name: id, cwd, argv, env });
|
||||||
record();
|
record();
|
||||||
|
schedulePaste();
|
||||||
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
||||||
// The session exists but its pane is sitting at a bare shell prompt — a
|
// The session exists but its pane is sitting at a bare shell prompt — a
|
||||||
// `agenthub lanes shell`, or a `claude` that already exited. Adopting it
|
// `agenthub lanes shell`, or a `claude` that already exited. Adopting it
|
||||||
@@ -318,6 +361,7 @@ function spawnRun(args) {
|
|||||||
// instead of erroring or opening a second session.
|
// instead of erroring or opening a second session.
|
||||||
tmux.sendCommand(id, argv, env, cwd);
|
tmux.sendCommand(id, argv, env, cwd);
|
||||||
record();
|
record();
|
||||||
|
schedulePaste();
|
||||||
}
|
}
|
||||||
// Pane is running something (a live `claude`, an editor, a build): adopt
|
// Pane is running something (a live `claude`, an editor, a build): adopt
|
||||||
// silently, same convention as this repo's server port-adoption logic — no
|
// silently, same convention as this repo's server port-adoption logic — no
|
||||||
|
|||||||
@@ -185,12 +185,15 @@ function applyToCodexLane(lane, profile) {
|
|||||||
* @param {{id:number, cwd:string, tool:string}} lane the lane row
|
* @param {{id:number, cwd:string, tool:string}} lane the lane row
|
||||||
* @param {string} profileName a saved profile's name, or `"native"`
|
* @param {string} profileName a saved profile's name, or `"native"`
|
||||||
*/
|
*/
|
||||||
|
const { ROUTER_CAPABLE } = require("./ai-tools");
|
||||||
|
|
||||||
function applyRouterToLane(lane, profileName) {
|
function applyRouterToLane(lane, profileName) {
|
||||||
const profile = routers.getRouter(profileName); // throws ENOTFOUND
|
const profile = routers.getRouter(profileName); // throws ENOTFOUND
|
||||||
// `agy` has no endpoint/model override to write, so only native fits.
|
// Only Claude and Codex have an endpoint/model override to write; any other
|
||||||
if (lane.tool === "antigravity") {
|
// AI tool runs on its vendor's own settings, so native is the one fit.
|
||||||
if (profile.builtin) return { tool: "antigravity", file: null, profile: profile.name };
|
if (!ROUTER_CAPABLE.has(lane.tool || "claude")) {
|
||||||
throw Object.assign(new Error("an Antigravity lane cannot use an AI Router"), {
|
if (profile.builtin) return { tool: lane.tool, file: null, profile: profile.name };
|
||||||
|
throw Object.assign(new Error(`a ${lane.tool} lane cannot use an AI Router`), {
|
||||||
code: "EUNSUPPORTED",
|
code: "EUNSUPPORTED",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* @file Merge the user's login-shell PATH into `process.env.PATH`. A systemd
|
||||||
|
* unit or LaunchAgent snapshots PATH when it is installed, so a CLI installed
|
||||||
|
* later into a new directory (nvm, ~/.local/bin, a vendor installer) is
|
||||||
|
* invisible to AI Tools detection and to every lane pane. `$SHELL -ilc` runs
|
||||||
|
* the rc files where those installers add to PATH; delimiters discard
|
||||||
|
* whatever the rc files print, stdin is closed so a prompt gets EOF, and a
|
||||||
|
* timeout bounds a hang. Off with `AGENTHUB_SHELL_PATH=0`. Adapted from Orca's
|
||||||
|
* `hydrate-shell-path.ts` (MIT).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { execFile } = require("node:child_process");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const MARK = "__AGENTHUB_PATH__";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<boolean>} whether a shell PATH was read and merged.
|
||||||
|
*/
|
||||||
|
function hydrateShellPath({
|
||||||
|
spawn = execFile,
|
||||||
|
shell = process.env.SHELL || "/bin/bash",
|
||||||
|
timeoutMs = 5000,
|
||||||
|
} = {}) {
|
||||||
|
if (/^(0|false|off)$/i.test(process.env.AGENTHUB_SHELL_PATH || "")) return Promise.resolve(false);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let child;
|
||||||
|
try {
|
||||||
|
child = spawn(
|
||||||
|
shell,
|
||||||
|
["-ilc", `printf '${MARK}%s${MARK}' "$PATH"`],
|
||||||
|
{ timeout: timeoutMs, encoding: "utf8" },
|
||||||
|
(err, stdout) => {
|
||||||
|
const m = String(stdout || "").match(new RegExp(`${MARK}(.*?)${MARK}`, "s"));
|
||||||
|
if (!m) return resolve(false); // timeout, spawn error, no output
|
||||||
|
const have = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
|
||||||
|
const add = m[1].split(path.delimiter).filter((d) => d && !have.includes(d));
|
||||||
|
process.env.PATH = [...have, ...new Set(add)].join(path.delimiter);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return resolve(false);
|
||||||
|
}
|
||||||
|
// An rc file that reads stdin gets EOF instead of hanging until the timeout.
|
||||||
|
child?.stdin?.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { hydrateShellPath };
|
||||||
@@ -137,6 +137,19 @@ function sendText(name, text) {
|
|||||||
execImpl(["send-keys", "-t", pane(name), "Enter"]);
|
execImpl(["send-keys", "-t", pane(name), "Enter"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paste `text` into the pane as one bracketed paste, then press Enter. Unlike
|
||||||
|
* `sendText` it may span lines: the text goes into a named buffer through an
|
||||||
|
* argv element (`set-buffer -- <text>`), never a shell, and `-d` deletes the
|
||||||
|
* buffer after the paste so a prompt does not linger in tmux's buffer list.
|
||||||
|
*/
|
||||||
|
function pasteText(name, text) {
|
||||||
|
const buf = `agenthub-paste-${name}`;
|
||||||
|
execImpl(["set-buffer", "-b", buf, "--", text]);
|
||||||
|
execImpl(["paste-buffer", "-p", "-d", "-b", buf, "-t", pane(name)]);
|
||||||
|
execImpl(["send-keys", "-t", pane(name), "Enter"]);
|
||||||
|
}
|
||||||
|
|
||||||
/** Idempotent — a session that's already gone is not an error. */
|
/** Idempotent — a session that's already gone is not an error. */
|
||||||
function killSession(name) {
|
function killSession(name) {
|
||||||
try {
|
try {
|
||||||
@@ -176,6 +189,7 @@ module.exports = {
|
|||||||
paneCommand,
|
paneCommand,
|
||||||
sendCommand,
|
sendCommand,
|
||||||
sendText,
|
sendText,
|
||||||
|
pasteText,
|
||||||
killSession,
|
killSession,
|
||||||
listSessions,
|
listSessions,
|
||||||
isTmuxAvailable,
|
isTmuxAvailable,
|
||||||
|
|||||||
@@ -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 path = require("node:path");
|
||||||
const { db } = require("../db");
|
const { db } = require("../db");
|
||||||
const lanesLib = require("../lib/lanes");
|
const lanesLib = require("../lib/lanes");
|
||||||
|
const aiTools = require("../lib/ai-tools");
|
||||||
const {
|
const {
|
||||||
listPipelines,
|
listPipelines,
|
||||||
getPipeline,
|
getPipeline,
|
||||||
@@ -333,7 +334,7 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
|
|||||||
typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir()
|
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" } });
|
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +371,12 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
|
|||||||
broadcastLane(lane.id);
|
broadcastLane(lane.id);
|
||||||
return res.status(201).json({ lane: payload(lane), created: true });
|
return res.status(201).json({ lane: payload(lane), created: true });
|
||||||
} catch (err) {
|
} 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 } });
|
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
|
// 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 === "EBADCWD" ||
|
||||||
err.code === "EBADPIPELINE" ||
|
err.code === "EBADPIPELINE" ||
|
||||||
err.code === "EBADKIND" ||
|
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 } });
|
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) => {
|
router.post("/worktree", sameOriginGuard, async (req, res) => {
|
||||||
const body = req.body || {};
|
const body = req.body || {};
|
||||||
|
|
||||||
if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) {
|
// Checked before any git work: a worktree for a tool that cannot get a lane
|
||||||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
// 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;
|
const sourceRepo = body.sourceRepo;
|
||||||
@@ -1709,6 +1721,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
|||||||
sandbox: body.sandbox,
|
sandbox: body.sandbox,
|
||||||
approval: body.approval,
|
approval: body.approval,
|
||||||
resumeLast: body.resumeLast === true,
|
resumeLast: body.resumeLast === true,
|
||||||
|
bypass: body.bypass === true,
|
||||||
resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
|
resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
|
||||||
});
|
});
|
||||||
startedRunId = handle.id;
|
startedRunId = handle.id;
|
||||||
|
|||||||
+15
-12
@@ -29,6 +29,7 @@ const runs = require("../lib/pty-run");
|
|||||||
const tmux = require("../lib/tmux");
|
const tmux = require("../lib/tmux");
|
||||||
const lanesLib = require("../lib/lanes");
|
const lanesLib = require("../lib/lanes");
|
||||||
const { sameOriginGuard } = require("../lib/same-origin");
|
const { sameOriginGuard } = require("../lib/same-origin");
|
||||||
|
const aiTools = require("../lib/ai-tools");
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -286,21 +287,16 @@ router.get("/files", (req, res) => {
|
|||||||
|
|
||||||
router.get("/binary", (req, res) => {
|
router.get("/binary", (req, res) => {
|
||||||
// Surface whether the lane's CLI is on PATH so the UI can show a helpful
|
// 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
|
// error before the user clicks Run. We don't actually invoke it. The tool id
|
||||||
// taken from a fixed allowlist, never from the query string directly — this
|
// is looked up in the AI Tools catalog; only a catalog file names a command.
|
||||||
// string reaches `which`.
|
|
||||||
const tool = req.query.tool === undefined ? "claude" : String(req.query.tool);
|
const tool = req.query.tool === undefined ? "claude" : String(req.query.tool);
|
||||||
const bins = { claude: "claude", codex: "codex", antigravity: "agy" };
|
if (!aiTools.has(tool)) {
|
||||||
if (!Object.hasOwn(bins, tool)) {
|
|
||||||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||||
}
|
}
|
||||||
const which = require("node:child_process").spawnSync(
|
// Fresh, not the catalog's cache: a CLI installed while the dashboard is up
|
||||||
process.platform === "win32" ? "where" : "which",
|
// should clear the missing-binary banner on the next look.
|
||||||
[bins[tool]],
|
const d = aiTools.detect(tool, { fresh: true });
|
||||||
{ encoding: "utf8" }
|
res.json({ found: d.installed, path: d.path });
|
||||||
);
|
|
||||||
const stdout = (which.stdout || "").trim();
|
|
||||||
res.json({ found: which.status === 0 && stdout.length > 0, path: stdout || null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/tmux", (_req, res) => {
|
router.get("/tmux", (_req, res) => {
|
||||||
@@ -333,6 +329,13 @@ router.post("/", (req, res) => {
|
|||||||
? body.permissionMode
|
? body.permissionMode
|
||||||
: "auto",
|
: "auto",
|
||||||
effort: typeof body.effort === "string" && body.effort ? body.effort : null,
|
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:
|
resumeSessionId:
|
||||||
typeof body.resumeSessionId === "string" && body.resumeSessionId
|
typeof body.resumeSessionId === "string" && body.resumeSessionId
|
||||||
? body.resumeSessionId
|
? body.resumeSessionId
|
||||||
|
|||||||
Reference in New Issue
Block a user