diff --git a/e2e/acp-agents-catalog.spec.ts b/e2e/acp-agents-catalog.spec.ts new file mode 100644 index 000000000..960849e9d --- /dev/null +++ b/e2e/acp-agents-catalog.spec.ts @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { test, expect } from '@playwright/test' +import { collectPageErrors, loginViaOidc } from './helpers' + +test.describe('Agents catalog', () => { + test('browse, link out, search, and empty state work without page errors', async ({ page }) => { + const errors = collectPageErrors(page) + await loginViaOidc(page) + + await page.goto('/settings/agents') + + // The bundled ACP registry snapshot renders immediately — no live network needed. + // Assert a few known registry cards by id. + const geminiCard = page.getByTestId('agent-catalog-card-gemini') + await expect(geminiCard).toBeVisible({ timeout: 10_000 }) + await expect(page.getByTestId('agent-catalog-card-claude-acp')).toBeVisible() + await expect(page.getByTestId('agent-catalog-card-goose')).toBeVisible() + + // At least one link-out is present on a card. + await expect(geminiCard.getByRole('link').first()).toBeVisible() + + // Search filters the grid: 'gemini' keeps the gemini card, drops claude-acp. + const search = page.getByPlaceholder('Search agents') + await search.fill('gemini') + await expect(page.getByTestId('agent-catalog-card-gemini')).toBeVisible() + await expect(page.getByTestId('agent-catalog-card-claude-acp')).toHaveCount(0) + + // The clear (X) button resets the query and restores the filtered-out card. + await page.getByRole('button', { name: /clear search/i }).click() + await expect(search).toHaveValue('') + await expect(page.getByTestId('agent-catalog-card-claude-acp')).toBeVisible() + + // A guaranteed-no-match query shows the empty state. + await search.fill('zzzqqqxx') + await expect(page.getByText(/no agents found/i)).toBeVisible() + + // The background CDN fetch must not surface page errors even if it fails — + // the snapshot fallback covers it. + expect(errors).toEqual([]) + }) +}) diff --git a/src/components/settings/agents/agent-catalog-card.tsx b/src/components/settings/agents/agent-catalog-card.tsx new file mode 100644 index 000000000..145f9aaec --- /dev/null +++ b/src/components/settings/agents/agent-catalog-card.tsx @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Code2, ExternalLink, Terminal } from 'lucide-react' +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader } from '@/components/ui/card' +import { distributionLabel, primaryDistributionKind } from '@/lib/agent-registry-filter' +import type { RegistryEntry } from '@/types/registry' + +type AgentCatalogCardProps = { + entry: RegistryEntry +} + +/** A read-only catalogue card for a "bridge" agent: shows the agent's identity + * and metadata and links out to its website and source. There's no install + * action — these CLIs run on the user's own machine, not inside Thunderbolt. */ +export const AgentCatalogCard = ({ entry }: AgentCatalogCardProps) => { + const [iconFailed, setIconFailed] = useState(false) + + const distributionKind = primaryDistributionKind(entry) + const websiteUrl = entry.website ?? entry.repository + const sourceUrl = entry.repository && entry.repository !== websiteUrl ? entry.repository : null + const showIcon = entry.icon && !iconFailed + const metadata = [entry.version ? `v${entry.version}` : null, entry.authors.join(', ') || null, entry.license || null] + .filter(Boolean) + .join(' · ') + + return ( + + +
+ {showIcon ? ( + setIconFailed(true)} + /> + ) : ( +
+
+ +

{entry.description}

+

{metadata}

+
+ {websiteUrl && ( + + )} + {sourceUrl && ( + + )} +
+
+
+ ) +} diff --git a/src/components/settings/agents/agent-catalog-view.test.tsx b/src/components/settings/agents/agent-catalog-view.test.tsx new file mode 100644 index 000000000..8905aaeb0 --- /dev/null +++ b/src/components/settings/agents/agent-catalog-view.test.tsx @@ -0,0 +1,147 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import '@testing-library/jest-dom' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'bun:test' +import type { RegistryEntry } from '@/types/registry' +import { AgentCatalogView } from './agent-catalog-view' + +const entry = (overrides: Partial & Pick): RegistryEntry => ({ + version: '1.2.3', + description: `${overrides.name} description`, + authors: ['Author Inc.'], + license: 'Apache-2.0', + website: `https://example.com/${overrides.id}`, + repository: `https://github.com/example/${overrides.id}`, + distribution: { npx: { package: `${overrides.id}@1.2.3` } }, + ...overrides, +}) + +const fixtures: ReadonlyArray = [ + entry({ id: 'goose', name: 'goose', description: 'Extensible agent from Block', icon: 'https://cdn/goose.svg' }), + entry({ id: 'gemini', name: 'Gemini CLI', description: 'Google terminal agent' }), +] + +const renderCatalog = (entries: ReadonlyArray = fixtures) => + render() + +describe('AgentCatalogView', () => { + afterEach(cleanup) + + it('renders one card per entry', () => { + renderCatalog() + expect(screen.getByTestId('agent-catalog-card-goose')).toBeInTheDocument() + expect(screen.getByTestId('agent-catalog-card-gemini')).toBeInTheDocument() + }) + + it('renders a distribution badge per card', () => { + renderCatalog() + expect(screen.getAllByText('Node.js')).toHaveLength(fixtures.length) + }) + + it('renders Website and Source link-outs with correct attributes', () => { + renderCatalog([entry({ id: 'goose', name: 'goose' })]) + const card = screen.getByTestId('agent-catalog-card-goose') + const links = card.querySelectorAll('a') + expect(links).toHaveLength(2) + + const website = card.querySelector('a[href="https://example.com/goose"]') + const source = card.querySelector('a[href="https://github.com/example/goose"]') + expect(website).toBeInTheDocument() + expect(source).toBeInTheDocument() + + for (const link of [website, source]) { + expect(link).toHaveAttribute('target', '_blank') + expect(link?.getAttribute('rel')).toContain('noopener') + expect(link?.getAttribute('rel')).toContain('noreferrer') + } + }) + + it('falls back the Website link to the repository when no website is set, and hides the duplicate Source link', () => { + renderCatalog([entry({ id: 'claude-acp', name: 'Claude Agent', website: undefined })]) + const card = screen.getByTestId('agent-catalog-card-claude-acp') + const links = card.querySelectorAll('a') + expect(links).toHaveLength(1) + expect(card.querySelector('a[href="https://github.com/example/claude-acp"]')).toBeInTheDocument() + }) + + it('filters by search query', () => { + renderCatalog() + fireEvent.change(screen.getByPlaceholderText('Search agents'), { target: { value: 'gemini' } }) + + expect(screen.getByTestId('agent-catalog-card-gemini')).toBeInTheDocument() + expect(screen.queryByTestId('agent-catalog-card-goose')).not.toBeInTheDocument() + }) + + it('clears the query and restores every card when the clear button is clicked', () => { + renderCatalog() + const input = screen.getByPlaceholderText('Search agents') as HTMLInputElement + + fireEvent.change(input, { target: { value: 'gemini' } }) + expect(input.value).toBe('gemini') + expect(screen.queryByTestId('agent-catalog-card-goose')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /clear search/i })) + + expect(input.value).toBe('') + expect(screen.getByTestId('agent-catalog-card-gemini')).toBeInTheDocument() + expect(screen.getByTestId('agent-catalog-card-goose')).toBeInTheDocument() + }) + + it('shows a no-results message when nothing matches', () => { + renderCatalog() + fireEvent.change(screen.getByPlaceholderText('Search agents'), { target: { value: 'zzzqqqxx' } }) + + expect(screen.getByText(/no agents found/i)).toBeInTheDocument() + expect(screen.queryByTestId('agent-catalog-card-goose')).not.toBeInTheDocument() + }) + + it('renders an icon image when icon is set', () => { + renderCatalog([entry({ id: 'goose', name: 'goose', icon: 'https://cdn/goose.svg' })]) + const header = screen.getByTestId('agent-catalog-card-goose').querySelector('[data-slot="card-header"]') + const img = header?.querySelector('img') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'https://cdn/goose.svg') + // The Terminal fallback (an svg) must not render while the icon image loads cleanly. + expect(header?.querySelector('svg')).not.toBeInTheDocument() + }) + + it('falls back to the Terminal icon when the image fails to load', () => { + renderCatalog([entry({ id: 'goose', name: 'goose', icon: 'https://cdn/broken.svg' })]) + const header = screen.getByTestId('agent-catalog-card-goose').querySelector('[data-slot="card-header"]') + const img = header?.querySelector('img') + expect(img).toBeInTheDocument() + + fireEvent.error(img as HTMLImageElement) + + expect(header?.querySelector('img')).not.toBeInTheDocument() + expect(header?.querySelector('svg')).toBeInTheDocument() + }) + + it('renders the Terminal icon when no icon is set', () => { + renderCatalog([entry({ id: 'goose', name: 'goose', icon: undefined })]) + const header = screen.getByTestId('agent-catalog-card-goose').querySelector('[data-slot="card-header"]') + expect(header?.querySelector('img')).not.toBeInTheDocument() + expect(header?.querySelector('svg')).toBeInTheDocument() + }) + + it('exposes only link-out actions per card, never an install action', () => { + renderCatalog([entry({ id: 'goose', name: 'goose' })]) + const card = screen.getByTestId('agent-catalog-card-goose') + + expect(card.querySelectorAll('a').length).toBeGreaterThan(0) + expect(card.querySelector('button')).not.toBeInTheDocument() + expect(card.querySelector('button[type="submit"]')).not.toBeInTheDocument() + }) + + it('keeps all cards visible for a whitespace-only query', () => { + renderCatalog() + fireEvent.change(screen.getByPlaceholderText('Search agents'), { target: { value: ' ' } }) + + expect(screen.queryByText(/no agents found/i)).not.toBeInTheDocument() + expect(screen.getByTestId('agent-catalog-card-goose')).toBeInTheDocument() + expect(screen.getByTestId('agent-catalog-card-gemini')).toBeInTheDocument() + }) +}) diff --git a/src/components/settings/agents/agent-catalog-view.tsx b/src/components/settings/agents/agent-catalog-view.tsx new file mode 100644 index 000000000..e6e769042 --- /dev/null +++ b/src/components/settings/agents/agent-catalog-view.tsx @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { SearchInput } from '@/components/ui/search-input' +import { useAgentRegistrySearch } from '@/hooks/use-agent-registry-search' +import type { RegistryEntry } from '@/types/registry' +import { useRef } from 'react' +import { AgentCatalogCard } from './agent-catalog-card' + +type AgentCatalogViewProps = { + /** The agents to render. Always non-empty in production (the snapshot seeds it). */ + entries: ReadonlyArray +} + +/** Presentational catalogue: search + grid of read-only agent cards. Takes its + * entries as a prop and owns no data fetching, so it renders purely from inputs + * and is unit-testable without react-query. */ +export const AgentCatalogView = ({ entries }: AgentCatalogViewProps) => { + const { query, setQuery, results, isEmpty } = useAgentRegistrySearch(entries) + const showEmptyState = isEmpty && query.trim().length > 0 + const searchRef = useRef(null) + + return ( +
+

Browse agents

+ setQuery(event.target.value)} + /> + {showEmptyState ? ( +

No agents found

+ ) : ( +
+ {results.map((entry) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/components/settings/agents/agent-catalog.tsx b/src/components/settings/agents/agent-catalog.tsx new file mode 100644 index 000000000..b39481ecc --- /dev/null +++ b/src/components/settings/agents/agent-catalog.tsx @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { useAgentRegistry } from '@/hooks/use-agent-registry' +import { AgentCatalogView } from './agent-catalog-view' + +/** Container for the bridgeable-agent catalogue: reads the live registry hook + * (snapshot-seeded, so always non-empty) and hands the entries to the + * presentational view. */ +export const AgentCatalog = () => { + const entries = useAgentRegistry() + return +} diff --git a/src/defaults/acp-registry-snapshot.json b/src/defaults/acp-registry-snapshot.json new file mode 100644 index 000000000..f0174c585 --- /dev/null +++ b/src/defaults/acp-registry-snapshot.json @@ -0,0 +1,1040 @@ +{ + "version": "1.0.0", + "agents": [ + { + "id": "agoragentic-acp", + "name": "Agoragentic", + "version": "1.3.0", + "description": "Agent marketplace with 174+ AI capabilities. Browse, invoke, and pay for agent services settled in USDC on Base L2.", + "repository": "https://github.com/rhein1/agoragentic-integrations", + "website": "https://agoragentic.com", + "authors": ["ACRE / Agoragentic"], + "license": "MIT", + "distribution": { + "npx": { + "package": "agoragentic-mcp@1.3.0", + "args": ["--acp"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/agoragentic-acp.svg" + }, + { + "id": "amp-acp", + "name": "Amp", + "version": "0.8.1", + "description": "ACP wrapper for Amp - the frontier coding agent", + "repository": "https://github.com/tao12345666333/amp-acp", + "authors": ["tao12345666333"], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/amp-acp.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-darwin-aarch64.tar.gz", + "cmd": "./amp-acp" + }, + "darwin-x86_64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-darwin-x86_64.tar.gz", + "cmd": "./amp-acp" + }, + "linux-aarch64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-linux-aarch64.tar.gz", + "cmd": "./amp-acp" + }, + "linux-x86_64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-linux-x86_64.tar.gz", + "cmd": "./amp-acp" + }, + "windows-x86_64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-windows-x86_64.zip", + "cmd": "amp-acp.exe" + } + } + } + }, + { + "id": "auggie", + "name": "Auggie CLI", + "version": "0.29.0", + "description": "Augment Code's powerful software agent, backed by industry-leading context engine", + "repository": "https://github.com/augmentcode/auggie", + "website": "https://www.augmentcode.com/", + "authors": ["Augment Code "], + "license": "proprietary", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/auggie.svg", + "distribution": { + "npx": { + "package": "@augmentcode/auggie@0.29.0", + "args": ["--acp"], + "env": { + "AUGMENT_DISABLE_AUTO_UPDATE": "1" + } + } + } + }, + { + "id": "autohand", + "name": "Autohand Code", + "version": "0.2.1", + "description": "Autohand Code - AI coding agent powered by Autohand AI", + "repository": "https://github.com/autohandai/autohand-acp", + "website": "https://www.autohand.ai/cli/", + "authors": ["Autohand AI"], + "license": "Apache-2.0", + "distribution": { + "npx": { + "package": "@autohandai/autohand-acp@0.2.1" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/autohand.svg" + }, + { + "id": "claude-acp", + "name": "Claude Agent", + "version": "0.44.0", + "description": "ACP wrapper for Anthropic's Claude", + "repository": "https://github.com/agentclientprotocol/claude-agent-acp", + "authors": ["Anthropic", "Zed Industries", "JetBrains"], + "license": "proprietary", + "distribution": { + "npx": { + "package": "@agentclientprotocol/claude-agent-acp@0.44.0" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/claude-acp.svg" + }, + { + "id": "cline", + "name": "Cline", + "version": "3.0.24", + "description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", + "repository": "https://github.com/cline/cline", + "website": "https://cline.bot/cli", + "authors": ["Cline Bot Inc."], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cline.svg", + "distribution": { + "npx": { + "package": "cline@3.0.24", + "args": ["--acp"] + } + } + }, + { + "id": "codebuddy-code", + "name": "Codebuddy Code", + "version": "2.106.4", + "description": "Tencent Cloud's official intelligent coding tool", + "website": "https://www.codebuddy.cn/cli/", + "authors": ["Tencent Cloud"], + "license": "Proprietary", + "distribution": { + "npx": { + "package": "@tencent-ai/codebuddy-code@2.106.4", + "args": ["--acp"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/codebuddy-code.svg" + }, + { + "id": "codex-acp", + "name": "Codex CLI", + "version": "0.16.0", + "description": "ACP adapter for OpenAI's coding assistant", + "repository": "https://github.com/zed-industries/codex-acp", + "authors": ["OpenAI", "Zed Industries"], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.16.0/codex-acp-0.16.0-aarch64-apple-darwin.tar.gz", + "cmd": "./codex-acp" + }, + "darwin-x86_64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.16.0/codex-acp-0.16.0-x86_64-apple-darwin.tar.gz", + "cmd": "./codex-acp" + }, + "linux-aarch64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.16.0/codex-acp-0.16.0-aarch64-unknown-linux-gnu.tar.gz", + "cmd": "./codex-acp" + }, + "linux-x86_64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.16.0/codex-acp-0.16.0-x86_64-unknown-linux-gnu.tar.gz", + "cmd": "./codex-acp" + }, + "windows-aarch64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.16.0/codex-acp-0.16.0-aarch64-pc-windows-msvc.zip", + "cmd": "./codex-acp.exe" + }, + "windows-x86_64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.16.0/codex-acp-0.16.0-x86_64-pc-windows-msvc.zip", + "cmd": "./codex-acp.exe" + } + }, + "npx": { + "package": "@zed-industries/codex-acp@0.16.0" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/codex-acp.svg" + }, + { + "id": "cortex-code", + "name": "Cortex Code", + "version": "1.0.73", + "description": "Snowflake's Cortex Code coding agent", + "repository": "https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code", + "authors": ["Snowflake"], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-darwin-arm64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-darwin-arm64/cortex", + "args": ["acp", "serve"] + }, + "darwin-x86_64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-darwin-amd64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-darwin-amd64/cortex", + "args": ["acp", "serve"] + }, + "linux-x86_64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-linux-amd64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-linux-amd64/cortex", + "args": ["acp", "serve"] + }, + "linux-aarch64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-linux-arm64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-linux-arm64/cortex", + "args": ["acp", "serve"] + }, + "windows-x86_64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-windows-amd64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-windows-amd64/cortex.exe", + "args": ["acp", "serve"] + }, + "windows-aarch64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-windows-arm64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-windows-arm64/cortex.exe", + "args": ["acp", "serve"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cortex-code.svg" + }, + { + "id": "corust-agent", + "name": "Corust Agent", + "version": "0.6.0", + "description": "Co-building with a seasoned Rust partner.", + "repository": "https://github.com/Corust-ai/corust-agent-release", + "website": "https://corust.ai/", + "authors": ["Corust AI "], + "license": "GPL-3.0-or-later", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-darwin-arm64.tar.gz", + "cmd": "./corust-agent-acp" + }, + "darwin-x86_64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-darwin-x64.tar.gz", + "cmd": "./corust-agent-acp" + }, + "linux-x86_64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-linux-x64.tar.gz", + "cmd": "./corust-agent-acp" + }, + "windows-x86_64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-windows-x64.zip", + "cmd": "./corust-agent-acp.exe" + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/corust-agent.svg" + }, + { + "id": "crow-cli", + "name": "crow-cli", + "version": "0.1.24", + "description": "Minimal ACP Native Coding Agent", + "repository": "https://github.com/crow-cli/crow-cli", + "website": "https://crow-ai.dev", + "authors": ["Thomas Wood"], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-darwin-aarch64.tar.gz", + "cmd": "./crow-cli", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-darwin-x86_64.tar.gz", + "cmd": "./crow-cli", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-linux-aarch64.tar.gz", + "cmd": "./crow-cli", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-linux-x86_64.tar.gz", + "cmd": "./crow-cli", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-windows-x86_64.zip", + "cmd": "./crow-cli.exe", + "args": ["acp"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/crow-cli.svg" + }, + { + "id": "cursor", + "name": "Cursor", + "version": "2026.06.15", + "description": "Cursor's coding agent", + "website": "https://cursor.com/docs/cli/acp", + "authors": ["Cursor"], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/darwin/arm64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/darwin/x64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/linux/arm64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/linux/x64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": ["acp"] + }, + "windows-aarch64": { + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/windows/arm64/agent-cli-package.zip", + "cmd": "./dist-package\\cursor-agent.cmd", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/windows/x64/agent-cli-package.zip", + "cmd": "./dist-package\\cursor-agent.cmd", + "args": ["acp"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cursor.svg" + }, + { + "id": "deepagents", + "name": "DeepAgents", + "version": "0.1.7", + "description": "Batteries-included AI coding and general purpose agent powered by LangChain.", + "repository": "https://github.com/langchain-ai/deepagentsjs", + "website": "https://docs.langchain.com/oss/javascript/deepagents/overview", + "authors": ["LangChain"], + "license": "MIT", + "distribution": { + "npx": { + "package": "deepagents-acp@0.1.7", + "args": [] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/deepagents.svg" + }, + { + "id": "devin", + "name": "Devin", + "version": "2026.5.26", + "description": "Devin CLI coding agent by Cognition", + "website": "https://docs.devin.ai/cli", + "authors": ["Cognition"], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-aarch64-apple-darwin.tar.gz", + "cmd": "./bin/devin", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-x86_64-apple-darwin.tar.gz", + "cmd": "./bin/devin", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-aarch64-unknown-linux.tar.gz", + "cmd": "./bin/devin", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-x86_64-unknown-linux.tar.gz", + "cmd": "./bin/devin", + "args": ["acp"] + }, + "windows-aarch64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-aarch64-pc-windows.zip", + "cmd": ".\\bin\\devin.exe", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-x86_64-pc-windows.zip", + "cmd": ".\\bin\\devin.exe", + "args": ["acp"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/devin.svg" + }, + { + "id": "dimcode", + "name": "DimCode", + "version": "0.2.2", + "description": "A coding agent that puts leading models at your command.", + "website": "https://dimcode.dev/docs/acp.html", + "authors": ["ArcShips"], + "license": "proprietary", + "distribution": { + "npx": { + "package": "dimcode@0.2.2", + "args": ["acp"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/dimcode.svg" + }, + { + "id": "dirac", + "name": "Dirac", + "version": "0.4.0", + "description": "Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.", + "repository": "https://github.com/dirac-run/dirac", + "website": "https://dirac.run", + "authors": ["Dirac Delta Labs"], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/dirac.svg", + "distribution": { + "npx": { + "package": "dirac-cli@0.4.0", + "args": ["--acp"] + } + } + }, + { + "id": "factory-droid", + "name": "Factory Droid", + "version": "0.148.0", + "description": "Factory Droid - AI coding agent powered by Factory AI", + "website": "https://factory.ai/product/cli", + "authors": ["Factory AI"], + "license": "proprietary", + "distribution": { + "npx": { + "package": "droid@0.148.0", + "args": ["exec", "--output-format", "acp-daemon"], + "env": { + "DROID_DISABLE_AUTO_UPDATE": "true", + "FACTORY_DROID_AUTO_UPDATE_ENABLED": "false" + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/factory-droid.svg" + }, + { + "id": "fast-agent", + "name": "fast-agent", + "version": "0.7.20", + "description": "Code and build agents with comprehensive multi-provider support", + "repository": "https://github.com/evalstate/fast-agent", + "website": "https://fast-agent.ai", + "authors": ["enquiries@fast-agent.ai"], + "license": "Apache 2.0", + "distribution": { + "uvx": { + "package": "fast-agent-acp==0.7.20", + "args": ["-x"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/fast-agent.svg" + }, + { + "id": "gemini", + "name": "Gemini CLI", + "version": "0.46.0", + "description": "Google's official CLI for Gemini", + "repository": "https://github.com/google-gemini/gemini-cli", + "website": "https://geminicli.com", + "authors": ["Google"], + "license": "Apache-2.0", + "distribution": { + "npx": { + "package": "@google/gemini-cli@0.46.0", + "args": ["--acp"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/gemini.svg" + }, + { + "id": "github-copilot-cli", + "name": "GitHub Copilot", + "version": "1.0.62", + "description": "GitHub's AI pair programmer", + "repository": "https://github.com/github/copilot-cli", + "website": "https://github.com/features/copilot/cli/", + "authors": ["GitHub"], + "license": "proprietary", + "distribution": { + "npx": { + "package": "@github/copilot@1.0.62", + "args": ["--acp"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/github-copilot-cli.svg" + }, + { + "id": "glm-acp-agent", + "name": "GLM Agent", + "version": "1.1.4", + "description": "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.", + "repository": "https://github.com/stefandevo/glm-acp-agent", + "authors": ["Stefan de Vogelaere"], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/glm-acp-agent.svg", + "distribution": { + "npx": { + "package": "glm-acp-agent@1.1.4" + } + } + }, + { + "id": "goose", + "name": "goose", + "version": "1.37.0", + "description": "A local, extensible, open source AI agent that automates engineering tasks", + "repository": "https://github.com/block/goose", + "website": "https://block.github.io/goose/", + "authors": ["Block"], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/block/goose/releases/download/v1.37.0/goose-aarch64-apple-darwin.tar.bz2", + "cmd": "./goose", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://github.com/block/goose/releases/download/v1.37.0/goose-x86_64-apple-darwin.tar.bz2", + "cmd": "./goose", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/block/goose/releases/download/v1.37.0/goose-aarch64-unknown-linux-gnu.tar.bz2", + "cmd": "./goose", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/block/goose/releases/download/v1.37.0/goose-x86_64-unknown-linux-gnu.tar.bz2", + "cmd": "./goose", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://github.com/block/goose/releases/download/v1.37.0/goose-x86_64-pc-windows-msvc.zip", + "cmd": "./goose-package\\goose.exe", + "args": ["acp"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/goose.svg" + }, + { + "id": "grok-build", + "name": "Grok Build", + "version": "0.2.39", + "description": "xAI's coding agent and CLI", + "website": "https://x.ai/cli", + "authors": ["xAI"], + "license": "proprietary", + "distribution": { + "npx": { + "package": "@xai-official/grok@0.2.39", + "args": ["agent", "stdio"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/grok-build.svg" + }, + { + "id": "junie", + "name": "Junie", + "version": "1892.26.0", + "description": "AI Coding Agent by JetBrains", + "repository": "https://github.com/JetBrains/junie", + "website": "https://junie.jetbrains.com", + "authors": ["JetBrains"], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1892.26/junie-release-1892.26-macos-aarch64.zip", + "cmd": "./Applications/junie.app/Contents/MacOS/junie", + "args": ["--acp=true"] + }, + "darwin-x86_64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1892.26/junie-release-1892.26-macos-amd64.zip", + "cmd": "./Applications/junie.app/Contents/MacOS/junie", + "args": ["--acp=true"] + }, + "linux-aarch64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1892.26/junie-release-1892.26-linux-aarch64.zip", + "cmd": "./junie-app/bin/junie", + "args": ["--acp=true"] + }, + "linux-x86_64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1892.26/junie-release-1892.26-linux-amd64.zip", + "cmd": "./junie-app/bin/junie", + "args": ["--acp=true"] + }, + "windows-x86_64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1892.26/junie-release-1892.26-windows-amd64.zip", + "cmd": "./junie/junie.exe", + "args": ["--acp=true"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/junie.svg" + }, + { + "id": "kilo", + "name": "Kilo", + "version": "7.3.46", + "description": "The open source coding agent", + "repository": "https://github.com/Kilo-Org/kilocode", + "website": "https://kilo.ai/", + "authors": ["Kilo Code"], + "license": "MIT", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-darwin-arm64.zip", + "cmd": "./kilo", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-darwin-x64.zip", + "cmd": "./kilo", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-linux-arm64.tar.gz", + "cmd": "./kilo", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-linux-x64.tar.gz", + "cmd": "./kilo", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-windows-x64.zip", + "cmd": "./kilo.exe", + "args": ["acp"] + } + }, + "npx": { + "package": "@kilocode/cli@7.3.46", + "args": ["acp"] + } + } + }, + { + "id": "kimi", + "name": "Kimi CLI", + "version": "1.47.0", + "description": "Moonshot AI's coding assistant", + "repository": "https://github.com/MoonshotAI/kimi-cli", + "website": "https://moonshotai.github.io/kimi-cli/", + "authors": ["Moonshot AI"], + "license": "MIT", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.47.0/kimi-1.47.0-aarch64-apple-darwin.tar.gz", + "cmd": "./kimi", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.47.0/kimi-1.47.0-aarch64-unknown-linux-gnu.tar.gz", + "cmd": "./kimi", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.47.0/kimi-1.47.0-x86_64-unknown-linux-gnu.tar.gz", + "cmd": "./kimi", + "args": ["acp"] + }, + "windows-aarch64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.47.0/kimi-1.47.0-aarch64-pc-windows-msvc.zip", + "cmd": "./kimi.exe", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.47.0/kimi-1.47.0-x86_64-pc-windows-msvc.zip", + "cmd": "./kimi.exe", + "args": ["acp"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/kimi.svg" + }, + { + "id": "minion-code", + "name": "Minion Code", + "version": "0.1.44", + "description": "An enhanced AI code assistant built on the Minion framework with rich development tools", + "repository": "https://github.com/femto/minion-code", + "authors": ["femto"], + "license": "AGPL-3.0", + "distribution": { + "uvx": { + "package": "minion-code@0.1.44", + "args": ["acp"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/minion-code.svg" + }, + { + "id": "mistral-vibe", + "name": "Mistral Vibe", + "version": "2.15.0", + "description": "Mistral's open-source coding assistant", + "repository": "https://github.com/mistralai/mistral-vibe", + "website": "https://mistral.ai/products/vibe", + "authors": ["Mistral AI"], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/mistral-vibe.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-darwin-aarch64-2.15.0.zip", + "cmd": "./vibe-acp" + }, + "darwin-x86_64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-darwin-x86_64-2.15.0.zip", + "cmd": "./vibe-acp" + }, + "linux-aarch64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-linux-aarch64-2.15.0.zip", + "cmd": "./vibe-acp" + }, + "linux-x86_64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-linux-x86_64-2.15.0.zip", + "cmd": "./vibe-acp" + }, + "windows-x86_64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-windows-x86_64-2.15.0.zip", + "cmd": "./vibe-acp.exe" + } + } + } + }, + { + "id": "nova", + "name": "Nova", + "version": "1.1.18", + "description": "Nova by Compass AI - a fully-fledged software engineer at your command", + "repository": "https://github.com/Compass-Agentic-Platform/nova", + "website": "https://www.compassap.ai/portfolio/nova.html", + "authors": ["Compass AI"], + "license": "proprietary", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/nova.svg", + "distribution": { + "npx": { + "package": "@compass-ai/nova@1.1.18", + "args": ["acp"] + } + } + }, + { + "id": "opencode", + "name": "OpenCode", + "version": "1.17.7", + "description": "The open source coding agent", + "repository": "https://github.com/anomalyco/opencode", + "website": "https://opencode.ai", + "authors": ["Anomaly"], + "license": "MIT", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/opencode.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-darwin-arm64.zip", + "cmd": "./opencode", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-darwin-x64.zip", + "cmd": "./opencode", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-linux-arm64.tar.gz", + "cmd": "./opencode", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-linux-x64.tar.gz", + "cmd": "./opencode", + "args": ["acp"] + }, + "windows-aarch64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-windows-arm64.zip", + "cmd": "./opencode", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-windows-x64.zip", + "cmd": "./opencode.exe", + "args": ["acp"] + } + } + } + }, + { + "id": "pi-acp", + "name": "pi ACP", + "version": "0.0.28", + "description": "ACP adapter for pi coding agent", + "repository": "https://github.com/svkozak/pi-acp", + "authors": ["Sergii Kozak "], + "license": "MIT", + "distribution": { + "npx": { + "package": "pi-acp@0.0.28" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/pi-acp.svg" + }, + { + "id": "poolside", + "name": "Poolside", + "version": "1.0.5", + "description": "Poolside's coding agent", + "repository": "https://github.com/poolsideai/pool", + "website": "https://poolside.ai", + "authors": ["Poolside "], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.5/pool-darwin-arm64.tar.gz", + "cmd": "./pool-darwin-arm64", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.5/pool-darwin-amd64.tar.gz", + "cmd": "./pool-darwin-amd64", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.5/pool-linux-arm64.tar.gz", + "cmd": "./pool-linux-arm64", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.5/pool-linux-amd64.tar.gz", + "cmd": "./pool-linux-amd64", + "args": ["acp"] + }, + "windows-aarch64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.5/pool-windows-arm64.tar.gz", + "cmd": "./pool-windows-arm64.exe", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.5/pool-windows-amd64.tar.gz", + "cmd": "./pool-windows-amd64.exe", + "args": ["acp"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/poolside.svg" + }, + { + "id": "qoder", + "name": "Qoder CLI", + "version": "0.2.14", + "description": "AI coding assistant with agentic capabilities", + "website": "https://qoder.com", + "authors": ["Qoder AI"], + "license": "proprietary", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/qoder.svg", + "distribution": { + "npx": { + "package": "@qoder-ai/qodercli@0.2.14", + "args": ["--acp"] + } + } + }, + { + "id": "qwen-code", + "name": "Qwen Code", + "version": "0.18.1", + "description": "Alibaba's Qwen coding assistant", + "repository": "https://github.com/QwenLM/qwen-code", + "website": "https://qwenlm.github.io/qwen-code-docs/en/users/overview", + "authors": ["Alibaba Qwen Team"], + "license": "Apache-2.0", + "distribution": { + "npx": { + "package": "@qwen-code/qwen-code@0.18.1", + "args": ["--acp", "--experimental-skills"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/qwen-code.svg" + }, + { + "id": "sigit", + "name": "siGit Code", + "version": "1.1.0", + "description": "Local-first coding agent. Runs entirely on your machine with optional on-device LLM inference via Onde.", + "repository": "https://github.com/getsigit/sigit", + "website": "https://github.com/getsigit/sigit", + "authors": ["smbCloud"], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.1.0/sigit-macos-arm64.tar.gz", + "cmd": "./sigit" + }, + "darwin-x86_64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.1.0/sigit-macos-amd64.tar.gz", + "cmd": "./sigit" + }, + "linux-aarch64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.1.0/sigit-linux-arm64", + "cmd": "./sigit-linux-arm64" + }, + "linux-x86_64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.1.0/sigit-linux-amd64", + "cmd": "./sigit-linux-amd64" + }, + "windows-aarch64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.1.0/sigit-win-arm64.exe", + "cmd": "./sigit-win-arm64.exe" + }, + "windows-x86_64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.1.0/sigit-win-amd64.exe", + "cmd": "./sigit-win-amd64.exe" + } + }, + "npx": { + "package": "@smbcloud/sigit@1.1.0" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/sigit.svg" + }, + { + "id": "stakpak", + "name": "Stakpak", + "version": "0.3.88", + "description": "Open-source DevOps agent in Rust with enterprise-grade security", + "repository": "https://github.com/stakpak/agent", + "website": "https://stakpak.dev", + "authors": ["Stakpak Team "], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/stakpak.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-darwin-aarch64.tar.gz", + "cmd": "./stakpak", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-darwin-x86_64.tar.gz", + "cmd": "./stakpak", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-linux-aarch64.tar.gz", + "cmd": "./stakpak", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-linux-x86_64.tar.gz", + "cmd": "./stakpak", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-windows-x86_64.zip", + "cmd": "./stakpak.exe", + "args": ["acp"] + } + } + } + }, + { + "id": "vtcode", + "name": "VT Code", + "version": "0.96.14", + "description": "An open-source coding agent with LLM-native code understanding and robust shell safety. Supports multiple LLM providers with automatic failover and efficient context management.", + "repository": "https://github.com/vinhnx/VTCode", + "website": "https://github.com/vinhnx/VTCode/blob/main/docs/guides/zed-acp.md", + "authors": ["vinhnx"], + "license": "MIT", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-aarch64-apple-darwin.tar.gz", + "cmd": "./vtcode", + "args": ["acp"], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "darwin-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-apple-darwin.tar.gz", + "cmd": "./vtcode", + "args": ["acp"], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "linux-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-unknown-linux-gnu.tar.gz", + "cmd": "./vtcode", + "args": ["acp"], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "windows-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-pc-windows-msvc.zip", + "cmd": "vtcode.exe", + "args": ["acp"], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/vtcode.svg" + } + ], + "extensions": [] +} diff --git a/src/defaults/agent-registry.ts b/src/defaults/agent-registry.ts new file mode 100644 index 000000000..a001c0a79 --- /dev/null +++ b/src/defaults/agent-registry.ts @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { parseRegistryJson } from '@/lib/agent-registry-filter' +import type { RegistryEntry } from '@/types/registry' +import snapshot from './acp-registry-snapshot.json' + +/** + * Bundled snapshot of the official ACP registry + * (https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json), + * parsed into typed entries. Shipped so the catalogue renders instantly and + * works offline; the live CDN fetch (see `useAgentRegistry`) refreshes it in the + * background and falls back here on any error. + */ +export const agentRegistrySnapshot: ReadonlyArray = parseRegistryJson(snapshot) diff --git a/src/hooks/use-agent-registry-search.test.ts b/src/hooks/use-agent-registry-search.test.ts new file mode 100644 index 000000000..0f9b86f71 --- /dev/null +++ b/src/hooks/use-agent-registry-search.test.ts @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'bun:test' +import type { RegistryEntry } from '@/types/registry' +import { useAgentRegistrySearch } from './use-agent-registry-search' + +const entry = (id: string, name: string, description: string): RegistryEntry => ({ + id, + name, + description, + version: '1.0.0', + authors: ['Author'], + license: 'Apache-2.0', + distribution: { npx: { package: `${id}@1.0.0` } }, +}) + +const entries: ReadonlyArray = [ + entry('goose', 'goose', 'Extensible agent from Block'), + entry('gemini', 'Gemini CLI', 'Google terminal agent'), +] + +describe('useAgentRegistrySearch', () => { + it('returns all entries with an empty query', () => { + const { result } = renderHook(() => useAgentRegistrySearch(entries)) + expect(result.current.results).toEqual(entries) + expect(result.current.isEmpty).toBe(false) + }) + + it('returns all entries for a whitespace-only query', () => { + const { result } = renderHook(() => useAgentRegistrySearch(entries)) + + act(() => { + result.current.setQuery(' ') + }) + + expect(result.current.results).toEqual(entries) + expect(result.current.isEmpty).toBe(false) + }) + + it('derives results as the query changes', () => { + const { result } = renderHook(() => useAgentRegistrySearch(entries)) + + act(() => { + result.current.setQuery('gemini') + }) + + expect(result.current.results).toHaveLength(1) + expect(result.current.results[0]?.id).toBe('gemini') + expect(result.current.isEmpty).toBe(false) + }) + + it('is empty when nothing matches', () => { + const { result } = renderHook(() => useAgentRegistrySearch(entries)) + + act(() => { + result.current.setQuery('zzzqqqxx') + }) + + expect(result.current.results).toEqual([]) + expect(result.current.isEmpty).toBe(true) + }) +}) diff --git a/src/hooks/use-agent-registry-search.ts b/src/hooks/use-agent-registry-search.ts new file mode 100644 index 000000000..99e03d297 --- /dev/null +++ b/src/hooks/use-agent-registry-search.ts @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { useMemo, useState } from 'react' +import { filterRegistry } from '@/lib/agent-registry-filter' +import type { RegistryEntry } from '@/types/registry' + +/** + * Owns the catalogue's search query and derives the filtered results during + * render — no effect, since the results are pure state of `entries` + `query`. + */ +export const useAgentRegistrySearch = (entries: ReadonlyArray) => { + const [query, setQuery] = useState('') + const results = useMemo(() => filterRegistry(entries, query), [entries, query]) + return { query, setQuery, results, isEmpty: results.length === 0 } +} diff --git a/src/hooks/use-agent-registry.test.tsx b/src/hooks/use-agent-registry.test.tsx new file mode 100644 index 000000000..05243e5b3 --- /dev/null +++ b/src/hooks/use-agent-registry.test.tsx @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'bun:test' +import { agentRegistrySnapshot } from '@/defaults/agent-registry' +import type { FetchFn } from '@/lib/proxy-fetch' +import { getClock } from '@/testing-library' +import { createQueryTestWrapper } from '@/test-utils/react-query' +import { useAgentRegistry } from './use-agent-registry' + +/** A `FetchFn` that always resolves to `body` as a JSON `Response`. */ +const proxyFetchReturning = (body: unknown): FetchFn => + Object.assign( + async () => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }), + { preconnect: () => Promise.resolve(false) }, + ) as FetchFn + +describe('useAgentRegistry', () => { + it('returns the bundled snapshot immediately as initialData', () => { + const { result } = renderHook(() => useAgentRegistry(), { wrapper: createQueryTestWrapper() }) + expect(result.current).toBe(agentRegistrySnapshot) + }) + + it('updates with the live registry when the proxy fetch succeeds', async () => { + const liveRegistry = { + version: '9.9.9', + agents: [{ id: 'live-only', name: 'Live Only', distribution: {} }], + } + const wrapper = createQueryTestWrapper({ proxyFetch: proxyFetchReturning(liveRegistry) }) + const { result } = renderHook(() => useAgentRegistry(), { wrapper }) + + // Seeded immediately from the snapshot... + expect(result.current).toBe(agentRegistrySnapshot) + + // ...then refreshed from the live proxy response. (The global fake-timer + // setup makes refetch-on-mount timing unreliable, so drive it explicitly.) + await act(async () => { + await wrapper.queryClient.refetchQueries({ queryKey: ['acp-agent-registry'] }) + await getClock().runAllAsync() + }) + + expect(result.current.map((entry) => entry.id)).toEqual(['live-only']) + }) + + it('keeps the snapshot when the live response is empty (degenerate-response guard)', async () => { + const wrapper = createQueryTestWrapper({ proxyFetch: proxyFetchReturning({ agents: [] }) }) + const { result } = renderHook(() => useAgentRegistry(), { wrapper }) + + await act(async () => { + await wrapper.queryClient.refetchQueries({ queryKey: ['acp-agent-registry'] }) + await getClock().runAllAsync() + }) + + expect(result.current).toEqual(agentRegistrySnapshot) + }) +}) diff --git a/src/hooks/use-agent-registry.ts b/src/hooks/use-agent-registry.ts new file mode 100644 index 000000000..fbec990f3 --- /dev/null +++ b/src/hooks/use-agent-registry.ts @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { agentRegistrySnapshot } from '@/defaults/agent-registry' +import { parseRegistryJson } from '@/lib/agent-registry-filter' +import type { FetchFn } from '@/lib/proxy-fetch' +import { useFetch } from '@/lib/proxy-fetch-context' +import type { RegistryEntry } from '@/types/registry' +import { useQuery } from '@tanstack/react-query' + +/** The official, machine-readable ACP registry. */ +export const acpRegistryUrl = 'https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json' + +const staleTime = 5 * 60 * 1000 // 5 minutes + +/** + * Fetches the live registry through the universal proxy. The CDN sends no + * `access-control-allow-origin`, so a direct browser fetch is CORS-blocked — + * the proxy fetch (Hosted: `/v1/proxy`; Standalone: upstream-direct) is the + * only way a browser can read it. A degenerate (empty) live response falls back + * to the snapshot so a bad CDN payload can never blank the catalogue. + */ +const fetchAgentRegistry = (proxyFetch: FetchFn) => async (): Promise> => { + const response = await proxyFetch(acpRegistryUrl) + const parsed = parseRegistryJson(await response.json()) + return parsed.length > 0 ? parsed : agentRegistrySnapshot +} + +/** + * Returns the ACP agent catalogue. The bundled snapshot is the immediate seed + * (`initialData`), so the array is always non-empty and the UI renders instantly + * even offline. React Query refreshes from the live CDN through the universal + * proxy in the background; on any fetch/parse error it keeps the last good data, + * which falls back to the snapshot. Anonymous / offline / proxy-unavailable users + * therefore always see the snapshot. + */ +export const useAgentRegistry = (): ReadonlyArray => { + const proxyFetch = useFetch() + const { data } = useQuery({ + queryKey: ['acp-agent-registry'], + queryFn: fetchAgentRegistry(proxyFetch), + initialData: agentRegistrySnapshot, + staleTime, + }) + return data +} diff --git a/src/lib/agent-registry-filter.test.ts b/src/lib/agent-registry-filter.test.ts new file mode 100644 index 000000000..71f90ae2e --- /dev/null +++ b/src/lib/agent-registry-filter.test.ts @@ -0,0 +1,215 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import type { RegistryEntry } from '@/types/registry' +import { + distributionLabel, + filterRegistry, + normalizeQuery, + parseRegistryJson, + primaryDistributionKind, +} from './agent-registry-filter' + +const makeEntry = (overrides: Partial = {}): RegistryEntry => ({ + id: 'goose', + name: 'goose', + version: '1.0.0', + description: 'Extensible open-source AI agent from Block', + authors: ['Block'], + license: 'Apache-2.0', + repository: 'https://github.com/block/goose', + website: 'https://block.github.io/goose/', + icon: 'https://cdn.example.com/goose.svg', + distribution: { npx: { package: 'goose@1.0.0' } }, + ...overrides, +}) + +const entries: ReadonlyArray = [ + makeEntry({ id: 'goose', name: 'goose', description: 'Extensible agent from Block', authors: ['Block'] }), + makeEntry({ id: 'gemini', name: 'Gemini CLI', description: 'Google terminal agent', authors: ['Google'] }), + makeEntry({ id: 'claude-acp', name: 'Claude Code', description: 'Anthropic coding tool', authors: ['Anthropic'] }), +] + +describe('parseRegistryJson', () => { + it('parses a valid registry object into typed entries', () => { + const raw = { + version: '1.0.0', + agents: [ + { + id: 'claude-acp', + name: 'Claude Agent', + version: '0.44.0', + description: 'ACP wrapper', + authors: ['Anthropic'], + license: 'proprietary', + repository: 'https://github.com/x/y', + icon: 'https://cdn/x.svg', + distribution: { npx: { package: '@x/y@0.44.0', args: ['--acp'] } }, + }, + ], + } + const parsed = parseRegistryJson(raw) + expect(parsed).toHaveLength(1) + expect(parsed[0]).toEqual({ + id: 'claude-acp', + name: 'Claude Agent', + version: '0.44.0', + description: 'ACP wrapper', + authors: ['Anthropic'], + license: 'proprietary', + repository: 'https://github.com/x/y', + website: undefined, + icon: 'https://cdn/x.svg', + distribution: { npx: { package: '@x/y@0.44.0', args: ['--acp'] } }, + }) + }) + + it('accepts a bare entry array', () => { + const parsed = parseRegistryJson([{ id: 'a', name: 'A', distribution: {} }]) + expect(parsed).toHaveLength(1) + expect(parsed[0]?.id).toBe('a') + }) + + it('drops entries missing id or name', () => { + const parsed = parseRegistryJson({ + agents: [{ id: 'ok', name: 'Ok', distribution: {} }, { name: 'No id' }, { id: 'no-name' }, 'garbage', null], + }) + expect(parsed.map((entry) => entry.id)).toEqual(['ok']) + }) + + it('defaults missing optional fields and normalizes distribution', () => { + const parsed = parseRegistryJson([{ id: 'min', name: 'Min', distribution: { uvx: { package: 'min' } } }]) + expect(parsed[0]).toEqual({ + id: 'min', + name: 'Min', + version: '', + description: '', + authors: [], + license: '', + repository: undefined, + website: undefined, + icon: undefined, + distribution: { uvx: { package: 'min', args: [] } }, + }) + }) + + it('drops non-http(s) repository / website / icon URLs (javascript:/data: injection)', () => { + const parsed = parseRegistryJson([ + { + id: 'evil', + name: 'Evil', + repository: 'data:text/html,', + website: 'javascript:alert(1)', + icon: 'javascript:alert(document.cookie)', + distribution: {}, + }, + ]) + expect(parsed[0]?.repository).toBeUndefined() + expect(parsed[0]?.website).toBeUndefined() + expect(parsed[0]?.icon).toBeUndefined() + }) + + it('keeps valid http(s) repository / website / icon URLs', () => { + const parsed = parseRegistryJson([ + { + id: 'safe', + name: 'Safe', + repository: 'https://github.com/x/y', + website: 'http://example.com', + icon: 'https://cdn.example.com/x.svg', + distribution: {}, + }, + ]) + expect(parsed[0]?.repository).toBe('https://github.com/x/y') + expect(parsed[0]?.website).toBe('http://example.com') + expect(parsed[0]?.icon).toBe('https://cdn.example.com/x.svg') + }) + + it('returns [] for non-array / garbage input', () => { + expect(parseRegistryJson(null)).toEqual([]) + expect(parseRegistryJson(undefined)).toEqual([]) + expect(parseRegistryJson(42)).toEqual([]) + expect(parseRegistryJson('nope')).toEqual([]) + expect(parseRegistryJson({})).toEqual([]) + expect(parseRegistryJson({ agents: 'not-an-array' })).toEqual([]) + }) +}) + +describe('primaryDistributionKind', () => { + it('prefers npx over uvx and binary', () => { + expect(primaryDistributionKind(makeEntry({ distribution: { npx: { package: 'a' }, uvx: { package: 'b' } } }))).toBe( + 'npx', + ) + }) + + it('falls back to uvx then binary', () => { + expect(primaryDistributionKind(makeEntry({ distribution: { uvx: { package: 'b' } } }))).toBe('uvx') + expect(primaryDistributionKind(makeEntry({ distribution: { binary: { 'darwin-aarch64': {} } } }))).toBe('binary') + }) + + it('returns null when there is no distribution', () => { + expect(primaryDistributionKind(makeEntry({ distribution: {} }))).toBeNull() + }) +}) + +describe('distributionLabel', () => { + it('maps kinds to human labels', () => { + expect(distributionLabel('npx')).toBe('Node.js') + expect(distributionLabel('uvx')).toBe('Python') + expect(distributionLabel('binary')).toBe('Binary') + }) +}) + +describe('normalizeQuery', () => { + it('trims and lowercases', () => { + expect(normalizeQuery(' GoOSE ')).toBe('goose') + }) + + it('returns empty string for whitespace-only input', () => { + expect(normalizeQuery(' ')).toBe('') + }) +}) + +describe('filterRegistry', () => { + it('returns all entries for an empty query', () => { + expect(filterRegistry(entries, '')).toEqual(entries) + }) + + it('returns all entries for a whitespace-only query', () => { + expect(filterRegistry(entries, ' ')).toEqual(entries) + }) + + it('matches on name case-insensitively', () => { + const result = filterRegistry(entries, 'GEMINI') + expect(result).toHaveLength(1) + expect(result[0]?.id).toBe('gemini') + }) + + it('matches on description', () => { + const result = filterRegistry(entries, 'anthropic') + expect(result).toHaveLength(1) + expect(result[0]?.id).toBe('claude-acp') + }) + + it('matches on id', () => { + const result = filterRegistry(entries, 'claude-acp') + expect(result.map((entry) => entry.id)).toEqual(['claude-acp']) + }) + + it('matches on authors', () => { + const result = filterRegistry(entries, 'google') + expect(result.map((entry) => entry.id)).toEqual(['gemini']) + }) + + it('returns an empty array when nothing matches', () => { + expect(filterRegistry(entries, 'zzzqqqxx')).toEqual([]) + }) + + it('returns multiple matches (but not all)', () => { + // "agent" appears in the goose and gemini descriptions only. + const result = filterRegistry(entries, 'agent') + expect(result.map((entry) => entry.id)).toEqual(['goose', 'gemini']) + }) +}) diff --git a/src/lib/agent-registry-filter.ts b/src/lib/agent-registry-filter.ts new file mode 100644 index 000000000..960de8638 --- /dev/null +++ b/src/lib/agent-registry-filter.ts @@ -0,0 +1,125 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { RegistryDistribution, RegistryEntry } from '@/types/registry' + +/** A distribution kind we surface as a badge. `binary` covers any platform map. */ +export type DistributionKind = 'npx' | 'uvx' | 'binary' + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const asStringArray = (value: unknown): ReadonlyArray => + Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] + +/** Accepts a value only when it's an `http(s)` URL, dropping anything else to + * `undefined`. The registry is untrusted network data, so this keeps + * `javascript:` / `data:` payloads out of `` and ``. */ +const asHttpUrl = (value: unknown): string | undefined => + typeof value === 'string' && /^https?:\/\//i.test(value) ? value : undefined + +/** Parse the registry's `distribution` object, dropping anything malformed. */ +const parseDistribution = (raw: unknown): RegistryDistribution => { + if (!isRecord(raw)) { + return {} + } + const distribution: RegistryDistribution = {} + if (isRecord(raw.npx) && typeof raw.npx.package === 'string') { + distribution.npx = { package: raw.npx.package, args: asStringArray(raw.npx.args) } + } + if (isRecord(raw.uvx) && typeof raw.uvx.package === 'string') { + distribution.uvx = { package: raw.uvx.package, args: asStringArray(raw.uvx.args) } + } + if (isRecord(raw.binary)) { + distribution.binary = raw.binary + } + return distribution +} + +const parseEntry = (raw: unknown): RegistryEntry | null => { + if (!isRecord(raw) || typeof raw.id !== 'string' || typeof raw.name !== 'string') { + return null + } + return { + id: raw.id, + name: raw.name, + version: typeof raw.version === 'string' ? raw.version : '', + description: typeof raw.description === 'string' ? raw.description : '', + authors: asStringArray(raw.authors), + license: typeof raw.license === 'string' ? raw.license : '', + repository: asHttpUrl(raw.repository), + website: asHttpUrl(raw.website), + icon: asHttpUrl(raw.icon), + distribution: parseDistribution(raw.distribution), + } +} + +/** + * Defensively normalizes untrusted registry JSON (live CDN fetch or bundled + * snapshot) into a typed `RegistryEntry[]`. Accepts either the raw registry + * object (`{ agents: [...] }`) or a bare entry array; drops any entry missing an + * `id` or `name`. This is the one place defensive parsing belongs — the input is + * network data we don't control. + */ +export const parseRegistryJson = (raw: unknown): ReadonlyArray => { + const agents = Array.isArray(raw) ? raw : isRecord(raw) ? raw.agents : null + if (!Array.isArray(agents)) { + return [] + } + return agents.map(parseEntry).filter((entry): entry is RegistryEntry => entry !== null) +} + +/** The distribution kind to surface on a card, preferring npx > uvx > binary. */ +export const primaryDistributionKind = (entry: RegistryEntry): DistributionKind | null => { + if (entry.distribution.npx) { + return 'npx' + } + if (entry.distribution.uvx) { + return 'uvx' + } + if (entry.distribution.binary) { + return 'binary' + } + return null +} + +/** Human-readable badge label for a distribution kind. */ +export const distributionLabel = (kind: DistributionKind): string => { + switch (kind) { + case 'npx': + return 'Node.js' + case 'uvx': + return 'Python' + case 'binary': + return 'Binary' + } +} + +/** + * Normalizes a raw search query for matching: trims surrounding whitespace and + * lowercases so comparisons are case-insensitive. + */ +export const normalizeQuery = (q: string): string => q.trim().toLowerCase() + +/** + * Filters registry entries by a search query. An empty or whitespace-only query + * returns every entry; otherwise entries whose name, description, id, or authors + * contain the query (case-insensitive) are kept. + * + * Matching is plain substring containment with no ranking — by design, the + * catalogue is small enough that relevance ordering adds no value. + */ +export const filterRegistry = (entries: ReadonlyArray, query: string): ReadonlyArray => { + const normalized = normalizeQuery(query) + if (normalized.length === 0) { + return entries + } + return entries.filter( + (entry) => + entry.name.toLowerCase().includes(normalized) || + entry.description.toLowerCase().includes(normalized) || + entry.id.toLowerCase().includes(normalized) || + entry.authors.some((author) => author.toLowerCase().includes(normalized)), + ) +} diff --git a/src/routes/settings/agents/index.tsx b/src/routes/settings/agents/index.tsx index 012457db4..e2b9d944f 100644 --- a/src/routes/settings/agents/index.tsx +++ b/src/routes/settings/agents/index.tsx @@ -9,6 +9,7 @@ import { Plus } from 'lucide-react' import { Button } from '@/components/ui/button' import { PageHeader } from '@/components/ui/page-header' import { AgentList } from '@/components/settings/agents/agent-list' +import { AgentCatalog } from '@/components/settings/agents/agent-catalog' import { AddCustomAgentDialog, type AddCustomAgentPayload } from '@/components/settings/agents/add-custom-agent-dialog' import { testAcpConnection } from '@/acp' import { createAgent, deleteAgent, updateAgent, useAllAgents } from '@/dal' @@ -106,6 +107,8 @@ export default function AgentsSettingsPage({ isStandalone }: AgentsSettingsPageP + + { const queryClient = new QueryClient({ defaultOptions: { @@ -39,13 +42,14 @@ export const createQueryTestWrapper = (options?: { }) const mockHttpClient = createMockHttpClient() + const proxyFetch = options?.proxyFetch ?? mockProxyFetch - return ({ children }: { children: ReactNode }) => { + const Wrapper = ({ children }: { children: ReactNode }) => { const inner = ( - {children} + {children} @@ -55,6 +59,9 @@ export const createQueryTestWrapper = (options?: { } return inner } + // Expose the client so tests can drive explicit refetch/invalidation when the + // global fake-timer setup makes automatic refetch-on-mount timing unreliable. + return Object.assign(Wrapper, { queryClient }) } /** diff --git a/src/types/registry.ts b/src/types/registry.ts new file mode 100644 index 000000000..60a723eb6 --- /dev/null +++ b/src/types/registry.ts @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Types mirroring the official Agent Client Protocol registry + * (https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json). + * + * Every shipped agent is a CLI (npx / uvx / binary). In Thunderbolt's + * remote-only model there is nothing to install locally — the catalogue is a + * read-only directory of "bridge" agents whose cards link out to their own + * websites and source repositories. + */ + +/** How an agent is distributed. Mirrors the registry's `distribution` object; + * `binary` is a per-platform record we only surface as a "Binary" badge, so its + * inner shape is left opaque. */ +export type RegistryDistribution = { + npx?: { package: string; args?: ReadonlyArray } + uvx?: { package: string; args?: ReadonlyArray } + binary?: Readonly> +} + +export type RegistryEntry = { + id: string + name: string + version: string + description: string + authors: ReadonlyArray + license: string + repository?: string + website?: string + icon?: string + distribution: RegistryDistribution +} + +export type AgentRegistry = { + version: string + agents: ReadonlyArray + extensions?: ReadonlyArray +}