diff --git a/src/backends/bootstrap.ts b/src/backends/bootstrap.ts index dd06b85b..5fdf2f1c 100644 --- a/src/backends/bootstrap.ts +++ b/src/backends/bootstrap.ts @@ -1,20 +1,28 @@ +import { registerEngineSettingsSchema } from '../config/engineSettings.js'; import { ClaudeCodeEngine } from './claude-code/index.js'; import { CodexEngine } from './codex/index.js'; import { LlmistEngine } from './llmist/index.js'; import { OpenCodeEngine } from './opencode/index.js'; import { getEngine, registerEngine } from './registry.js'; +function registerEngineWithSettings(engine: import('./types.js').AgentEngine): void { + registerEngine(engine); + if (engine.getSettingsSchema) { + registerEngineSettingsSchema(engine.definition.id, engine.getSettingsSchema()); + } +} + export function registerBuiltInEngines(): void { if (!getEngine('llmist')) { - registerEngine(new LlmistEngine()); + registerEngineWithSettings(new LlmistEngine()); } if (!getEngine('claude-code')) { - registerEngine(new ClaudeCodeEngine()); + registerEngineWithSettings(new ClaudeCodeEngine()); } if (!getEngine('codex')) { - registerEngine(new CodexEngine()); + registerEngineWithSettings(new CodexEngine()); } if (!getEngine('opencode')) { - registerEngine(new OpenCodeEngine()); + registerEngineWithSettings(new OpenCodeEngine()); } } diff --git a/src/backends/codex/index.ts b/src/backends/codex/index.ts index d1768995..b55bb3b8 100644 --- a/src/backends/codex/index.ts +++ b/src/backends/codex/index.ts @@ -17,7 +17,11 @@ import { logLlmCall } from '../shared/llmCallLogger.js'; import type { AgentEngine, AgentEngineResult, AgentExecutionPlan, LogWriter } from '../types.js'; import { buildEnv } from './env.js'; import { CODEX_MODEL_IDS, DEFAULT_CODEX_MODEL } from './models.js'; -import { assertHeadlessCodexSettings, resolveCodexSettings } from './settings.js'; +import { + CodexSettingsSchema, + assertHeadlessCodexSettings, + resolveCodexSettings, +} from './settings.js'; const CODEX_AUTH_DIR = join(homedir(), '.codex'); const CODEX_AUTH_FILE = join(CODEX_AUTH_DIR, 'auth.json'); @@ -489,6 +493,10 @@ export class CodexEngine implements AgentEngine { return resolveCodexModel(cascadeModel); } + getSettingsSchema() { + return CodexSettingsSchema; + } + async beforeExecute(plan: AgentExecutionPlan): Promise { this._adapterLifecycleActive = true; this._originalAuthJson = await writeCodexAuthFile(plan.projectSecrets, plan.logWriter); diff --git a/src/backends/codex/settings.ts b/src/backends/codex/settings.ts index ac08d977..bdffde99 100644 --- a/src/backends/codex/settings.ts +++ b/src/backends/codex/settings.ts @@ -1,7 +1,16 @@ -import { CodexSettingsSchema, getEngineSettings } from '../../config/engineSettings.js'; -import type { CodexSettings } from '../../config/engineSettings.js'; +import { z } from 'zod'; +import { getEngineSettings } from '../../config/engineSettings.js'; import type { ProjectConfig } from '../../types/index.js'; +export const CodexSettingsSchema = z.object({ + approvalPolicy: z.enum(['never', 'on-request', 'untrusted']).optional(), + sandboxMode: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), + reasoningEffort: z.enum(['low', 'medium', 'high', 'xhigh']).optional(), + webSearch: z.boolean().optional(), +}); + +export type CodexSettings = z.infer; + export interface ResolvedCodexSettings extends Required> { reasoningEffort?: CodexSettings['reasoningEffort']; diff --git a/src/backends/opencode/index.ts b/src/backends/opencode/index.ts index 85b1e445..27ac0094 100644 --- a/src/backends/opencode/index.ts +++ b/src/backends/opencode/index.ts @@ -31,7 +31,7 @@ import { logLlmCall } from '../shared/llmCallLogger.js'; import type { AgentEngine, AgentEngineResult, AgentExecutionPlan } from '../types.js'; import { buildEnv } from './env.js'; import { DEFAULT_OPENCODE_MODEL } from './models.js'; -import { resolveOpenCodeSettings } from './settings.js'; +import { OpenCodeSettingsSchema, resolveOpenCodeSettings } from './settings.js'; function appendEngineLog(path: string | undefined, chunk: string): void { if (!path || chunk.length === 0) return; @@ -805,6 +805,10 @@ export class OpenCodeEngine implements AgentEngine { return resolveOpenCodeModel(cascadeModel); } + getSettingsSchema() { + return OpenCodeSettingsSchema; + } + async afterExecute(plan: AgentExecutionPlan, _result: AgentEngineResult): Promise { // Clean up offloaded context files — idempotent, safe to call from adapter hook. // Server process and session cleanup happen inside execute()'s finally block diff --git a/src/backends/opencode/settings.ts b/src/backends/opencode/settings.ts index f00a98f7..ab479004 100644 --- a/src/backends/opencode/settings.ts +++ b/src/backends/opencode/settings.ts @@ -1,7 +1,13 @@ -import { OpenCodeSettingsSchema, getEngineSettings } from '../../config/engineSettings.js'; -import type { OpenCodeSettings } from '../../config/engineSettings.js'; +import { z } from 'zod'; +import { getEngineSettings } from '../../config/engineSettings.js'; import type { ProjectConfig } from '../../types/index.js'; +export const OpenCodeSettingsSchema = z.object({ + webSearch: z.boolean().optional(), +}); + +export type OpenCodeSettings = z.infer; + export interface ResolvedOpenCodeSettings extends Required> {} export function resolveOpenCodeSettings(project: ProjectConfig): ResolvedOpenCodeSettings { diff --git a/src/backends/types.ts b/src/backends/types.ts index 54c46c1a..44ee1dca 100644 --- a/src/backends/types.ts +++ b/src/backends/types.ts @@ -1,3 +1,4 @@ +import type { z } from 'zod'; import type { AgentInput, CascadeConfig, ProjectConfig } from '../types/index.js'; import type { CompletionRequirements } from './completion.js'; @@ -153,6 +154,12 @@ export interface AgentEngine { * Engines that pass the model through unchanged (e.g., LLMist) do not need to implement it. */ resolveModel?(cascadeModel: string): string; + /** + * Optional method that returns the Zod schema for this engine's settings. + * Engines that have configurable settings implement this method so the schema + * can be registered dynamically during bootstrap. + */ + getSettingsSchema?(): z.ZodType>; /** * Optional hook called by the adapter before engine.execute(). * Use for engine-specific environment setup (e.g., writing auth files, checking directories). diff --git a/src/config/engineSettings.ts b/src/config/engineSettings.ts index 5b592777..f1e06396 100644 --- a/src/config/engineSettings.ts +++ b/src/config/engineSettings.ts @@ -1,20 +1,36 @@ import { z } from 'zod'; -export const CodexSettingsSchema = z.object({ - approvalPolicy: z.enum(['never', 'on-request', 'untrusted']).optional(), - sandboxMode: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), - reasoningEffort: z.enum(['low', 'medium', 'high', 'xhigh']).optional(), - webSearch: z.boolean().optional(), -}); - -export const OpenCodeSettingsSchema = z.object({ - webSearch: z.boolean().optional(), -}); - -const ENGINE_SETTINGS_SCHEMAS: Record>> = { - codex: CodexSettingsSchema, - opencode: OpenCodeSettingsSchema, -}; +// Re-export schemas from engine directories for backward compatibility. +export { CodexSettingsSchema } from '../backends/codex/settings.js'; +export type { CodexSettings } from '../backends/codex/settings.js'; +export { OpenCodeSettingsSchema } from '../backends/opencode/settings.js'; +export type { OpenCodeSettings } from '../backends/opencode/settings.js'; + +/** + * Dynamic registry of engine settings schemas. + * Engines register their schema during bootstrap via registerEngineSettingsSchema(). + */ +const ENGINE_SETTINGS_SCHEMAS: Map>> = new Map(); + +/** + * Register a settings schema for an engine. Called during bootstrap when an engine + * implementing getSettingsSchema() is registered. + */ +export function registerEngineSettingsSchema( + engineId: string, + schema: z.ZodType>, +): void { + ENGINE_SETTINGS_SCHEMAS.set(engineId, schema); +} + +/** + * Retrieve the registered settings schema for an engine, if any. + */ +export function getEngineSettingsSchema( + engineId: string, +): z.ZodType> | undefined { + return ENGINE_SETTINGS_SCHEMAS.get(engineId); +} const EngineSettingsValueSchema = z.record(z.string(), z.unknown()); @@ -22,7 +38,7 @@ export const EngineSettingsSchema = z .record(z.string(), EngineSettingsValueSchema) .superRefine((settings, ctx) => { for (const [engineId, rawSettings] of Object.entries(settings)) { - const schema = ENGINE_SETTINGS_SCHEMAS[engineId]; + const schema = ENGINE_SETTINGS_SCHEMAS.get(engineId); if (!schema) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -45,8 +61,6 @@ export const EngineSettingsSchema = z }) .transform((settings) => normalizeEngineSettings(settings) ?? {}); -export type CodexSettings = z.infer; -export type OpenCodeSettings = z.infer; export type EngineSettings = Record>; type EngineSettingsInput = Record | undefined>; diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts index 2a9ba026..ec6bcfce 100644 --- a/tests/unit/api/routers/projects.test.ts +++ b/tests/unit/api/routers/projects.test.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { TRPCContext } from '../../../../src/api/trpc.js'; +import { registerBuiltInEngines } from '../../../../src/backends/bootstrap.js'; import { createMockUser } from '../../../helpers/factories.js'; const mockListProjectsForOrg = vi.fn(); @@ -64,6 +65,10 @@ function createCaller(ctx: TRPCContext) { const mockUser = createMockUser(); +beforeAll(() => { + registerBuiltInEngines(); +}); + describe('projectsRouter', () => { beforeEach(() => { mockDbSelect.mockReturnValue({ from: mockDbFrom }); diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index 48fbd08c..48585bf6 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { registerBuiltInEngines } from '../../../src/backends/bootstrap.js'; import { ProjectConfigSchema, validateConfig } from '../../../src/config/schema.js'; +beforeAll(() => { + registerBuiltInEngines(); +}); + describe.concurrent('ProjectConfigSchema', () => { it('validates a valid project config', () => { const config = {