feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)
This commit is contained in:
+89
-11
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* @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`),
|
||||
* (`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 (`<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`.
|
||||
* 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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,6 +22,10 @@ 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
|
||||
@@ -33,6 +40,47 @@ function makeErr(code, message) {
|
||||
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;
|
||||
@@ -50,10 +98,13 @@ function validate(doc, source) {
|
||||
}
|
||||
let launcher = null;
|
||||
if (doc.launcher !== undefined) {
|
||||
if (source !== "builtin" || !LAUNCHERS.has(doc.launcher) || doc.launcher !== doc.id)
|
||||
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 fields = installFields(doc);
|
||||
if (!fields) return null;
|
||||
let prompt = null;
|
||||
if (!launcher) {
|
||||
const p = doc.prompt;
|
||||
@@ -84,15 +135,33 @@ function validate(doc, source) {
|
||||
bypass: doc.bypass || null,
|
||||
bypassEnv: doc.bypassEnv || null,
|
||||
launcher,
|
||||
install: fields.install,
|
||||
update: fields.update,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
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 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";
|
||||
catalogDirs().forEach(([dir, source]) => {
|
||||
let files = [];
|
||||
try {
|
||||
files = fs
|
||||
@@ -113,8 +182,8 @@ function load() {
|
||||
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")) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -135,8 +204,9 @@ 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)) {
|
||||
/** 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 {
|
||||
@@ -248,13 +318,18 @@ function view() {
|
||||
resume: !!t.resume,
|
||||
bypass: !!(t.bypass || t.bypassEnv),
|
||||
routerCapable: ROUTER_CAPABLE.has(t.id),
|
||||
install: t.install,
|
||||
update: t.update,
|
||||
};
|
||||
}),
|
||||
default: effectiveDefault(),
|
||||
catalog: dirsOverride
|
||||
? { source: "builtin", tag: null, fetchedAt: null }
|
||||
: require("./ai-tools-sync").catalogMeta(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam: `[builtinDir, customDir]`, or null for the real ones. */
|
||||
/** Test seam: `[builtin, custom]` or `[builtin, remote, custom]`, or null for the real ones. */
|
||||
function __setCatalogDirs(dirs) {
|
||||
dirsOverride = dirs;
|
||||
refresh();
|
||||
@@ -272,6 +347,9 @@ module.exports = {
|
||||
setDefault,
|
||||
effectiveDefault,
|
||||
view,
|
||||
validate,
|
||||
which,
|
||||
siteOf,
|
||||
ROUTER_CAPABLE,
|
||||
__setCatalogDirs,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user