diff --git a/.claude/.gitignore b/.claude/.gitignore index 735e81ff..2cad7c3c 100644 --- a/.claude/.gitignore +++ b/.claude/.gitignore @@ -1 +1,2 @@ -hans/ \ No newline at end of file +hans/ +skills/ \ No newline at end of file diff --git a/apps/server/src/lib/agent-discovery.ts b/apps/server/src/lib/agent-discovery.ts new file mode 100644 index 00000000..b831bdec --- /dev/null +++ b/apps/server/src/lib/agent-discovery.ts @@ -0,0 +1,257 @@ +/** + * Agent Discovery - Scans filesystem for AGENT.md files + * + * Discovers agents from: + * - ~/.claude/agents/ (user-level, global) + * - .claude/agents/ (project-level) + * + * Similar to Skills, but for custom subagents defined in AGENT.md files. + */ + +import path from 'path'; +import os from 'os'; +import { createLogger } from '@automaker/utils'; +import { secureFs, systemPaths } from '@automaker/platform'; +import type { AgentDefinition } from '@automaker/types'; + +const logger = createLogger('AgentDiscovery'); + +export interface FilesystemAgent { + name: string; // Directory name (e.g., 'code-reviewer') + definition: AgentDefinition; + source: 'user' | 'project'; + filePath: string; // Full path to AGENT.md +} + +/** + * Parse agent content string into AgentDefinition + * Format: + * --- + * name: agent-name # Optional + * description: When to use this agent + * tools: tool1, tool2, tool3 # Optional (comma or space separated list) + * model: sonnet # Optional: sonnet, opus, haiku + * --- + * System prompt content here... + */ +function parseAgentContent(content: string, filePath: string): AgentDefinition | null { + // Extract frontmatter + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!frontmatterMatch) { + logger.warn(`Invalid agent file format (missing frontmatter): ${filePath}`); + return null; + } + + const [, frontmatter, prompt] = frontmatterMatch; + + // Parse description (required) + const description = frontmatter.match(/description:\s*(.+)/)?.[1]?.trim(); + if (!description) { + logger.warn(`Missing description in agent file: ${filePath}`); + return null; + } + + // Parse tools (optional) - supports both comma-separated and space-separated + const toolsMatch = frontmatter.match(/tools:\s*(.+)/); + const tools = toolsMatch + ? toolsMatch[1] + .split(/[,\s]+/) // Split by comma or whitespace + .map((t) => t.trim()) + .filter((t) => t && t !== '') + : undefined; + + // Parse model (optional) - validate against allowed values + const modelMatch = frontmatter.match(/model:\s*(\w+)/); + const modelValue = modelMatch?.[1]?.trim(); + const validModels = ['sonnet', 'opus', 'haiku', 'inherit'] as const; + const model = + modelValue && validModels.includes(modelValue as (typeof validModels)[number]) + ? (modelValue as 'sonnet' | 'opus' | 'haiku' | 'inherit') + : undefined; + + if (modelValue && !model) { + logger.warn( + `Invalid model "${modelValue}" in agent file: ${filePath}. Expected one of: ${validModels.join(', ')}` + ); + } + + return { + description, + prompt: prompt.trim(), + tools, + model, + }; +} + +/** + * Directory entry with type information + */ +interface DirEntry { + name: string; + isFile: boolean; + isDirectory: boolean; +} + +/** + * Filesystem adapter interface for abstracting systemPaths vs secureFs + */ +interface FsAdapter { + exists: (filePath: string) => Promise; + readdir: (dirPath: string) => Promise; + readFile: (filePath: string) => Promise; +} + +/** + * Create a filesystem adapter for system paths (user directory) + */ +function createSystemPathAdapter(): FsAdapter { + return { + exists: (filePath) => Promise.resolve(systemPaths.systemPathExists(filePath)), + readdir: async (dirPath) => { + const entryNames = await systemPaths.systemPathReaddir(dirPath); + const entries: DirEntry[] = []; + for (const name of entryNames) { + const stat = await systemPaths.systemPathStat(path.join(dirPath, name)); + entries.push({ + name, + isFile: stat.isFile(), + isDirectory: stat.isDirectory(), + }); + } + return entries; + }, + readFile: (filePath) => systemPaths.systemPathReadFile(filePath, 'utf-8') as Promise, + }; +} + +/** + * Create a filesystem adapter for project paths (secureFs) + */ +function createSecureFsAdapter(): FsAdapter { + return { + exists: (filePath) => + secureFs + .access(filePath) + .then(() => true) + .catch(() => false), + readdir: async (dirPath) => { + const entries = await secureFs.readdir(dirPath, { withFileTypes: true }); + return entries.map((entry) => ({ + name: entry.name, + isFile: entry.isFile(), + isDirectory: entry.isDirectory(), + })); + }, + readFile: (filePath) => secureFs.readFile(filePath, 'utf-8') as Promise, + }; +} + +/** + * Parse agent file using the provided filesystem adapter + */ +async function parseAgentFileWithAdapter( + filePath: string, + fsAdapter: FsAdapter +): Promise { + try { + const content = await fsAdapter.readFile(filePath); + return parseAgentContent(content, filePath); + } catch (error) { + logger.error(`Failed to parse agent file: ${filePath}`, error); + return null; + } +} + +/** + * Scan a directory for agent .md files + * Agents can be in two formats: + * 1. Flat: agent-name.md (file directly in agents/) + * 2. Subdirectory: agent-name/AGENT.md (folder + file, similar to Skills) + */ +async function scanAgentsDirectory( + baseDir: string, + source: 'user' | 'project' +): Promise { + const agents: FilesystemAgent[] = []; + const fsAdapter = source === 'user' ? createSystemPathAdapter() : createSecureFsAdapter(); + + try { + // Check if directory exists + const exists = await fsAdapter.exists(baseDir); + if (!exists) { + logger.debug(`Directory does not exist: ${baseDir}`); + return agents; + } + + // Read all entries in the directory + const entries = await fsAdapter.readdir(baseDir); + + for (const entry of entries) { + // Check for flat .md file format (agent-name.md) + if (entry.isFile && entry.name.endsWith('.md')) { + const agentName = entry.name.slice(0, -3); // Remove .md extension + const agentFilePath = path.join(baseDir, entry.name); + const definition = await parseAgentFileWithAdapter(agentFilePath, fsAdapter); + if (definition) { + agents.push({ + name: agentName, + definition, + source, + filePath: agentFilePath, + }); + logger.debug(`Discovered ${source} agent (flat): ${agentName}`); + } + } + // Check for subdirectory format (agent-name/AGENT.md) + else if (entry.isDirectory) { + const agentFilePath = path.join(baseDir, entry.name, 'AGENT.md'); + const agentFileExists = await fsAdapter.exists(agentFilePath); + + if (agentFileExists) { + const definition = await parseAgentFileWithAdapter(agentFilePath, fsAdapter); + if (definition) { + agents.push({ + name: entry.name, + definition, + source, + filePath: agentFilePath, + }); + logger.debug(`Discovered ${source} agent (subdirectory): ${entry.name}`); + } + } + } + } + } catch (error) { + logger.error(`Failed to scan agents directory: ${baseDir}`, error); + } + + return agents; +} + +/** + * Discover all filesystem-based agents from user and project sources + */ +export async function discoverFilesystemAgents( + projectPath?: string, + sources: Array<'user' | 'project'> = ['user', 'project'] +): Promise { + const agents: FilesystemAgent[] = []; + + // Discover user-level agents from ~/.claude/agents/ + if (sources.includes('user')) { + const userAgentsDir = path.join(os.homedir(), '.claude', 'agents'); + const userAgents = await scanAgentsDirectory(userAgentsDir, 'user'); + agents.push(...userAgents); + logger.info(`Discovered ${userAgents.length} user-level agents from ${userAgentsDir}`); + } + + // Discover project-level agents from .claude/agents/ + if (sources.includes('project') && projectPath) { + const projectAgentsDir = path.join(projectPath, '.claude', 'agents'); + const projectAgents = await scanAgentsDirectory(projectAgentsDir, 'project'); + agents.push(...projectAgents); + logger.info(`Discovered ${projectAgents.length} project-level agents from ${projectAgentsDir}`); + } + + return agents; +} diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index a56efbc6..da3c08fe 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -241,3 +241,83 @@ export async function getPromptCustomization( enhancement: mergeEnhancementPrompts(customization.enhancement), }; } + +/** + * Get Skills configuration from settings. + * Returns configuration for enabling skills and which sources to load from. + * + * @param settingsService - Settings service instance + * @returns Skills configuration with enabled state, sources, and tool inclusion flag + */ +export async function getSkillsConfiguration(settingsService: SettingsService): Promise<{ + enabled: boolean; + sources: Array<'user' | 'project'>; + shouldIncludeInTools: boolean; +}> { + const settings = await settingsService.getGlobalSettings(); + const enabled = settings.enableSkills ?? true; // Default enabled + const sources = settings.skillsSources ?? ['user', 'project']; // Default both sources + + return { + enabled, + sources, + shouldIncludeInTools: enabled && sources.length > 0, + }; +} + +/** + * Get Subagents configuration from settings. + * Returns configuration for enabling subagents and which sources to load from. + * + * @param settingsService - Settings service instance + * @returns Subagents configuration with enabled state, sources, and tool inclusion flag + */ +export async function getSubagentsConfiguration(settingsService: SettingsService): Promise<{ + enabled: boolean; + sources: Array<'user' | 'project'>; + shouldIncludeInTools: boolean; +}> { + const settings = await settingsService.getGlobalSettings(); + const enabled = settings.enableSubagents ?? true; // Default enabled + const sources = settings.subagentsSources ?? ['user', 'project']; // Default both sources + + return { + enabled, + sources, + shouldIncludeInTools: enabled && sources.length > 0, + }; +} + +/** + * Get custom subagents from settings, merging global and project-level definitions. + * Project-level subagents take precedence over global ones with the same name. + * + * @param settingsService - Settings service instance + * @param projectPath - Path to the project for loading project-specific subagents + * @returns Record of agent names to definitions, or undefined if none configured + */ +export async function getCustomSubagents( + settingsService: SettingsService, + projectPath?: string +): Promise | undefined> { + // Get global subagents + const globalSettings = await settingsService.getGlobalSettings(); + const globalSubagents = globalSettings.customSubagents || {}; + + // If no project path, return only global subagents + if (!projectPath) { + return Object.keys(globalSubagents).length > 0 ? globalSubagents : undefined; + } + + // Get project-specific subagents + const projectSettings = await settingsService.getProjectSettings(projectPath); + const projectSubagents = projectSettings.customSubagents || {}; + + // Merge: project-level takes precedence + const merged = { + ...globalSubagents, + ...projectSubagents, + }; + + return Object.keys(merged).length > 0 ? merged : undefined; +} diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 92b0fdf7..ba86bfad 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -10,7 +10,7 @@ import { BaseProvider } from './base-provider.js'; import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils'; const logger = createLogger('ClaudeProvider'); -import { getThinkingTokenBudget } from '@automaker/types'; +import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types'; import type { ExecuteOptions, ProviderMessage, @@ -53,6 +53,10 @@ export class ClaudeProvider extends BaseProvider { * Execute a query using Claude Agent SDK */ async *executeQuery(options: ExecuteOptions): AsyncGenerator { + // Validate that model doesn't have a provider prefix + // AgentService should strip prefixes before passing to providers + validateBareModelId(options.model, 'ClaudeProvider'); + const { prompt, model, @@ -93,6 +97,8 @@ export class ClaudeProvider extends BaseProvider { ...(options.mcpServers && { mcpServers: options.mcpServers }), // Extended thinking configuration ...(maxThinkingTokens && { maxThinkingTokens }), + // Subagents configuration for specialized task delegation + ...(options.agents && { agents: options.agents }), }; // Build prompt payload diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 858ff206..54e13989 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -31,6 +31,7 @@ import type { import { CODEX_MODEL_MAP, supportsReasoningEffort, + validateBareModelId, type CodexApprovalPolicy, type CodexSandboxMode, type CodexAuthStatus, @@ -663,6 +664,10 @@ export class CodexProvider extends BaseProvider { } async *executeQuery(options: ExecuteOptions): AsyncGenerator { + // Validate that model doesn't have a provider prefix + // AgentService should strip prefixes before passing to providers + validateBareModelId(options.model, 'CodexProvider'); + try { const mcpServers = options.mcpServers ?? {}; const hasMcpServers = Object.keys(mcpServers).length > 0; @@ -760,6 +765,7 @@ export class CodexProvider extends BaseProvider { } } + // Model is already bare (no prefix) - validated by executeQuery const args = [ CODEX_EXEC_SUBCOMMAND, CODEX_YOLO_FLAG, diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts index aedae441..6cefc279 100644 --- a/apps/server/src/providers/cursor-provider.ts +++ b/apps/server/src/providers/cursor-provider.ts @@ -28,7 +28,7 @@ import type { ModelDefinition, ContentBlock, } from './types.js'; -import { stripProviderPrefix } from '@automaker/types'; +import { validateBareModelId } from '@automaker/types'; import { validateApiKey } from '../lib/auth-utils.js'; import { getEffectivePermissions } from '../services/cursor-config-service.js'; import { @@ -317,8 +317,8 @@ export class CursorProvider extends CliProvider { } buildCliArgs(options: ExecuteOptions): string[] { - // Extract model (strip 'cursor-' prefix if present) - const model = stripProviderPrefix(options.model || 'auto'); + // Model is already bare (no prefix) - validated by executeQuery + const model = options.model || 'auto'; // Build CLI arguments for cursor-agent // NOTE: Prompt is NOT included here - it's passed via stdin to avoid @@ -649,6 +649,10 @@ export class CursorProvider extends CliProvider { async *executeQuery(options: ExecuteOptions): AsyncGenerator { this.ensureCliDetected(); + // Validate that model doesn't have a provider prefix + // AgentService should strip prefixes before passing to providers + validateBareModelId(options.model, 'CursorProvider'); + if (!this.cliPath) { throw this.createError( CursorErrorCode.NOT_INSTALLED, diff --git a/apps/server/src/routes/settings/index.ts b/apps/server/src/routes/settings/index.ts index cc164856..6f6f6d40 100644 --- a/apps/server/src/routes/settings/index.ts +++ b/apps/server/src/routes/settings/index.ts @@ -23,6 +23,7 @@ import { createGetProjectHandler } from './routes/get-project.js'; import { createUpdateProjectHandler } from './routes/update-project.js'; import { createMigrateHandler } from './routes/migrate.js'; import { createStatusHandler } from './routes/status.js'; +import { createDiscoverAgentsHandler } from './routes/discover-agents.js'; /** * Create settings router with all endpoints @@ -39,6 +40,7 @@ import { createStatusHandler } from './routes/status.js'; * - POST /project - Get project settings (requires projectPath in body) * - PUT /project - Update project settings * - POST /migrate - Migrate settings from localStorage + * - POST /agents/discover - Discover filesystem agents from .claude/agents/ (read-only) * * @param settingsService - Instance of SettingsService for file I/O * @returns Express Router configured with all settings endpoints @@ -72,5 +74,8 @@ export function createSettingsRoutes(settingsService: SettingsService): Router { // Migration from localStorage router.post('/migrate', createMigrateHandler(settingsService)); + // Filesystem agents discovery (read-only) + router.post('/agents/discover', createDiscoverAgentsHandler()); + return router; } diff --git a/apps/server/src/routes/settings/routes/discover-agents.ts b/apps/server/src/routes/settings/routes/discover-agents.ts new file mode 100644 index 00000000..aee4a2a2 --- /dev/null +++ b/apps/server/src/routes/settings/routes/discover-agents.ts @@ -0,0 +1,61 @@ +/** + * Discover Agents Route - Returns filesystem-based agents from .claude/agents/ + * + * Scans both user-level (~/.claude/agents/) and project-level (.claude/agents/) + * directories for AGENT.md files and returns parsed agent definitions. + */ + +import type { Request, Response } from 'express'; +import { discoverFilesystemAgents } from '../../../lib/agent-discovery.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('DiscoverAgentsRoute'); + +interface DiscoverAgentsRequest { + projectPath?: string; + sources?: Array<'user' | 'project'>; +} + +/** + * Create handler for discovering filesystem agents + * + * POST /api/settings/agents/discover + * Body: { projectPath?: string, sources?: ['user', 'project'] } + * + * Returns: + * { + * success: true, + * agents: Array<{ + * name: string, + * definition: AgentDefinition, + * source: 'user' | 'project', + * filePath: string + * }> + * } + */ +export function createDiscoverAgentsHandler() { + return async (req: Request, res: Response) => { + try { + const { projectPath, sources = ['user', 'project'] } = req.body as DiscoverAgentsRequest; + + logger.info( + `Discovering agents from sources: ${sources.join(', ')}${projectPath ? ` (project: ${projectPath})` : ''}` + ); + + const agents = await discoverFilesystemAgents(projectPath, sources); + + logger.info(`Discovered ${agents.length} filesystem agents`); + + res.json({ + success: true, + agents, + }); + } catch (error) { + logger.error('Failed to discover agents:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to discover agents', + }); + } + }; +} diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 5e29d0db..a76db780 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -7,6 +7,7 @@ import path from 'path'; import * as secureFs from '../lib/secure-fs.js'; import type { EventEmitter } from '../lib/events.js'; import type { ExecuteOptions, ThinkingLevel, ReasoningEffort } from '@automaker/types'; +import { stripProviderPrefix } from '@automaker/types'; import { readImageAsBase64, buildPromptWithImages, @@ -25,6 +26,9 @@ import { filterClaudeMdFromContext, getMCPServersFromSettings, getPromptCustomization, + getSkillsConfiguration, + getSubagentsConfiguration, + getCustomSubagents, } from '../lib/settings-helpers.js'; interface Message { @@ -254,6 +258,22 @@ export class AgentService { // Load MCP servers from settings (global setting only) const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]'); + // Get Skills configuration from settings + const skillsConfig = this.settingsService + ? await getSkillsConfiguration(this.settingsService) + : { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false }; + + // Get Subagents configuration from settings + const subagentsConfig = this.settingsService + ? await getSubagentsConfiguration(this.settingsService) + : { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false }; + + // Get custom subagents from settings (merge global + project-level) only if enabled + const customSubagents = + this.settingsService && subagentsConfig.enabled + ? await getCustomSubagents(this.settingsService, effectiveWorkDir) + : undefined; + // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) const contextResult = await loadContextFiles({ projectPath: effectiveWorkDir, @@ -288,24 +308,69 @@ export class AgentService { // Extract model, maxTurns, and allowedTools from SDK options const effectiveModel = sdkOptions.model!; const maxTurns = sdkOptions.maxTurns; - const allowedTools = sdkOptions.allowedTools as string[] | undefined; + let allowedTools = sdkOptions.allowedTools as string[] | undefined; - // Get provider for this model + // Build merged settingSources array using Set for automatic deduplication + const sdkSettingSources = (sdkOptions.settingSources ?? []).filter( + (source): source is 'user' | 'project' => source === 'user' || source === 'project' + ); + const skillSettingSources = skillsConfig.enabled ? skillsConfig.sources : []; + const settingSources = [...new Set([...sdkSettingSources, ...skillSettingSources])]; + + // Enhance allowedTools with Skills and Subagents tools + // These tools are not in the provider's default set - they're added dynamically based on settings + const needsSkillTool = skillsConfig.shouldIncludeInTools; + const needsTaskTool = + subagentsConfig.shouldIncludeInTools && + customSubagents && + Object.keys(customSubagents).length > 0; + + // Base tools that match the provider's default set + const baseTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; + + if (allowedTools) { + allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options + // Add Skill tool if skills are enabled + if (needsSkillTool && !allowedTools.includes('Skill')) { + allowedTools.push('Skill'); + } + // Add Task tool if custom subagents are configured + if (needsTaskTool && !allowedTools.includes('Task')) { + allowedTools.push('Task'); + } + } else if (needsSkillTool || needsTaskTool) { + // If no allowedTools specified but we need to add Skill/Task tools, + // build the full list including base tools + allowedTools = [...baseTools]; + if (needsSkillTool) { + allowedTools.push('Skill'); + } + if (needsTaskTool) { + allowedTools.push('Task'); + } + } + + // Get provider for this model (with prefix) const provider = ProviderFactory.getProviderForModel(effectiveModel); + // Strip provider prefix - providers should receive bare model IDs + const bareModel = stripProviderPrefix(effectiveModel); + // Build options for provider const options: ExecuteOptions = { prompt: '', // Will be set below based on images - model: effectiveModel, + model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1") + originalModel: effectiveModel, // Original with prefix for logging (e.g., "codex-gpt-5.1-codex-max") cwd: effectiveWorkDir, systemPrompt: sdkOptions.systemPrompt, maxTurns: maxTurns, allowedTools: allowedTools, abortController: session.abortController!, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, - settingSources: sdkOptions.settingSources, + settingSources: settingSources.length > 0 ? settingSources : undefined, sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration + agents: customSubagents, // Pass custom subagents for task delegation thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models }; diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index 7973db05..81fc3de6 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -40,6 +40,7 @@ import type { SettingsService } from './settings-service.js'; import type { FeatureLoader } from './feature-loader.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; import { resolveModelString } from '@automaker/model-resolver'; +import { stripProviderPrefix } from '@automaker/types'; const logger = createLogger('IdeationService'); @@ -201,7 +202,7 @@ export class IdeationService { existingWorkContext ); - // Resolve model alias to canonical identifier + // Resolve model alias to canonical identifier (with prefix) const modelId = resolveModelString(options?.model ?? 'sonnet'); // Create SDK options @@ -214,9 +215,13 @@ export class IdeationService { const provider = ProviderFactory.getProviderForModel(modelId); + // Strip provider prefix - providers need bare model IDs + const bareModel = stripProviderPrefix(modelId); + const executeOptions: ExecuteOptions = { prompt: message, - model: modelId, + model: bareModel, + originalModel: modelId, cwd: projectPath, systemPrompt: sdkOptions.systemPrompt, maxTurns: 1, // Single turn for ideation @@ -648,7 +653,7 @@ export class IdeationService { existingWorkContext ); - // Resolve model alias to canonical identifier + // Resolve model alias to canonical identifier (with prefix) const modelId = resolveModelString('sonnet'); // Create SDK options @@ -661,9 +666,13 @@ export class IdeationService { const provider = ProviderFactory.getProviderForModel(modelId); + // Strip provider prefix - providers need bare model IDs + const bareModel = stripProviderPrefix(modelId); + const executeOptions: ExecuteOptions = { prompt: prompt.prompt, - model: modelId, + model: bareModel, + originalModel: modelId, cwd: projectPath, systemPrompt: sdkOptions.systemPrompt, maxTurns: 1, diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index a02d3b5a..f107c4f4 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -37,6 +37,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Hello', + model: 'claude-opus-4-5-20251101', cwd: '/test', }); @@ -88,6 +89,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', + model: 'claude-opus-4-5-20251101', cwd: '/test', }); @@ -112,6 +114,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', + model: 'claude-opus-4-5-20251101', cwd: '/test', abortController, }); @@ -140,6 +143,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Current message', + model: 'claude-opus-4-5-20251101', cwd: '/test', conversationHistory, sdkSessionId: 'test-session-id', @@ -170,6 +174,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: arrayPrompt as any, + model: 'claude-opus-4-5-20251101', cwd: '/test', }); @@ -189,6 +194,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', + model: 'claude-opus-4-5-20251101', cwd: '/test', }); @@ -214,6 +220,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', + model: 'claude-opus-4-5-20251101', cwd: '/test', }); diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx index 9ccb0119..e9e7431b 100644 --- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx @@ -5,6 +5,8 @@ import { useCliStatus } from '../hooks/use-cli-status'; import { ClaudeCliStatus } from '../cli-status/claude-cli-status'; import { ClaudeMdSettings } from '../claude/claude-md-settings'; import { ClaudeUsageSection } from '../api-keys/claude-usage-section'; +import { SkillsSection } from './claude-settings-tab/skills-section'; +import { SubagentsSection } from './claude-settings-tab/subagents-section'; import { Info } from 'lucide-react'; export function ClaudeSettingsTab() { @@ -43,6 +45,13 @@ export function ClaudeSettingsTab() { autoLoadClaudeMd={autoLoadClaudeMd} onAutoLoadClaudeMdChange={setAutoLoadClaudeMd} /> + + {/* Skills Configuration */} + + + {/* Custom Subagents */} + + {showUsageTracking && } ); diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/index.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/index.ts new file mode 100644 index 00000000..f2f57d04 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/index.ts @@ -0,0 +1,6 @@ +/** + * Hooks barrel export for Claude Settings Tab + */ + +export { useSkillsSettings } from './use-skills-settings'; +export { useSubagents } from './use-subagents'; diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts new file mode 100644 index 00000000..233e0fdd --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts @@ -0,0 +1,63 @@ +/** + * Skills Settings Hook - Manages Skills configuration state + * + * Provides state management for enabling/disabling Skills and + * configuring which sources to load Skills from (user/project). + */ + +import { useState } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { toast } from 'sonner'; +import { getElectronAPI } from '@/lib/electron'; + +export function useSkillsSettings() { + const enabled = useAppStore((state) => state.enableSkills); + const sources = useAppStore((state) => state.skillsSources); + const [isLoading, setIsLoading] = useState(false); + + const updateEnabled = async (newEnabled: boolean) => { + setIsLoading(true); + try { + const api = getElectronAPI(); + if (!api.settings) { + throw new Error('Settings API not available'); + } + await api.settings.updateGlobal({ enableSkills: newEnabled }); + // Update local store after successful server update + useAppStore.setState({ enableSkills: newEnabled }); + toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled'); + } catch (error) { + toast.error('Failed to update skills settings'); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + const updateSources = async (newSources: Array<'user' | 'project'>) => { + setIsLoading(true); + try { + const api = getElectronAPI(); + if (!api.settings) { + throw new Error('Settings API not available'); + } + await api.settings.updateGlobal({ skillsSources: newSources }); + // Update local store after successful server update + useAppStore.setState({ skillsSources: newSources }); + toast.success('Skills sources updated'); + } catch (error) { + toast.error('Failed to update skills sources'); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + return { + enabled, + sources, + updateEnabled, + updateSources, + isLoading, + }; +} diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts new file mode 100644 index 00000000..ccf7664a --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts @@ -0,0 +1,63 @@ +/** + * Subagents Settings Hook - Manages Subagents configuration state + * + * Provides state management for enabling/disabling Subagents and + * configuring which sources to load Subagents from (user/project). + */ + +import { useState } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { toast } from 'sonner'; +import { getElectronAPI } from '@/lib/electron'; + +export function useSubagentsSettings() { + const enabled = useAppStore((state) => state.enableSubagents); + const sources = useAppStore((state) => state.subagentsSources); + const [isLoading, setIsLoading] = useState(false); + + const updateEnabled = async (newEnabled: boolean) => { + setIsLoading(true); + try { + const api = getElectronAPI(); + if (!api.settings) { + throw new Error('Settings API not available'); + } + await api.settings.updateGlobal({ enableSubagents: newEnabled }); + // Update local store after successful server update + useAppStore.setState({ enableSubagents: newEnabled }); + toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled'); + } catch (error) { + toast.error('Failed to update subagents settings'); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + const updateSources = async (newSources: Array<'user' | 'project'>) => { + setIsLoading(true); + try { + const api = getElectronAPI(); + if (!api.settings) { + throw new Error('Settings API not available'); + } + await api.settings.updateGlobal({ subagentsSources: newSources }); + // Update local store after successful server update + useAppStore.setState({ subagentsSources: newSources }); + toast.success('Subagents sources updated'); + } catch (error) { + toast.error('Failed to update subagents sources'); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + return { + enabled, + sources, + updateEnabled, + updateSources, + isLoading, + }; +} diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts new file mode 100644 index 00000000..50f82393 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts @@ -0,0 +1,85 @@ +/** + * Subagents Hook - Manages custom subagent definitions + * + * Provides read-only view of custom subagent configurations + * used for specialized task delegation. Supports: + * - Filesystem agents (AGENT.md files in .claude/agents/) - user and project-level (read-only) + * + * Filesystem agents are discovered via the server API and displayed in the UI. + * Agent definitions in settings JSON are used server-side only. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useAppStore } from '@/store/app-store'; +import type { AgentDefinition } from '@automaker/types'; +import { getElectronAPI } from '@/lib/electron'; + +export type SubagentScope = 'global' | 'project'; +export type SubagentType = 'filesystem'; +export type FilesystemSource = 'user' | 'project'; + +export interface SubagentWithScope { + name: string; + definition: AgentDefinition; + scope: SubagentScope; + type: SubagentType; + source: FilesystemSource; + filePath: string; +} + +interface FilesystemAgent { + name: string; + definition: AgentDefinition; + source: FilesystemSource; + filePath: string; +} + +export function useSubagents() { + const currentProject = useAppStore((state) => state.currentProject); + const [isLoading, setIsLoading] = useState(false); + const [subagentsWithScope, setSubagentsWithScope] = useState([]); + + // Fetch filesystem agents + const fetchFilesystemAgents = useCallback(async () => { + setIsLoading(true); + try { + const api = getElectronAPI(); + if (!api.settings) { + console.warn('Settings API not available'); + return; + } + const data = await api.settings.discoverAgents(currentProject?.path, ['user', 'project']); + + if (data.success && data.agents) { + // Transform filesystem agents to SubagentWithScope format + const agents: SubagentWithScope[] = data.agents.map( + ({ name, definition, source, filePath }: FilesystemAgent) => ({ + name, + definition, + scope: source === 'user' ? 'global' : 'project', + type: 'filesystem' as const, + source, + filePath, + }) + ); + setSubagentsWithScope(agents); + } + } catch (error) { + console.error('Failed to fetch filesystem agents:', error); + } finally { + setIsLoading(false); + } + }, [currentProject?.path]); + + // Fetch filesystem agents on mount and when project changes + useEffect(() => { + fetchFilesystemAgents(); + }, [fetchFilesystemAgents]); + + return { + subagentsWithScope, + isLoading, + hasProject: !!currentProject, + refreshFilesystemAgents: fetchFilesystemAgents, + }; +} diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/index.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/index.ts new file mode 100644 index 00000000..8b79a1f8 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/index.ts @@ -0,0 +1,7 @@ +/** + * Claude Settings Tab components barrel export + */ + +export { SkillsSection } from './skills-section'; +export { SubagentsSection } from './subagents-section'; +export { SubagentCard } from './subagent-card'; diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/skills-section.tsx b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/skills-section.tsx new file mode 100644 index 00000000..856184ad --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/skills-section.tsx @@ -0,0 +1,169 @@ +/** + * Skills Section - UI for managing Skills configuration + * + * Allows users to enable/disable Skills and select which directories + * to load Skills from (user ~/.claude/skills/ or project .claude/skills/). + */ + +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Checkbox } from '@/components/ui/checkbox'; +import { cn } from '@/lib/utils'; +import { Zap, Globe, FolderOpen, ExternalLink, Sparkles } from 'lucide-react'; +import { useSkillsSettings } from './hooks/use-skills-settings'; + +export function SkillsSection() { + const { enabled, sources, updateEnabled, updateSources, isLoading } = useSkillsSettings(); + + const toggleSource = (source: 'user' | 'project') => { + if (sources.includes(source)) { + updateSources(sources.filter((s: 'user' | 'project') => s !== source)); + } else { + updateSources([...sources, source]); + } + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

+ Skills + {enabled && ( + + {sources.length} source{sources.length !== 1 ? 's' : ''} active + + )} +

+

+ Filesystem-based capabilities Claude invokes autonomously +

+
+
+ +
+ + {/* Content */} +
+ {/* Sources Selection */} + {enabled && ( +
+ +
+ {/* User Skills Option */} + + + {/* Project Skills Option */} + +
+
+ )} + + {/* Help Text */} + {enabled && ( +
+
+
+ +
+
+

Auto-Discovery

+

+ Skills are automatically discovered when agents start. Define skills as{' '} + SKILL.md files. +

+
+
+ + + View Skills documentation + +
+ )} + + {/* Disabled State Empty Message */} + {!enabled && ( +
+ +

Skills are disabled

+

Enable to load filesystem-based capabilities

+
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagent-card.tsx b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagent-card.tsx new file mode 100644 index 00000000..0b4e0371 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagent-card.tsx @@ -0,0 +1,138 @@ +/** + * Subagent Card - Display card for a single subagent definition + * + * Shows the subagent's name, description, model, tool count, scope, and type. + * Read-only view - agents are managed by editing .md files directly. + */ + +import { useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Markdown } from '@/components/ui/markdown'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; +import { + Globe, + FolderOpen, + ChevronDown, + ChevronRight, + Bot, + Cpu, + Wrench, + FileCode, +} from 'lucide-react'; +import type { SubagentWithScope } from './hooks/use-subagents'; + +interface SubagentCardProps { + agent: SubagentWithScope; +} + +export function SubagentCard({ agent }: SubagentCardProps) { + const [isExpanded, setIsExpanded] = useState(false); + const { name, definition, scope, filePath } = agent; + + const toolCount = definition.tools?.length ?? 'all'; + const modelDisplay = + definition.model === 'inherit' || !definition.model + ? 'Inherit' + : definition.model.charAt(0).toUpperCase() + definition.model.slice(1); + + // Scope icon and label + const ScopeIcon = scope === 'global' ? Globe : FolderOpen; + const scopeLabel = scope === 'global' ? 'User' : 'Project'; + + // Model color based on type + const getModelColor = () => { + const model = definition.model?.toLowerCase(); + if (model === 'opus') return 'text-violet-500 bg-violet-500/10 border-violet-500/30'; + if (model === 'sonnet') return 'text-blue-500 bg-blue-500/10 border-blue-500/30'; + if (model === 'haiku') return 'text-emerald-500 bg-emerald-500/10 border-emerald-500/30'; + return 'text-muted-foreground bg-muted/50 border-border/50'; + }; + + return ( + +
+ {/* Main Card Content */} +
+ {/* Agent Icon */} +
+ +
+ + {/* Content */} +
+ {/* Header Row */} +
+

{name}

+ + + {modelDisplay} + + + + {toolCount === 'all' ? 'All' : toolCount} tools + + + + {scopeLabel} + +
+ + {/* Description */} +

+ {definition.description} +

+ + {/* File Path */} + {filePath && ( +
+ + {filePath} +
+ )} +
+ + {/* Expand Button */} + + + +
+ + {/* Expandable Prompt Section */} + +
+
+
+ System Prompt +
+ {definition.prompt} +
+
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagents-section.tsx b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagents-section.tsx new file mode 100644 index 00000000..08800331 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagents-section.tsx @@ -0,0 +1,257 @@ +/** + * Subagents Section - UI for managing Subagents configuration + * + * Allows users to enable/disable Subagents and select which directories + * to load Subagents from (user ~/.claude/agents/ or project .claude/agents/). + * + * Displays agents discovered from: + * - User-level: ~/.claude/agents/ + * - Project-level: .claude/agents/ + */ + +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Checkbox } from '@/components/ui/checkbox'; +import { cn } from '@/lib/utils'; +import { + Bot, + RefreshCw, + Loader2, + Users, + ExternalLink, + Globe, + FolderOpen, + Sparkles, +} from 'lucide-react'; +import { useSubagents } from './hooks/use-subagents'; +import { useSubagentsSettings } from './hooks/use-subagents-settings'; +import { SubagentCard } from './subagent-card'; + +export function SubagentsSection() { + const { + subagentsWithScope, + isLoading: isLoadingAgents, + hasProject, + refreshFilesystemAgents, + } = useSubagents(); + const { + enabled, + sources, + updateEnabled, + updateSources, + isLoading: isLoadingSettings, + } = useSubagentsSettings(); + + const isLoading = isLoadingAgents || isLoadingSettings; + + const handleRefresh = async () => { + await refreshFilesystemAgents(); + }; + + const toggleSource = (source: 'user' | 'project') => { + if (sources.includes(source)) { + updateSources(sources.filter((s: 'user' | 'project') => s !== source)); + } else { + updateSources([...sources, source]); + } + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

+ Custom Subagents + {enabled && subagentsWithScope.length > 0 && ( + + {subagentsWithScope.length} agent{subagentsWithScope.length !== 1 ? 's' : ''} + + )} +

+

+ Specialized agents Claude delegates to automatically +

+
+
+ +
+ + {/* Content */} +
+ {/* Sources Selection */} + {enabled && ( +
+ +
+ {/* User Subagents Option */} + + + {/* Project Subagents Option */} + +
+
+ )} + + {/* Agents List */} + {enabled && ( + <> + {/* Refresh Button */} +
+ + +
+ + {subagentsWithScope.length === 0 ? ( +
+ +

No agents found

+

+ Create .md files in{' '} + {sources.includes('user') && ( + ~/.claude/agents/ + )} + {sources.includes('user') && sources.includes('project') && ' or '} + {sources.includes('project') && ( + .claude/agents/ + )} +

+
+ ) : ( +
+ {subagentsWithScope.map((agent) => ( + + ))} +
+ )} + + )} + + {/* Help Text */} + {enabled && ( +
+
+
+ +
+
+

Auto-Discovery

+

+ Subagents are automatically discovered when agents start. Define agents as{' '} + AGENT.md files or{' '} + agent-name.md files. +

+
+
+ + + View Agents documentation + +
+ )} + + {/* Disabled State Empty Message */} + {!enabled && ( +
+ +

Subagents are disabled

+

Enable to load custom agent definitions

+
+ )} +
+
+ ); +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 74ca9cc4..acc7a7d8 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -725,6 +725,83 @@ export interface ElectronAPI { }>; }; ideation?: IdeationAPI; + settings?: { + getStatus: () => Promise<{ + success: boolean; + hasGlobalSettings: boolean; + hasCredentials: boolean; + dataDir: string; + needsMigration: boolean; + }>; + getGlobal: () => Promise<{ + success: boolean; + settings?: Record; + error?: string; + }>; + updateGlobal: (updates: Record) => Promise<{ + success: boolean; + settings?: Record; + error?: string; + }>; + getCredentials: () => Promise<{ + success: boolean; + credentials?: { + anthropic: { configured: boolean; masked: string }; + google: { configured: boolean; masked: string }; + openai: { configured: boolean; masked: string }; + }; + error?: string; + }>; + updateCredentials: (updates: { + apiKeys?: { anthropic?: string; google?: string; openai?: string }; + }) => Promise<{ + success: boolean; + credentials?: { + anthropic: { configured: boolean; masked: string }; + google: { configured: boolean; masked: string }; + openai: { configured: boolean; masked: string }; + }; + error?: string; + }>; + getProject: (projectPath: string) => Promise<{ + success: boolean; + settings?: Record; + error?: string; + }>; + updateProject: ( + projectPath: string, + updates: Record + ) => Promise<{ + success: boolean; + settings?: Record; + error?: string; + }>; + migrate: (data: Record) => Promise<{ + success: boolean; + migratedGlobalSettings: boolean; + migratedCredentials: boolean; + migratedProjectCount: number; + errors: string[]; + }>; + discoverAgents: ( + projectPath?: string, + sources?: Array<'user' | 'project'> + ) => Promise<{ + success: boolean; + agents?: Array<{ + name: string; + definition: { + description: string; + prompt: string; + tools?: string[]; + model?: 'sonnet' | 'opus' | 'haiku' | 'inherit'; + }; + source: 'user' | 'project'; + filePath: string; + }>; + error?: string; + }>; + }; } // Note: Window interface is declared in @/types/electron.d.ts diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 6853c775..ca7414df 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -23,8 +23,6 @@ import type { SpecRegenerationEvent, SuggestionType, GitHubAPI, - GitHubIssue, - GitHubPR, IssueValidationInput, IssueValidationEvent, IdeationAPI, @@ -374,7 +372,13 @@ export const verifySession = async (): Promise => { 'Content-Type': 'application/json', }; - // Add session token header if available + // Electron mode: use API key header + const apiKey = getApiKey(); + if (apiKey) { + headers['X-API-Key'] = apiKey; + } + + // Add session token header if available (web mode) const sessionToken = getSessionToken(); if (sessionToken) { headers['X-Session-Token'] = sessionToken; @@ -1877,6 +1881,26 @@ export class HttpApiClient implements ElectronAPI { migratedProjectCount: number; errors: string[]; }> => this.post('/api/settings/migrate', { data }), + + // Filesystem agents discovery (read-only) + discoverAgents: ( + projectPath?: string, + sources?: Array<'user' | 'project'> + ): Promise<{ + success: boolean; + agents?: Array<{ + name: string; + definition: { + description: string; + prompt: string; + tools?: string[]; + model?: 'sonnet' | 'opus' | 'haiku' | 'inherit'; + }; + source: 'user' | 'project'; + filePath: string; + }>; + error?: string; + }> => this.post('/api/settings/agents/discover', { projectPath, sources }), }; // Sessions API diff --git a/apps/ui/src/lib/workspace-config.ts b/apps/ui/src/lib/workspace-config.ts index d92bd671..e1d32837 100644 --- a/apps/ui/src/lib/workspace-config.ts +++ b/apps/ui/src/lib/workspace-config.ts @@ -5,7 +5,6 @@ import { createLogger } from '@automaker/utils/logger'; import { getHttpApiClient } from './http-api-client'; -import { getElectronAPI } from './electron'; import { useAppStore } from '@/store/app-store'; const logger = createLogger('WorkspaceConfig'); @@ -33,9 +32,17 @@ function joinPath(...parts: string[]): string { */ async function getDefaultDocumentsPath(): Promise { try { - const api = getElectronAPI(); - const documentsPath = await api.getPath('documents'); - return joinPath(documentsPath, 'Automaker'); + // In Electron mode, use the native getPath API directly from the preload script + // This returns the actual system Documents folder (e.g., C:\Users\\Documents on Windows) + // Note: The HTTP client's getPath returns incorrect Unix-style paths for 'documents' + if (typeof window !== 'undefined' && (window as any).electronAPI?.getPath) { + const documentsPath = await (window as any).electronAPI.getPath('documents'); + return joinPath(documentsPath, 'Automaker'); + } + + // In web mode (no Electron), we can't access the user's Documents folder + // Return null to let the caller use other fallback mechanisms (like server's DATA_DIR) + return null; } catch (error) { logger.error('Failed to get documents path:', error); return null; @@ -76,6 +83,7 @@ export async function getDefaultWorkspaceDirectory(): Promise { // Try to get Documents/Automaker const documentsPath = await getDefaultDocumentsPath(); + logger.info('Default documentsPath resolved to:', documentsPath); if (documentsPath) { return documentsPath; } diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index c6127320..2f55ab96 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -585,6 +585,14 @@ export interface AppState { // MCP Servers mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use + // Skills Configuration + enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories) + skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from + + // Subagents Configuration + enableSubagents: boolean; // Enable Custom Subagents functionality (loads from .claude/agents/ directories) + subagentsSources: Array<'user' | 'project'>; // Which directories to load Subagents from + // Prompt Customization promptCustomization: PromptCustomization; // Custom prompts for Auto Mode, Agent, Backlog Plan, Enhancement @@ -1188,6 +1196,10 @@ const initialState: AppState = { autoLoadClaudeMd: false, // Default to disabled (user must opt-in) skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) mcpServers: [], // No MCP servers configured by default + enableSkills: true, // Skills enabled by default + skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default + enableSubagents: true, // Subagents enabled by default + subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default promptCustomization: {}, // Empty by default - all prompts use built-in defaults aiProfiles: DEFAULT_AI_PROFILES, projectAnalysis: null, diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 34c7f7a3..259e251d 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -17,6 +17,7 @@ export type { McpStdioServerConfig, McpSSEServerConfig, McpHttpServerConfig, + AgentDefinition, ReasoningEffort, } from './provider.js'; @@ -189,6 +190,7 @@ export { addProviderPrefix, getBareModelId, normalizeModelString, + validateBareModelId, } from './provider-utils.js'; // Pipeline types diff --git a/libs/types/src/provider-utils.ts b/libs/types/src/provider-utils.ts index 81453990..af1a2267 100644 --- a/libs/types/src/provider-utils.ts +++ b/libs/types/src/provider-utils.ts @@ -240,3 +240,37 @@ export function normalizeModelString(model: string | undefined | null): string { return model; } + +/** + * Validate that a model ID does not contain a provider prefix + * + * Providers should receive bare model IDs (e.g., "gpt-5.1-codex-max", "composer-1") + * without provider prefixes (e.g., NOT "codex-gpt-5.1-codex-max", NOT "cursor-composer-1"). + * + * This validation ensures the ProviderFactory properly stripped prefixes before + * passing models to providers. + * + * @param model - Model ID to validate + * @param providerName - Name of the provider for error messages + * @throws Error if model contains a provider prefix + * + * @example + * validateBareModelId("gpt-5.1-codex-max", "CodexProvider"); // ✅ OK + * validateBareModelId("codex-gpt-5.1-codex-max", "CodexProvider"); // ❌ Throws error + */ +export function validateBareModelId(model: string, providerName: string): void { + if (!model || typeof model !== 'string') { + throw new Error(`[${providerName}] Invalid model ID: expected string, got ${typeof model}`); + } + + for (const [provider, prefix] of Object.entries(PROVIDER_PREFIXES)) { + if (model.startsWith(prefix)) { + throw new Error( + `[${providerName}] Model ID should not contain provider prefix '${prefix}'. ` + + `Got: '${model}'. ` + + `This is likely a bug in ProviderFactory - it should strip the '${provider}' prefix ` + + `before passing the model to the provider.` + ); + } + } +} diff --git a/libs/types/src/provider.ts b/libs/types/src/provider.ts index 3c41259b..45481ed2 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -76,12 +76,29 @@ export interface McpHttpServerConfig { headers?: Record; } +/** + * Subagent definition for specialized task delegation + */ +export interface AgentDefinition { + /** Natural language description of when to use this agent */ + description: string; + /** System prompt defining the agent's role and behavior */ + prompt: string; + /** Restricted tool list (if omitted, inherits all tools) */ + tools?: string[]; + /** Model override for this agent */ + model?: 'sonnet' | 'opus' | 'haiku' | 'inherit'; +} + /** * Options for executing a query via a provider */ export interface ExecuteOptions { prompt: string | Array<{ type: string; text?: string; source?: object }>; + /** Bare model ID without provider prefix (e.g., "gpt-5.1-codex-max", "composer-1") */ model: string; + /** Original model ID with provider prefix for logging (e.g., "codex-gpt-5.1-codex-max") */ + originalModel?: string; cwd: string; systemPrompt?: string | SystemPromptPreset; maxTurns?: number; @@ -107,6 +124,11 @@ export interface ExecuteOptions { * Only applies to Claude models; Cursor models handle thinking internally. */ thinkingLevel?: ThinkingLevel; + /** + * Custom subagents for specialized task delegation + * Key: agent name, Value: agent definition + */ + agents?: Record; /** * Reasoning effort for Codex/OpenAI models with reasoning capabilities. * Controls how many reasoning tokens the model generates before responding. diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 8d5bf5fa..a90e1fcb 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -564,6 +564,43 @@ export interface GlobalSettings { // Prompt Customization /** Custom prompts for Auto Mode, Agent Runner, Backlog Planning, and Enhancements */ promptCustomization?: PromptCustomization; + + // Skills Configuration + /** + * Enable Skills functionality (loads from .claude/skills/ directories) + * @default true + */ + enableSkills?: boolean; + + /** + * Which directories to load Skills from + * - 'user': ~/.claude/skills/ (personal skills) + * - 'project': .claude/skills/ (project-specific skills) + * @default ['user', 'project'] + */ + skillsSources?: Array<'user' | 'project'>; + + // Subagents Configuration + /** + * Enable Custom Subagents functionality (loads from .claude/agents/ directories) + * @default true + */ + enableSubagents?: boolean; + + /** + * Which directories to load Subagents from + * - 'user': ~/.claude/agents/ (personal agents) + * - 'project': .claude/agents/ (project-specific agents) + * @default ['user', 'project'] + */ + subagentsSources?: Array<'user' | 'project'>; + + /** + * Custom subagent definitions for specialized task delegation (programmatic) + * Key: agent name (e.g., 'code-reviewer', 'test-runner') + * Value: agent configuration + */ + customSubagents?: Record; } /** @@ -663,6 +700,15 @@ export interface ProjectSettings { // Claude Agent SDK Settings /** Auto-load CLAUDE.md files using SDK's settingSources option (project override) */ autoLoadClaudeMd?: boolean; + + // Subagents Configuration + /** + * Project-specific custom subagent definitions for specialized task delegation + * Merged with global customSubagents, project-level takes precedence + * Key: agent name (e.g., 'code-reviewer', 'test-runner') + * Value: agent configuration + */ + customSubagents?: Record; } /** @@ -766,6 +812,10 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { codexAdditionalDirs: DEFAULT_CODEX_ADDITIONAL_DIRS, codexThreadId: undefined, mcpServers: [], + enableSkills: true, + skillsSources: ['user', 'project'], + enableSubagents: true, + subagentsSources: ['user', 'project'], }; /** Default credentials (empty strings - user must provide API keys) */