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]

Reply via email to