From 236989bf6eef3a7b9b41ab6157af327f1e091754 Mon Sep 17 00:00:00 2001 From: Shirone Date: Tue, 6 Jan 2026 04:31:57 +0100 Subject: [PATCH 1/6] feat: add skills and subagents configuration support - Updated .gitignore to include skills directory. - Introduced agent discovery functionality to scan for AGENT.md files in user and project directories. - Added new API endpoint for discovering filesystem agents. - Implemented UI components for managing skills and viewing custom subagents. - Enhanced settings helpers to retrieve skills configuration and custom subagents. - Updated agent service to incorporate skills and subagents in task delegation. These changes enhance the capabilities of the system by allowing users to define and manage skills and custom subagents effectively. --- .claude/.gitignore | 3 +- apps/server/src/lib/agent-discovery.ts | 234 ++++++++++++++++++ apps/server/src/lib/settings-helpers.ts | 57 +++++ apps/server/src/providers/claude-provider.ts | 14 +- apps/server/src/routes/settings/index.ts | 5 + .../routes/settings/routes/discover-agents.ts | 61 +++++ apps/server/src/services/agent-service.ts | 54 +++- .../providers/claude-settings-tab.tsx | 9 + .../claude-settings-tab/hooks/index.ts | 6 + .../hooks/use-skills-settings.ts | 55 ++++ .../hooks/use-subagents.ts | 109 ++++++++ .../providers/claude-settings-tab/index.ts | 7 + .../claude-settings-tab/skills-section.tsx | 169 +++++++++++++ .../claude-settings-tab/subagent-card.tsx | 138 +++++++++++ .../claude-settings-tab/subagents-section.tsx | 109 ++++++++ apps/ui/src/lib/http-api-client.ts | 22 +- libs/types/src/index.ts | 1 + libs/types/src/provider.ts | 19 ++ libs/types/src/settings.ts | 32 +++ 19 files changed, 1098 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/lib/agent-discovery.ts create mode 100644 apps/server/src/routes/settings/routes/discover-agents.ts create mode 100644 apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/index.ts create mode 100644 apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts create mode 100644 apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts create mode 100644 apps/ui/src/components/views/settings-view/providers/claude-settings-tab/index.ts create mode 100644 apps/ui/src/components/views/settings-view/providers/claude-settings-tab/skills-section.tsx create mode 100644 apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagent-card.tsx create mode 100644 apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagents-section.tsx 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..955cbb1d --- /dev/null +++ b/apps/server/src/lib/agent-discovery.ts @@ -0,0 +1,234 @@ +/** + * 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 .md file frontmatter and content + * 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... + */ +async function parseAgentFile( + filePath: string, + isSystemPath: boolean +): Promise { + try { + const content = isSystemPath + ? ((await systemPaths.systemPathReadFile(filePath, 'utf-8')) as string) + : ((await secureFs.readFile(filePath, 'utf-8')) as string); + + // 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) + const modelMatch = frontmatter.match(/model:\s*(\w+)/); + const model = modelMatch + ? (modelMatch[1].trim() as 'sonnet' | 'opus' | 'haiku' | 'inherit') + : undefined; + + return { + description, + prompt: prompt.trim(), + tools, + model, + }; + } 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 isSystemPath = source === 'user'; // User directories use systemPaths + + try { + // Check if directory exists + const exists = isSystemPath + ? await systemPaths.systemPathExists(baseDir) + : await secureFs + .access(baseDir) + .then(() => true) + .catch(() => false); + + if (!exists) { + logger.debug(`Directory does not exist: ${baseDir}`); + return agents; + } + + // Read all entries in the directory + if (isSystemPath) { + // For system paths (user directory) + const entryNames = await systemPaths.systemPathReaddir(baseDir); + for (const entryName of entryNames) { + const entryPath = path.join(baseDir, entryName); + const stat = await systemPaths.systemPathStat(entryPath); + + // Check for flat .md file format (agent-name.md) + if (stat.isFile() && entryName.endsWith('.md')) { + const agentName = entryName.slice(0, -3); // Remove .md extension + const definition = await parseAgentFile(entryPath, true); + if (definition) { + agents.push({ + name: agentName, + definition, + source, + filePath: entryPath, + }); + logger.debug(`Discovered ${source} agent (flat): ${agentName}`); + } + } + // Check for subdirectory format (agent-name/AGENT.md) + else if (stat.isDirectory()) { + const agentFilePath = path.join(entryPath, 'AGENT.md'); + const agentFileExists = await systemPaths.systemPathExists(agentFilePath); + + if (agentFileExists) { + const definition = await parseAgentFile(agentFilePath, true); + if (definition) { + agents.push({ + name: entryName, + definition, + source, + filePath: agentFilePath, + }); + logger.debug(`Discovered ${source} agent (subdirectory): ${entryName}`); + } + } + } + } + } else { + // For project paths (use secureFs) + const entries = await secureFs.readdir(baseDir, { withFileTypes: true }); + 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 parseAgentFile(agentFilePath, false); + 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 agentDir = path.join(baseDir, entry.name); + const agentFilePath = path.join(agentDir, 'AGENT.md'); + + const agentFileExists = await secureFs + .access(agentFilePath) + .then(() => true) + .catch(() => false); + + if (agentFileExists) { + const definition = await parseAgentFile(agentFilePath, false); + 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 9a322994..07564085 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -269,3 +269,60 @@ 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 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 50e378be..5d033a3b 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -72,7 +72,17 @@ export class ClaudeProvider extends BaseProvider { // Build Claude SDK options // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0; - const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; + const defaultTools = [ + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'Bash', + 'WebSearch', + 'WebFetch', + 'Skill', + ]; // AUTONOMOUS MODE: Always bypass permissions and allow unrestricted tools // Only restrict tools when no MCP servers are configured @@ -104,6 +114,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/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 7736fd6a..e8678d63 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -24,6 +24,8 @@ import { filterClaudeMdFromContext, getMCPServersFromSettings, getPromptCustomization, + getSkillsConfiguration, + getCustomSubagents, } from '../lib/settings-helpers.js'; interface Message { @@ -241,6 +243,16 @@ 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 custom subagents from settings (merge global + project-level) + const customSubagents = this.settingsService + ? await getCustomSubagents(this.settingsService, effectiveWorkDir) + : undefined; + // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) const contextResult = await loadContextFiles({ projectPath: effectiveWorkDir, @@ -275,7 +287,44 @@ 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; + + // Build merged settingSources array (filter to only 'user' and 'project') + const settingSources: Array<'user' | 'project'> = []; + if (sdkOptions.settingSources) { + sdkOptions.settingSources.forEach((source) => { + if (source === 'user' || source === 'project') { + if (!settingSources.includes(source)) { + settingSources.push(source); + } + } + }); + } + // Merge skills sources (avoid duplicates) + if (skillsConfig.enabled && skillsConfig.sources.length > 0) { + skillsConfig.sources.forEach((source) => { + if (!settingSources.includes(source)) { + settingSources.push(source); + } + }); + } + + // Enhance allowedTools with Skills and Subagents tools + if (allowedTools) { + allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options + // Add Skill tool if skills are enabled + if (skillsConfig.shouldIncludeInTools && !allowedTools.includes('Skill')) { + allowedTools.push('Skill'); + } + // Add Task tool if custom subagents are configured + if ( + customSubagents && + Object.keys(customSubagents).length > 0 && + !allowedTools.includes('Task') + ) { + allowedTools.push('Task'); + } + } // Get provider for this model const provider = ProviderFactory.getProviderForModel(effectiveModel); @@ -290,10 +339,11 @@ export class AgentService { allowedTools: allowedTools, abortController: session.abortController!, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, - settingSources: sdkOptions.settingSources, + settingSources: settingSources.length > 0 ? settingSources : undefined, sandbox: sdkOptions.sandbox, // Pass sandbox configuration 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 }; // Build prompt content with images 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 57b2fe97..b8885100 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 @@ -4,6 +4,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() { @@ -42,6 +44,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..c6ab231a --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts @@ -0,0 +1,55 @@ +/** + * 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 { settings } = useAppStore(); + const [isLoading, setIsLoading] = useState(false); + + const enabled = settings?.enableSkills ?? true; + const sources = settings?.skillsSources ?? ['user', 'project']; + + const updateEnabled = async (newEnabled: boolean) => { + setIsLoading(true); + try { + const api = getElectronAPI(); + await api.settings.updateGlobal({ 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(); + await api.settings.updateGlobal({ 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.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts new file mode 100644 index 00000000..d3b1ba04 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts @@ -0,0 +1,109 @@ +/** + * Subagents Hook - Manages custom subagent definitions + * + * Provides read-only view of custom subagent configurations + * used for specialized task delegation. Supports: + * - Programmatic agents (stored in settings JSON) - global and project-level + * - Filesystem agents (AGENT.md files in .claude/agents/) - user and project-level (read-only) + */ + +import { useState, useEffect } 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 = 'programmatic' | 'filesystem'; +export type FilesystemSource = 'user' | 'project'; + +export interface SubagentWithScope { + name: string; + definition: AgentDefinition; + scope: SubagentScope; // For programmatic agents + type: SubagentType; + // For filesystem agents: + source?: FilesystemSource; + filePath?: string; +} + +interface FilesystemAgent { + name: string; + definition: AgentDefinition; + source: FilesystemSource; + filePath: string; +} + +export function useSubagents() { + const { settings, currentProject, projectSettings } = useAppStore(); + const [isLoading, setIsLoading] = useState(false); + const [subagentsWithScope, setSubagentsWithScope] = useState([]); + const [filesystemAgents, setFilesystemAgents] = useState([]); + + // Fetch filesystem agents + const fetchFilesystemAgents = async () => { + try { + const api = getElectronAPI(); + const data = await api.settings.discoverAgents(currentProject?.path, ['user', 'project']); + + if (data.success) { + setFilesystemAgents(data.agents || []); + } + } catch (error) { + console.error('Failed to fetch filesystem agents:', error); + } + }; + + // Fetch filesystem agents on mount and when project changes + useEffect(() => { + fetchFilesystemAgents(); + }, [currentProject?.path]); + + // Merge programmatic and filesystem agents + useEffect(() => { + const globalSubagents = settings?.customSubagents || {}; + const projectSubagents = projectSettings?.customSubagents || {}; + + const merged: SubagentWithScope[] = []; + + // Add programmatic global agents + Object.entries(globalSubagents).forEach(([name, definition]) => { + merged.push({ name, definition, scope: 'global', type: 'programmatic' }); + }); + + // Add programmatic project agents (override globals with same name) + Object.entries(projectSubagents).forEach(([name, definition]) => { + const globalIndex = merged.findIndex((s) => s.name === name && s.scope === 'global'); + if (globalIndex !== -1) { + merged.splice(globalIndex, 1); + } + merged.push({ name, definition, scope: 'project', type: 'programmatic' }); + }); + + // Add filesystem agents + filesystemAgents.forEach(({ name, definition, source, filePath }) => { + // Remove any programmatic agents with the same name (filesystem takes precedence) + const programmaticIndex = merged.findIndex((s) => s.name === name); + if (programmaticIndex !== -1) { + merged.splice(programmaticIndex, 1); + } + + merged.push({ + name, + definition, + scope: source === 'user' ? 'global' : 'project', + type: 'filesystem', + source, + filePath, + }); + }); + + setSubagentsWithScope(merged); + }, [settings?.customSubagents, projectSettings?.customSubagents, filesystemAgents]); + + 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..c6bcd979 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagents-section.tsx @@ -0,0 +1,109 @@ +/** + * Subagents Section - UI for viewing filesystem-based agents + * + * Displays agents discovered from: + * - User-level: ~/.claude/agents/ + * - Project-level: .claude/agents/ + * + * Read-only view - agents are managed by editing .md files directly. + */ + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { Bot, RefreshCw, Loader2, Users, ExternalLink } from 'lucide-react'; +import { useSubagents } from './hooks/use-subagents'; +import { SubagentCard } from './subagent-card'; + +export function SubagentsSection() { + const { subagentsWithScope, isLoading, hasProject, refreshFilesystemAgents } = useSubagents(); + + const handleRefresh = async () => { + await refreshFilesystemAgents(); + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

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

+

+ Specialized agents Claude delegates to automatically +

+
+
+ +
+ + {/* Content */} +
+ {subagentsWithScope.length === 0 ? ( +
+ +

No agents found

+

+ Create .md files in{' '} + ~/.claude/agents/ + {hasProject && ( + <> + {' or '} + .claude/agents/ + + )} +

+ + + View Agents documentation + +
+ ) : ( +
+ {subagentsWithScope.map((agent) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 75c1e6c5..7ce0ab60 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, @@ -1677,6 +1675,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/libs/types/src/index.ts b/libs/types/src/index.ts index 57784b2a..004261a7 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -17,6 +17,7 @@ export type { McpStdioServerConfig, McpSSEServerConfig, McpHttpServerConfig, + AgentDefinition, } from './provider.js'; // Feature types diff --git a/libs/types/src/provider.ts b/libs/types/src/provider.ts index 5b3549a6..71e15975 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -62,6 +62,20 @@ 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 */ @@ -90,6 +104,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; } /** diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 0220da3a..4a8814db 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -486,6 +486,29 @@ 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 + /** + * Custom subagent definitions for specialized task delegation + * Key: agent name (e.g., 'code-reviewer', 'test-runner') + * Value: agent configuration + */ + customSubagents?: Record; } /** @@ -585,6 +608,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; } /** From 33acf502ed1c1d2b6436925e17a5f0c9291c366e Mon Sep 17 00:00:00 2001 From: Shirone Date: Tue, 6 Jan 2026 23:43:31 +0100 Subject: [PATCH 2/6] refactor: enhance skills and subagents settings management - Updated useSkillsSettings and useSubagents hooks to improve state management and error handling. - Added new settings API methods for skills configuration and agent discovery. - Refactored app-store to include enableSkills and skillsSources state management. - Enhanced settings migration to sync skills configuration with the server. These changes streamline the management of skills and subagents, ensuring better integration and user experience. --- .../hooks/use-skills-settings.ts | 16 +++- .../hooks/use-subagents.ts | 88 +++++++------------ apps/ui/src/hooks/use-settings-migration.ts | 2 + apps/ui/src/lib/electron.ts | 77 ++++++++++++++++ apps/ui/src/store/app-store.ts | 6 ++ 5 files changed, 129 insertions(+), 60 deletions(-) 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 index c6ab231a..233e0fdd 100644 --- 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 @@ -11,17 +11,20 @@ import { toast } from 'sonner'; import { getElectronAPI } from '@/lib/electron'; export function useSkillsSettings() { - const { settings } = useAppStore(); + const enabled = useAppStore((state) => state.enableSkills); + const sources = useAppStore((state) => state.skillsSources); const [isLoading, setIsLoading] = useState(false); - const enabled = settings?.enableSkills ?? true; - const sources = settings?.skillsSources ?? ['user', 'project']; - 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'); @@ -35,7 +38,12 @@ export function useSkillsSettings() { 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'); 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 index d3b1ba04..50f82393 100644 --- 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 @@ -3,27 +3,28 @@ * * Provides read-only view of custom subagent configurations * used for specialized task delegation. Supports: - * - Programmatic agents (stored in settings JSON) - global and project-level * - 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 } from 'react'; +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 = 'programmatic' | 'filesystem'; +export type SubagentType = 'filesystem'; export type FilesystemSource = 'user' | 'project'; export interface SubagentWithScope { name: string; definition: AgentDefinition; - scope: SubagentScope; // For programmatic agents + scope: SubagentScope; type: SubagentType; - // For filesystem agents: - source?: FilesystemSource; - filePath?: string; + source: FilesystemSource; + filePath: string; } interface FilesystemAgent { @@ -34,71 +35,46 @@ interface FilesystemAgent { } export function useSubagents() { - const { settings, currentProject, projectSettings } = useAppStore(); + const currentProject = useAppStore((state) => state.currentProject); const [isLoading, setIsLoading] = useState(false); const [subagentsWithScope, setSubagentsWithScope] = useState([]); - const [filesystemAgents, setFilesystemAgents] = useState([]); // Fetch filesystem agents - const fetchFilesystemAgents = async () => { + 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) { - setFilesystemAgents(data.agents || []); + 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(); - }, [currentProject?.path]); - - // Merge programmatic and filesystem agents - useEffect(() => { - const globalSubagents = settings?.customSubagents || {}; - const projectSubagents = projectSettings?.customSubagents || {}; - - const merged: SubagentWithScope[] = []; - - // Add programmatic global agents - Object.entries(globalSubagents).forEach(([name, definition]) => { - merged.push({ name, definition, scope: 'global', type: 'programmatic' }); - }); - - // Add programmatic project agents (override globals with same name) - Object.entries(projectSubagents).forEach(([name, definition]) => { - const globalIndex = merged.findIndex((s) => s.name === name && s.scope === 'global'); - if (globalIndex !== -1) { - merged.splice(globalIndex, 1); - } - merged.push({ name, definition, scope: 'project', type: 'programmatic' }); - }); - - // Add filesystem agents - filesystemAgents.forEach(({ name, definition, source, filePath }) => { - // Remove any programmatic agents with the same name (filesystem takes precedence) - const programmaticIndex = merged.findIndex((s) => s.name === name); - if (programmaticIndex !== -1) { - merged.splice(programmaticIndex, 1); - } - - merged.push({ - name, - definition, - scope: source === 'user' ? 'global' : 'project', - type: 'filesystem', - source, - filePath, - }); - }); - - setSubagentsWithScope(merged); - }, [settings?.customSubagents, projectSettings?.customSubagents, filesystemAgents]); + }, [fetchFilesystemAgents]); return { subagentsWithScope, diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 3674036b..91374e6e 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -234,6 +234,8 @@ export async function syncSettingsToServer(): Promise { keyboardShortcuts: state.keyboardShortcuts, aiProfiles: state.aiProfiles, mcpServers: state.mcpServers, + enableSkills: state.enableSkills, + skillsSources: state.skillsSources, promptCustomization: state.promptCustomization, projects: state.projects, trashedProjects: state.trashedProjects, diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index d81b46b6..6c2bea8b 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -758,6 +758,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/store/app-store.ts b/apps/ui/src/store/app-store.ts index d799b1a7..6429fadc 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -512,6 +512,10 @@ 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 + // Prompt Customization promptCustomization: PromptCustomization; // Custom prompts for Auto Mode, Agent, Backlog Plan, Enhancement @@ -1022,6 +1026,8 @@ const initialState: AppState = { enableSandboxMode: false, // Default to disabled (can be enabled for additional security) 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 promptCustomization: {}, // Empty by default - all prompts use built-in defaults aiProfiles: DEFAULT_AI_PROFILES, projectAnalysis: null, From fe13d47b246f1987d1efcd3849327d02305508d1 Mon Sep 17 00:00:00 2001 From: Shirone Date: Wed, 7 Jan 2026 00:05:33 +0100 Subject: [PATCH 3/6] refactor: improve agent file model validation and settings source deduplication - Enhanced model parsing in agent discovery to validate against allowed values and log warnings for invalid models. - Refactored settingSources construction in AgentService to utilize Set for automatic deduplication, simplifying the merging of user and project settings with skills sources. - Updated tests to reflect changes in allowedTools for improved functionality. These changes enhance the robustness of agent configuration and streamline settings management. --- apps/server/src/lib/agent-discovery.ts | 17 ++++++++++--- apps/server/src/services/agent-service.ts | 25 +++++-------------- .../unit/providers/claude-provider.test.ts | 12 ++++++++- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/apps/server/src/lib/agent-discovery.ts b/apps/server/src/lib/agent-discovery.ts index 955cbb1d..d94c87d3 100644 --- a/apps/server/src/lib/agent-discovery.ts +++ b/apps/server/src/lib/agent-discovery.ts @@ -68,11 +68,20 @@ async function parseAgentFile( .filter((t) => t && t !== '') : undefined; - // Parse model (optional) + // Parse model (optional) - validate against allowed values const modelMatch = frontmatter.match(/model:\s*(\w+)/); - const model = modelMatch - ? (modelMatch[1].trim() as 'sonnet' | 'opus' | 'haiku' | 'inherit') - : undefined; + 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, diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index e8678d63..1d543efc 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -289,25 +289,12 @@ export class AgentService { const maxTurns = sdkOptions.maxTurns; let allowedTools = sdkOptions.allowedTools as string[] | undefined; - // Build merged settingSources array (filter to only 'user' and 'project') - const settingSources: Array<'user' | 'project'> = []; - if (sdkOptions.settingSources) { - sdkOptions.settingSources.forEach((source) => { - if (source === 'user' || source === 'project') { - if (!settingSources.includes(source)) { - settingSources.push(source); - } - } - }); - } - // Merge skills sources (avoid duplicates) - if (skillsConfig.enabled && skillsConfig.sources.length > 0) { - skillsConfig.sources.forEach((source) => { - if (!settingSources.includes(source)) { - settingSources.push(source); - } - }); - } + // 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 if (allowedTools) { diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index 38e1bf4c..3a91652a 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -96,7 +96,17 @@ describe('claude-provider.ts', () => { expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test', options: expect.objectContaining({ - allowedTools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'], + allowedTools: [ + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'Bash', + 'WebSearch', + 'WebFetch', + 'Skill', + ], }), }); }); From 5c601ff2007ab62d8025229fcacc784506f162df Mon Sep 17 00:00:00 2001 From: Shirone Date: Wed, 7 Jan 2026 10:32:42 +0100 Subject: [PATCH 4/6] feat: implement subagents configuration management - Added a new function to retrieve subagents configuration from settings, allowing users to enable/disable subagents and select sources for loading them. - Updated the AgentService to incorporate subagents configuration, dynamically adding tools based on the settings. - Enhanced the UI components to manage subagents, including a settings section for enabling/disabling and selecting sources. - Introduced a new hook for managing subagents settings state and interactions. These changes improve the flexibility and usability of subagents within the application, enhancing user experience and configuration options. --- apps/server/src/lib/settings-helpers.ts | 23 ++ apps/server/src/providers/claude-provider.ts | 15 +- apps/server/src/services/agent-service.ts | 43 +++- .../unit/providers/claude-provider.test.ts | 14 +- .../hooks/use-subagents-settings.ts | 63 +++++ .../claude-settings-tab/subagents-section.tsx | 236 ++++++++++++++---- apps/ui/src/hooks/use-settings-migration.ts | 2 + apps/ui/src/store/app-store.ts | 6 + libs/types/src/settings.ts | 20 +- 9 files changed, 345 insertions(+), 77 deletions(-) create mode 100644 apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index 07564085..0cab0121 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -293,6 +293,29 @@ export async function getSkillsConfiguration(settingsService: SettingsService): }; } +/** + * 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. diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 5d033a3b..90defa72 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -72,17 +72,10 @@ export class ClaudeProvider extends BaseProvider { // Build Claude SDK options // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0; - const defaultTools = [ - 'Read', - 'Write', - 'Edit', - 'Glob', - 'Grep', - 'Bash', - 'WebSearch', - 'WebFetch', - 'Skill', - ]; + // Base tools available to all agents + // Note: 'Skill' and 'Task' tools are added dynamically by agent-service.ts + // based on whether skills/subagents are enabled in settings + const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; // AUTONOMOUS MODE: Always bypass permissions and allow unrestricted tools // Only restrict tools when no MCP servers are configured diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 1d543efc..bbc060d1 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -25,6 +25,7 @@ import { getMCPServersFromSettings, getPromptCustomization, getSkillsConfiguration, + getSubagentsConfiguration, getCustomSubagents, } from '../lib/settings-helpers.js'; @@ -248,10 +249,16 @@ export class AgentService { ? await getSkillsConfiguration(this.settingsService) : { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false }; - // Get custom subagents from settings (merge global + project-level) - const customSubagents = this.settingsService - ? await getCustomSubagents(this.settingsService, effectiveWorkDir) - : undefined; + // 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({ @@ -297,18 +304,34 @@ export class AgentService { 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 (skillsConfig.shouldIncludeInTools && !allowedTools.includes('Skill')) { + if (needsSkillTool && !allowedTools.includes('Skill')) { allowedTools.push('Skill'); } // Add Task tool if custom subagents are configured - if ( - customSubagents && - Object.keys(customSubagents).length > 0 && - !allowedTools.includes('Task') - ) { + 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'); } } diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index 3a91652a..40d3b5b7 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -96,17 +96,9 @@ describe('claude-provider.ts', () => { expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test', options: expect.objectContaining({ - allowedTools: [ - 'Read', - 'Write', - 'Edit', - 'Glob', - 'Grep', - 'Bash', - 'WebSearch', - 'WebFetch', - 'Skill', - ], + // Note: 'Skill' and 'Task' tools are added dynamically by agent-service.ts + // based on settings, not included in base default tools + allowedTools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'], }), }); }); 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/subagents-section.tsx b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagents-section.tsx index c6bcd979..08800331 100644 --- 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 @@ -1,26 +1,62 @@ /** - * Subagents Section - UI for viewing filesystem-based agents + * 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/ - * - * Read-only view - agents are managed by editing .md files directly. */ 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 } from 'lucide-react'; +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, hasProject, refreshFilesystemAgents } = useSubagents(); + 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 (

Custom Subagents - {subagentsWithScope.length > 0 && ( + {enabled && subagentsWithScope.length > 0 && ( {subagentsWithScope.length} agent{subagentsWithScope.length !== 1 ? 's' : ''} @@ -50,57 +86,169 @@ export function SubagentsSection() {

- + /> {/* Content */} -
- {subagentsWithScope.length === 0 ? ( -
- -

No agents found

-

- Create .md files in{' '} - ~/.claude/agents/ - {hasProject && ( - <> - {' or '} - .claude/agents/ - - )} -

+
+ {/* 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
- ) : ( -
- {subagentsWithScope.map((agent) => ( - - ))} + )} + + {/* Disabled State Empty Message */} + {!enabled && ( +
+ +

Subagents are disabled

+

Enable to load custom agent definitions

)}
diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 91374e6e..52ff0b61 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -236,6 +236,8 @@ export async function syncSettingsToServer(): Promise { mcpServers: state.mcpServers, enableSkills: state.enableSkills, skillsSources: state.skillsSources, + enableSubagents: state.enableSubagents, + subagentsSources: state.subagentsSources, promptCustomization: state.promptCustomization, projects: state.projects, trashedProjects: state.trashedProjects, diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 6429fadc..6e8853a7 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -516,6 +516,10 @@ export interface AppState { 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 @@ -1028,6 +1032,8 @@ const initialState: AppState = { 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/settings.ts b/libs/types/src/settings.ts index 4a8814db..c3431f52 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -504,7 +504,21 @@ export interface GlobalSettings { // Subagents Configuration /** - * Custom subagent definitions for specialized task delegation + * 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 */ @@ -707,6 +721,10 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { enableSandboxMode: false, skipSandboxWarning: false, mcpServers: [], + enableSkills: true, + skillsSources: ['user', 'project'], + enableSubagents: true, + subagentsSources: ['user', 'project'], }; /** Default credentials (empty strings - user must provide API keys) */ From e649c4ced56dd3e5a60b073baed40b2372e849e5 Mon Sep 17 00:00:00 2001 From: Kacper Date: Thu, 8 Jan 2026 23:02:41 +0100 Subject: [PATCH 5/6] refactor: reduce code duplication in agent-discovery.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR feedback to reduce duplicated code in scanAgentsDirectory by introducing an FsAdapter interface that abstracts the differences between systemPaths (user directory) and secureFs (project directory). Changes: - Extract parseAgentContent helper for parsing agent file content - Add FsAdapter interface with exists, readdir, and readFile methods - Create createSystemPathAdapter for user-level paths - Create createSecureFsAdapter for project-level paths - Refactor scanAgentsDirectory to use a single loop with the adapter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/server/src/lib/agent-discovery.ts | 268 +++++++++++++------------ 1 file changed, 141 insertions(+), 127 deletions(-) diff --git a/apps/server/src/lib/agent-discovery.ts b/apps/server/src/lib/agent-discovery.ts index d94c87d3..b831bdec 100644 --- a/apps/server/src/lib/agent-discovery.ts +++ b/apps/server/src/lib/agent-discovery.ts @@ -24,7 +24,7 @@ export interface FilesystemAgent { } /** - * Parse agent .md file frontmatter and content + * Parse agent content string into AgentDefinition * Format: * --- * name: agent-name # Optional @@ -34,61 +34,128 @@ export interface FilesystemAgent { * --- * System prompt content here... */ -async function parseAgentFile( - filePath: string, - isSystemPath: boolean -): Promise { - try { - const content = isSystemPath - ? ((await systemPaths.systemPathReadFile(filePath, 'utf-8')) as string) - : ((await secureFs.readFile(filePath, 'utf-8')) as string); +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; + } - // 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; - 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 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 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 !== '') + // 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; - // 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(', ')}` + ); + } - if (modelValue && !model) { - logger.warn( - `Invalid model "${modelValue}" in agent file: ${filePath}. Expected one of: ${validModels.join(', ')}` - ); - } + return { + description, + prompt: prompt.trim(), + tools, + model, + }; +} - 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; @@ -106,103 +173,50 @@ async function scanAgentsDirectory( source: 'user' | 'project' ): Promise { const agents: FilesystemAgent[] = []; - const isSystemPath = source === 'user'; // User directories use systemPaths + const fsAdapter = source === 'user' ? createSystemPathAdapter() : createSecureFsAdapter(); try { // Check if directory exists - const exists = isSystemPath - ? await systemPaths.systemPathExists(baseDir) - : await secureFs - .access(baseDir) - .then(() => true) - .catch(() => false); - + const exists = await fsAdapter.exists(baseDir); if (!exists) { logger.debug(`Directory does not exist: ${baseDir}`); return agents; } // Read all entries in the directory - if (isSystemPath) { - // For system paths (user directory) - const entryNames = await systemPaths.systemPathReaddir(baseDir); - for (const entryName of entryNames) { - const entryPath = path.join(baseDir, entryName); - const stat = await systemPaths.systemPathStat(entryPath); + const entries = await fsAdapter.readdir(baseDir); - // Check for flat .md file format (agent-name.md) - if (stat.isFile() && entryName.endsWith('.md')) { - const agentName = entryName.slice(0, -3); // Remove .md extension - const definition = await parseAgentFile(entryPath, true); - if (definition) { - agents.push({ - name: agentName, - definition, - source, - filePath: entryPath, - }); - logger.debug(`Discovered ${source} agent (flat): ${agentName}`); - } - } - // Check for subdirectory format (agent-name/AGENT.md) - else if (stat.isDirectory()) { - const agentFilePath = path.join(entryPath, 'AGENT.md'); - const agentFileExists = await systemPaths.systemPathExists(agentFilePath); - - if (agentFileExists) { - const definition = await parseAgentFile(agentFilePath, true); - if (definition) { - agents.push({ - name: entryName, - definition, - source, - filePath: agentFilePath, - }); - logger.debug(`Discovered ${source} agent (subdirectory): ${entryName}`); - } - } + 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}`); } } - } else { - // For project paths (use secureFs) - const entries = await secureFs.readdir(baseDir, { withFileTypes: true }); - 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 parseAgentFile(agentFilePath, false); + // 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: agentName, + name: entry.name, definition, source, filePath: agentFilePath, }); - logger.debug(`Discovered ${source} agent (flat): ${agentName}`); - } - } - // Check for subdirectory format (agent-name/AGENT.md) - else if (entry.isDirectory()) { - const agentDir = path.join(baseDir, entry.name); - const agentFilePath = path.join(agentDir, 'AGENT.md'); - - const agentFileExists = await secureFs - .access(agentFilePath) - .then(() => true) - .catch(() => false); - - if (agentFileExists) { - const definition = await parseAgentFile(agentFilePath, false); - if (definition) { - agents.push({ - name: entry.name, - definition, - source, - filePath: agentFilePath, - }); - logger.debug(`Discovered ${source} agent (subdirectory): ${entry.name}`); - } + logger.debug(`Discovered ${source} agent (subdirectory): ${entry.name}`); } } } From f6738ff26cc9aa2b360b4562e3c39bba5476de4b Mon Sep 17 00:00:00 2001 From: Kacper Date: Fri, 9 Jan 2026 00:04:28 +0100 Subject: [PATCH 6/6] fix: update getDefaultDocumentsPath to use window.electronAPI for Electron mode - Removed dependency on getElectronAPI and directly accessed window.electronAPI for retrieving the documents path in Electron mode. - Added handling for web mode to return null when the documents path cannot be accessed. - Included logging for the resolved documents path to aid in debugging. --- apps/ui/src/lib/workspace-config.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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; }