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

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),
};
}
/**
* 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
// 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

View File

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

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,
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