This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new 4beda64 feat(agent): list the agents this machine has not installed
4beda64 is described below
commit 4beda6435f3e1f86be3126f145fbd65fe9759cfb
Author: Yang Luo <[email protected]>
AuthorDate: Wed Sep 2 20:35:19 2026 +0800
feat(agent): list the agents this machine has not installed
---
agent/agent.go | 25 ++++++++++
agent/fingerprint.go | 3 ++
agent/fingerprint_load.go | 4 ++
agent/fingerprints/claude-code.json | 1 +
agent/fingerprints/claude-desktop.json | 1 +
agent/fingerprints/codex-cli.json | 1 +
agent/fingerprints/codex.json | 1 +
agent/fingerprints/cursor-agent.json | 1 +
agent/fingerprints/cursor.json | 1 +
agent/fingerprints/dsh.json | 1 +
agent/fingerprints/openagent.json | 1 +
agent/fingerprints/openclaw.json | 1 +
agent/fingerprints/opencode-desktop.json | 1 +
agent/fingerprints/opencode.json | 1 +
agent/fingerprints/windsurf.json | 1 +
controllers/agent.go | 11 +++++
routers/router.go | 1 +
web/src/backend/AgentBackend.ts | 6 +++
web/src/components/AgentCatalog.tsx | 83 ++++++++++++++++++++++++++++++++
web/src/lib/agents.ts | 32 +++++++++++-
web/src/locales/en/data.json | 3 ++
web/src/locales/zh/data.json | 3 ++
web/src/pages/AgentsPage.tsx | 4 ++
web/src/pages/HomePage.tsx | 4 ++
web/src/types.ts | 12 +++++
25 files changed, 202 insertions(+), 1 deletion(-)
diff --git a/agent/agent.go b/agent/agent.go
index fa79739..0c4fa99 100644
--- a/agent/agent.go
+++ b/agent/agent.go
@@ -25,6 +25,31 @@ type Installation struct {
Owner string `json:"owner"`
}
+// Known is one agent Gateway can detect, whether or not it is installed here.
+type Known struct {
+ AgentId string `json:"agentId"`
+ Name string `json:"name"`
+ InstallUrl string `json:"installUrl,omitempty"`
+ // Desktop marks a windowed app, which is downloaded rather than
installed
+ // from a package manager.
+ Desktop bool `json:"desktop,omitempty"`
+}
+
+// KnownAgents lists every agent a fingerprint declares, so the pages can show
+// the ones this host has not installed next to the ones it has.
+func KnownAgents() []Known {
+ result := make([]Known, 0, len(fingerprints))
+ for i := range fingerprints {
+ result = append(result, Known{
+ AgentId: fingerprints[i].ID,
+ Name: fingerprints[i].DisplayName,
+ InstallUrl: fingerprints[i].InstallUrl,
+ Desktop: fingerprints[i].Desktop,
+ })
+ }
+ return result
+}
+
// DisplayNameOf is the human-readable name of a known agent id, empty for an
id
// no fingerprint declares.
func DisplayNameOf(id string) string {
diff --git a/agent/fingerprint.go b/agent/fingerprint.go
index 5ac56d3..31e71cd 100644
--- a/agent/fingerprint.go
+++ b/agent/fingerprint.go
@@ -20,6 +20,9 @@ import "github.com/apache/casbin-gateway/localserver"
type Fingerprint struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
+ // InstallUrl is the vendor's own install page, which is all Gateway
has to
+ // offer for an agent this host has not installed.
+ InstallUrl string `json:"installUrl,omitempty"`
ExecName string `json:"execName,omitempty"`
// LaunchArgs are the arguments a start passes, literally: an agent that
diff --git a/agent/fingerprint_load.go b/agent/fingerprint_load.go
index a26dac3..dae4701 100644
--- a/agent/fingerprint_load.go
+++ b/agent/fingerprint_load.go
@@ -103,6 +103,10 @@ func validateFingerprint(f Fingerprint, name string) error
{
if f.DisplayName == "" {
return errors.New("displayName is required")
}
+ // The install page is rendered as a link, so nothing but the web
schemes.
+ if f.InstallUrl != "" && !strings.HasPrefix(f.InstallUrl, "https://")
&& !strings.HasPrefix(f.InstallUrl, "http://") {
+ return fmt.Errorf("installUrl %q is not an http(s) URL",
f.InstallUrl)
+ }
if err := validateLocalServer(f.LocalServer); err != nil {
return err
diff --git a/agent/fingerprints/claude-code.json
b/agent/fingerprints/claude-code.json
index d488603..ccb192c 100644
--- a/agent/fingerprints/claude-code.json
+++ b/agent/fingerprints/claude-code.json
@@ -1,6 +1,7 @@
{
"id": "claude-code",
"displayName": "Claude Code",
+ "installUrl": "https://docs.claude.com/en/docs/claude-code/setup",
"execName": "claude",
"stateDir": "claude",
"npmPackage": "@anthropic-ai/claude-code",
diff --git a/agent/fingerprints/claude-desktop.json
b/agent/fingerprints/claude-desktop.json
index ccf1d31..327fc2b 100644
--- a/agent/fingerprints/claude-desktop.json
+++ b/agent/fingerprints/claude-desktop.json
@@ -1,6 +1,7 @@
{
"id": "claude-desktop",
"displayName": "Claude Desktop",
+ "installUrl": "https://claude.ai/download",
"execName": "claude",
"desktop": true,
"sandboxed": true,
diff --git a/agent/fingerprints/codex-cli.json
b/agent/fingerprints/codex-cli.json
index c8c523e..78c60fd 100644
--- a/agent/fingerprints/codex-cli.json
+++ b/agent/fingerprints/codex-cli.json
@@ -1,6 +1,7 @@
{
"id": "codex-cli",
"displayName": "Codex CLI",
+ "installUrl": "https://developers.openai.com/codex/cli/",
"execName": "codex",
"stateDir": "codex",
"npmPackage": "@openai/codex",
diff --git a/agent/fingerprints/codex.json b/agent/fingerprints/codex.json
index c19294d..7e72851 100644
--- a/agent/fingerprints/codex.json
+++ b/agent/fingerprints/codex.json
@@ -1,6 +1,7 @@
{
"id": "codex",
"displayName": "ChatGPT Desktop (Codex)",
+ "installUrl": "https://openai.com/chatgpt/download/",
"execName": "ChatGPT",
"desktop": true,
"msixFamily": "OpenAI.Codex_2p2nqsd0c76g0"
diff --git a/agent/fingerprints/cursor-agent.json
b/agent/fingerprints/cursor-agent.json
index 779e53c..4590313 100644
--- a/agent/fingerprints/cursor-agent.json
+++ b/agent/fingerprints/cursor-agent.json
@@ -1,6 +1,7 @@
{
"id": "cursor-agent",
"displayName": "Cursor Agent",
+ "installUrl": "https://cursor.com/docs/cli/installation",
"execName": "cursor-agent",
"stateDir": "cursor-agent",
"windowsProgramDirs": ["cursor-agent"]
diff --git a/agent/fingerprints/cursor.json b/agent/fingerprints/cursor.json
index a6c9208..792c115 100644
--- a/agent/fingerprints/cursor.json
+++ b/agent/fingerprints/cursor.json
@@ -1,6 +1,7 @@
{
"id": "cursor",
"displayName": "Cursor",
+ "installUrl": "https://cursor.com/download",
"execName": "Cursor",
"desktop": true,
"windowsProgramDirs": ["cursor"],
diff --git a/agent/fingerprints/dsh.json b/agent/fingerprints/dsh.json
index 7441e30..663fe44 100644
--- a/agent/fingerprints/dsh.json
+++ b/agent/fingerprints/dsh.json
@@ -1,6 +1,7 @@
{
"id": "dsh",
"displayName": "DeepSeek Harness",
+ "installUrl": "https://github.com/deepseek-ai/deepseek-harness",
"execName": "dsh",
"launchArgs": ["web"],
"npmPackage": "@deepseek-ai/dsh"
diff --git a/agent/fingerprints/openagent.json
b/agent/fingerprints/openagent.json
index ac4b31b..e0dc673 100644
--- a/agent/fingerprints/openagent.json
+++ b/agent/fingerprints/openagent.json
@@ -1,6 +1,7 @@
{
"id": "openagent",
"displayName": "OpenAgent",
+ "installUrl": "https://openagentai.org",
"execName": "openagent",
"stateDir": "openagent",
"windowsUserDirs": ["openagent"],
diff --git a/agent/fingerprints/openclaw.json b/agent/fingerprints/openclaw.json
index 1c60c1f..772b617 100644
--- a/agent/fingerprints/openclaw.json
+++ b/agent/fingerprints/openclaw.json
@@ -1,6 +1,7 @@
{
"id": "openclaw",
"displayName": "OpenClaw",
+ "installUrl": "https://openclaw.ai",
"execName": "openclaw",
"npmPackage": "openclaw",
"extraUnixNpmDirs": [".openclaw/tools/node-v*/lib"],
diff --git a/agent/fingerprints/opencode-desktop.json
b/agent/fingerprints/opencode-desktop.json
index c8ce666..30ee0b5 100644
--- a/agent/fingerprints/opencode-desktop.json
+++ b/agent/fingerprints/opencode-desktop.json
@@ -1,6 +1,7 @@
{
"id": "opencode-desktop",
"displayName": "opencode Desktop",
+ "installUrl": "https://opencode.ai/download",
"execName": "OpenCode",
"desktop": true,
"windowsProgramDirs": ["@opencode-aidesktop"]
diff --git a/agent/fingerprints/opencode.json b/agent/fingerprints/opencode.json
index 6a30483..94747a3 100644
--- a/agent/fingerprints/opencode.json
+++ b/agent/fingerprints/opencode.json
@@ -1,6 +1,7 @@
{
"id": "opencode",
"displayName": "opencode",
+ "installUrl": "https://opencode.ai/docs/",
"execName": "opencode",
"npmPackage": "opencode-ai",
"homeDirs": [".opencode/bin"]
diff --git a/agent/fingerprints/windsurf.json b/agent/fingerprints/windsurf.json
index 14cff1e..b4f8313 100644
--- a/agent/fingerprints/windsurf.json
+++ b/agent/fingerprints/windsurf.json
@@ -1,6 +1,7 @@
{
"id": "windsurf",
"displayName": "Windsurf",
+ "installUrl": "https://windsurf.com/download",
"execName": "Windsurf",
"desktop": true,
"windowsProgramDirs": ["Windsurf"],
diff --git a/controllers/agent.go b/controllers/agent.go
index 5a09be2..6bd40bd 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -105,6 +105,17 @@ func (c *ApiController) GetAgents() {
c.ResponseOk(result, agent.InContainer())
}
+// GetAgentCatalog returns every agent Gateway knows how to detect, installed
or
+// not, so the pages can list the ones missing from this machine and point at
+// the vendor's install page.
+func (c *ApiController) GetAgentCatalog() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ c.ResponseOk(agent.KnownAgents())
+}
+
// UpdateAgentRouting binds one agent to the provider its requests are
forwarded
// to, to the providers tried when that one cannot answer, and to the way it
// reaches them. The binding is per agent id.
diff --git a/routers/router.go b/routers/router.go
index 390ec28..5453648 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -45,6 +45,7 @@ func initAPI() {
beego.Router("/api/update-setting", &controllers.ApiController{},
"POST:UpdateSetting")
beego.Router("/api/get-agents", &controllers.ApiController{},
"GET:GetAgents")
+ beego.Router("/api/get-agent-catalog", &controllers.ApiController{},
"GET:GetAgentCatalog")
beego.Router("/api/patch-agent", &controllers.ApiController{},
"POST:PatchAgent")
beego.Router("/api/unpatch-agent", &controllers.ApiController{},
"POST:UnpatchAgent")
beego.Router("/api/update-agent-routing", &controllers.ApiController{},
"POST:UpdateAgentRouting")
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index d7b7cf4..4e2b811 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -15,6 +15,7 @@
import {query, request} from "@/backend/request";
import type {
Agent,
+ AgentCatalogEntry,
AgentProviderConfig,
AgentProviderFile,
AgentRecord,
@@ -34,6 +35,11 @@ export function getAgents(forceRefresh = false) {
return request<Agent[], boolean>(`/api/get-agents${forceRefresh ?
"?refresh=true" : ""}`);
}
+/** Every agent Gateway knows, which is more than the ones installed here. */
+export function getAgentCatalog() {
+ return request<AgentCatalogEntry[]>("/api/get-agent-catalog");
+}
+
export function patchAgent(target: PatchTarget) {
return request<{followup?: string}>("/api/patch-agent", "POST", target);
}
diff --git a/web/src/components/AgentCatalog.tsx
b/web/src/components/AgentCatalog.tsx
new file mode 100644
index 0000000..32cbd0c
--- /dev/null
+++ b/web/src/components/AgentCatalog.tsx
@@ -0,0 +1,83 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {Bot, Download} from "lucide-react";
+import i18next from "i18next";
+
+import {AgentIcon} from "@/components/AgentIcon";
+import {useAgentCatalog} from "@/lib/agents";
+import type {Agent} from "@/types";
+
+/**
+ * The agents Gateway supports that this machine has none of, each linking to
+ * the vendor's own install page. Without them a fresh machine says only that
+ * nothing was found, which is the one thing it cannot act on.
+ */
+export function AgentCatalog({agents, enabled = true}: {agents: Agent[];
enabled?: boolean}) {
+ const missing = useAgentCatalog(agents, enabled);
+
+ if (missing.length === 0) {
+ return null;
+ }
+
+ return (
+ <section className="space-y-3">
+ <div>
+ <h2 className="font-medium">{i18next.t("agent:Not installed")}</h2>
+ <p className="text-muted-foreground text-sm">{i18next.t("agent:Not
installed hint")}</p>
+ </div>
+
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3
2xl:grid-cols-4">
+ {missing.map(item => {
+ const inside = (
+ <>
+ {/* The icons are keyed by vendor name, which every entry has.
*/}
+ <AgentIcon
+ agent={item.name}
+ size={22}
+ fallback={<Bot className="text-muted-foreground size-5" />}
+ />
+ <span className="min-w-0 truncate text-sm
font-medium">{item.name}</span>
+ {item.installUrl ? (
+ <span className="text-muted-foreground ml-auto inline-flex
shrink-0 items-center gap-1 text-xs">
+ <Download className="size-3.5" />
+ {i18next.t("agent:Install")}
+ </span>
+ ) : null}
+ </>
+ );
+
+ return item.installUrl ? (
+ <a
+ key={item.agentId}
+ href={item.installUrl}
+ target="_blank"
+ rel="noreferrer"
+ className="hover:border-primary hover:text-primary flex
items-center gap-2.5 rounded-lg border p-3 transition-colors"
+ >
+ {inside}
+ </a>
+ ) : (
+ <div
+ key={item.agentId}
+ className="text-muted-foreground flex items-center gap-2.5
rounded-lg border p-3"
+ >
+ {inside}
+ </div>
+ );
+ })}
+ </div>
+ </section>
+ );
+}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index f8a5e48..55d3b0f 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -19,7 +19,13 @@ import * as AgentBackend from "@/backend/AgentBackend";
import * as Setting from "@/Setting";
import type {BadgeVariant} from "@/components/ui/badge";
import {providerProtocol, servesResponsesApi} from "@/lib/providers";
-import type {Agent, AgentRuntime, AgentSession, Provider} from "@/types";
+import type {
+ Agent,
+ AgentCatalogEntry,
+ AgentRuntime,
+ AgentSession,
+ Provider,
+} from "@/types";
/** How long a started app is given before its process is looked for again. */
const runtimeSettleMs = 2000;
@@ -409,6 +415,30 @@ export function useAgents(enabled = true) {
};
}
+/**
+ * useAgentCatalog lists the agents Gateway knows how to work with that this
+ * machine has none of. The catalogue is built into the binary, so it is read
+ * once and only the scan it is compared against changes.
+ */
+export function useAgentCatalog(agents: Agent[], enabled = true) {
+ const [catalog, setCatalog] = React.useState<AgentCatalogEntry[]>([]);
+
+ React.useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
+ AgentBackend.getAgentCatalog()
+ .then(res => setCatalog(res.status === "ok" ? (res.data ?? []) : []))
+ .catch(() => undefined);
+ }, [enabled]);
+
+ return React.useMemo(
+ () => catalog.filter(item => !agents.some(agent => agent.agentId ===
item.agentId)),
+ [catalog, agents],
+ );
+}
+
/** The run state of one installation, before the first listing has landed. */
export function runtimeOf(runtime: Record<string, AgentRuntime>, agent: Agent)
{
return runtime[agentKey(agent)];
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 5a6fb88..c50ffe6 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -75,6 +75,7 @@
"Gateway only hint": "Codex speaks only the OpenAI Responses API. Gateway
serves it by translating to the chat completions this provider answers, so
Codex cannot be pointed at it directly.",
"Image": "Image",
"In use": "In use",
+ "Install": "Install",
"Install Method": "Install Method",
"Install an AI agent on this machine, then scan again": "Install an AI
agent on this machine, then scan again",
"Installation": "Installation",
@@ -92,6 +93,8 @@
"No other provider speaks this API": "No other provider speaks this API",
"No supported agents found": "No supported agents found",
"No usage yet": "No requests have been relayed for this agent yet",
+ "Not installed": "Not installed",
+ "Not installed hint": "Agents Gateway works with that were not found on
this machine. Open one to install it from its vendor, then scan again.",
"Not patched": "Not monitored",
"Not recorded": "not recorded",
"Not supported": "Not supported yet",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 68ceb2e..3174120 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -75,6 +75,7 @@
"Gateway only hint": "Codex 只会说 OpenAI Responses API。Gateway 会把它翻译成该供应商应答的
chat completions,所以 Codex 无法直连这个供应商。",
"Image": "图片",
"In use": "使用中",
+ "Install": "安装",
"Install Method": "安装方式",
"Install an AI agent on this machine, then scan again": "在本机安装一个 AI
智能体后,再扫描一次",
"Installation": "安装信息",
@@ -92,6 +93,8 @@
"No other provider speaks this API": "没有其他供应商使用相同的 API 协议",
"No supported agents found": "没有发现本机安装的智能体",
"No usage yet": "该 Agent 还没有经过网关的请求",
+ "Not installed": "未安装",
+ "Not installed hint": "Gateway 支持、但本机未发现的智能体。点击可打开官方安装页面,安装后重新扫描即可。",
"Not patched": "未开启",
"Not recorded": "未保留",
"Not supported": "暂不支持",
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index 0ace95a..c8541ab 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -17,6 +17,7 @@ import {ArrowLeft, Bot, Container, RefreshCw} from
"lucide-react";
import i18next from "i18next";
import * as Setting from "@/Setting";
+import {AgentCatalog} from "@/components/AgentCatalog";
import {AgentIcon} from "@/components/AgentIcon";
import {RunBadge, RunButton} from "@/components/AgentRunControl";
import {ConfirmDialog} from "@/components/shared/confirm-dialog";
@@ -46,6 +47,7 @@ export default function AgentsPage({account}: {account:
Account}) {
error,
busyKey,
inContainer,
+ scanned,
runtime,
runBusyKey,
scan,
@@ -253,6 +255,8 @@ export default function AgentsPage({account}: {account:
Account}) {
</Button>
}
/>
+
+ <AgentCatalog agents={agents} enabled={scanned} />
</PageContainer>
);
}
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index cdf4790..254d417 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -20,6 +20,7 @@ import i18next from "i18next";
import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
import * as ProviderBackend from "@/backend/ProviderBackend";
import * as Setting from "@/Setting";
+import {AgentCatalog} from "@/components/AgentCatalog";
import {AgentGridCard} from "@/components/AgentGridCard";
import {EmptyState, ErrorState} from "@/components/shared/empty-state";
import {Loading} from "@/components/shared/loading";
@@ -246,6 +247,9 @@ export default function HomePage({account}: {account:
Account}) {
))}
</div>
)}
+
+ {/* What is not here yet, so an empty machine has somewhere to go. */}
+ <AgentCatalog agents={agents} enabled={scanned} />
</PageContainer>
);
}
diff --git a/web/src/types.ts b/web/src/types.ts
index 3953d7b..338f2e3 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -151,6 +151,18 @@ export interface Agent {
proxyBaseUrl?: string;
}
+/**
+ * One agent Gateway knows how to detect, whether or not it is installed here.
+ * The ones missing from a scan are listed as something to install.
+ */
+export interface AgentCatalogEntry {
+ agentId: string;
+ name: string;
+ /** The vendor's own install page, opened in a new tab. */
+ installUrl?: string;
+ desktop?: boolean;
+}
+
/** The live processes of one agent installation, keyed by owner and path. */
export interface AgentRuntime {
agentId: string;
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]