/** * Pre-stream engine readiness check. * * Before an engine streams, verify it is actually usable and, when it is not, * return an actionable message that routes the user to the right place: * - SDK missing or version-mismatched → Settings → Stack (install / update) * - required account not set up → Settings → Engines (sign in) * * SDK readiness is checked first, so a not-installed engine is fixed before an * account is asked for. Engines that also need an external CLI binary (Open * Code) are only ready once that binary resolves too — the SDK alone cannot run * them, and waving them through would send the user off to sign in for an engine * that fails at spawn. * * The message text embeds the literal "Settings → Stack" / "Settings → Engines" * phrases; the chat error renderer * (`frontend/components/chat/formatters/ErrorMessage.svelte`) turns those into * clickable buttons that open the matching settings section. */ import type { EngineType } from '$shared/types/unified'; import { readEngineSdkVersion, getRequiredSdkVersion } from './sdk-loader'; import { getRequiredEngineCliSpec, resolveEngineCli } from './engine-cli'; import type { ToolId } from './install-recipes'; /** Primary SDK package clopen imports for each engine. */ export const ENGINE_SDK: Record = { 'claude-code': '@anthropic-ai/claude-agent-sdk', opencode: '@opencode-ai/sdk', copilot: '@github/copilot-sdk', codex: '@openai/codex-sdk', qwen: '@qwen-code/sdk', pi: '@earendil-works/pi-coding-agent', cline: '@cline/sdk', cursor: '@cursor/sdk', }; /** * Engine identity in the Stack's id space. The two namespaces exist because * Stack also manages non-engine tools (git, chrome) and spells Claude Code * "claude"; `install-recipes.test.ts` guards this mapping against drift. */ export const TOOL_FOR_ENGINE: Record = { 'claude-code': 'claude', opencode: 'opencode', copilot: 'copilot', codex: 'codex', qwen: 'qwen', pi: 'pi', cline: 'cline', cursor: 'cursor', }; /** * Engines that require a signed-in account/credential before use. OpenCode * ships built-in providers and works without one, so it is exempt. */ const ENGINES_WITHOUT_ACCOUNT = new Set(['opencode']); export interface EngineSetupIssue { reason: 'not-installed' | 'needs-update' | 'needs-account'; message: string; } /** * Returns null when the engine is ready to stream, or an actionable issue. * @param accountId the resolved account id for this request (0 = none selected). */ export async function checkEngineSetup(engine: EngineType, accountId: number): Promise { const sdkPkg = ENGINE_SDK[engine]; const installed = readEngineSdkVersion(sdkPkg); const required = getRequiredSdkVersion(sdkPkg); if (installed === null) { return { reason: 'not-installed', message: `Engine "${engine}" is not installed. Open Settings → Stack to install it before using this engine.` }; } if (required !== null && installed !== required) { return { reason: 'needs-update', message: `Engine "${engine}" needs an update (installed ${installed}, required ${required}). ` + `Open Settings → Stack to update it before using this engine.` }; } // A CLI the engine cannot run without is installed by the same Stack action // as the SDK, so a missing one is the same fix for the user: install the // engine. CLIs the SDK bundles and locates itself are not checked here. const tool = TOOL_FOR_ENGINE[engine]; if (getRequiredEngineCliSpec(tool) && !(await resolveEngineCli(tool))) { return { reason: 'not-installed', message: `Engine "${engine}" is missing its CLI. ` + `Open Settings → Stack to install it before using this engine.` }; } if (!ENGINES_WITHOUT_ACCOUNT.has(engine) && accountId === 0) { return { reason: 'needs-account', message: `Engine "${engine}" has no account set up. Open Settings → Engines to sign in before using this engine.` }; } return null; }