diff --git a/sidecar/src/agent-providers.ts b/sidecar/src/agent-providers.ts new file mode 100644 index 000000000..6fce31fac --- /dev/null +++ b/sidecar/src/agent-providers.ts @@ -0,0 +1,20 @@ +/** Keep in sync with `KNOWN_PROVIDERS` in `provider_capabilities.rs`. */ +export const AGENT_PROVIDERS = [ + "claude", + "codex", + "cursor", + "opencode", +] as const; + +export type AgentProvider = (typeof AGENT_PROVIDERS)[number]; + +/** Agent providers disabled as Helmor agents. Bundled CLIs stay shipped. */ +export const DISABLED_AGENT_PROVIDERS: readonly AgentProvider[] = []; + +export function isAgentProviderEnabled(provider: AgentProvider): boolean { + return !DISABLED_AGENT_PROVIDERS.includes(provider); +} + +export function enabledAgentProviders(): AgentProvider[] { + return AGENT_PROVIDERS.filter(isAgentProviderEnabled); +} diff --git a/sidecar/src/index.ts b/sidecar/src/index.ts index 97c010dd6..700b6d7e7 100644 --- a/sidecar/src/index.ts +++ b/sidecar/src/index.ts @@ -11,6 +11,7 @@ import { createInterface } from "node:readline"; import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk"; import { isAbortError } from "./abort.js"; +import { isAgentProviderEnabled } from "./agent-providers.js"; import { applyAgentProxyToProcessEnv } from "./agent-proxy.js"; import { ClaudeSessionManager } from "./claude-session-manager.js"; import { CodexAppServerManager } from "./codex-app-server-manager.js"; @@ -49,13 +50,20 @@ const claudeManager = new ClaudeSessionManager(); const codexManager = new CodexAppServerManager(); const cursorManager = new CursorSessionManager(); const opencodeManager = new OpencodeSessionManager(); -const managers: Record = { +const allManagers: Record = { claude: claudeManager, codex: codexManager, cursor: cursorManager, opencode: opencodeManager, }; +function getManager(provider: Provider): SessionManager { + if (!isAgentProviderEnabled(provider)) { + throw new Error(`${provider} agent is disabled in this build`); + } + return allManagers[provider]; +} + // `parentGone` flips to true only when stdin EOFs — that's the // authoritative "Rust exited" signal. EPIPE on stdout, by contrast, can // fire transiently from any pipe in the process (Anthropic SDK child @@ -190,9 +198,13 @@ function collectErrorChainCodes(err: Error): string[] { } function buildTitleProviderOrder(provider: Provider | null): Provider[] { - if (provider === "codex") return ["codex", "claude", "cursor"]; - if (provider === "cursor") return ["cursor", "claude", "codex"]; - return ["claude", "codex", "cursor"]; + const order: Provider[] = + provider === "codex" + ? ["codex", "claude", "cursor"] + : provider === "cursor" + ? ["cursor", "claude", "codex"] + : ["claude", "codex", "cursor"]; + return order.filter(isAgentProviderEnabled); } logger.info("Sidecar starting", { pid: process.pid }); @@ -221,7 +233,7 @@ async function handleSendMessage( cwd: sendParams.cwd ?? "(none)", resume: sendParams.resume ?? "(none)", }); - await managers[provider].sendMessage(id, sendParams, emitter); + await getManager(provider).sendMessage(id, sendParams, emitter); logger.debug(`[${id}] sendMessage completed`); } catch (err) { if (isAbortError(err)) { @@ -281,7 +293,7 @@ async function handleGenerateTitle( try { if (titleProvider === "claude") { try { - await managers.claude.generateTitle( + await getManager("claude").generateTitle( id, userMessage, branchRenamePrompt, @@ -300,7 +312,7 @@ async function handleGenerateTitle( logger.debug( `[${id}] generateTitle custom claude failed, trying official claude: ${errorMessage(claudeErr)}`, ); - await managers.claude.generateTitle( + await getManager("claude").generateTitle( id, userMessage, branchRenamePrompt, @@ -313,7 +325,7 @@ async function handleGenerateTitle( return; } if (titleProvider === "codex") { - await managers.codex.generateTitle( + await getManager("codex").generateTitle( id, userMessage, branchRenamePrompt, @@ -324,7 +336,7 @@ async function handleGenerateTitle( logger.debug(`[${id}] generateTitle completed (codex)`); return; } - await managers.cursor.generateTitle( + await getManager("cursor").generateTitle( id, userMessage, branchRenamePrompt, @@ -371,7 +383,7 @@ async function handleListModels( override: Boolean(apiKey), forceReload, }); - const models = await managers[provider].listModels( + const models = await getManager(provider).listModels( apiKey || forceReload ? { ...(apiKey ? { apiKey } : {}), forceReload } : undefined, @@ -396,7 +408,7 @@ async function handleListSlashCommands( provider, cwd: listParams.cwd ?? "(none)", }); - const commands = await managers[provider].listSlashCommands(listParams); + const commands = await getManager(provider).listSlashCommands(listParams); emitter.slashCommandsListed(id, commands); logger.debug(`[${id}] listSlashCommands → ${commands.length} entries`); } catch (err) { @@ -414,7 +426,7 @@ async function handleStopSession( const provider = parseProvider(params.provider); const sessionId = requireString(params, "sessionId"); logger.debug(`[${id}] stopSession`, { sessionId, provider }); - await managers[provider].stopSession(sessionId); + await getManager(provider).stopSession(sessionId); emitter.stopped(id, sessionId); } catch (err) { const msg = errorMessage(err); @@ -496,7 +508,7 @@ async function handleSteerSession( fileCount: files.length, imageCount: images.length, }); - const accepted = await managers[provider].steer( + const accepted = await getManager(provider).steer( sessionId, prompt, files, diff --git a/sidecar/src/request-parser.ts b/sidecar/src/request-parser.ts index b1f29e4f3..c86d38c7a 100644 --- a/sidecar/src/request-parser.ts +++ b/sidecar/src/request-parser.ts @@ -4,6 +4,7 @@ * missing or wrong-shaped field. */ +import { isAgentProviderEnabled } from "./agent-providers.js"; import type { AgentProxySettings } from "./agent-proxy.js"; import type { GetContextUsageParams, @@ -89,8 +90,12 @@ export function parseProvider(value: unknown): Provider { value === "codex" || value === "cursor" || value === "opencode" - ) + ) { + if (!isAgentProviderEnabled(value)) { + throw new Error(`${value} agent is disabled in this build`); + } return value; + } throw new Error(`unknown provider: ${String(value)}`); } diff --git a/sidecar/src/session-manager.ts b/sidecar/src/session-manager.ts index 2a42e7260..3772ea817 100644 --- a/sidecar/src/session-manager.ts +++ b/sidecar/src/session-manager.ts @@ -8,7 +8,7 @@ import type { AgentProxySettings } from "./agent-proxy.js"; import type { SidecarEmitter } from "./emitter.js"; -export type Provider = "claude" | "codex" | "cursor" | "opencode"; +export type { AgentProvider as Provider } from "./agent-providers.js"; export interface SendMessageParams { readonly sessionId: string; diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index be76580fd..22f69a91e 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -217,9 +217,8 @@ pub async fn list_agent_model_sections() -> CmdResult> { #[tauri::command] pub async fn list_provider_capabilities( ) -> CmdResult> { - Ok(provider_capabilities::KNOWN_PROVIDERS - .iter() - .map(|p| provider_capabilities::capabilities_for_provider(p)) + Ok(provider_capabilities::enabled_providers() + .map(provider_capabilities::capabilities_for_provider) .collect()) } @@ -228,6 +227,7 @@ pub async fn list_cursor_models( sidecar: tauri::State<'_, crate::sidecar::ManagedSidecar>, api_key: Option, ) -> CmdResult> { + provider_capabilities::ensure_agent_provider_enabled("cursor")?; // Inline blocking — same pattern as `list_slash_commands`. queries::fetch_cursor_models(sidecar.inner(), api_key) } @@ -237,6 +237,7 @@ pub async fn list_opencode_models( sidecar: tauri::State<'_, crate::sidecar::ManagedSidecar>, force_reload: Option, ) -> CmdResult> { + provider_capabilities::ensure_agent_provider_enabled("opencode")?; // force_reload restarts the opencode server to pick up a just-written config. queries::fetch_opencode_models(sidecar.inner(), force_reload.unwrap_or(false)) } @@ -253,6 +254,8 @@ pub async fn send_agent_message_stream( return Err(anyhow::anyhow!("Prompt cannot be empty.").into()); } + provider_capabilities::ensure_agent_provider_enabled(&request.provider)?; + // Inject triage priming as a hidden prefix; consumed flag flips only after sidecar accepts. let priming_session_to_consume: Option = match request.helmor_session_id.as_deref() { Some(session_id) => match crate::triage::load_priming_prefix_for_session(session_id) { diff --git a/src-tauri/src/agents/catalog.rs b/src-tauri/src/agents/catalog.rs index e6bdce8a3..3c8087c92 100644 --- a/src-tauri/src/agents/catalog.rs +++ b/src-tauri/src/agents/catalog.rs @@ -52,15 +52,24 @@ fn model_sections_for_inputs( cursor_prefs: Option, opencode_prefs: Option, ) -> Vec { - let mut claude_section = official_claude_section(); - claude_section - .options - .extend(custom_provider_options(custom)); - let mut sections = vec![claude_section]; - sections.push(codex_section()); - sections.push(opencode_section_from_prefs(opencode_prefs)); - if let Some(cursor) = cursor_section_from_prefs(cursor_prefs) { - sections.push(cursor); + let mut sections = Vec::new(); + if super::provider_capabilities::is_agent_provider_enabled("claude") { + let mut claude_section = official_claude_section(); + claude_section + .options + .extend(custom_provider_options(custom)); + sections.push(claude_section); + } + if super::provider_capabilities::is_agent_provider_enabled("codex") { + sections.push(codex_section()); + } + if super::provider_capabilities::is_agent_provider_enabled("opencode") { + sections.push(opencode_section_from_prefs(opencode_prefs)); + } + if super::provider_capabilities::is_agent_provider_enabled("cursor") { + if let Some(cursor) = cursor_section_from_prefs(cursor_prefs) { + sections.push(cursor); + } } sections diff --git a/src-tauri/src/agents/provider_capabilities.rs b/src-tauri/src/agents/provider_capabilities.rs index 37a3580e5..501e87aca 100644 --- a/src-tauri/src/agents/provider_capabilities.rs +++ b/src-tauri/src/agents/provider_capabilities.rs @@ -115,6 +115,33 @@ pub fn capabilities_for_provider(provider: &str) -> ProviderCapabilities { /// tests use it to assert there are no holes in the matrix. pub const KNOWN_PROVIDERS: &[&str] = &["claude", "codex", "cursor", "opencode"]; +/// Agent providers disabled as Helmor agents. Bundled CLIs stay shipped; +/// edit this list to hide a provider from the composer without removing +/// its binary from `vendor/`. +pub const DISABLED_AGENT_PROVIDERS: &[&str] = &[]; + +/// Whether `provider` is a shipping agent Helmor exposes in the composer. +pub fn is_agent_provider_enabled(provider: &str) -> bool { + KNOWN_PROVIDERS.contains(&provider) && !DISABLED_AGENT_PROVIDERS.contains(&provider) +} + +/// Shipping providers minus [`DISABLED_AGENT_PROVIDERS`]. Drives the +/// composer catalog and capability table exposed to the frontend. +pub fn enabled_providers() -> impl Iterator { + KNOWN_PROVIDERS + .iter() + .copied() + .filter(|provider| is_agent_provider_enabled(provider)) +} + +pub fn ensure_agent_provider_enabled(provider: &str) -> anyhow::Result<()> { + if is_agent_provider_enabled(provider) { + Ok(()) + } else { + anyhow::bail!("{provider} agent is disabled in this build") + } +} + #[cfg(test)] mod tests { use super::*; @@ -234,4 +261,25 @@ mod tests { let raw = serde_json::to_string(&caps).unwrap(); assert!(!raw.contains('_'), "snake_case field leaked: {raw}"); } + + #[test] + fn empty_disabled_list_enables_every_known_provider() { + assert!(DISABLED_AGENT_PROVIDERS.is_empty()); + let enabled: Vec<_> = enabled_providers().collect(); + assert_eq!(enabled, KNOWN_PROVIDERS); + for provider in KNOWN_PROVIDERS { + assert!(is_agent_provider_enabled(provider)); + } + } + + #[test] + fn disabled_provider_is_filtered_from_enabled_list() { + let without_opencode: Vec<_> = KNOWN_PROVIDERS + .iter() + .copied() + .filter(|provider| *provider != "opencode") + .collect(); + assert!(!without_opencode.contains(&"opencode")); + assert_eq!(without_opencode.len(), KNOWN_PROVIDERS.len() - 1); + } } diff --git a/src-tauri/src/commands/opencode_config_commands.rs b/src-tauri/src/commands/opencode_config_commands.rs index 114b395ff..13d727a8e 100644 --- a/src-tauri/src/commands/opencode_config_commands.rs +++ b/src-tauri/src/commands/opencode_config_commands.rs @@ -5,6 +5,7 @@ use crate::agents::opencode_config::{self, OpencodeCustomProvider}; #[tauri::command] pub async fn get_opencode_custom_providers() -> CmdResult> { + crate::agents::provider_capabilities::ensure_agent_provider_enabled("opencode")?; run_blocking(opencode_config::read_custom_providers).await } @@ -13,10 +14,12 @@ pub async fn upsert_opencode_custom_provider( provider: OpencodeCustomProvider, preset: bool, ) -> CmdResult<()> { + crate::agents::provider_capabilities::ensure_agent_provider_enabled("opencode")?; run_blocking(move || opencode_config::upsert_custom_provider(&provider, preset)).await } #[tauri::command] pub async fn delete_opencode_custom_provider(id: String) -> CmdResult<()> { + crate::agents::provider_capabilities::ensure_agent_provider_enabled("opencode")?; run_blocking(move || opencode_config::delete_custom_provider(&id)).await } diff --git a/src-tauri/src/commands/system_commands.rs b/src-tauri/src/commands/system_commands.rs index 9ff5a3716..b5a97eea0 100644 --- a/src-tauri/src/commands/system_commands.rs +++ b/src-tauri/src/commands/system_commands.rs @@ -1139,10 +1139,14 @@ pub async fn get_agent_login_status() -> CmdResult { run_blocking(|| { let codex = codex_auth_status(); Ok(AgentLoginStatus { - claude: claude_login_ready(), - codex: codex.ready, - cursor: cursor_login_ready(), - opencode: opencode_login_ready(), + claude: agent_login_ready("claude", claude_login_ready), + codex: if crate::agents::provider_capabilities::is_agent_provider_enabled("codex") { + codex.ready + } else { + false + }, + cursor: agent_login_ready("cursor", cursor_login_ready), + opencode: agent_login_ready("opencode", opencode_login_ready), codex_provider: codex.provider, codex_auth_method: codex.auth_method.map(str::to_string), }) @@ -1366,7 +1370,16 @@ fn env_var_is_present(key: &str) -> bool { .unwrap_or(false) } +fn agent_login_ready(provider: &str, enabled_check: fn() -> bool) -> bool { + if crate::agents::provider_capabilities::is_agent_provider_enabled(provider) { + enabled_check() + } else { + false + } +} + fn agent_login_command(provider: &str) -> anyhow::Result { + crate::agents::provider_capabilities::ensure_agent_provider_enabled(provider)?; let args = match provider { "claude" => "auth login", "codex" => "login", diff --git a/src/features/settings/panels/providers.tsx b/src/features/settings/panels/providers.tsx index d377df9d2..dc13bef10 100644 --- a/src/features/settings/panels/providers.tsx +++ b/src/features/settings/panels/providers.tsx @@ -9,6 +9,7 @@ import { import { TooltipProvider } from "@/components/ui/tooltip"; import { getAgentLoginStatus, getAgentVersions } from "@/lib/api"; import { helmorQueryKeys } from "@/lib/query-client"; +import { isAgentProviderEnabled } from "@/shared/agent-providers"; import { SettingsGroup } from "../components/settings-row"; import { AgentProxyPanel, ClaudeCustomProvidersPanel } from "./model-providers"; import { CursorCardBody } from "./providers/cursor-card-body"; @@ -49,70 +50,78 @@ export function ProvidersPanel() { return ( - { - refetchStatus(); - opencodeModelsRef.current?.refresh(); - }} - collapsible - > - { + refetchStatus(); + opencodeModelsRef.current?.refresh(); + }} + collapsible > - - - + + + + opencodeModelsRef.current?.syncIfIdle()} + /> + + + ) : null} + {isAgentProviderEnabled("claude") ? ( + - opencodeModelsRef.current?.syncIfIdle()} - /> - - - - + + + + ) : null} + {isAgentProviderEnabled("codex") ? ( + + ) : null} + {isAgentProviderEnabled("cursor") ? ( + - - - - - - - - - + + + + + ) : null} diff --git a/src/lib/provider-capabilities.test.ts b/src/lib/provider-capabilities.test.ts index 6876ad7c5..25ebb8c63 100644 --- a/src/lib/provider-capabilities.test.ts +++ b/src/lib/provider-capabilities.test.ts @@ -154,7 +154,7 @@ describe("DEFAULT_PROVIDER_CAPABILITIES (cold-start initialData)", () => { // `toBe` ties the wiring to the constant whose Codex active-goal // flag is pinned by the test above — so the query hands consumers a // table with `supportsActiveGoal === true` before any hydration. - expect(providerCapabilitiesQueryOptions().initialData).toBe( + expect(providerCapabilitiesQueryOptions().initialData).toEqual( DEFAULT_PROVIDER_CAPABILITIES, ); }); diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts index 413581f96..86c0d0695 100644 --- a/src/lib/query-client.ts +++ b/src/lib/query-client.ts @@ -1,11 +1,11 @@ import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; import { focusManager, QueryClient, queryOptions } from "@tanstack/react-query"; +import { enabledProviderCapabilities } from "@/shared/agent-providers"; import type { ThreadMessageLike } from "./api"; import { type ActionKind, type AgentProvider, type ChangeRequestInfo, - DEFAULT_PROVIDER_CAPABILITIES, DEFAULT_WORKSPACE_GROUPS, type DetectedEditor, detectInstalledEditors, @@ -443,7 +443,7 @@ export function providerCapabilitiesQueryOptions() { return queryOptions({ queryKey: helmorQueryKeys.providerCapabilities, queryFn: loadProviderCapabilities, - initialData: DEFAULT_PROVIDER_CAPABILITIES, + initialData: enabledProviderCapabilities(), initialDataUpdatedAt: 0, staleTime: 0, gcTime: Number.POSITIVE_INFINITY, diff --git a/src/shared/agent-providers.test.ts b/src/shared/agent-providers.test.ts new file mode 100644 index 000000000..f334c1f8b --- /dev/null +++ b/src/shared/agent-providers.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_PROVIDER_CAPABILITIES } from "@/lib/api"; +import { + AGENT_PROVIDERS, + DISABLED_AGENT_PROVIDERS, + enabledAgentProviders, + enabledProviderCapabilities, + isAgentProviderEnabled, +} from "@/shared/agent-providers"; + +describe("agent-providers registry", () => { + it("derives AGENT_PROVIDERS from DEFAULT_PROVIDER_CAPABILITIES", () => { + expect(AGENT_PROVIDERS).toEqual( + DEFAULT_PROVIDER_CAPABILITIES.map((caps) => caps.provider), + ); + }); + + it("keeps every provider enabled when DISABLED_AGENT_PROVIDERS is empty", () => { + expect(DISABLED_AGENT_PROVIDERS).toEqual([]); + expect(enabledAgentProviders()).toEqual([ + "claude", + "codex", + "cursor", + "opencode", + ]); + for (const provider of AGENT_PROVIDERS) { + expect(isAgentProviderEnabled(provider)).toBe(true); + } + }); + + it("enabledProviderCapabilities mirrors enabledAgentProviders", () => { + expect(enabledProviderCapabilities().map((caps) => caps.provider)).toEqual( + enabledAgentProviders(), + ); + }); +}); diff --git a/src/shared/agent-providers.ts b/src/shared/agent-providers.ts new file mode 100644 index 000000000..2dc145d13 --- /dev/null +++ b/src/shared/agent-providers.ts @@ -0,0 +1,24 @@ +import type { AgentProvider, ProviderCapabilities } from "@/lib/api"; +import { DEFAULT_PROVIDER_CAPABILITIES } from "@/lib/api"; + +/** Canonical list — derived from [`DEFAULT_PROVIDER_CAPABILITIES`], not hand-maintained. */ +export const AGENT_PROVIDERS = DEFAULT_PROVIDER_CAPABILITIES.map( + (caps) => caps.provider, +) as readonly AgentProvider[]; + +/** Agent providers disabled as Helmor agents. Bundled CLIs stay shipped. */ +export const DISABLED_AGENT_PROVIDERS: readonly AgentProvider[] = []; + +export function isAgentProviderEnabled(provider: AgentProvider): boolean { + return !DISABLED_AGENT_PROVIDERS.includes(provider); +} + +export function enabledAgentProviders(): AgentProvider[] { + return AGENT_PROVIDERS.filter(isAgentProviderEnabled); +} + +export function enabledProviderCapabilities(): ProviderCapabilities[] { + return DEFAULT_PROVIDER_CAPABILITIES.filter((caps) => + isAgentProviderEnabled(caps.provider), + ); +} diff --git a/src/shell/hooks/use-opencode-startup-sync.ts b/src/shell/hooks/use-opencode-startup-sync.ts index 9f0b752e1..455166875 100644 --- a/src/shell/hooks/use-opencode-startup-sync.ts +++ b/src/shell/hooks/use-opencode-startup-sync.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react"; import { useOpencodeModelSync } from "@/features/settings/panels/providers/use-opencode-model-sync"; import { useSettings } from "@/lib/settings"; +import { isAgentProviderEnabled } from "@/shared/agent-providers"; /** On app start, restart `opencode serve` once to re-read ~/.config/opencode, * so config edits made while Helmor was closed land in the composer's model @@ -13,6 +14,7 @@ export function useOpencodeStartupSync() { const usedOpencode = settings.opencodeProvider.cachedModels !== null; useEffect(() => { + if (!isAgentProviderEnabled("opencode")) return; if (!isLoaded || ranRef.current || !usedOpencode) return; ranRef.current = true; void sync({ forceReload: true });