/** * SDK Options Factory - Centralized configuration for Claude Agent SDK * * Provides presets for common use cases: * - Spec generation: Long-running analysis with read-only tools * - Feature generation: Quick JSON generation from specs * - Feature building: Autonomous feature implementation with full tool access * - Suggestions: Analysis with read-only tools * - Chat: Full tool access for interactive coding * * Uses model-resolver for consistent model handling across the application. * * SECURITY: All factory functions validate the working directory (cwd) against * ALLOWED_ROOT_DIRECTORY before returning options. This provides a centralized * security check that applies to ALL AI model invocations, regardless of provider. */ import type { Options } from '@anthropic-ai/claude-agent-sdk'; import path from 'path'; import { resolveModelString } from '@automaker/model-resolver'; import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from '@automaker/types'; import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform'; /** * Validate that a working directory is allowed by ALLOWED_ROOT_DIRECTORY. * This is the centralized security check for ALL AI model invocations. * * @param cwd - The working directory to validate * @throws PathNotAllowedError if the directory is not within ALLOWED_ROOT_DIRECTORY * * This function is called by all create*Options() factory functions to ensure * that AI models can only operate within allowed directories. This applies to: * - All current models (Claude, future models) * - All invocation types (chat, auto-mode, spec generation, etc.) */ export function validateWorkingDirectory(cwd: string): void { const resolvedCwd = path.resolve(cwd); if (!isPathAllowed(resolvedCwd)) { const allowedRoot = getAllowedRootDirectory(); throw new PathNotAllowedError( `Working directory "${cwd}" (resolved: ${resolvedCwd}) is not allowed. ` + (allowedRoot ? `Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}` : 'ALLOWED_ROOT_DIRECTORY is configured but path is not within allowed directories.') ); } } /** * Tool presets for different use cases */ export const TOOL_PRESETS = { /** Read-only tools for analysis */ readOnly: ['Read', 'Glob', 'Grep'] as const, /** Tools for spec generation that needs to read the codebase */ specGeneration: ['Read', 'Glob', 'Grep'] as const, /** Full tool access for feature implementation */ fullAccess: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'] as const, /** Tools for chat/interactive mode */ chat: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'] as const, } as const; /** * Max turns presets for different use cases */ export const MAX_TURNS = { /** Quick operations that shouldn't need many iterations */ quick: 50, /** Standard operations */ standard: 100, /** Long-running operations like full spec generation */ extended: 250, /** Very long operations that may require extensive exploration */ maximum: 1000, } as const; /** * Model presets for different use cases * * These can be overridden via environment variables: * - AUTOMAKER_MODEL_SPEC: Model for spec generation * - AUTOMAKER_MODEL_FEATURES: Model for feature generation * - AUTOMAKER_MODEL_SUGGESTIONS: Model for suggestions * - AUTOMAKER_MODEL_CHAT: Model for chat * - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations */ export function getModelForUseCase( useCase: 'spec' | 'features' | 'suggestions' | 'chat' | 'auto' | 'default', explicitModel?: string ): string { // Explicit model takes precedence if (explicitModel) { return resolveModelString(explicitModel); } // Check environment variable override for this use case const envVarMap: Record = { spec: process.env.AUTOMAKER_MODEL_SPEC, features: process.env.AUTOMAKER_MODEL_FEATURES, suggestions: process.env.AUTOMAKER_MODEL_SUGGESTIONS, chat: process.env.AUTOMAKER_MODEL_CHAT, auto: process.env.AUTOMAKER_MODEL_AUTO, default: process.env.AUTOMAKER_MODEL_DEFAULT, }; const envModel = envVarMap[useCase] || envVarMap.default; if (envModel) { return resolveModelString(envModel); } const defaultModels: Record = { spec: CLAUDE_MODEL_MAP['haiku'], // used to generate app specs features: CLAUDE_MODEL_MAP['haiku'], // used to generate features from app specs suggestions: CLAUDE_MODEL_MAP['haiku'], // used for suggestions chat: CLAUDE_MODEL_MAP['haiku'], // used for chat auto: CLAUDE_MODEL_MAP['opus'], // used to implement kanban cards default: CLAUDE_MODEL_MAP['opus'], }; return resolveModelString(defaultModels[useCase] || DEFAULT_MODELS.claude); } /** * Base options that apply to all SDK calls */ function getBaseOptions(): Partial { return { permissionMode: 'acceptEdits', }; } /** * Options configuration for creating SDK options */ export interface CreateSdkOptionsConfig { /** Working directory for the agent */ cwd: string; /** Optional explicit model override */ model?: string; /** Optional session model (used as fallback if explicit model not provided) */ sessionModel?: string; /** Optional system prompt */ systemPrompt?: string; /** Optional abort controller for cancellation */ abortController?: AbortController; /** Optional output format for structured outputs */ outputFormat?: { type: 'json_schema'; schema: Record; }; } /** * Create SDK options for spec generation * * Configuration: * - Uses read-only tools for codebase analysis * - Extended turns for thorough exploration * - Opus model by default (can be overridden) */ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Options { // Validate working directory before creating options validateWorkingDirectory(config.cwd); return { ...getBaseOptions(), // Override permissionMode - spec generation only needs read-only tools // Using "acceptEdits" can cause Claude to write files to unexpected locations // See: https://github.com/AutoMaker-Org/automaker/issues/149 permissionMode: 'default', model: getModelForUseCase('spec', config.model), maxTurns: MAX_TURNS.maximum, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.specGeneration], ...(config.systemPrompt && { systemPrompt: config.systemPrompt }), ...(config.abortController && { abortController: config.abortController }), ...(config.outputFormat && { outputFormat: config.outputFormat }), }; } /** * Create SDK options for feature generation from specs * * Configuration: * - Uses read-only tools (just needs to read the spec) * - Quick turns since it's mostly JSON generation * - Sonnet model by default for speed */ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): Options { // Validate working directory before creating options validateWorkingDirectory(config.cwd); return { ...getBaseOptions(), // Override permissionMode - feature generation only needs read-only tools permissionMode: 'default', model: getModelForUseCase('features', config.model), maxTurns: MAX_TURNS.quick, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.readOnly], ...(config.systemPrompt && { systemPrompt: config.systemPrompt }), ...(config.abortController && { abortController: config.abortController }), }; } /** * Create SDK options for generating suggestions * * Configuration: * - Uses read-only tools for analysis * - Standard turns to allow thorough codebase exploration and structured output generation * - Opus model by default for thorough analysis */ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Options { // Validate working directory before creating options validateWorkingDirectory(config.cwd); return { ...getBaseOptions(), model: getModelForUseCase('suggestions', config.model), maxTurns: MAX_TURNS.extended, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.readOnly], ...(config.systemPrompt && { systemPrompt: config.systemPrompt }), ...(config.abortController && { abortController: config.abortController }), ...(config.outputFormat && { outputFormat: config.outputFormat }), }; } /** * Create SDK options for chat/interactive mode * * Configuration: * - Full tool access for code modification * - Standard turns for interactive sessions * - Model priority: explicit model > session model > chat default * - Sandbox enabled for bash safety */ export function createChatOptions(config: CreateSdkOptionsConfig): Options { // Validate working directory before creating options validateWorkingDirectory(config.cwd); // Model priority: explicit model > session model > chat default const effectiveModel = config.model || config.sessionModel; return { ...getBaseOptions(), model: getModelForUseCase('chat', effectiveModel), maxTurns: MAX_TURNS.standard, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.chat], sandbox: { enabled: true, autoAllowBashIfSandboxed: true, }, ...(config.systemPrompt && { systemPrompt: config.systemPrompt }), ...(config.abortController && { abortController: config.abortController }), }; } /** * Create SDK options for autonomous feature building/implementation * * Configuration: * - Full tool access for code modification and implementation * - Extended turns for thorough feature implementation * - Uses default model (can be overridden) * - Sandbox enabled for bash safety */ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { // Validate working directory before creating options validateWorkingDirectory(config.cwd); return { ...getBaseOptions(), model: getModelForUseCase('auto', config.model), maxTurns: MAX_TURNS.maximum, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.fullAccess], sandbox: { enabled: true, autoAllowBashIfSandboxed: true, }, ...(config.systemPrompt && { systemPrompt: config.systemPrompt }), ...(config.abortController && { abortController: config.abortController }), }; } /** * Create custom SDK options with explicit configuration * * Use this when the preset options don't fit your use case. */ export function createCustomOptions( config: CreateSdkOptionsConfig & { maxTurns?: number; allowedTools?: readonly string[]; sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; } ): Options { // Validate working directory before creating options validateWorkingDirectory(config.cwd); return { ...getBaseOptions(), model: getModelForUseCase('default', config.model), maxTurns: config.maxTurns ?? MAX_TURNS.maximum, cwd: config.cwd, allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly], ...(config.sandbox && { sandbox: config.sandbox }), ...(config.systemPrompt && { systemPrompt: config.systemPrompt }), ...(config.abortController && { abortController: config.abortController }), }; }