| 1 | import * as vscode from "vscode"; |
| 2 | import { Catalog, RegistryAgent, parseCatalog } from "./catalog"; |
| 3 | |
| 4 | /** |
| 5 | * The remote ACP agent registry: fetches a curated catalog of ACP-compatible |
| 6 | * agents, caches the last good copy for offline use, and installs a chosen |
| 7 | * agent by writing its definition into the user's `sigit.agents` configuration. |
| 8 | * |
| 9 | * "Install" here means *register* — siGit never downloads or runs an agent |
| 10 | * binary on the user's behalf. Each catalog entry may carry a human-readable |
| 11 | * `install` hint that we surface but never execute. |
| 12 | */ |
| 13 | |
| 14 | const DEFAULT_REGISTRY_URL = |
| 15 | "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; |
| 16 | const CACHE_KEY = "sigit.registry.cache"; |
| 17 | const FETCH_TIMEOUT_MS = 10_000; |
| 18 | |
| 19 | interface CacheEntry { |
| 20 | url: string; |
| 21 | fetchedAt: number; |
| 22 | agents: RegistryAgent[]; |
| 23 | } |
| 24 | |
| 25 | export interface FetchResult { |
| 26 | agents: RegistryAgent[]; |
| 27 | /** True when the network fetch failed and cached data was served instead. */ |
| 28 | fromCache: boolean; |
| 29 | /** The network error, present only when `fromCache` is true. */ |
| 30 | error?: string; |
| 31 | } |
| 32 | |
| 33 | function config(): vscode.WorkspaceConfiguration { |
| 34 | return vscode.workspace.getConfiguration("sigit"); |
| 35 | } |
| 36 | |
| 37 | /** The configured registry URL, or the default Agent Client Protocol catalog. */ |
| 38 | export function registryUrl(): string { |
| 39 | const configured = config().get<string>("registry.url"); |
| 40 | return configured && configured.trim() !== "" ? configured.trim() : DEFAULT_REGISTRY_URL; |
| 41 | } |
| 42 | |
| 43 | /** Keys of agents already present in the `sigit.agents` configuration. */ |
| 44 | export function installedKeys(): Set<string> { |
| 45 | const registry = config().get<Record<string, unknown>>("agents") ?? {}; |
| 46 | return new Set(Object.keys(registry)); |
| 47 | } |
| 48 | |
| 49 | async function fetchFromNetwork(url: string): Promise<Catalog> { |
| 50 | const controller = new AbortController(); |
| 51 | const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); |
| 52 | try { |
| 53 | const response = await fetch(url, { |
| 54 | signal: controller.signal, |
| 55 | headers: { accept: "application/json" } |
| 56 | }); |
| 57 | if (!response.ok) { |
| 58 | throw new Error(`HTTP ${response.status} ${response.statusText}`); |
| 59 | } |
| 60 | return parseCatalog(await response.text()); |
| 61 | } finally { |
| 62 | clearTimeout(timer); |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | /** |
| 67 | * Fetch the agent catalog. On success the result is cached in global state. On |
| 68 | * failure the last cached catalog (for the same URL) is served when available, |
| 69 | * with `fromCache: true`; otherwise the error is re-thrown. |
| 70 | */ |
| 71 | export async function fetchCatalog(context: vscode.ExtensionContext): Promise<FetchResult> { |
| 72 | const url = registryUrl(); |
| 73 | try { |
| 74 | const catalog = await fetchFromNetwork(url); |
| 75 | const entry: CacheEntry = { url, fetchedAt: Date.now(), agents: catalog.agents }; |
| 76 | await context.globalState.update(CACHE_KEY, entry); |
| 77 | return { agents: catalog.agents, fromCache: false }; |
| 78 | } catch (err) { |
| 79 | const cached = context.globalState.get<CacheEntry>(CACHE_KEY); |
| 80 | if (cached && cached.url === url) { |
| 81 | return { |
| 82 | agents: cached.agents, |
| 83 | fromCache: true, |
| 84 | error: (err as Error).message |
| 85 | }; |
| 86 | } |
| 87 | throw err; |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | /** |
| 92 | * Register a catalog agent into the user's `sigit.agents` configuration. By |
| 93 | * default this is install-only: it returns `false` without writing when an |
| 94 | * agent with the same key is already present. Pass `overwrite: true` to update |
| 95 | * an existing entry in place (e.g. to pick up a newer catalog version). |
| 96 | */ |
| 97 | export async function installAgent( |
| 98 | agent: RegistryAgent, |
| 99 | options: { overwrite?: boolean } = {} |
| 100 | ): Promise<boolean> { |
| 101 | const cfg = config(); |
| 102 | const registry = { ...(cfg.get<Record<string, unknown>>("agents") ?? {}) }; |
| 103 | if (!options.overwrite && Object.prototype.hasOwnProperty.call(registry, agent.key)) { |
| 104 | return false; |
| 105 | } |
| 106 | registry[agent.key] = { |
| 107 | name: agent.name, |
| 108 | command: agent.command, |
| 109 | args: agent.args, |
| 110 | env: agent.env |
| 111 | }; |
| 112 | await cfg.update("agents", registry, vscode.ConfigurationTarget.Global); |
| 113 | return true; |
| 114 | } |
| 115 | |
| 116 | /** |
| 117 | * Remove an agent from the user's `sigit.agents` configuration. Returns `false` |
| 118 | * when no entry with that key exists. When the removed agent was the configured |
| 119 | * default, the default is cleared so it falls back to the on-device `sigit`. |
| 120 | */ |
| 121 | export async function uninstallAgent(key: string): Promise<boolean> { |
| 122 | const cfg = config(); |
| 123 | const registry = { ...(cfg.get<Record<string, unknown>>("agents") ?? {}) }; |
| 124 | if (!Object.prototype.hasOwnProperty.call(registry, key)) { |
| 125 | return false; |
| 126 | } |
| 127 | delete registry[key]; |
| 128 | await cfg.update("agents", registry, vscode.ConfigurationTarget.Global); |
| 129 | if (cfg.get<string>("agent.default") === key) { |
| 130 | await cfg.update("agent.default", undefined, vscode.ConfigurationTarget.Global); |
| 131 | } |
| 132 | return true; |
| 133 | } |
| 134 | |
| 135 | /** Make `key` the default agent (`sigit.agent.default`), user-wide. */ |
| 136 | export async function setDefaultAgent(key: string): Promise<void> { |
| 137 | await config().update("agent.default", key, vscode.ConfigurationTarget.Global); |
| 138 | } |