import { createAcpAdapter } from './adapters/acp'; import { claudeAdapter } from './adapters/claude'; import { codexAdapter } from './adapters/codex'; import { copilotAdapter } from './adapters/copilot'; import { gatewayAdapter, gatewaySettingsDefaults, validateGatewaySettings, } from './adapters/gateway'; import { geminiAdapter } from './adapters/gemini'; import { opencodeAdapter } from './adapters/opencode'; import { ompAdapter } from './adapters/omp'; import { piAdapter } from './adapters/pi'; import { resolveClaudeCommand } from './claude-command'; import { OMP_DOCKER_INSTALL_COMMAND, OMP_DOCKER_PLATFORM, OMP_INSTALL_COMMAND, } from './omp/release'; import { OMP_SDK_SETTINGS_DEFAULTS, validateOmpSdkSettings } from './omp/sdk-settings'; import { PI_INSTALL_COMMAND } from './pi/release'; import type { OmpSettingsValidationContext } from './omp/sdk-settings'; import type { ModelLevel, ProviderAdapter, StructuredOutputRecoveryAdapter } from './types'; export type ProviderCapabilityState = boolean | 'experimental'; export interface ProviderCapabilities { readonly dockerIsolation: ProviderCapabilityState; readonly worktreeIsolation: ProviderCapabilityState; readonly mcpServers: ProviderCapabilityState; readonly jsonSchema: ProviderCapabilityState; readonly streamJson: ProviderCapabilityState; readonly thinkingMode: ProviderCapabilityState; readonly reasoningEffort: ProviderCapabilityState; readonly sessionResume: ProviderCapabilityState; readonly webSearch: ProviderCapabilityState; } interface FixedProviderCommandSpec { readonly kind: 'fixed'; readonly command: string; readonly args: readonly string[]; } interface ConfiguredClaudeCommandSpec { readonly kind: 'configured-claude'; } export interface SpawnProviderInvokeSpec { readonly lane: 'spawn'; } export interface AcpStdioProviderInvokeSpec { readonly lane: 'acp-stdio'; readonly transport: 'stdio'; } export interface RpcStdioProviderInvokeSpec { readonly lane: 'rpc-stdio'; readonly protocol: 'omp-v2'; } export type ProviderInvokeSpec = | SpawnProviderInvokeSpec | AcpStdioProviderInvokeSpec | RpcStdioProviderInvokeSpec; export type ProviderCommandSpec = FixedProviderCommandSpec | ConfiguredClaudeCommandSpec; export interface ProviderDocsMetadata { readonly label: string; readonly setupHeading: string; } export interface ProviderDockerMountPreset { readonly host: string; // Optional host-only config-root override. Its value selects the mount source but is never // forwarded into the container, where `container` remains the canonical provider path. readonly hostEnv?: string; readonly container: string; readonly readonly: boolean; } export interface ProviderDockerEnvAuth { // At least one of these env vars must carry a usable (non-empty, non-whitespace) value for the // effective container plan to be considered automatically authenticated. This is the AUTOMATIC // allowlist only; a registry-known credential outside it is accepted when — and only when — the // user explicitly opted it in (dockerEnvPassthrough / --mount). // Providers with no `mount` (env-only) fail closed when nothing is satisfied. readonly requireOneOf: readonly string[]; // Each inner group must be all-set-or-all-unset (e.g. a broker URL + token pair); a partial // group is treated as malformed auth, not "missing". readonly requireTogether?: readonly (readonly string[])[]; // Env vars whose value must parse as an absolute http(s) URL to count as set. A non-URL value // is malformed auth, not "missing". readonly requireUrl?: readonly string[]; } export interface ProviderDockerMetadata { // Omitted for env-only providers with zero automatic credential mounts (e.g. omp). readonly mount?: ProviderDockerMountPreset; readonly envPassthrough: readonly string[]; // False when the mounted dir doesn't hold the secret (auth is via an envPassthrough token). readonly credentialInMount?: boolean; // Shell command that installs this provider's CLI inside the Debian-based cluster image, run as // a docker-cached build layer for the per-provider image variant. Omit for providers already // baked into the base image (e.g. Claude) or not installable via a single command. readonly install?: string; // Docker platform (e.g. 'linux/amd64') passed to both image build and container run. Omitted // providers keep today's unset (host-native) behavior. readonly platform?: string; // $HOME-placeholder directories created owner-only inside the container for this provider's // config/session state (never mounted/copied from the host). readonly configRoots?: readonly string[]; // Fail-closed env/broker auth requirement, checked against the effective container env plan. readonly envAuth?: ProviderDockerEnvAuth; } interface ProviderRegistryEntryBase { readonly id: string; readonly default: boolean; readonly aliases: readonly string[]; readonly displayName: string; readonly binary: string; readonly command: ProviderCommandSpec; readonly invoke: ProviderInvokeSpec; readonly installInstructions: string; readonly authInstructions: string; readonly credentialPaths: readonly string[]; readonly credentialEnvKeys: readonly string[]; readonly configurationEnvKeys?: readonly string[]; readonly settingsFields: readonly string[]; readonly settingsDefaults?: Readonly>; readonly settingsValidator?: ( settings: Record, context?: OmpSettingsValidationContext ) => string | null; readonly availabilityProbe?: 'command' | 'help-or-version' | 'supported-version'; readonly docs: ProviderDocsMetadata; readonly docker: ProviderDockerMetadata; readonly defaultLevels: Readonly<{ readonly min: ModelLevel; readonly default: ModelLevel; readonly max: ModelLevel; }>; } export interface StructuredOutputProviderRegistryEntry extends ProviderRegistryEntryBase { readonly capabilities: Omit & { readonly jsonSchema: true | 'experimental'; }; readonly adapter: StructuredOutputRecoveryAdapter; } export interface UnstructuredOutputProviderRegistryEntry extends ProviderRegistryEntryBase { readonly capabilities: Omit & { readonly jsonSchema: false; }; readonly adapter: ProviderAdapter; } export type ProviderRegistryEntry = | StructuredOutputProviderRegistryEntry | UnstructuredOutputProviderRegistryEntry; const STANDARD_CAPABILITIES: Readonly< Pick< ProviderCapabilities, | 'dockerIsolation' | 'worktreeIsolation' | 'mcpServers' | 'streamJson' | 'thinkingMode' | 'sessionResume' | 'webSearch' > > = { dockerIsolation: true, worktreeIsolation: true, mcpServers: true, streamJson: true, thinkingMode: true, sessionResume: false, webSearch: false, }; const CLAUDE_DOCKER_ENV_PASSTHROUGH = [ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL', 'AWS_BEARER_TOKEN_BEDROCK', 'AWS_REGION', 'CLAUDE_CODE_USE_BEDROCK', ] as const; const PI_EXPLICIT_DOCKER_CREDENTIALS = new Set([ 'AWS_CONTAINER_CREDENTIALS_FULL_URI', 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', 'AWS_PROFILE', 'AWS_WEB_IDENTITY_TOKEN_FILE', 'GOOGLE_APPLICATION_CREDENTIALS', ]); const PI_AGENT_CONFIGURATION_ENV = [ 'AWS_BEDROCK_FORCE_CACHE', 'AWS_BEDROCK_FORCE_HTTP1', 'AWS_BEDROCK_SKIP_AUTH', 'AWS_DEFAULT_REGION', 'AWS_ENDPOINT_URL_BEDROCK_RUNTIME', 'AWS_REGION', 'AZURE_OPENAI_API_VERSION', 'AZURE_OPENAI_BASE_URL', 'AZURE_OPENAI_DEPLOYMENT_NAME_MAP', 'AZURE_OPENAI_RESOURCE_NAME', 'CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_GATEWAY_ID', 'GCLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION', 'GOOGLE_CLOUD_PROJECT', 'KIMI_CODE_OAUTH_HOST', 'KIMI_OAUTH_HOST', 'PI_CACHE_RETENTION', ] as const; const SPAWN_INVOKE = Object.freeze({ lane: 'spawn' }) as SpawnProviderInvokeSpec; const ACP_STDIO_INVOKE = Object.freeze({ lane: 'acp-stdio', transport: 'stdio', }) as AcpStdioProviderInvokeSpec; const RPC_STDIO_INVOKE = Object.freeze({ lane: 'rpc-stdio', protocol: 'omp-v2', }) as RpcStdioProviderInvokeSpec; const kiroAdapter = createAcpAdapter({ provider: 'kiro', displayName: 'Kiro', binary: 'kiro-cli', commandArgs: ['acp'], credentialEnvKeys: ['KIRO_API_KEY'], supportsPromptImages: true, supportsLoadSession: false, supportsSessionCancel: true, supportsSessionSetModel: false, supportsSessionSetMode: false, retryableErrorPatterns: [ /\brate(?:[ _])?limit\b/i, /\btemporar(?:y|ily)\b/i, /\btimeout\b/i, /\bunavailable\b/i, ], permanentErrorPatterns: [ /\bauth(?:entication)?\b/i, /\bapi[_ -]?key\b/i, /\bforbidden\b/i, /\bunauthorized\b/i, /\bcancelled\b/i, /\bmalformed\b/i, /\bunsupported\b/i, ], }); export const providerRegistry = [ { id: 'claude', default: true, aliases: ['anthropic'], displayName: 'Claude', binary: 'claude', command: { kind: 'configured-claude' }, invoke: SPAWN_INVOKE, installInstructions: 'npm install -g @anthropic-ai/claude-code\nOr (macOS): brew install claude', authInstructions: 'claude login', credentialPaths: ['~/.claude'], credentialEnvKeys: claudeAdapter.credentialEnvKeys, settingsFields: ['anthropicApiKey', 'bedrockApiKey', 'bedrockRegion'], capabilities: { ...STANDARD_CAPABILITIES, jsonSchema: true, reasoningEffort: true, sessionResume: true, }, docs: { label: 'Claude', setupHeading: 'Claude Setup', }, docker: { mount: { host: '~/.claude', container: '$HOME/.claude', readonly: true, }, envPassthrough: CLAUDE_DOCKER_ENV_PASSTHROUGH, }, defaultLevels: { min: claudeAdapter.defaultMinLevel, default: claudeAdapter.defaultLevel, max: claudeAdapter.defaultMaxLevel, }, adapter: claudeAdapter as StructuredOutputRecoveryAdapter, }, { id: 'codex', default: false, aliases: ['openai'], displayName: 'Codex', binary: 'codex', command: { kind: 'fixed', command: 'codex', args: ['exec'] }, invoke: SPAWN_INVOKE, installInstructions: 'npm install -g @openai/codex', authInstructions: 'codex login', credentialPaths: ['~/.config/codex', '~/.codex'], credentialEnvKeys: codexAdapter.credentialEnvKeys, settingsFields: ['webSearch', 'trustIsolatedRecoveryProfile'], settingsDefaults: { webSearch: false, trustIsolatedRecoveryProfile: false }, settingsValidator: validateCodexSettings, capabilities: { ...STANDARD_CAPABILITIES, jsonSchema: true, reasoningEffort: true, sessionResume: true, webSearch: true, }, docs: { label: 'Codex', setupHeading: 'Codex Setup', }, docker: { mount: { host: '~/.config/codex', container: '$HOME/.config/codex', readonly: true, }, install: 'npm install -g @openai/codex', envPassthrough: [], }, defaultLevels: { min: codexAdapter.defaultMinLevel, default: codexAdapter.defaultLevel, max: codexAdapter.defaultMaxLevel, }, adapter: codexAdapter as StructuredOutputRecoveryAdapter, }, { id: 'gateway', default: false, aliases: [], displayName: 'Gateway', binary: 'node', command: { kind: 'fixed', command: 'node', args: [] }, invoke: SPAWN_INVOKE, installInstructions: 'Bundled with Zeroshot; no external provider CLI install is required.', authInstructions: 'Configure providerSettings.gateway protocol, base URL, apiKey or apiKeyEnv, model, ' + 'maxTokens, and toolPolicy in Zeroshot settings.', credentialPaths: [], credentialEnvKeys: gatewayAdapter.credentialEnvKeys, settingsFields: [ 'protocol', 'baseUrl', 'apiKey', 'apiKeyEnv', 'headers', 'model', 'maxTokens', 'toolPolicy', ], settingsDefaults: gatewaySettingsDefaults, settingsValidator: validateGatewaySettings, capabilities: { ...STANDARD_CAPABILITIES, mcpServers: false, jsonSchema: false, reasoningEffort: false, }, docs: { label: 'Gateway', setupHeading: 'Gateway Setup', }, docker: { mount: { host: '~/.zeroshot', container: '$HOME/.zeroshot', readonly: true, }, envPassthrough: [], }, defaultLevels: { min: gatewayAdapter.defaultMinLevel, default: gatewayAdapter.defaultLevel, max: gatewayAdapter.defaultMaxLevel, }, adapter: gatewayAdapter, }, { id: 'gemini', default: false, aliases: ['google'], displayName: 'Gemini', binary: 'gemini', command: { kind: 'fixed', command: 'gemini', args: [] }, invoke: SPAWN_INVOKE, installInstructions: 'npm install -g @google/gemini-cli', authInstructions: 'gemini auth login', credentialPaths: ['~/.config/gcloud', '~/.config/gemini', '~/.gemini'], credentialEnvKeys: geminiAdapter.credentialEnvKeys, settingsFields: [], capabilities: { ...STANDARD_CAPABILITIES, jsonSchema: 'experimental', reasoningEffort: false, }, docs: { label: 'Gemini', setupHeading: 'Gemini Setup', }, docker: { mount: { host: '~/.config/gemini', container: '$HOME/.config/gemini', readonly: true, }, install: 'npm install -g @google/gemini-cli', envPassthrough: [], }, defaultLevels: { min: geminiAdapter.defaultMinLevel, default: geminiAdapter.defaultLevel, max: geminiAdapter.defaultMaxLevel, }, adapter: geminiAdapter as StructuredOutputRecoveryAdapter, }, { id: 'opencode', default: false, aliases: [], displayName: 'Opencode', binary: 'opencode', command: { kind: 'fixed', command: 'opencode', args: ['run'] }, invoke: SPAWN_INVOKE, installInstructions: 'See https://opencode.ai for installation instructions.', authInstructions: 'opencode auth login', credentialPaths: ['~/.local/share/opencode'], credentialEnvKeys: opencodeAdapter.credentialEnvKeys, settingsFields: ['webSearch'], settingsDefaults: { webSearch: false }, settingsValidator: (settings): string | null => validateWebSearchSettings('opencode', settings), capabilities: { ...STANDARD_CAPABILITIES, jsonSchema: 'experimental', reasoningEffort: true, sessionResume: true, webSearch: true, }, docs: { label: 'Opencode', setupHeading: 'Opencode Setup', }, docker: { mount: { host: '~/.local/share/opencode', container: '$HOME/.local/share/opencode', readonly: true, }, envPassthrough: [], }, defaultLevels: { min: opencodeAdapter.defaultMinLevel, default: opencodeAdapter.defaultLevel, max: opencodeAdapter.defaultMaxLevel, }, adapter: opencodeAdapter as StructuredOutputRecoveryAdapter, }, { id: 'pi', default: false, aliases: [], displayName: 'Pi', binary: 'pi', command: { kind: 'fixed', command: 'pi', args: [] }, invoke: SPAWN_INVOKE, installInstructions: PI_INSTALL_COMMAND, authInstructions: 'pi\n/login', credentialPaths: ['$PI_CODING_AGENT_DIR/auth.json', '~/.pi/agent/auth.json'], credentialEnvKeys: piAdapter.credentialEnvKeys, settingsFields: [], availabilityProbe: 'supported-version', capabilities: { ...STANDARD_CAPABILITIES, mcpServers: false, jsonSchema: false, reasoningEffort: true, }, docs: { label: 'Pi', setupHeading: 'Pi Setup', }, docker: { mount: { host: '~/.pi/agent', hostEnv: 'PI_CODING_AGENT_DIR', container: '$HOME/.pi/agent', readonly: false, }, install: PI_INSTALL_COMMAND, configRoots: ['$HOME/.pi/agent'], envPassthrough: [ ...piAdapter.credentialEnvKeys.filter((name) => !PI_EXPLICIT_DOCKER_CREDENTIALS.has(name)), ...PI_AGENT_CONFIGURATION_ENV, ], }, defaultLevels: { min: piAdapter.defaultMinLevel, default: piAdapter.defaultLevel, max: piAdapter.defaultMaxLevel, }, adapter: piAdapter, }, { id: 'omp', default: false, aliases: ['oh-my-pi'], displayName: 'OMP (Oh My Pi)', binary: 'omp', command: { kind: 'fixed', command: 'omp', args: [] }, invoke: RPC_STDIO_INVOKE, installInstructions: OMP_INSTALL_COMMAND, authInstructions: 'Manually edit providerSettings.omp in ZEROSHOT_SETTINGS_FILE or $HOME/.zeroshot/settings.json (file 0600, parent directory 0700). Use declared environment or broker variables, an explicit host-only OMP agent directory containing agent.db, or keyless mode; Zeroshot never logs in or stores credential values.', credentialPaths: ['~/.omp'], credentialEnvKeys: ompAdapter.credentialEnvKeys, configurationEnvKeys: PI_AGENT_CONFIGURATION_ENV, settingsFields: [ 'transport', 'minLevel', 'defaultLevel', 'maxLevel', 'levelOverrides', 'modelsConfig', 'auth', 'tools', 'nestedAgents', 'mcp', ], settingsDefaults: { ...OMP_SDK_SETTINGS_DEFAULTS }, settingsValidator: (settings, context): string | null => settings.transport === 'rpc' ? null : validateOmpSdkSettings(settings, context), availabilityProbe: 'help-or-version', // Written out explicitly rather than spread from STANDARD_CAPABILITIES, which defaults // dockerIsolation to true; OMP's Docker path is env/broker-only and sessionless (see // AGENTS.md OMP Docker section) rather than the standard credential-mount + resume shape. // sessionResume is true as of issue #866: verified UUID partitions, two-phase file // verification, and the owner-fenced ownership FSM (task-lib/omp-session-ownership.js) are // live end to end for host, worktree, detached cluster-agent, and standalone manual resume. // The two are independent: an isolated (Docker) OMP task allocates no session partition at all // and launches `--no-session`, so `sessionResume: true` never implies a resumable container // turn (task-lib/runner.js#resolveOmpSessionPlan, OMP_SESSIONLESS_ENV). capabilities: { dockerIsolation: true, worktreeIsolation: true, mcpServers: false, jsonSchema: false, streamJson: true, thinkingMode: true, reasoningEffort: true, sessionResume: true, webSearch: false, }, docs: { label: 'OMP', setupHeading: 'OMP Manual Configuration', }, docker: { // No `mount`: OMP's Docker credential surface is env/broker-only with zero automatic // mounts. `~/.omp`, agent.db, WAL/SHM files, and host refresh tokens are never mounted or // copied into the container. // // envPassthrough is deliberately narrower than `credentialEnvKeys` above (the full adapter // credential inventory, used for host inspection/redaction). Exact 5-name automatic // allowlist per the maintainer's authoritative clarification (verified verbatim via // `gh api repos/the-open-engine/zeroshot/issues/comments/5160272623`): "the exact automatic // OMP Docker environment allowlist is only ANTHROPIC_API_KEY, OPENAI_API_KEY, // GEMINI_API_KEY, OMP_AUTH_BROKER_URL, and OMP_AUTH_BROKER_TOKEN ... ANTHROPIC_OAUTH_TOKEN, // ANTHROPIC_FOUNDRY_API_KEY, GOOGLE_API_KEY, OPENROUTER_API_KEY, and every other // credential/path require explicit dockerEnvPassthrough/mount opt-in; OAuth users should // prefer the auth broker so host refresh/access tokens do not cross automatically." This // supersedes PLAN_READY step 2's nine-name list. Any validator flagging this as missing the // four excluded names is checking stale plan text against a clarification it never read. platform: OMP_DOCKER_PLATFORM, install: OMP_DOCKER_INSTALL_COMMAND, configRoots: ['$HOME/.omp'], credentialInMount: false, envPassthrough: [ 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY', 'OMP_AUTH_BROKER_TOKEN', 'OMP_AUTH_BROKER_URL', 'OPENAI_API_KEY', ], envAuth: { // The four automatic *credential* names (the fifth allowlist entry, OMP_AUTH_BROKER_URL, // is a locator, not a credential — it authenticates nothing on its own). Any other // registry-known OMP credential (ANTHROPIC_OAUTH_TOKEN, OPENROUTER_API_KEY, …) is usable // only via explicit dockerEnvPassthrough/--mount opt-in, never automatic forwarding. requireOneOf: [ 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY', 'OMP_AUTH_BROKER_TOKEN', 'OPENAI_API_KEY', ], requireTogether: [['OMP_AUTH_BROKER_URL', 'OMP_AUTH_BROKER_TOKEN']], // Per OMP v17.2.1 docs/environment-variables.md, OMP_AUTH_BROKER_URL is the broker's base // URL (e.g. https://broker.tailnet:8765); anything that is not an http(s) URL is // malformed broker config, and OMP hard-errors on a broker URL with no resolvable token. requireUrl: ['OMP_AUTH_BROKER_URL'], }, }, defaultLevels: { min: ompAdapter.defaultMinLevel, default: ompAdapter.defaultLevel, max: ompAdapter.defaultMaxLevel, }, adapter: ompAdapter, }, { id: 'kiro', default: false, aliases: [], displayName: 'Kiro', binary: 'kiro-cli', command: { kind: 'fixed', command: 'kiro-cli', args: ['acp'] }, invoke: ACP_STDIO_INVOKE, installInstructions: 'See https://kiro.dev/docs/cli/', authInstructions: 'See https://kiro.dev/docs/cli/authentication/', credentialPaths: ['~/.kiro'], credentialEnvKeys: kiroAdapter.credentialEnvKeys, settingsFields: [], capabilities: { ...STANDARD_CAPABILITIES, mcpServers: false, jsonSchema: false, reasoningEffort: false, }, docs: { label: 'Kiro', setupHeading: 'Kiro Setup', }, docker: { mount: { host: '~/.kiro', container: '$HOME/.kiro', readonly: true, }, envPassthrough: ['KIRO_API_KEY'], }, defaultLevels: { min: kiroAdapter.defaultMinLevel, default: kiroAdapter.defaultLevel, max: kiroAdapter.defaultMaxLevel, }, adapter: kiroAdapter, }, { id: 'copilot', default: false, aliases: [], displayName: 'Copilot', binary: 'copilot', command: { kind: 'fixed', command: 'copilot', args: [] }, invoke: SPAWN_INVOKE, installInstructions: 'npm install -g @github/copilot', // Docker/CI can't use the keychain token; export COPILOT_GITHUB_TOKEN instead. authInstructions: 'copilot\n/login\n(Docker/CI: export COPILOT_GITHUB_TOKEN=)', credentialPaths: ['~/.copilot'], credentialEnvKeys: copilotAdapter.credentialEnvKeys, settingsFields: [], availabilityProbe: 'help-or-version', // MCP servers pass through via the `--additional-mcp-config` CLI flag (see copilot adapter // addMcpArgs). No native output-schema or reasoning-effort flag. capabilities: { ...STANDARD_CAPABILITIES, mcpServers: true, jsonSchema: false, reasoningEffort: false, }, docs: { label: 'Copilot', setupHeading: 'Copilot Setup', }, docker: { mount: { host: '~/.copilot', container: '$HOME/.copilot', readonly: true, }, install: 'npm install -g @github/copilot', envPassthrough: ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'], credentialInMount: false, // token is in the OS keychain, not ~/.copilot }, defaultLevels: { min: copilotAdapter.defaultMinLevel, default: copilotAdapter.defaultLevel, max: copilotAdapter.defaultMaxLevel, }, adapter: copilotAdapter, }, ] as const satisfies readonly ProviderRegistryEntry[]; function validateCodexSettings(settings: Record): string | null { const webSearchError = validateWebSearchSettings('codex', settings); if (webSearchError) return webSearchError; if ( settings.trustIsolatedRecoveryProfile === undefined || typeof settings.trustIsolatedRecoveryProfile === 'boolean' ) { return null; } return 'providerSettings.codex.trustIsolatedRecoveryProfile must be a boolean'; } function validateWebSearchSettings( provider: 'codex' | 'opencode', settings: Record ): string | null { if (settings.webSearch === undefined || typeof settings.webSearch === 'boolean') return null; return `providerSettings.${provider}.webSearch must be a boolean`; } type RegistryProviderId = (typeof providerRegistry)[number]['id']; type RegistryProviderAlias = (typeof providerRegistry)[number]['aliases'][number]; export const providerIds = providerRegistry.map( (entry) => entry.id ) as readonly RegistryProviderId[]; export const providerAliases = providerRegistry.flatMap( (entry) => entry.aliases ) as readonly RegistryProviderAlias[]; export const knownProviderNames = providerRegistry.flatMap((entry) => [ entry.id, ...entry.aliases, ]) as readonly (RegistryProviderId | RegistryProviderAlias)[]; export const providerAliasMap: Readonly> = Object.freeze( providerRegistry.reduce>((result, entry) => { result[entry.id] = entry.id; for (const alias of entry.aliases) { result[alias] = entry.id; } return result; }, {}) ); export function assertExactlyOneDefaultProvider( entries: readonly T[] ): T['id'] { const defaults = entries.filter((e) => e.default); const [onlyDefault, ...rest] = defaults; if (!onlyDefault || rest.length > 0) { throw new Error( `Provider registry must declare exactly one default provider; found ${defaults.length}${defaults.length ? ' (' + defaults.map((e) => e.id).join(', ') + ')' : ''}` ); } return onlyDefault.id; } const DEFAULT_PROVIDER_ID = assertExactlyOneDefaultProvider(providerRegistry); export function getDefaultProviderId(): RegistryProviderId { return DEFAULT_PROVIDER_ID; } export function normalizeProviderName(name: string): RegistryProviderId | string { const normalized = name.toLowerCase(); return providerAliasMap[normalized] ?? name; } export function listProviderRegistryEntries(): readonly ProviderRegistryEntry[] { return providerRegistry; } export function findProviderRegistryEntry( name: string | null | undefined ): ProviderRegistryEntry | undefined { if (!name) return undefined; const normalized = normalizeProviderName(name); return providerRegistry.find((entry) => entry.id === normalized); } export function getProviderRegistryEntry(name: string): ProviderRegistryEntry { const entry = findProviderRegistryEntry(name); if (entry) return entry; throw new Error(`Unknown provider: ${name}. Valid: ${providerIds.join(', ')}`); } export function credentialEnvKeysForProvider(providerId: string): readonly string[] { return getProviderRegistryEntry(providerId).credentialEnvKeys; } export function resolveProviderCommand(name: string): { readonly command: string; readonly args: readonly string[]; } { const entry = getProviderRegistryEntry(name); if (entry.command.kind === 'configured-claude') { return resolveClaudeCommand(); } return { command: entry.command.command, args: entry.command.args, }; } export function supportsProviderCapability( name: string, capability: keyof ProviderCapabilities ): boolean { return getProviderRegistryEntry(name).capabilities[capability] === true; } export function supportsProviderOutputReformatting(name: string): boolean { return getProviderRegistryEntry(name).capabilities.jsonSchema !== false; }