feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)

This commit is contained in:
2026-09-29 08:25:37 +07:00
parent fcfebe44f1
commit 7ec5a7cd85
40 changed files with 3145 additions and 92 deletions
@@ -51,6 +51,8 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
resume: false,
bypass: false,
routerCapable: false,
install: null,
update: null,
...over,
});
const TOOLS: AiToolsView = {
@@ -2,23 +2,40 @@
* @file AiToolsSettings.tsx
* @description Settings → AI Tools: every AI coding CLI in the server's catalog
* (GET /api/ai-tools), split into the ones found on PATH — each with
* Enabled/Disabled and Set default — and the ones available to install, which
* only link to their homepage. Refresh re-reads the login shell's PATH and
* re-detects. A disabled tool is hidden from Add lane and the server refuses a
* new lane for it; existing lanes keep working.
* Enabled/Disabled, Set default and, when the catalog names one, Update with a
* new-version badge — and the ones available to install, with Install where
* the catalog knows how. Install/Update/Update tools confirm the exact command
* first, run as ONE server job at a time and show its log under the row
* (polled from GET /api/ai-tools/job). Refresh re-reads the login shell's PATH
* and re-detects. Icons are the server's cached favicons, a letter otherwise.
* A disabled tool is hidden from Add lane; existing lanes keep working.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Bot, ExternalLink, RefreshCw } from "lucide-react";
import { Bot, Download, ExternalLink, RefreshCw } from "lucide-react";
import { useAiTools } from "../../hooks/useAiTools";
import type { AiTool } from "../../lib/api";
import { ConfirmModal } from "../ConfirmModal";
import { api, type AiTool, type AiToolJob, type AiToolVersion } from "../../lib/api";
function Avatar({ label }: { label: string }) {
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
function Avatar({ tool }: { tool: AiTool }) {
const [failed, setFailed] = useState(false);
if (!failed) {
return (
<img
src={api.aiTools.iconUrl(tool.id)}
alt=""
onError={() => setFailed(true)}
className="h-7 w-7 shrink-0 rounded-md bg-surface-4 object-contain p-1"
/>
);
}
return (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary">
{label.charAt(0).toUpperCase()}
{tool.label.charAt(0).toUpperCase()}
</span>
);
}
@@ -40,11 +57,44 @@ function Homepage({ tool }: { tool: AiTool }) {
);
}
function JobLog({ job }: { job: AiToolJob }) {
const { t } = useTranslation("settings");
const ref = useRef<HTMLPreElement | null>(null);
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [job.log]);
const state =
job.status === "running"
? t("aiTools.jobRunning")
: job.status === "ok"
? t("aiTools.jobOk")
: t("aiTools.jobFailed", { code: job.exitCode ?? "?" });
return (
<div className="mt-2 w-full">
<p
className={`text-[11px] ${job.status === "failed" ? "text-status-danger" : "text-fg-muted"}`}
>
{state}
</p>
<pre
ref={ref}
data-testid="ai-tool-log"
className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-secondary"
>
{job.log}
</pre>
</div>
);
}
export function AiToolsSettings() {
const { t } = useTranslation("settings");
const { tools, loaded, defaultId, refresh, update } = useAiTools();
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [job, setJob] = useState<AiToolJob | null>(null);
const [versions, setVersions] = useState<Record<string, AiToolVersion>>({});
const [confirm, setConfirm] = useState<Confirm | null>(null);
const sectionRef = useRef<HTMLElement | null>(null);
// The Add-lane modal links here as /settings#ai-tools; the section renders
@@ -53,6 +103,41 @@ export function AiToolsSettings() {
if (window.location.hash === "#ai-tools") sectionRef.current?.scrollIntoView?.();
}, []);
// Deferred like useAiTools' load: an unreachable server (or a mocked api
// without these calls) degrades to "no job, no badges", never a throw.
const loadVersions = () =>
Promise.resolve()
.then(() => api.aiTools.versions())
.then(setVersions)
.catch(() => undefined);
// A page opened mid-job picks it up; versions are cached a day server-side.
useEffect(() => {
Promise.resolve()
.then(() => api.aiTools.job())
.then(setJob)
.catch(() => undefined);
void loadVersions();
}, []);
const running = job?.status === "running";
useEffect(() => {
if (!running) return;
const h = window.setInterval(async () => {
try {
const j = await api.aiTools.job();
setJob(j);
if (j?.status !== "running") {
void reload();
void loadVersions();
}
} catch {
/* next tick */
}
}, 1000);
return () => window.clearInterval(h);
}, [running, reload]);
const run = async (key: string, fn: () => Promise<void>) => {
setBusy(key);
setError(null);
@@ -65,9 +150,52 @@ export function AiToolsSettings() {
}
};
const npmCmd = (pkg: string) => `npm install -g ${pkg}@latest`;
const commandFor = (tool: AiTool, action: "install" | "update"): string => {
const inst = tool.install;
if (action === "update" && tool.update?.kind === "self") {
return [tool.command ?? tool.id, ...tool.update.args].join(" ");
}
if (inst?.kind === "npm") return npmCmd(inst.package);
if (inst?.kind === "script") return t("aiTools.runScript", { url: inst.url });
return "";
};
// Mirrors the server's update-all: badged npm tools, then every self updater.
const updateAllTools = tools.filter(
(x) =>
x.installed &&
(x.update?.kind === "self" || (x.update?.kind === "npm" && versions[x.id]?.updateAvailable))
);
const canUpdate = (tool: AiTool) =>
!!tool.update && (tool.update.kind !== "npm" || versions[tool.id]?.npmManaged === true);
const startConfirmed = async () => {
const c = confirm;
setConfirm(null);
if (!c) return;
await run("job", async () => {
const r =
c.kind === "install"
? await api.aiTools.install(c.tool.id)
: c.kind === "update"
? await api.aiTools.updateTool(c.tool.id)
: await api.aiTools.updateAll();
setJob(r.job);
});
};
const jobBusy = running || busy !== null;
const logFor = (id: string | null) => (job && job.tool === id ? <JobLog job={job} /> : null);
const installed = tools.filter((x) => x.installed);
const available = tools.filter((x) => !x.installed);
const confirmLines =
confirm?.kind === "update-all"
? updateAllTools.map((x) => commandFor(x, "update"))
: confirm
? [commandFor(confirm.tool, confirm.kind)]
: [];
return (
<section id="ai-tools" ref={sectionRef} className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
@@ -82,42 +210,79 @@ export function AiToolsSettings() {
<p className="text-xs font-medium text-fg-secondary">
{t("aiTools.installed", { count: installed.length })}
</p>
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setConfirm({ kind: "update-all" })}
disabled={jobBusy || updateAllTools.length === 0}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
<Download className="w-3.5 h-3.5" />
{t("aiTools.updateTools")}
</button>
<button
type="button"
onClick={() =>
void run("refresh", async () => {
await refresh();
await loadVersions();
})
}
disabled={busy !== null}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
</div>
</div>
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
{logFor(null)}
{loaded && installed.length === 0 && (
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
)}
<ul className="divide-y divide-border rounded-md border border-border">
{installed.map((tool) => {
const isDefault = tool.id === defaultId;
const v = versions[tool.id];
return (
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
className={`flex items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
className={`flex flex-wrap items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
>
<Avatar label={tool.label} />
<Avatar tool={tool} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm text-fg-primary">
{tool.label}
<Homepage tool={tool} />
{v?.updateAvailable && v.latest && (
<span className="rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent">
{t("aiTools.newVersion", { version: v.latest })}
</span>
)}
</div>
<div
className="truncate font-mono text-[11px] text-fg-muted"
title={tool.path ?? ""}
>
{tool.command}
{v?.installed ? ` · v${v.installed}` : ""}
{tool.update?.kind === "npm" && v && !v.npmManaged && (
<span className="ml-1.5 font-sans">· {t("aiTools.outsideNpm")}</span>
)}
</div>
</div>
{canUpdate(tool) && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className="rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
{t("aiTools.update")}
</button>
)}
{isDefault ? (
<span className="text-xs text-accent">✓ {t("aiTools.default")}</span>
) : (
@@ -147,6 +312,7 @@ export function AiToolsSettings() {
>
{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
</button>
{logFor(tool.id)}
</li>
);
})}
@@ -163,22 +329,61 @@ export function AiToolsSettings() {
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5"
className={`flex flex-wrap items-center gap-2 rounded-md border border-border px-2.5 py-1.5 ${
job?.tool === tool.id ? "sm:col-span-2 lg:col-span-3" : ""
}`}
>
<Avatar label={tool.label} />
<Avatar tool={tool} />
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{tool.label}
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">
{tool.command}
</span>
</span>
{tool.install && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "install", tool })}
className="rounded-md border border-border px-2 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
{t("aiTools.install")}
</button>
)}
<Homepage tool={tool} />
{logFor(tool.id)}
</li>
))}
</ul>
</div>
)}
</div>
<ConfirmModal
open={confirm !== null}
destructive={false}
title={
confirm?.kind === "update-all"
? t("aiTools.confirmUpdateAll")
: confirm
? t(confirm.kind === "install" ? "aiTools.confirmInstall" : "aiTools.confirmUpdate", {
tool: confirm.tool.label,
})
: ""
}
message={t("aiTools.confirmBody")}
confirmLabel={t("aiTools.run")}
cancelLabel={t("aiTools.cancel")}
onConfirm={() => void startConfirmed()}
onCancel={() => setConfirm(null)}
>
<pre
data-testid="ai-tool-confirm-command"
className="whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-primary"
>
{confirmLines.join("\n")}
</pre>
</ConfirmModal>
</section>
);
}
@@ -1,25 +1,38 @@
/**
* @file AiToolsSettings.test.tsx
* @description Settings → AI Tools: installed rows carry Enabled/Disabled and
* Set default, available rows only a homepage link, Refresh re-detects, and a
* disabled tool's Settings tab disappears from the hub.
* Set default, available rows a homepage link, Refresh re-detects, a
* disabled tool's Settings tab disappears from the hub, and Install/Update
* confirm the exact command, then show the polled job log.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { MemoryRouter } from "react-router-dom";
import { render, screen, within } from "@testing-library/react";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { AiToolsSettings } from "../AiToolsSettings";
import { api, type AiTool, type AiToolsView } from "../../../lib/api";
import { api, type AiTool, type AiToolJob, type AiToolsView } from "../../../lib/api";
import { __resetAiTools } from "../../../hooks/useAiTools";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { aiTools: { list: vi.fn(), refresh: vi.fn(), update: vi.fn() } },
api: {
aiTools: {
list: vi.fn(),
refresh: vi.fn(),
update: vi.fn(),
install: vi.fn(),
updateTool: vi.fn(),
updateAll: vi.fn(),
job: vi.fn(),
versions: vi.fn(),
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
},
},
};
});
@@ -37,6 +50,8 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
resume: false,
bypass: false,
routerCapable: false,
install: null,
update: null,
...over,
});
const VIEW: AiToolsView = {
@@ -55,6 +70,23 @@ beforeEach(() => {
vi.mocked(api.aiTools.list).mockReset().mockResolvedValue(VIEW);
vi.mocked(api.aiTools.update).mockReset().mockResolvedValue(VIEW);
vi.mocked(api.aiTools.refresh).mockReset().mockResolvedValue(VIEW);
vi.mocked(api.aiTools.job).mockReset().mockResolvedValue(null);
vi.mocked(api.aiTools.versions).mockReset().mockResolvedValue({});
vi.mocked(api.aiTools.install).mockReset();
vi.mocked(api.aiTools.updateTool).mockReset();
vi.mocked(api.aiTools.updateAll).mockReset();
});
const job = (over: Partial<AiToolJob> = {}): AiToolJob => ({
id: 1,
tool: "qwen",
action: "install",
status: "running",
startedAt: "2026-09-28T00:00:00Z",
endedAt: null,
exitCode: null,
log: "$ npm install -g @qwen-code/qwen-code@latest\n",
...over,
});
describe("AiToolsSettings", () => {
@@ -87,8 +119,124 @@ describe("AiToolsSettings", () => {
it("refreshes detection", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
await user.click(await screen.findByRole("button", { name: "Refresh" }));
await screen.findByTestId("ai-tool-claude");
const before = vi.mocked(api.aiTools.versions).mock.calls.length;
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
expect(vi.mocked(api.aiTools.versions).mock.calls.length).toBe(before + 1);
});
});
describe("AiToolsSettings — install and update", () => {
const A2: AiToolsView = {
default: "claude",
tools: [
tool("claude", "Claude Code", { update: { kind: "self", args: ["update"] } }),
tool("codex", "Codex", {
install: { kind: "npm", package: "@openai/codex" },
update: { kind: "npm" },
}),
tool("gemini", "Gemini", {
install: { kind: "npm", package: "@google/gemini-cli" },
update: { kind: "npm" },
}),
tool("qwen", "Qwen", {
installed: false,
path: null,
install: { kind: "npm", package: "@qwen-code/qwen-code" },
}),
tool("cursor", "Cursor", {
installed: false,
path: null,
install: { kind: "script", url: "https://cursor.com/install" },
}),
],
};
beforeEach(() => {
vi.mocked(api.aiTools.list).mockResolvedValue(A2);
vi.mocked(api.aiTools.versions).mockResolvedValue({
codex: { npmManaged: false, installed: null, latest: null, updateAvailable: false },
gemini: { npmManaged: true, installed: "0.60.0", latest: "0.61.0", updateAvailable: true },
});
});
it("confirms the exact npm command, then polls and shows the log", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
vi.mocked(api.aiTools.install).mockResolvedValue({ job: job() });
vi.mocked(api.aiTools.job)
.mockResolvedValueOnce(null)
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
render(<AiToolsSettings />);
const qwen = await screen.findByTestId("ai-tool-qwen");
await user.click(within(qwen).getByRole("button", { name: "Install" }));
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
"npm install -g @qwen-code/qwen-code@latest"
);
expect(api.aiTools.install).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Run" }));
expect(api.aiTools.install).toHaveBeenCalledWith("qwen");
expect(await within(qwen).findByTestId("ai-tool-log")).toHaveTextContent("npm install -g");
await act(async () => {
vi.advanceTimersByTime(1100);
});
expect(await within(qwen).findByText("Done")).toBeInTheDocument();
expect(within(qwen).getByTestId("ai-tool-log")).toHaveTextContent("added 1 package");
const calls = vi.mocked(api.aiTools.job).mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(3000);
});
expect(vi.mocked(api.aiTools.job).mock.calls.length).toBe(calls); // polling stopped
} finally {
vi.useRealTimers();
}
});
it("names the vendor script for a script install", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
const cursor = await screen.findByTestId("ai-tool-cursor");
await user.click(within(cursor).getByRole("button", { name: "Install" }));
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
"Download https://cursor.com/install and run it with bash"
);
});
it("badges a newer npm version and offers no npm update outside npm", async () => {
render(<AiToolsSettings />);
const gemini = await screen.findByTestId("ai-tool-gemini");
expect(await within(gemini).findByText("v0.61.0 available")).toBeInTheDocument();
expect(within(gemini).getByRole("button", { name: "Update" })).toBeInTheDocument();
const codex = screen.getByTestId("ai-tool-codex");
expect(within(codex).getByText(/Installed outside npm/)).toBeInTheDocument();
expect(within(codex).queryByRole("button", { name: "Update" })).toBeNull();
});
it("Update tools lists the badged npm tools and the self updaters", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
await screen.findByText("v0.61.0 available");
await user.click(screen.getByRole("button", { name: "Update tools" }));
const cmd = screen.getByTestId("ai-tool-confirm-command");
expect(cmd).toHaveTextContent("claude update");
expect(cmd).toHaveTextContent("npm install -g @google/gemini-cli@latest");
expect(cmd).not.toHaveTextContent("@openai/codex");
});
it("disables Update tools when nothing has an update", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
render(<AiToolsSettings />);
expect(await screen.findByRole("button", { name: "Update tools" })).toBeDisabled();
});
it("falls back to the letter when the icon fails", async () => {
const { container } = render(<AiToolsSettings />);
await screen.findByTestId("ai-tool-qwen");
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
expect(img).toBeTruthy();
fireEvent.error(img);
expect(within(screen.getByTestId("ai-tool-qwen")).getByText("Q")).toBeInTheDocument();
});
});
+16 -1
View File
@@ -508,7 +508,22 @@
"disabled": "Disabled",
"enabledOf": "{{tool}} enabled",
"homepage": "Homepage",
"homepageOf": "{{tool}} homepage"
"homepageOf": "{{tool}} homepage",
"install": "Install",
"update": "Update",
"updateTools": "Update tools",
"newVersion": "v{{version}} available",
"outsideNpm": "Installed outside npm — update it the way you installed it",
"runScript": "Download {{url}} and run it with bash",
"confirmInstall": "Install {{tool}}?",
"confirmUpdate": "Update {{tool}}?",
"confirmUpdateAll": "Update AI tools?",
"confirmBody": "AgentHub will run on this machine:",
"run": "Run",
"cancel": "Cancel",
"jobRunning": "Running…",
"jobOk": "Done",
"jobFailed": "Failed (exit {{code}})"
},
"appearance": {
"title": "Appearance",
+16 -1
View File
@@ -508,7 +508,22 @@
"disabled": "Đang tắt",
"enabledOf": "Bật {{tool}}",
"homepage": "Trang chủ",
"homepageOf": "Trang chủ {{tool}}"
"homepageOf": "Trang chủ {{tool}}",
"install": "Cài đặt",
"update": "Cập nhật",
"updateTools": "Cập nhật tool",
"newVersion": "Có bản v{{version}}",
"outsideNpm": "Cài ngoài npm — cập nhật theo cách bạn đã cài",
"runScript": "Tải {{url}} và chạy bằng bash",
"confirmInstall": "Cài {{tool}}?",
"confirmUpdate": "Cập nhật {{tool}}?",
"confirmUpdateAll": "Cập nhật các AI tool?",
"confirmBody": "AgentHub sẽ chạy trên máy này:",
"run": "Chạy",
"cancel": "Huỷ",
"jobRunning": "Đang chạy…",
"jobOk": "Xong",
"jobFailed": "Lỗi (exit {{code}})"
},
"appearance": {
"title": "Giao diện",
+51 -1
View File
@@ -1950,6 +1950,28 @@ export const api = {
method: "PUT",
body: JSON.stringify(body),
}),
/** POST /api/ai-tools/:id/install - start the catalog's install job. */
install: (id: string) =>
request<{ job: AiToolJob }>(`/ai-tools/${encodeURIComponent(id)}/install`, {
method: "POST",
}),
/** POST /api/ai-tools/:id/update - start the catalog's update job. */
updateTool: (id: string) =>
request<{ job: AiToolJob }>(`/ai-tools/${encodeURIComponent(id)}/update`, {
method: "POST",
}),
/** POST /api/ai-tools/update-all - one job updating every tool with an update. */
updateAll: () => request<{ job: AiToolJob }>("/ai-tools/update-all", { method: "POST" }),
/** GET /api/ai-tools/job - the current or last job, or null. */
job: () => request<AiToolJob | null>("/ai-tools/job"),
/** GET /api/ai-tools/versions - npm version badges, cached a day server-side. */
versions: () => request<Record<string, AiToolVersion>>("/ai-tools/versions"),
/** The server-cached icon; an <img> cannot send the bearer header, so the token rides the query. */
iconUrl: (id: string) => {
const token = dashboardToken();
const q = token ? `?token=${encodeURIComponent(token)}` : "";
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
},
},
aiRouters: {
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
@@ -2985,7 +3007,7 @@ export interface AiTool {
installed: boolean;
path: string | null;
enabled: boolean;
source: "builtin" | "custom";
source: "builtin" | "remote" | "custom";
/** `claude` / `codex` keep their own run form; null for every other tool. */
launcher: "claude" | "codex" | null;
prompt: { mode: "argv" | "flag" | "after-start" } | null;
@@ -2995,12 +3017,40 @@ export interface AiTool {
bypass: boolean;
/** Can use an AI Router profile (Claude, Codex); others are native only. */
routerCapable: boolean;
/** What Install runs; null when the catalog names no install. */
install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null;
/** What Update runs; `self` is the tool's own updater with these args. */
update: { kind: "npm" } | { kind: "script" } | { kind: "self"; args: string[] } | null;
}
export interface AiToolsView {
tools: AiTool[];
/** The effective default tool id, or null when nothing is installed and enabled. */
default: string | null;
/** Where the catalog came from: this build, or the newer release synced daily. */
catalog?: { source: "builtin" | "remote"; tag: string | null; fetchedAt: string | null };
}
/** An install/update job (GET /api/ai-tools/job). */
export interface AiToolJob {
id: number;
/** null for update-all. */
tool: string | null;
action: "install" | "update" | "update-all";
status: "running" | "ok" | "failed";
startedAt: string;
endedAt: string | null;
exitCode: number | null;
log: string;
}
/** One row of GET /api/ai-tools/versions. */
export interface AiToolVersion {
/** Its binary lives under npm's global prefix, so an npm update replaces it. */
npmManaged: boolean;
installed: string | null;
latest: string | null;
updateAvailable: boolean;
}
export interface AiRouter {
@@ -8191,37 +8191,73 @@ exports[`screen snapshots > Settings 1`] = `
>
Installed (0 detected)
</p>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
type="button"
<div
class="flex items-center gap-2"
>
<svg
class="lucide lucide-refresh-cw w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
disabled=""
type="button"
>
<path
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
/>
<path
d="M21 3v5h-5"
/>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
/>
<path
d="M8 16H3v5"
/>
</svg>
Refresh
</button>
<svg
class="lucide lucide-download w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/>
<polyline
points="7 10 12 15 17 10"
/>
<line
x1="12"
x2="12"
y1="15"
y2="3"
/>
</svg>
Update tools
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
type="button"
>
<svg
class="lucide lucide-refresh-cw w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
/>
<path
d="M21 3v5h-5"
/>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
/>
<path
d="M8 16H3v5"
/>
</svg>
Refresh
</button>
</div>
</div>
<ul
class="divide-y divide-border rounded-md border border-border"