From 236989bf6eef3a7b9b41ab6157af327f1e091754 Mon Sep 17 00:00:00 2001 From: Shirone Date: Tue, 6 Jan 2026 04:31:57 +0100 Subject: [PATCH] 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; } /**