/** * @file The AI Tools catalog: which AI coding CLIs AgentHub can run in a lane * (`server/data/ai-tools/*.json`, the newer release's copy synced by * ai-tools-sync.js, and overrides in `DASHBOARD_AI_TOOLS_DIR`, in rising * precedence), how each is installed/updated (built-in and release files only), * which of them are installed (a walk of the PATH directories — no process per * tool), and the user's enable/default choice (`/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` nor * an `install`/`update` — an Install click would run whatever it named. * @author Nguyễn Ngọc Trí Vĩ */ 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"]); const PKG_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; // Files shipped in a release (built-in, or synced from the newest release) are // trusted to name a launcher and an install/update; a custom file is not. const TRUSTED = new Set(["builtin", "remote"]); 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))); // ponytail: compares the last two host labels, not a public suffix list — // wrong for co.uk-style hosts (none in the catalog); swap in a PSL if one comes. const siteOf = (host) => host.toLowerCase().split(".").slice(-2).join("."); /** The script URL, when it is https on the homepage's site; else null. */ function scriptUrl(url, homepageUrl) { try { const u = new URL(url); const home = new URL(homepageUrl); if (u.protocol !== "https:" || u.username || u.password || u.port) return null; return siteOf(u.hostname) === siteOf(home.hostname) ? u.href : null; } catch { return null; } } /** `{install, update}` normalised, or null when either is malformed. */ function installFields(doc) { let install = null; if (doc.install !== undefined) { const i = doc.install; if (i?.kind === "npm" && typeof i.package === "string" && PKG_RE.test(i.package)) { install = { kind: "npm", package: i.package }; } else if (i?.kind === "script" && typeof i.url === "string") { const url = scriptUrl(i.url, doc.homepageUrl); if (!url) return null; install = { kind: "script", url }; } else return null; } let update = null; if (doc.update !== undefined) { const u = doc.update; if ((u?.kind === "npm" || u?.kind === "script") && install?.kind === u.kind) { update = { kind: u.kind }; } else if (u?.kind === "self" && isStrArray(u.args) && u.args.length) { update = { kind: "self", args: u.args }; } else return null; } return { install, update }; } /** 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 (!TRUSTED.has(source) || !LAUNCHERS.has(doc.launcher) || doc.launcher !== doc.id) return null; launcher = doc.launcher; } if ((doc.install !== undefined || doc.update !== undefined) && !TRUSTED.has(source)) return null; const config = configFiles(doc.config, source); if (!config) return null; const fields = installFields(doc); if (!fields) return null; 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; } } // `model`: how a run picks a model on this CLI. Only a flag name and a // vendor (which pricing rows to offer as choices) — the value itself is // the user's, validated by validModel() at spawn time. let model = null; if (doc.model !== undefined) { const m = doc.model; if (!m || typeof m.flag !== "string" || !/^--?[a-z][a-z0-9-]*$/.test(m.flag)) return null; if (m.vendor !== undefined && !MODEL_VENDORS.has(m.vendor)) return null; model = { flag: m.flag, vendor: m.vendor || null }; } return { id: doc.id, label: doc.label.trim(), model, 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, install: fields.install, update: fields.update, config, source, }; } /** A name no config editor may open: logins, tokens, keys. */ const CONFIG_SECRET_RE = /(auth|credential|token|secret|oauth|cookie|passw|api[-_]?key|\.env$|\.pem$|\.key$|id_rsa)/i; const CONFIG_FORMATS = new Set(["json", "jsonc", "text"]); /** * `config`: the tool's own config files its Settings tab may edit — like * `install`, only from a trusted file. Each is `~/`-relative, never climbs * out with `..`, and never names a credential. Answers [] when absent, null * when invalid. */ function configFiles(raw, source) { if (raw === undefined) return []; if (!TRUSTED.has(source) || !Array.isArray(raw) || raw.length > 8) return null; const out = []; for (const c of raw) { const p = c && c.path; if (typeof p !== "string" || !p.startsWith("~/") || p.includes("\0")) return null; if (p.split("/").some((seg) => seg === "..") || CONFIG_SECRET_RE.test(path.basename(p))) return null; const format = c.format ?? (/\.jsonc$/.test(p) ? "jsonc" : /\.json$/.test(p) ? "json" : "text"); if (!CONFIG_FORMATS.has(format)) return null; out.push({ path: p, format }); } return out; } const MODEL_VENDORS = new Set(["claude", "openai", "google"]); /** * A model id that may follow a model flag as one argv element: a letter or * digit first (never `-`, so it can not become a flag of its own), then the * characters real ids use — `opus[1m]`, `openai/gpt-5.5`, `gemini-3.1-pro`. */ function validModel(m) { return typeof m === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/@+[\]-]{0,127}$/.test(m); } const RANK = { builtin: 0, remote: 1, custom: 2 }; /** `[dir, source]` pairs, lowest precedence first. */ function catalogDirs() { if (dirsOverride) { const names = dirsOverride.length === 2 ? ["builtin", "custom"] : ["builtin", "remote", "custom"]; return dirsOverride.map((d, i) => [d, names[i]]); } // Lazy: ai-tools-sync.js requires this module for validate(). const remote = require("./ai-tools-sync").activeRemoteDir(); return [ [BUILTIN_DIR, "builtin"], [remote, "remote"], [process.env.DASHBOARD_AI_TOOLS_DIR, "custom"], ].filter(([d]) => d); } function load() { const byId = new Map(); catalogDirs().forEach(([dir, source]) => { 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; } // A later tier beats an earlier one; within one dir the first file wins. if (!byId.has(tool.id) || RANK[source] > RANK[byId.get(tool.id).source]) { 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); /** First executable `name` on `pathStr` (default: the server's PATH). */ function which(name, pathStr = process.env.PATH || "") { for (const dir of pathStr.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")); const models = {}; if (s.models && typeof s.models === "object") { for (const [id, m] of Object.entries(s.models)) if (validModel(m)) models[id] = m; } return { disabled: Array.isArray(s.disabled) ? s.disabled.filter((x) => typeof x === "string") : [], default: typeof s.default === "string" ? s.default : null, models, }; } catch { return { disabled: [], default: null, models: {} }; } } 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 }); } /** The model a new run of `id` starts with; null clears it (the CLI's own). */ function setDefaultModel(id, model) { requireTool(id); if (model !== null && !validModel(model)) { throw makeErr("EBADMODEL", "not a valid model id"); } const s = readState(); if (model === null) delete s.models[id]; else s.models[id] = model; writeState(s); } 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 state = readState(); const disabled = new Set(state.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), // claude/codex take a model through their own argv builders. modelFlag: Boolean(t.model || t.launcher), defaultModel: state.models[t.id] || null, modelVendor: t.model?.vendor || null, configFiles: t.config.length, install: t.install, update: t.update, }; }), default: effectiveDefault(), catalog: dirsOverride ? { source: "builtin", tag: null, fetchedAt: null } : require("./ai-tools-sync").catalogMeta(), }; } /** Test seam: `[builtin, custom]` or `[builtin, remote, custom]`, or null for the real ones. */ function __setCatalogDirs(dirs) { dirsOverride = dirs; refresh(); } module.exports = { list, get, has, detect, refresh, readState, isEnabled, setEnabled, setDefault, setDefaultModel, validModel, effectiveDefault, view, validate, which, siteOf, ROUTER_CAPABLE, CONFIG_SECRET_RE, __setCatalogDirs, };