mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
merge: sync with upstream v0.9.0rc branch
This commit is contained in:
3
.claude/.gitignore
vendored
3
.claude/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
hans/
|
||||
hans/
|
||||
skills/
|
||||
257
apps/server/src/lib/agent-discovery.ts
Normal file
257
apps/server/src/lib/agent-discovery.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Agent Discovery - Scans filesystem for AGENT.md files
|
||||
*
|
||||
* Discovers agents from:
|
||||
* - ~/.claude/agents/ (user-level, global)
|
||||
* - .claude/agents/ (project-level)
|
||||
*
|
||||
* Similar to Skills, but for custom subagents defined in AGENT.md files.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { secureFs, systemPaths } from '@automaker/platform';
|
||||
import type { AgentDefinition } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('AgentDiscovery');
|
||||
|
||||
export interface FilesystemAgent {
|
||||
name: string; // Directory name (e.g., 'code-reviewer')
|
||||
definition: AgentDefinition;
|
||||
source: 'user' | 'project';
|
||||
filePath: string; // Full path to AGENT.md
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse agent content string into AgentDefinition
|
||||
* Format:
|
||||
* ---
|
||||
* name: agent-name # Optional
|
||||
* description: When to use this agent
|
||||
* tools: tool1, tool2, tool3 # Optional (comma or space separated list)
|
||||
* model: sonnet # Optional: sonnet, opus, haiku
|
||||
* ---
|
||||
* System prompt content here...
|
||||
*/
|
||||
function parseAgentContent(content: string, filePath: string): AgentDefinition | null {
|
||||
// Extract frontmatter
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!frontmatterMatch) {
|
||||
logger.warn(`Invalid agent file format (missing frontmatter): ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, frontmatter, prompt] = frontmatterMatch;
|
||||
|
||||
// Parse description (required)
|
||||
const description = frontmatter.match(/description:\s*(.+)/)?.[1]?.trim();
|
||||
if (!description) {
|
||||
logger.warn(`Missing description in agent file: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse tools (optional) - supports both comma-separated and space-separated
|
||||
const toolsMatch = frontmatter.match(/tools:\s*(.+)/);
|
||||
const tools = toolsMatch
|
||||
? toolsMatch[1]
|
||||
.split(/[,\s]+/) // Split by comma or whitespace
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t && t !== '')
|
||||
: undefined;
|
||||
|
||||
// Parse model (optional) - validate against allowed values
|
||||
const modelMatch = frontmatter.match(/model:\s*(\w+)/);
|
||||
const modelValue = modelMatch?.[1]?.trim();
|
||||
const validModels = ['sonnet', 'opus', 'haiku', 'inherit'] as const;
|
||||
const model =
|
||||
modelValue && validModels.includes(modelValue as (typeof validModels)[number])
|
||||
? (modelValue as 'sonnet' | 'opus' | 'haiku' | 'inherit')
|
||||
: undefined;
|
||||
|
||||
if (modelValue && !model) {
|
||||
logger.warn(
|
||||
`Invalid model "${modelValue}" in agent file: ${filePath}. Expected one of: ${validModels.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
description,
|
||||
prompt: prompt.trim(),
|
||||
tools,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory entry with type information
|
||||
*/
|
||||
interface DirEntry {
|
||||
name: string;
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filesystem adapter interface for abstracting systemPaths vs secureFs
|
||||
*/
|
||||
interface FsAdapter {
|
||||
exists: (filePath: string) => Promise<boolean>;
|
||||
readdir: (dirPath: string) => Promise<DirEntry[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filesystem adapter for system paths (user directory)
|
||||
*/
|
||||
function createSystemPathAdapter(): FsAdapter {
|
||||
return {
|
||||
exists: (filePath) => Promise.resolve(systemPaths.systemPathExists(filePath)),
|
||||
readdir: async (dirPath) => {
|
||||
const entryNames = await systemPaths.systemPathReaddir(dirPath);
|
||||
const entries: DirEntry[] = [];
|
||||
for (const name of entryNames) {
|
||||
const stat = await systemPaths.systemPathStat(path.join(dirPath, name));
|
||||
entries.push({
|
||||
name,
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
},
|
||||
readFile: (filePath) => systemPaths.systemPathReadFile(filePath, 'utf-8') as Promise<string>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filesystem adapter for project paths (secureFs)
|
||||
*/
|
||||
function createSecureFsAdapter(): FsAdapter {
|
||||
return {
|
||||
exists: (filePath) =>
|
||||
secureFs
|
||||
.access(filePath)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
readdir: async (dirPath) => {
|
||||
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isFile: entry.isFile(),
|
||||
isDirectory: entry.isDirectory(),
|
||||
}));
|
||||
},
|
||||
readFile: (filePath) => secureFs.readFile(filePath, 'utf-8') as Promise<string>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse agent file using the provided filesystem adapter
|
||||
*/
|
||||
async function parseAgentFileWithAdapter(
|
||||
filePath: string,
|
||||
fsAdapter: FsAdapter
|
||||
): Promise<AgentDefinition | null> {
|
||||
try {
|
||||
const content = await fsAdapter.readFile(filePath);
|
||||
return parseAgentContent(content, filePath);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to parse agent file: ${filePath}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a directory for agent .md files
|
||||
* Agents can be in two formats:
|
||||
* 1. Flat: agent-name.md (file directly in agents/)
|
||||
* 2. Subdirectory: agent-name/AGENT.md (folder + file, similar to Skills)
|
||||
*/
|
||||
async function scanAgentsDirectory(
|
||||
baseDir: string,
|
||||
source: 'user' | 'project'
|
||||
): Promise<FilesystemAgent[]> {
|
||||
const agents: FilesystemAgent[] = [];
|
||||
const fsAdapter = source === 'user' ? createSystemPathAdapter() : createSecureFsAdapter();
|
||||
|
||||
try {
|
||||
// Check if directory exists
|
||||
const exists = await fsAdapter.exists(baseDir);
|
||||
if (!exists) {
|
||||
logger.debug(`Directory does not exist: ${baseDir}`);
|
||||
return agents;
|
||||
}
|
||||
|
||||
// Read all entries in the directory
|
||||
const entries = await fsAdapter.readdir(baseDir);
|
||||
|
||||
for (const entry of entries) {
|
||||
// Check for flat .md file format (agent-name.md)
|
||||
if (entry.isFile && entry.name.endsWith('.md')) {
|
||||
const agentName = entry.name.slice(0, -3); // Remove .md extension
|
||||
const agentFilePath = path.join(baseDir, entry.name);
|
||||
const definition = await parseAgentFileWithAdapter(agentFilePath, fsAdapter);
|
||||
if (definition) {
|
||||
agents.push({
|
||||
name: agentName,
|
||||
definition,
|
||||
source,
|
||||
filePath: agentFilePath,
|
||||
});
|
||||
logger.debug(`Discovered ${source} agent (flat): ${agentName}`);
|
||||
}
|
||||
}
|
||||
// Check for subdirectory format (agent-name/AGENT.md)
|
||||
else if (entry.isDirectory) {
|
||||
const agentFilePath = path.join(baseDir, entry.name, 'AGENT.md');
|
||||
const agentFileExists = await fsAdapter.exists(agentFilePath);
|
||||
|
||||
if (agentFileExists) {
|
||||
const definition = await parseAgentFileWithAdapter(agentFilePath, fsAdapter);
|
||||
if (definition) {
|
||||
agents.push({
|
||||
name: entry.name,
|
||||
definition,
|
||||
source,
|
||||
filePath: agentFilePath,
|
||||
});
|
||||
logger.debug(`Discovered ${source} agent (subdirectory): ${entry.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to scan agents directory: ${baseDir}`, error);
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all filesystem-based agents from user and project sources
|
||||
*/
|
||||
export async function discoverFilesystemAgents(
|
||||
projectPath?: string,
|
||||
sources: Array<'user' | 'project'> = ['user', 'project']
|
||||
): Promise<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;
|
||||
}
|
||||
@@ -241,3 +241,83 @@ export async function getPromptCustomization(
|
||||
enhancement: mergeEnhancementPrompts(customization.enhancement),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Skills configuration from settings.
|
||||
* Returns configuration for enabling skills and which sources to load from.
|
||||
*
|
||||
* @param settingsService - Settings service instance
|
||||
* @returns Skills configuration with enabled state, sources, and tool inclusion flag
|
||||
*/
|
||||
export async function getSkillsConfiguration(settingsService: SettingsService): Promise<{
|
||||
enabled: boolean;
|
||||
sources: Array<'user' | 'project'>;
|
||||
shouldIncludeInTools: boolean;
|
||||
}> {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const enabled = settings.enableSkills ?? true; // Default enabled
|
||||
const sources = settings.skillsSources ?? ['user', 'project']; // Default both sources
|
||||
|
||||
return {
|
||||
enabled,
|
||||
sources,
|
||||
shouldIncludeInTools: enabled && sources.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Subagents configuration from settings.
|
||||
* Returns configuration for enabling subagents and which sources to load from.
|
||||
*
|
||||
* @param settingsService - Settings service instance
|
||||
* @returns Subagents configuration with enabled state, sources, and tool inclusion flag
|
||||
*/
|
||||
export async function getSubagentsConfiguration(settingsService: SettingsService): Promise<{
|
||||
enabled: boolean;
|
||||
sources: Array<'user' | 'project'>;
|
||||
shouldIncludeInTools: boolean;
|
||||
}> {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const enabled = settings.enableSubagents ?? true; // Default enabled
|
||||
const sources = settings.subagentsSources ?? ['user', 'project']; // Default both sources
|
||||
|
||||
return {
|
||||
enabled,
|
||||
sources,
|
||||
shouldIncludeInTools: enabled && sources.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom subagents from settings, merging global and project-level definitions.
|
||||
* Project-level subagents take precedence over global ones with the same name.
|
||||
*
|
||||
* @param settingsService - Settings service instance
|
||||
* @param projectPath - Path to the project for loading project-specific subagents
|
||||
* @returns Record of agent names to definitions, or undefined if none configured
|
||||
*/
|
||||
export async function getCustomSubagents(
|
||||
settingsService: SettingsService,
|
||||
projectPath?: string
|
||||
): Promise<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;
|
||||
}
|
||||
|
||||
@@ -97,6 +97,8 @@ export class ClaudeProvider extends BaseProvider {
|
||||
...(options.mcpServers && { mcpServers: options.mcpServers }),
|
||||
// Extended thinking configuration
|
||||
...(maxThinkingTokens && { maxThinkingTokens }),
|
||||
// Subagents configuration for specialized task delegation
|
||||
...(options.agents && { agents: options.agents }),
|
||||
};
|
||||
|
||||
// Build prompt payload
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
61
apps/server/src/routes/settings/routes/discover-agents.ts
Normal file
61
apps/server/src/routes/settings/routes/discover-agents.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getPromptCustomization,
|
||||
getSkillsConfiguration,
|
||||
getSubagentsConfiguration,
|
||||
getCustomSubagents,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
interface Message {
|
||||
@@ -255,6 +258,22 @@ export class AgentService {
|
||||
// Load MCP servers from settings (global setting only)
|
||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
|
||||
|
||||
// Get Skills configuration from settings
|
||||
const skillsConfig = this.settingsService
|
||||
? await getSkillsConfiguration(this.settingsService)
|
||||
: { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false };
|
||||
|
||||
// Get Subagents configuration from settings
|
||||
const subagentsConfig = this.settingsService
|
||||
? await getSubagentsConfiguration(this.settingsService)
|
||||
: { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false };
|
||||
|
||||
// Get custom subagents from settings (merge global + project-level) only if enabled
|
||||
const customSubagents =
|
||||
this.settingsService && subagentsConfig.enabled
|
||||
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
|
||||
: undefined;
|
||||
|
||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath: effectiveWorkDir,
|
||||
@@ -289,7 +308,47 @@ 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 using Set for automatic deduplication
|
||||
const sdkSettingSources = (sdkOptions.settingSources ?? []).filter(
|
||||
(source): source is 'user' | 'project' => source === 'user' || source === 'project'
|
||||
);
|
||||
const skillSettingSources = skillsConfig.enabled ? skillsConfig.sources : [];
|
||||
const settingSources = [...new Set([...sdkSettingSources, ...skillSettingSources])];
|
||||
|
||||
// Enhance allowedTools with Skills and Subagents tools
|
||||
// These tools are not in the provider's default set - they're added dynamically based on settings
|
||||
const needsSkillTool = skillsConfig.shouldIncludeInTools;
|
||||
const needsTaskTool =
|
||||
subagentsConfig.shouldIncludeInTools &&
|
||||
customSubagents &&
|
||||
Object.keys(customSubagents).length > 0;
|
||||
|
||||
// Base tools that match the provider's default set
|
||||
const baseTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
|
||||
if (allowedTools) {
|
||||
allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options
|
||||
// Add Skill tool if skills are enabled
|
||||
if (needsSkillTool && !allowedTools.includes('Skill')) {
|
||||
allowedTools.push('Skill');
|
||||
}
|
||||
// Add Task tool if custom subagents are configured
|
||||
if (needsTaskTool && !allowedTools.includes('Task')) {
|
||||
allowedTools.push('Task');
|
||||
}
|
||||
} else if (needsSkillTool || needsTaskTool) {
|
||||
// If no allowedTools specified but we need to add Skill/Task tools,
|
||||
// build the full list including base tools
|
||||
allowedTools = [...baseTools];
|
||||
if (needsSkillTool) {
|
||||
allowedTools.push('Skill');
|
||||
}
|
||||
if (needsTaskTool) {
|
||||
allowedTools.push('Task');
|
||||
}
|
||||
}
|
||||
|
||||
// Get provider for this model (with prefix)
|
||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||
@@ -308,9 +367,10 @@ export class AgentService {
|
||||
allowedTools: allowedTools,
|
||||
abortController: session.abortController!,
|
||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||
settingSources: sdkOptions.settingSources,
|
||||
settingSources: settingSources.length > 0 ? settingSources : undefined,
|
||||
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||
agents: customSubagents, // Pass custom subagents for task delegation
|
||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
||||
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useCliStatus } from '../hooks/use-cli-status';
|
||||
import { ClaudeCliStatus } from '../cli-status/claude-cli-status';
|
||||
import { ClaudeMdSettings } from '../claude/claude-md-settings';
|
||||
import { ClaudeUsageSection } from '../api-keys/claude-usage-section';
|
||||
import { SkillsSection } from './claude-settings-tab/skills-section';
|
||||
import { SubagentsSection } from './claude-settings-tab/subagents-section';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
export function ClaudeSettingsTab() {
|
||||
@@ -43,6 +45,13 @@ export function ClaudeSettingsTab() {
|
||||
autoLoadClaudeMd={autoLoadClaudeMd}
|
||||
onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}
|
||||
/>
|
||||
|
||||
{/* Skills Configuration */}
|
||||
<SkillsSection />
|
||||
|
||||
{/* Custom Subagents */}
|
||||
<SubagentsSection />
|
||||
|
||||
{showUsageTracking && <ClaudeUsageSection />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Hooks barrel export for Claude Settings Tab
|
||||
*/
|
||||
|
||||
export { useSkillsSettings } from './use-skills-settings';
|
||||
export { useSubagents } from './use-subagents';
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Skills Settings Hook - Manages Skills configuration state
|
||||
*
|
||||
* Provides state management for enabling/disabling Skills and
|
||||
* configuring which sources to load Skills from (user/project).
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
export function useSkillsSettings() {
|
||||
const enabled = useAppStore((state) => state.enableSkills);
|
||||
const sources = useAppStore((state) => state.skillsSources);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const updateEnabled = async (newEnabled: boolean) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.settings) {
|
||||
throw new Error('Settings API not available');
|
||||
}
|
||||
await api.settings.updateGlobal({ enableSkills: newEnabled });
|
||||
// Update local store after successful server update
|
||||
useAppStore.setState({ enableSkills: newEnabled });
|
||||
toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled');
|
||||
} catch (error) {
|
||||
toast.error('Failed to update skills settings');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSources = async (newSources: Array<'user' | 'project'>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.settings) {
|
||||
throw new Error('Settings API not available');
|
||||
}
|
||||
await api.settings.updateGlobal({ skillsSources: newSources });
|
||||
// Update local store after successful server update
|
||||
useAppStore.setState({ skillsSources: newSources });
|
||||
toast.success('Skills sources updated');
|
||||
} catch (error) {
|
||||
toast.error('Failed to update skills sources');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
enabled,
|
||||
sources,
|
||||
updateEnabled,
|
||||
updateSources,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Subagents Settings Hook - Manages Subagents configuration state
|
||||
*
|
||||
* Provides state management for enabling/disabling Subagents and
|
||||
* configuring which sources to load Subagents from (user/project).
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
export function useSubagentsSettings() {
|
||||
const enabled = useAppStore((state) => state.enableSubagents);
|
||||
const sources = useAppStore((state) => state.subagentsSources);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const updateEnabled = async (newEnabled: boolean) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.settings) {
|
||||
throw new Error('Settings API not available');
|
||||
}
|
||||
await api.settings.updateGlobal({ enableSubagents: newEnabled });
|
||||
// Update local store after successful server update
|
||||
useAppStore.setState({ enableSubagents: newEnabled });
|
||||
toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled');
|
||||
} catch (error) {
|
||||
toast.error('Failed to update subagents settings');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSources = async (newSources: Array<'user' | 'project'>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.settings) {
|
||||
throw new Error('Settings API not available');
|
||||
}
|
||||
await api.settings.updateGlobal({ subagentsSources: newSources });
|
||||
// Update local store after successful server update
|
||||
useAppStore.setState({ subagentsSources: newSources });
|
||||
toast.success('Subagents sources updated');
|
||||
} catch (error) {
|
||||
toast.error('Failed to update subagents sources');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
enabled,
|
||||
sources,
|
||||
updateEnabled,
|
||||
updateSources,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Subagents Hook - Manages custom subagent definitions
|
||||
*
|
||||
* Provides read-only view of custom subagent configurations
|
||||
* used for specialized task delegation. Supports:
|
||||
* - Filesystem agents (AGENT.md files in .claude/agents/) - user and project-level (read-only)
|
||||
*
|
||||
* Filesystem agents are discovered via the server API and displayed in the UI.
|
||||
* Agent definitions in settings JSON are used server-side only.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { AgentDefinition } from '@automaker/types';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
export type SubagentScope = 'global' | 'project';
|
||||
export type SubagentType = 'filesystem';
|
||||
export type FilesystemSource = 'user' | 'project';
|
||||
|
||||
export interface SubagentWithScope {
|
||||
name: string;
|
||||
definition: AgentDefinition;
|
||||
scope: SubagentScope;
|
||||
type: SubagentType;
|
||||
source: FilesystemSource;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
interface FilesystemAgent {
|
||||
name: string;
|
||||
definition: AgentDefinition;
|
||||
source: FilesystemSource;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export function useSubagents() {
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [subagentsWithScope, setSubagentsWithScope] = useState<SubagentWithScope[]>([]);
|
||||
|
||||
// Fetch filesystem agents
|
||||
const fetchFilesystemAgents = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.settings) {
|
||||
console.warn('Settings API not available');
|
||||
return;
|
||||
}
|
||||
const data = await api.settings.discoverAgents(currentProject?.path, ['user', 'project']);
|
||||
|
||||
if (data.success && data.agents) {
|
||||
// Transform filesystem agents to SubagentWithScope format
|
||||
const agents: SubagentWithScope[] = data.agents.map(
|
||||
({ name, definition, source, filePath }: FilesystemAgent) => ({
|
||||
name,
|
||||
definition,
|
||||
scope: source === 'user' ? 'global' : 'project',
|
||||
type: 'filesystem' as const,
|
||||
source,
|
||||
filePath,
|
||||
})
|
||||
);
|
||||
setSubagentsWithScope(agents);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch filesystem agents:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentProject?.path]);
|
||||
|
||||
// Fetch filesystem agents on mount and when project changes
|
||||
useEffect(() => {
|
||||
fetchFilesystemAgents();
|
||||
}, [fetchFilesystemAgents]);
|
||||
|
||||
return {
|
||||
subagentsWithScope,
|
||||
isLoading,
|
||||
hasProject: !!currentProject,
|
||||
refreshFilesystemAgents: fetchFilesystemAgents,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Subagents Section - UI for managing Subagents configuration
|
||||
*
|
||||
* Allows users to enable/disable Subagents and select which directories
|
||||
* to load Subagents from (user ~/.claude/agents/ or project .claude/agents/).
|
||||
*
|
||||
* Displays agents discovered from:
|
||||
* - User-level: ~/.claude/agents/
|
||||
* - Project-level: .claude/agents/
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Bot,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Users,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
FolderOpen,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useSubagents } from './hooks/use-subagents';
|
||||
import { useSubagentsSettings } from './hooks/use-subagents-settings';
|
||||
import { SubagentCard } from './subagent-card';
|
||||
|
||||
export function SubagentsSection() {
|
||||
const {
|
||||
subagentsWithScope,
|
||||
isLoading: isLoadingAgents,
|
||||
hasProject,
|
||||
refreshFilesystemAgents,
|
||||
} = useSubagents();
|
||||
const {
|
||||
enabled,
|
||||
sources,
|
||||
updateEnabled,
|
||||
updateSources,
|
||||
isLoading: isLoadingSettings,
|
||||
} = useSubagentsSettings();
|
||||
|
||||
const isLoading = isLoadingAgents || isLoadingSettings;
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await refreshFilesystemAgents();
|
||||
};
|
||||
|
||||
const toggleSource = (source: 'user' | 'project') => {
|
||||
if (sources.includes(source)) {
|
||||
updateSources(sources.filter((s: 'user' | 'project') => s !== source));
|
||||
} else {
|
||||
updateSources([...sources, source]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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
|
||||
{enabled && 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>
|
||||
<Switch
|
||||
id="enable-subagents"
|
||||
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 Subagents from
|
||||
</Label>
|
||||
<div className="grid gap-2">
|
||||
{/* User Subagents Option */}
|
||||
<label
|
||||
htmlFor="subagent-source-user"
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all duration-200',
|
||||
sources.includes('user')
|
||||
? 'border-violet-500/50 bg-violet-500/10'
|
||||
: 'border-border/50 bg-accent/20 hover:bg-accent/30'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id="subagent-source-user"
|
||||
checked={sources.includes('user')}
|
||||
onCheckedChange={() => toggleSource('user')}
|
||||
disabled={isLoading}
|
||||
className="data-[state=checked]:bg-violet-500 data-[state=checked]:border-violet-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 Subagents</span>
|
||||
<span className="block text-xs text-muted-foreground mt-0.5 truncate">
|
||||
~/.claude/agents/ — Available across all projects
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Project Subagents Option */}
|
||||
<label
|
||||
htmlFor="subagent-source-project"
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all duration-200',
|
||||
sources.includes('project')
|
||||
? 'border-violet-500/50 bg-violet-500/10'
|
||||
: 'border-border/50 bg-accent/20 hover:bg-accent/30'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id="subagent-source-project"
|
||||
checked={sources.includes('project')}
|
||||
onCheckedChange={() => toggleSource('project')}
|
||||
disabled={isLoading}
|
||||
className="data-[state=checked]:bg-violet-500 data-[state=checked]:border-violet-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 Subagents</span>
|
||||
<span className="block text-xs text-muted-foreground mt-0.5 truncate">
|
||||
.claude/agents/ — Version-controlled with project
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agents List */}
|
||||
{enabled && (
|
||||
<>
|
||||
{/* Refresh Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Discovered Agents
|
||||
</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
title="Refresh agents from disk"
|
||||
className="gap-1.5 h-7 px-2 text-xs"
|
||||
>
|
||||
{isLoadingAgents ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{subagentsWithScope.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground border border-dashed border-border/50 rounded-xl">
|
||||
<Users className="w-10 h-10 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm font-medium">No agents found</p>
|
||||
<p className="text-xs mt-1 max-w-sm mx-auto">
|
||||
Create <code className="text-xs bg-muted px-1 rounded">.md</code> files in{' '}
|
||||
{sources.includes('user') && (
|
||||
<code className="text-xs bg-muted px-1 rounded">~/.claude/agents/</code>
|
||||
)}
|
||||
{sources.includes('user') && sources.includes('project') && ' or '}
|
||||
{sources.includes('project') && (
|
||||
<code className="text-xs bg-muted px-1 rounded">.claude/agents/</code>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{subagentsWithScope.map((agent) => (
|
||||
<SubagentCard
|
||||
key={`${agent.type}-${agent.source || agent.scope}-${agent.name}`}
|
||||
agent={agent}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
Subagents are automatically discovered when agents start. Define agents as{' '}
|
||||
<code className="text-xs bg-muted px-1 rounded">AGENT.md</code> files or{' '}
|
||||
<code className="text-xs bg-muted px-1 rounded">agent-name.md</code> files.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://code.claude.com/docs/en/agents"
|
||||
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 Agents documentation
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disabled State Empty Message */}
|
||||
{!enabled && (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<Bot className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">Subagents are disabled</p>
|
||||
<p className="text-xs mt-1">Enable to load custom agent definitions</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -728,6 +728,83 @@ export interface ElectronAPI {
|
||||
codex?: {
|
||||
getUsage: () => Promise<CodexUsageResponse>;
|
||||
};
|
||||
settings?: {
|
||||
getStatus: () => Promise<{
|
||||
success: boolean;
|
||||
hasGlobalSettings: boolean;
|
||||
hasCredentials: boolean;
|
||||
dataDir: string;
|
||||
needsMigration: boolean;
|
||||
}>;
|
||||
getGlobal: () => Promise<{
|
||||
success: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}>;
|
||||
updateGlobal: (updates: Record<string, unknown>) => Promise<{
|
||||
success: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}>;
|
||||
getCredentials: () => Promise<{
|
||||
success: boolean;
|
||||
credentials?: {
|
||||
anthropic: { configured: boolean; masked: string };
|
||||
google: { configured: boolean; masked: string };
|
||||
openai: { configured: boolean; masked: string };
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
updateCredentials: (updates: {
|
||||
apiKeys?: { anthropic?: string; google?: string; openai?: string };
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
credentials?: {
|
||||
anthropic: { configured: boolean; masked: string };
|
||||
google: { configured: boolean; masked: string };
|
||||
openai: { configured: boolean; masked: string };
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
getProject: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}>;
|
||||
updateProject: (
|
||||
projectPath: string,
|
||||
updates: Record<string, unknown>
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}>;
|
||||
migrate: (data: Record<string, string>) => Promise<{
|
||||
success: boolean;
|
||||
migratedGlobalSettings: boolean;
|
||||
migratedCredentials: boolean;
|
||||
migratedProjectCount: number;
|
||||
errors: string[];
|
||||
}>;
|
||||
discoverAgents: (
|
||||
projectPath?: string,
|
||||
sources?: Array<'user' | 'project'>
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
agents?: Array<{
|
||||
name: string;
|
||||
definition: {
|
||||
description: string;
|
||||
prompt: string;
|
||||
tools?: string[];
|
||||
model?: 'sonnet' | 'opus' | 'haiku' | 'inherit';
|
||||
};
|
||||
source: 'user' | 'project';
|
||||
filePath: string;
|
||||
}>;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// Note: Window interface is declared in @/types/electron.d.ts
|
||||
|
||||
@@ -23,8 +23,6 @@ import type {
|
||||
SpecRegenerationEvent,
|
||||
SuggestionType,
|
||||
GitHubAPI,
|
||||
GitHubIssue,
|
||||
GitHubPR,
|
||||
IssueValidationInput,
|
||||
IssueValidationEvent,
|
||||
IdeationAPI,
|
||||
@@ -1891,6 +1889,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
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getHttpApiClient } from './http-api-client';
|
||||
import { getElectronAPI } from './electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
const logger = createLogger('WorkspaceConfig');
|
||||
@@ -33,9 +32,17 @@ function joinPath(...parts: string[]): string {
|
||||
*/
|
||||
async function getDefaultDocumentsPath(): Promise<string | null> {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const documentsPath = await api.getPath('documents');
|
||||
return joinPath(documentsPath, 'Automaker');
|
||||
// In Electron mode, use the native getPath API directly from the preload script
|
||||
// This returns the actual system Documents folder (e.g., C:\Users\<user>\Documents on Windows)
|
||||
// Note: The HTTP client's getPath returns incorrect Unix-style paths for 'documents'
|
||||
if (typeof window !== 'undefined' && (window as any).electronAPI?.getPath) {
|
||||
const documentsPath = await (window as any).electronAPI.getPath('documents');
|
||||
return joinPath(documentsPath, 'Automaker');
|
||||
}
|
||||
|
||||
// In web mode (no Electron), we can't access the user's Documents folder
|
||||
// Return null to let the caller use other fallback mechanisms (like server's DATA_DIR)
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get documents path:', error);
|
||||
return null;
|
||||
@@ -76,6 +83,7 @@ export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
|
||||
|
||||
// Try to get Documents/Automaker
|
||||
const documentsPath = await getDefaultDocumentsPath();
|
||||
logger.info('Default documentsPath resolved to:', documentsPath);
|
||||
if (documentsPath) {
|
||||
return documentsPath;
|
||||
}
|
||||
|
||||
@@ -574,6 +574,14 @@ export interface AppState {
|
||||
// MCP Servers
|
||||
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
|
||||
|
||||
// Skills Configuration
|
||||
enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories)
|
||||
skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from
|
||||
|
||||
// Subagents Configuration
|
||||
enableSubagents: boolean; // Enable Custom Subagents functionality (loads from .claude/agents/ directories)
|
||||
subagentsSources: Array<'user' | 'project'>; // Which directories to load Subagents from
|
||||
|
||||
// Prompt Customization
|
||||
promptCustomization: PromptCustomization; // Custom prompts for Auto Mode, Agent, Backlog Plan, Enhancement
|
||||
|
||||
@@ -1170,6 +1178,10 @@ const initialState: AppState = {
|
||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
||||
mcpServers: [], // No MCP servers configured by default
|
||||
enableSkills: true, // Skills enabled by default
|
||||
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
|
||||
enableSubagents: true, // Subagents enabled by default
|
||||
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
|
||||
promptCustomization: {}, // Empty by default - all prompts use built-in defaults
|
||||
aiProfiles: DEFAULT_AI_PROFILES,
|
||||
projectAnalysis: null,
|
||||
|
||||
@@ -17,6 +17,7 @@ export type {
|
||||
McpStdioServerConfig,
|
||||
McpSSEServerConfig,
|
||||
McpHttpServerConfig,
|
||||
AgentDefinition,
|
||||
ReasoningEffort,
|
||||
} from './provider.js';
|
||||
|
||||
|
||||
@@ -76,6 +76,20 @@ export interface McpHttpServerConfig {
|
||||
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
|
||||
*/
|
||||
@@ -110,6 +124,11 @@ export interface ExecuteOptions {
|
||||
* Only applies to Claude models; Cursor models handle thinking internally.
|
||||
*/
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/**
|
||||
* Custom subagents for specialized task delegation
|
||||
* Key: agent name, Value: agent definition
|
||||
*/
|
||||
agents?: Record<string, AgentDefinition>;
|
||||
/**
|
||||
* Reasoning effort for Codex/OpenAI models with reasoning capabilities.
|
||||
* Controls how many reasoning tokens the model generates before responding.
|
||||
|
||||
@@ -543,6 +543,43 @@ export interface GlobalSettings {
|
||||
// Prompt Customization
|
||||
/** Custom prompts for Auto Mode, Agent Runner, Backlog Planning, and Enhancements */
|
||||
promptCustomization?: PromptCustomization;
|
||||
|
||||
// Skills Configuration
|
||||
/**
|
||||
* Enable Skills functionality (loads from .claude/skills/ directories)
|
||||
* @default true
|
||||
*/
|
||||
enableSkills?: boolean;
|
||||
|
||||
/**
|
||||
* Which directories to load Skills from
|
||||
* - 'user': ~/.claude/skills/ (personal skills)
|
||||
* - 'project': .claude/skills/ (project-specific skills)
|
||||
* @default ['user', 'project']
|
||||
*/
|
||||
skillsSources?: Array<'user' | 'project'>;
|
||||
|
||||
// Subagents Configuration
|
||||
/**
|
||||
* Enable Custom Subagents functionality (loads from .claude/agents/ directories)
|
||||
* @default true
|
||||
*/
|
||||
enableSubagents?: boolean;
|
||||
|
||||
/**
|
||||
* Which directories to load Subagents from
|
||||
* - 'user': ~/.claude/agents/ (personal agents)
|
||||
* - 'project': .claude/agents/ (project-specific agents)
|
||||
* @default ['user', 'project']
|
||||
*/
|
||||
subagentsSources?: Array<'user' | 'project'>;
|
||||
|
||||
/**
|
||||
* Custom subagent definitions for specialized task delegation (programmatic)
|
||||
* Key: agent name (e.g., 'code-reviewer', 'test-runner')
|
||||
* Value: agent configuration
|
||||
*/
|
||||
customSubagents?: Record<string, import('./provider.js').AgentDefinition>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -642,6 +679,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<string, import('./provider.js').AgentDefinition>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -743,6 +789,10 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
codexAdditionalDirs: DEFAULT_CODEX_ADDITIONAL_DIRS,
|
||||
codexThreadId: undefined,
|
||||
mcpServers: [],
|
||||
enableSkills: true,
|
||||
skillsSources: ['user', 'project'],
|
||||
enableSubagents: true,
|
||||
subagentsSources: ['user', 'project'],
|
||||
};
|
||||
|
||||
/** Default credentials (empty strings - user must provide API keys) */
|
||||
|
||||
Reference in New Issue
Block a user