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.
This commit is contained in:
Shirone
2026-01-06 04:31:57 +01:00
parent 5d675561ba
commit 236989bf6e
19 changed files with 1098 additions and 6 deletions

3
.claude/.gitignore vendored
View File

@@ -1 +1,2 @@
hans/ hans/
skills/

View File

@@ -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<AgentDefinition | null> {
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<FilesystemAgent[]> {
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<FilesystemAgent[]> {
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;
}

View File

@@ -269,3 +269,60 @@ export async function getPromptCustomization(
enhancement: mergeEnhancementPrompts(customization.enhancement), 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<Record<string, import('@automaker/types').AgentDefinition> | 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;
}

View File

@@ -72,7 +72,17 @@ export class ClaudeProvider extends BaseProvider {
// Build Claude SDK options // Build Claude SDK options
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0; 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 // AUTONOMOUS MODE: Always bypass permissions and allow unrestricted tools
// Only restrict tools when no MCP servers are configured // Only restrict tools when no MCP servers are configured
@@ -104,6 +114,8 @@ export class ClaudeProvider extends BaseProvider {
...(options.mcpServers && { mcpServers: options.mcpServers }), ...(options.mcpServers && { mcpServers: options.mcpServers }),
// Extended thinking configuration // Extended thinking configuration
...(maxThinkingTokens && { maxThinkingTokens }), ...(maxThinkingTokens && { maxThinkingTokens }),
// Subagents configuration for specialized task delegation
...(options.agents && { agents: options.agents }),
}; };
// Build prompt payload // Build prompt payload

View File

@@ -23,6 +23,7 @@ import { createGetProjectHandler } from './routes/get-project.js';
import { createUpdateProjectHandler } from './routes/update-project.js'; import { createUpdateProjectHandler } from './routes/update-project.js';
import { createMigrateHandler } from './routes/migrate.js'; import { createMigrateHandler } from './routes/migrate.js';
import { createStatusHandler } from './routes/status.js'; import { createStatusHandler } from './routes/status.js';
import { createDiscoverAgentsHandler } from './routes/discover-agents.js';
/** /**
* Create settings router with all endpoints * 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) * - POST /project - Get project settings (requires projectPath in body)
* - PUT /project - Update project settings * - PUT /project - Update project settings
* - POST /migrate - Migrate settings from localStorage * - 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 * @param settingsService - Instance of SettingsService for file I/O
* @returns Express Router configured with all settings endpoints * @returns Express Router configured with all settings endpoints
@@ -72,5 +74,8 @@ export function createSettingsRoutes(settingsService: SettingsService): Router {
// Migration from localStorage // Migration from localStorage
router.post('/migrate', createMigrateHandler(settingsService)); router.post('/migrate', createMigrateHandler(settingsService));
// Filesystem agents discovery (read-only)
router.post('/agents/discover', createDiscoverAgentsHandler());
return router; return router;
} }

View File

@@ -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',
});
}
};
}

View File

@@ -24,6 +24,8 @@ import {
filterClaudeMdFromContext, filterClaudeMdFromContext,
getMCPServersFromSettings, getMCPServersFromSettings,
getPromptCustomization, getPromptCustomization,
getSkillsConfiguration,
getCustomSubagents,
} from '../lib/settings-helpers.js'; } from '../lib/settings-helpers.js';
interface Message { interface Message {
@@ -241,6 +243,16 @@ export class AgentService {
// Load MCP servers from settings (global setting only) // Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]'); 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.) // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
const contextResult = await loadContextFiles({ const contextResult = await loadContextFiles({
projectPath: effectiveWorkDir, projectPath: effectiveWorkDir,
@@ -275,7 +287,44 @@ export class AgentService {
// Extract model, maxTurns, and allowedTools from SDK options // Extract model, maxTurns, and allowedTools from SDK options
const effectiveModel = sdkOptions.model!; const effectiveModel = sdkOptions.model!;
const maxTurns = sdkOptions.maxTurns; 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 // Get provider for this model
const provider = ProviderFactory.getProviderForModel(effectiveModel); const provider = ProviderFactory.getProviderForModel(effectiveModel);
@@ -290,10 +339,11 @@ export class AgentService {
allowedTools: allowedTools, allowedTools: allowedTools,
abortController: session.abortController!, abortController: session.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
settingSources: sdkOptions.settingSources, settingSources: settingSources.length > 0 ? settingSources : undefined,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration sandbox: sdkOptions.sandbox, // Pass sandbox configuration
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration 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 // Build prompt content with images

View File

@@ -4,6 +4,8 @@ import { useCliStatus } from '../hooks/use-cli-status';
import { ClaudeCliStatus } from '../cli-status/claude-cli-status'; import { ClaudeCliStatus } from '../cli-status/claude-cli-status';
import { ClaudeMdSettings } from '../claude/claude-md-settings'; import { ClaudeMdSettings } from '../claude/claude-md-settings';
import { ClaudeUsageSection } from '../api-keys/claude-usage-section'; 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'; import { Info } from 'lucide-react';
export function ClaudeSettingsTab() { export function ClaudeSettingsTab() {
@@ -42,6 +44,13 @@ export function ClaudeSettingsTab() {
autoLoadClaudeMd={autoLoadClaudeMd} autoLoadClaudeMd={autoLoadClaudeMd}
onAutoLoadClaudeMdChange={setAutoLoadClaudeMd} onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}
/> />
{/* Skills Configuration */}
<SkillsSection />
{/* Custom Subagents */}
<SubagentsSection />
{showUsageTracking && <ClaudeUsageSection />} {showUsageTracking && <ClaudeUsageSection />}
</div> </div>
); );

View File

@@ -0,0 +1,6 @@
/**
* Hooks barrel export for Claude Settings Tab
*/
export { useSkillsSettings } from './use-skills-settings';
export { useSubagents } from './use-subagents';

View File

@@ -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,
};
}

View File

@@ -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<SubagentWithScope[]>([]);
const [filesystemAgents, setFilesystemAgents] = useState<FilesystemAgent[]>([]);
// 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,
};
}

View File

@@ -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';

View File

@@ -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 (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-linear-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border/30">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-amber-500/20 flex items-center justify-center">
<Zap className="w-5 h-5 text-amber-500" />
</div>
<div>
<h3 className="font-semibold text-base flex items-center gap-2">
Skills
{enabled && (
<span className="text-xs font-normal px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-500">
{sources.length} source{sources.length !== 1 ? 's' : ''} active
</span>
)}
</h3>
<p className="text-sm text-muted-foreground mt-0.5">
Filesystem-based capabilities Claude invokes autonomously
</p>
</div>
</div>
<Switch
id="enable-skills"
checked={enabled}
onCheckedChange={updateEnabled}
disabled={isLoading}
/>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* Sources Selection */}
{enabled && (
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
Load Skills from
</Label>
<div className="grid gap-2">
{/* User Skills Option */}
<label
htmlFor="source-user"
className={cn(
'flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all duration-200',
sources.includes('user')
? 'border-amber-500/50 bg-amber-500/10'
: 'border-border/50 bg-accent/20 hover:bg-accent/30'
)}
>
<Checkbox
id="source-user"
checked={sources.includes('user')}
onCheckedChange={() => toggleSource('user')}
disabled={isLoading}
className="data-[state=checked]:bg-amber-500 data-[state=checked]:border-amber-500"
/>
<div className="w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center shrink-0">
<Globe className="w-4 h-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">User Skills</span>
<span className="block text-xs text-muted-foreground mt-0.5 truncate">
~/.claude/skills/ Available across all projects
</span>
</div>
</label>
{/* Project Skills Option */}
<label
htmlFor="source-project"
className={cn(
'flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all duration-200',
sources.includes('project')
? 'border-amber-500/50 bg-amber-500/10'
: 'border-border/50 bg-accent/20 hover:bg-accent/30'
)}
>
<Checkbox
id="source-project"
checked={sources.includes('project')}
onCheckedChange={() => toggleSource('project')}
disabled={isLoading}
className="data-[state=checked]:bg-amber-500 data-[state=checked]:border-amber-500"
/>
<div className="w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center shrink-0">
<FolderOpen className="w-4 h-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">Project Skills</span>
<span className="block text-xs text-muted-foreground mt-0.5 truncate">
.claude/skills/ Version-controlled with project
</span>
</div>
</label>
</div>
</div>
)}
{/* Help Text */}
{enabled && (
<div className="rounded-xl border border-border/30 bg-muted/30 p-4 space-y-3">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-md bg-brand-500/20 flex items-center justify-center shrink-0 mt-0.5">
<Sparkles className="w-3.5 h-3.5 text-brand-500" />
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium text-foreground/80">Auto-Discovery</p>
<p>
Skills are automatically discovered when agents start. Define skills as{' '}
<code className="text-xs bg-muted px-1 rounded">SKILL.md</code> files.
</p>
</div>
</div>
<a
href="https://code.claude.com/docs/en/skills"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs text-brand-500 hover:text-brand-400 transition-colors"
>
<ExternalLink className="w-3.5 h-3.5" />
View Skills documentation
</a>
</div>
)}
{/* Disabled State Empty Message */}
{!enabled && (
<div className="text-center py-6 text-muted-foreground">
<Zap className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">Skills are disabled</p>
<p className="text-xs mt-1">Enable to load filesystem-based capabilities</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -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 (
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<div
className={cn(
'rounded-xl border transition-all duration-200',
'border-border/50 bg-accent/20',
'hover:bg-accent/30 hover:border-border/70'
)}
>
{/* Main Card Content */}
<div className="flex items-start gap-3 p-4">
{/* Agent Icon */}
<div className="w-9 h-9 rounded-lg bg-violet-500/15 flex items-center justify-center shrink-0 mt-0.5">
<Bot className="w-4 h-4 text-violet-500" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header Row */}
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-medium text-sm">{name}</h4>
<Badge
variant="outline"
size="sm"
className={cn('flex items-center gap-1', getModelColor())}
>
<Cpu className="h-3 w-3" />
{modelDisplay}
</Badge>
<Badge variant="muted" size="sm" className="flex items-center gap-1">
<Wrench className="h-3 w-3" />
{toolCount === 'all' ? 'All' : toolCount} tools
</Badge>
<Badge variant="muted" size="sm" className="flex items-center gap-1">
<ScopeIcon className="h-3 w-3" />
{scopeLabel}
</Badge>
</div>
{/* Description */}
<p className="text-sm text-muted-foreground mt-1.5 line-clamp-2">
{definition.description}
</p>
{/* File Path */}
{filePath && (
<div className="flex items-center gap-1.5 mt-2 text-xs text-muted-foreground/60">
<FileCode className="h-3 w-3" />
<span className="font-mono truncate">{filePath}</span>
</div>
)}
</div>
{/* Expand Button */}
<CollapsibleTrigger asChild>
<button
className={cn(
'p-1.5 rounded-md transition-colors shrink-0',
'hover:bg-muted/50 text-muted-foreground hover:text-foreground',
'cursor-pointer'
)}
title={isExpanded ? 'Hide prompt' : 'View prompt'}
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
</CollapsibleTrigger>
</div>
{/* Expandable Prompt Section */}
<CollapsibleContent>
<div className="px-4 pb-4 pt-0">
<div className="ml-12 rounded-lg border border-border/30 bg-muted/30 p-4 overflow-auto max-h-64">
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
System Prompt
</div>
<Markdown className="text-xs prose-sm">{definition.prompt}</Markdown>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}

View File

@@ -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 (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-linear-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border/30">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-violet-500/20 flex items-center justify-center">
<Bot className="w-5 h-5 text-violet-500" />
</div>
<div>
<h3 className="font-semibold text-base flex items-center gap-2">
Custom Subagents
{subagentsWithScope.length > 0 && (
<span className="text-xs font-normal px-2 py-0.5 rounded-full bg-violet-500/20 text-violet-500">
{subagentsWithScope.length} agent{subagentsWithScope.length !== 1 ? 's' : ''}
</span>
)}
</h3>
<p className="text-sm text-muted-foreground mt-0.5">
Specialized agents Claude delegates to automatically
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isLoading}
title="Refresh agents from disk"
className="gap-2"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span className="text-xs">Refresh</span>
</Button>
</div>
{/* Content */}
<div className="p-6">
{subagentsWithScope.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Users className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm font-medium">No agents found</p>
<p className="text-xs mt-2 max-w-sm mx-auto">
Create <code className="text-xs bg-muted px-1 rounded">.md</code> files in{' '}
<code className="text-xs bg-muted px-1 rounded">~/.claude/agents/</code>
{hasProject && (
<>
{' or '}
<code className="text-xs bg-muted px-1 rounded">.claude/agents/</code>
</>
)}
</p>
<a
href="https://code.claude.com/docs/en/agents"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 mt-4 text-xs text-brand-500 hover:text-brand-400 transition-colors"
>
<ExternalLink className="w-3.5 h-3.5" />
View Agents documentation
</a>
</div>
) : (
<div className="space-y-3">
{subagentsWithScope.map((agent) => (
<SubagentCard
key={`${agent.type}-${agent.source || agent.scope}-${agent.name}`}
agent={agent}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -23,8 +23,6 @@ import type {
SpecRegenerationEvent, SpecRegenerationEvent,
SuggestionType, SuggestionType,
GitHubAPI, GitHubAPI,
GitHubIssue,
GitHubPR,
IssueValidationInput, IssueValidationInput,
IssueValidationEvent, IssueValidationEvent,
IdeationAPI, IdeationAPI,
@@ -1677,6 +1675,26 @@ export class HttpApiClient implements ElectronAPI {
migratedProjectCount: number; migratedProjectCount: number;
errors: string[]; errors: string[];
}> => this.post('/api/settings/migrate', { data }), }> => 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 // Sessions API

View File

@@ -17,6 +17,7 @@ export type {
McpStdioServerConfig, McpStdioServerConfig,
McpSSEServerConfig, McpSSEServerConfig,
McpHttpServerConfig, McpHttpServerConfig,
AgentDefinition,
} from './provider.js'; } from './provider.js';
// Feature types // Feature types

View File

@@ -62,6 +62,20 @@ export interface McpHttpServerConfig {
headers?: Record<string, string>; headers?: Record<string, string>;
} }
/**
* 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 * 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. * Only applies to Claude models; Cursor models handle thinking internally.
*/ */
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
/**
* Custom subagents for specialized task delegation
* Key: agent name, Value: agent definition
*/
agents?: Record<string, AgentDefinition>;
} }
/** /**

View File

@@ -486,6 +486,29 @@ export interface GlobalSettings {
// Prompt Customization // Prompt Customization
/** Custom prompts for Auto Mode, Agent Runner, Backlog Planning, and Enhancements */ /** Custom prompts for Auto Mode, Agent Runner, Backlog Planning, and Enhancements */
promptCustomization?: PromptCustomization; 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<string, import('./provider.js').AgentDefinition>;
} }
/** /**
@@ -585,6 +608,15 @@ export interface ProjectSettings {
// Claude Agent SDK Settings // Claude Agent SDK Settings
/** Auto-load CLAUDE.md files using SDK's settingSources option (project override) */ /** Auto-load CLAUDE.md files using SDK's settingSources option (project override) */
autoLoadClaudeMd?: boolean; 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<string, import('./provider.js').AgentDefinition>;
} }
/** /**