UNPKG

claudekit

Version:

CLI tools for Claude Code development workflow

1,716 lines (1,488 loc) 53.6 kB
import { promises as fs } from 'node:fs'; import * as path from 'node:path'; import { pathExists, getFileStats } from './filesystem.js'; import type { Component, ComponentType, ComponentCategory, ProjectInfo } from '../types/config.js'; import { HOOK_REGISTRY } from '../hooks/registry.js'; /** * Component Discovery System * * Scans and catalogs commands and hooks with metadata parsing, * dependency resolution, and caching for performance. */ // ============================================================================ // Types and Interfaces // ============================================================================ interface ComponentMetadata { id: string; name: string; description: string; category: ComponentCategory; dependencies: string[]; enabled?: boolean; // Whether this component is enabled (defaults to true) allowedTools?: string[]; argumentHint?: string; version?: string; author?: string; shellOptions?: string[]; timeout?: number; retries?: number; requiredBy?: string[]; // Components that require this one optional?: boolean; // Whether this is an optional dependency // Agent-specific fields agentCategory?: string; // Category for agent grouping (universal, framework, testing, etc.) universal?: boolean; displayName?: string; color?: string; bundle?: string[]; defaultSelected?: boolean; } interface ComponentFile { path: string; type: ComponentType; metadata: ComponentMetadata; hash: string; lastModified: Date; } export interface ComponentRegistry { components: Map<string, ComponentFile>; dependencies: Map<string, Set<string>>; dependents: Map<string, Set<string>>; categories: Map<ComponentCategory, Set<string>>; lastScan: Date; cacheValid: boolean; dependencyGraph?: DependencyGraph; } interface DependencyGraph { nodes: Map<string, DependencyNode>; edges: Map<string, Set<string>>; // from -> to reverseEdges: Map<string, Set<string>>; // to -> from cycles: string[][]; // List of detected cycles } interface DependencyNode { id: string; component?: ComponentFile; external: boolean; // True if this is an external dependency depth: number; // Depth in dependency tree visited: boolean; // For traversal algorithms } interface ScanOptions { includeDisabled?: boolean; forceRefresh?: boolean; filterByCategory?: ComponentCategory[]; filterByType?: ComponentType[]; } // ============================================================================ // Embedded Hook Definitions // ============================================================================ /** * Generate embedded hook component definitions from the hook registry * These hooks are built into the claudekit-hooks command */ function generateEmbeddedHookComponents(): ComponentFile[] { const components: ComponentFile[] = []; for (const [id, HookClass] of Object.entries(HOOK_REGISTRY)) { const metadata = HookClass.metadata; if (metadata === undefined) { continue; } components.push({ path: `embedded:${id}`, type: 'hook', metadata: { id: metadata.id, name: metadata.displayName, description: metadata.description, category: metadata.category, dependencies: metadata.dependencies ?? [], }, hash: `embedded-${id}`, lastModified: new Date(), }); } return components; } /** * Embedded hook component definitions * Generated from HOOK_REGISTRY at runtime */ const EMBEDDED_HOOK_COMPONENTS: ComponentFile[] = generateEmbeddedHookComponents(); // ============================================================================ // Dependency Definitions // ============================================================================ /** * Static dependency definitions for components * Maps component IDs to their required dependencies */ const COMPONENT_DEPENDENCIES: Record<string, string[]> = { // Commands that depend on other commands 'spec:decompose': ['spec:validate'], 'spec:execute': ['spec:validate'], 'git:push': ['git:status'], 'checkpoint:restore': ['checkpoint:list'], }; /** * Optional dependencies that enhance functionality but aren't required */ const OPTIONAL_DEPENDENCIES: Record<string, string[]> = { typecheck: ['typescript'], eslint: ['eslint'], prettier: ['prettier'], 'git-status': ['git'], 'auto-checkpoint': ['git'], }; /** * System/external dependencies that can't be auto-installed */ const EXTERNAL_DEPENDENCIES = new Set([ 'git', 'npm', 'yarn', 'pnpm', 'bun', 'node', 'typescript', 'tsc', 'eslint', 'prettier', 'jq', 'gh', 'ripgrep', 'rg', ]); // ============================================================================ // Component Discovery Cache // ============================================================================ const componentCache = new Map<string, ComponentRegistry>(); const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes /** * Get cached registry for a directory or create new one */ function getOrCreateRegistry(baseDir: string): ComponentRegistry { const existing = componentCache.get(baseDir); const now = new Date(); if ( existing && existing.cacheValid && now.getTime() - existing.lastScan.getTime() < CACHE_DURATION ) { return existing; } const registry: ComponentRegistry = { components: new Map(), dependencies: new Map(), dependents: new Map(), categories: new Map(), lastScan: now, cacheValid: false, }; componentCache.set(baseDir, registry); return registry; } /** * Invalidate cache for a directory */ export function invalidateCache(baseDir?: string): void { if (baseDir !== undefined && baseDir !== '') { componentCache.delete(baseDir); } else { componentCache.clear(); } } // ============================================================================ // Metadata Parsing // ============================================================================ /** * Parse YAML frontmatter from markdown command files */ function parseFrontmatter(content: string): Record<string, unknown> { const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); if (!frontmatterMatch) { return {}; } const frontmatter = frontmatterMatch[1]; const parsed: Record<string, unknown> = {}; // Simple YAML parser for key-value pairs const lines = frontmatter?.split('\n') || []; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) { continue; } const colonIndex = trimmed.indexOf(':'); if (colonIndex === -1) { continue; } const key = trimmed.substring(0, colonIndex).trim(); const value = trimmed.substring(colonIndex + 1).trim(); // Remove quotes if present const cleanValue = value.replace(/^["']|["']$/g, ''); parsed[key] = cleanValue; } return parsed; } /** * Parse shell script header comments (legacy function, kept for backward compatibility) */ function parseShellHeader(content: string): Record<string, unknown> { const lines = content.split('\n').slice(0, 50); // Check first 50 lines const metadata: Record<string, unknown> = {}; let inHeaderBlock = false; let description = ''; for (const line of lines) { const trimmed = line.trim(); // Detect header block start if (trimmed.includes('##########') || trimmed.includes('====')) { inHeaderBlock = true; continue; } // Exit header block if (inHeaderBlock && !trimmed.startsWith('#') && trimmed.length > 0) { break; } if (!inHeaderBlock || !trimmed.startsWith('#')) { continue; } // Remove comment prefix const lineContent = trimmed.replace(/^#+\s*/, '').trim(); if (!lineContent) { continue; } // Extract title if it looks like one if (lineContent.endsWith('Hook') || lineContent.endsWith('Script')) { metadata['name'] = lineContent.replace(/\s+(Hook|Script)\s*$/, ''); continue; } // Look for field patterns if (lineContent.includes(':')) { const colonIndex = lineContent.indexOf(':'); const field = lineContent.substring(0, colonIndex).trim().toLowerCase(); const value = lineContent.substring(colonIndex + 1).trim(); if ( ['description', 'category', 'dependencies', 'platforms', 'version', 'author'].includes( field ) ) { metadata[field] = value.replace(/#+\s*$/, '').trim(); continue; } } // Accumulate description (exclude padding symbols) if ( metadata['description'] === undefined && !lineContent.includes(':') && !lineContent.match(/^#+$/) ) { const cleanContent = lineContent.replace(/#+\s*$/, '').trim(); if (cleanContent !== '') { description += (description ? ' ' : '') + cleanContent; } } } if (metadata['description'] === undefined && description !== '') { metadata['description'] = description.trim(); } // Parse shell options from set line const setMatch = content.match(/set\s+(.+)/); if (setMatch !== null && setMatch[1] !== undefined) { metadata['shellOptions'] = setMatch[1].split(/\s+/); } return metadata; } /** * Extract dependencies from file content */ function extractDependencies(content: string, type: ComponentType, componentId?: string): string[] { const dependencies = new Set<string>(); if (type === 'command') { // Extract from allowed-tools in frontmatter const toolsMatch = content.match(/allowed-tools:\s*(.+)/); if (toolsMatch) { const tools = toolsMatch[1]?.split(',').map((t) => t.trim()) || []; tools.forEach((tool) => { // Extract basic tool names (Read, Write, etc.) const match = tool.match(/(\w+)(?:\([^)]*\))?/); if (match) { const toolName = match[1]?.toLowerCase(); // Only add non-standard tools as dependencies if ( toolName !== undefined && toolName !== '' && !['read', 'write', 'edit', 'multiedit', 'bash'].includes(toolName) ) { dependencies.add(toolName); } else if (toolName === 'read' || toolName === 'write') { dependencies.add(toolName); } } }); } // Extract from command references (be more selective) const commandRefs = content.match(/(?:^|\s)\/([a-zA-Z][a-zA-Z0-9-]+)(?:\s|$)/gm); if (commandRefs) { commandRefs.forEach((ref) => { const cmd = ref.trim().slice(1); // Remove leading slash // Skip common non-command references if ( cmd !== 'claudekit' && cmd !== 'etc' && cmd !== 'usr' && cmd !== 'var' && cmd !== 'Users' && cmd !== 'home' && cmd.length > 2 ) { dependencies.add(cmd); } }); } } else { // Extract from hook content // Look for command calls const bashCommands = content.match(/\b(git|npm|yarn|pnpm|node|eslint|tsc|jq)\b/g); if (bashCommands) { bashCommands.forEach((cmd) => { // Skip self-reference if ( componentId !== null && componentId !== undefined && componentId !== '' && cmd === componentId ) { return; } dependencies.add(cmd); }); } // Look for other hook references const hookRefs = content.match(/\.claude\/hooks\/([^.\s]+)/g); if (hookRefs) { hookRefs.forEach((ref) => { const hookName = ref.split('/').pop(); if (hookName !== undefined && hookName !== '' && hookName !== componentId) { dependencies.add(hookName); } }); } } return Array.from(dependencies); } /** * Determine component category from content and path */ function inferCategory( filePath: string, content: string, metadata: Record<string, unknown> ): ComponentCategory { // Use explicit category if provided if (metadata['category'] !== undefined) { const normalizedCategory = String(metadata['category']) .toLowerCase() .replace(/[-_\s]/g, '-'); const validCategories: ComponentCategory[] = [ 'git', 'validation', 'development', 'testing', 'claude-setup', 'workflow', 'project-management', 'debugging', 'utility', ]; const match = validCategories.find( (cat) => cat === normalizedCategory || cat.includes(normalizedCategory) ); if (match) { return match; } } // Infer from path const pathSegments = filePath.toLowerCase().split('/'); if (pathSegments.includes('git')) { return 'git'; } if (pathSegments.includes('spec') || pathSegments.includes('validate')) { return 'validation'; } if (pathSegments.includes('checkpoint')) { return 'git'; } if (pathSegments.includes('agent')) { return 'claude-setup'; } if (pathSegments.includes('dev') || pathSegments.includes('cleanup')) { return 'development'; } // Infer from content const contentLower = content.toLowerCase(); if ( contentLower.includes('git') && (contentLower.includes('stash') || contentLower.includes('commit')) ) { return 'git'; } if ( contentLower.includes('eslint') || contentLower.includes('typecheck') || contentLower.includes('validate') ) { return 'validation'; } if (contentLower.includes('test') && contentLower.includes('run')) { return 'testing'; } if (contentLower.includes('claude') || contentLower.includes('agent')) { return 'claude-setup'; } if (contentLower.includes('todo') || contentLower.includes('task')) { return 'project-management'; } if (contentLower.includes('debug') || contentLower.includes('log')) { return 'debugging'; } return 'utility'; } /** * Create component ID from file path */ function createComponentId(filePath: string, type: ComponentType): string { const fileName = path.basename(filePath, '.md'); const parentDir = path.basename(path.dirname(filePath)); if (parentDir === 'commands' || parentDir === 'agents') { return fileName; } // For agents, always use just the filename (no directory prefix) if (type === 'agent') { return fileName; } // For commands, keep the namespace:name format return `${parentDir}:${fileName}`; } /** * Parse component file and extract metadata */ async function parseComponentFile( filePath: string, type: ComponentType ): Promise<ComponentFile | null> { try { const content = await fs.readFile(filePath, 'utf-8'); const stats = await getFileStats(filePath); if (!stats) { return null; } // Parse metadata based on file type const rawMetadata = type === 'command' || type === 'agent' ? parseFrontmatter(content) : parseShellHeader(content); // For agents, validate that required fields are present if (type === 'agent') { // Agent files must have 'name' and 'description' in frontmatter if ( rawMetadata['name'] === undefined || rawMetadata['name'] === null || rawMetadata['name'] === '' || rawMetadata['description'] === undefined || rawMetadata['description'] === null || rawMetadata['description'] === '' ) { // Skip files without proper agent metadata return null; } } // Create component ID first so we can use it for dependency extraction const id = createComponentId(filePath, type); // Extract dependencies (excluding self-references) const dependencies = extractDependencies(content, type, id); const metadata: ComponentMetadata = { id, name: (rawMetadata['name'] as string) || path.basename(filePath, '.md'), description: (rawMetadata['description'] as string) || 'No description available', category: inferCategory(filePath, content, rawMetadata), dependencies, // Parse enabled field (defaults to true if not specified) enabled: rawMetadata['enabled'] !== undefined ? rawMetadata['enabled'] === true || rawMetadata['enabled'] === 'true' : true, ...(rawMetadata['allowed-tools'] !== undefined && rawMetadata['allowed-tools'] !== null && { allowedTools: (rawMetadata['allowed-tools'] as string) .split(',') .map((t: string) => t.trim()), }), ...(rawMetadata['argument-hint'] !== undefined && rawMetadata['argument-hint'] !== null && { argumentHint: rawMetadata['argument-hint'] as string, }), // Preserve custom agent fields for grouping ...(type === 'agent' && rawMetadata['universal'] !== undefined && { universal: rawMetadata['universal'] === true || rawMetadata['universal'] === 'true', }), ...(type === 'agent' && rawMetadata['category'] !== undefined && { agentCategory: rawMetadata['category'] as string, }), ...(type === 'agent' && rawMetadata['displayName'] !== undefined && { displayName: rawMetadata['displayName'] as string, }), ...(type === 'agent' && rawMetadata['color'] !== undefined && { color: rawMetadata['color'] as string, }), ...(type === 'agent' && rawMetadata['bundle'] !== undefined && { bundle: ((): string[] => { const bundleValue = rawMetadata['bundle']; if (typeof bundleValue === 'string') { // Parse string format like "[typescript-type-expert, typescript-build-expert]" return bundleValue .replace(/^\[|\]$/g, '') .split(',') .map((s: string) => s.trim()) .filter((s: string) => s.length > 0); // Filter out empty strings } if (Array.isArray(bundleValue)) { // Ensure all items are non-empty strings const filtered = bundleValue.filter( (item): item is string => typeof item === 'string' && item.length > 0 ); return filtered; } return []; })(), }), ...(type === 'agent' && rawMetadata['defaultSelected'] !== undefined && { defaultSelected: rawMetadata['defaultSelected'] === true || rawMetadata['defaultSelected'] === 'true', }), ...(rawMetadata['version'] !== undefined && rawMetadata['version'] !== null && { version: rawMetadata['version'] as string }), ...(rawMetadata['author'] !== undefined && rawMetadata['author'] !== null && { author: rawMetadata['author'] as string }), ...(rawMetadata['shellOptions'] !== undefined && rawMetadata['shellOptions'] !== null && { shellOptions: typeof rawMetadata['shellOptions'] === 'string' ? (rawMetadata['shellOptions'] as string).split(',').map((opt: string) => opt.trim()) : (rawMetadata['shellOptions'] as string[]), }), ...(rawMetadata['timeout'] !== undefined && rawMetadata['timeout'] !== null && { timeout: parseInt(rawMetadata['timeout'] as string, 10), }), ...(rawMetadata['retries'] !== undefined && rawMetadata['retries'] !== null && { retries: parseInt(rawMetadata['retries'] as string, 10), }), }; // Calculate content hash for change detection const crypto = await import('crypto'); const hash = crypto.createHash('sha256').update(content).digest('hex'); return { path: filePath, type, metadata, hash, lastModified: stats.mtime, }; } catch (error) { console.warn(`Failed to parse component file ${filePath}:`, error); return null; } } // ============================================================================ // Directory Scanning // ============================================================================ /** * Recursively scan directory for component files */ async function scanDirectory(dirPath: string, type: ComponentType): Promise<ComponentFile[]> { const components: ComponentFile[] = []; if (!(await pathExists(dirPath))) { return components; } // Only scan for command and agent files since hooks are embedded if (type !== 'command' && type !== 'agent') { return []; } const extension = '.md'; async function scanRecursive(currentPath: string): Promise<void> { try { const entries = await fs.readdir(currentPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentPath, entry.name); if (entry.isDirectory()) { await scanRecursive(fullPath); } else if (entry.isFile() && entry.name.endsWith(extension)) { const component = await parseComponentFile(fullPath, type); if (component) { components.push(component); } } } } catch (error) { console.warn(`Failed to scan directory ${currentPath}:`, error); } } await scanRecursive(dirPath); return components; } // ============================================================================ // Dependency Resolution // ============================================================================ /** * Build dependency graphs from components */ function buildDependencyGraphs(components: ComponentFile[]): { dependencies: Map<string, Set<string>>; dependents: Map<string, Set<string>>; } { const dependencies = new Map<string, Set<string>>(); const dependents = new Map<string, Set<string>>(); // Initialize maps for (const component of components) { dependencies.set(component.metadata.id, new Set()); dependents.set(component.metadata.id, new Set()); } // Build dependency relationships for (const component of components) { const componentId = component.metadata.id; const componentDeps = dependencies.get(componentId); if (!componentDeps) { continue; } for (const dep of component.metadata.dependencies) { // Check if dependency exists in our component set const depComponent = components.find( (c) => c.metadata.id === dep || c.metadata.name.toLowerCase() === dep.toLowerCase() || c.metadata.name.toLowerCase().replace(/\.sh$/, '') === dep.toLowerCase() ); if (depComponent) { componentDeps.add(depComponent.metadata.id); const depComponentDependents = dependents.get(depComponent.metadata.id); if (depComponentDependents) { depComponentDependents.add(componentId); } } else { // External dependency componentDeps.add(dep); } } } return { dependencies, dependents }; } /** * Build a complete dependency graph for the registry */ function buildDependencyGraph(registry: ComponentRegistry): DependencyGraph { const nodes = new Map<string, DependencyNode>(); const edges = new Map<string, Set<string>>(); const reverseEdges = new Map<string, Set<string>>(); const cycles: string[][] = []; // Initialize nodes for all components for (const [id, component] of registry.components) { nodes.set(id, { id, component, external: false, depth: 0, visited: false, }); edges.set(id, new Set()); reverseEdges.set(id, new Set()); } // Add edges based on dependencies for (const [id, component] of registry.components) { const deps = new Set<string>(); // Add static dependencies const staticDeps = COMPONENT_DEPENDENCIES[id] || []; staticDeps.forEach((dep) => deps.add(dep)); // Add detected dependencies component.metadata.dependencies.forEach((dep) => deps.add(dep)); // Process each dependency for (const dep of deps) { // Check if dependency exists in registry if (!registry.components.has(dep)) { // Create external node if needed if (!nodes.has(dep)) { nodes.set(dep, { id: dep, external: EXTERNAL_DEPENDENCIES.has(dep), depth: 0, visited: false, }); edges.set(dep, new Set()); reverseEdges.set(dep, new Set()); } } // Add edge (skip self-references) if (dep !== id) { const idEdges = edges.get(id); const depReverseEdges = reverseEdges.get(dep); if (idEdges && depReverseEdges) { idEdges.add(dep); depReverseEdges.add(id); } } } } // Detect cycles using DFS const detectCycles = (): string[][] => { const visited = new Set<string>(); const recursionStack = new Set<string>(); const path: string[] = []; function dfs(node: string): boolean { if (recursionStack.has(node)) { // Found a cycle const cycleStart = path.indexOf(node); if (cycleStart !== -1) { cycles.push([...path.slice(cycleStart), node]); } return true; } if (visited.has(node)) { return false; } visited.add(node); recursionStack.add(node); path.push(node); const neighbors = edges.get(node) || new Set(); for (const neighbor of neighbors) { // Skip self-references if (neighbor === node) { continue; } if (dfs(neighbor)) { // Continue searching for more cycles } } recursionStack.delete(node); path.pop(); return false; } for (const node of nodes.keys()) { if (!visited.has(node)) { dfs(node); } } return cycles; }; const cycleResults = detectCycles(); cycles.push(...cycleResults); // Calculate depth for each node const calculateDepths = (): Map<string, number> => { const depths = new Map<string, number>(); function getDepth(node: string, visiting = new Set<string>()): number { if (depths.has(node)) { const depth = depths.get(node); return depth ?? 0; } if (visiting.has(node)) { return 0; } // Cycle, return 0 visiting.add(node); const deps = edges.get(node) || new Set(); let maxDepth = 0; for (const dep of deps) { maxDepth = Math.max(maxDepth, getDepth(dep, visiting) + 1); } visiting.delete(node); depths.set(node, maxDepth); const nodeData = nodes.get(node); if (nodeData) { nodeData.depth = maxDepth; } return maxDepth; } for (const node of nodes.keys()) { getDepth(node); } return depths; }; calculateDepths(); return { nodes, edges, reverseEdges, cycles }; } /** * Resolve all dependencies for a set of components * Returns components in installation order with all dependencies included */ export function resolveAllDependencies( componentIds: string[], registry: ComponentRegistry, options: { includeOptional?: boolean; maxDepth?: number } = {} ): string[] { const { includeOptional = false, maxDepth = 10 } = options; // Build or use cached dependency graph if (!registry.dependencyGraph) { registry.dependencyGraph = buildDependencyGraph(registry); } // const graph = registry.dependencyGraph; // Unused const required = new Set<string>(componentIds); const resolved = new Set<string>(); const visiting = new Set<string>(); // Recursively add all dependencies function addDependencies(id: string, depth = 0): void { if (depth > maxDepth) { console.warn(`Max dependency depth reached for ${id}`); return; } if (resolved.has(id) || visiting.has(id)) { return; } visiting.add(id); // Add static dependencies (skip external dependencies for recursion) const staticDeps = COMPONENT_DEPENDENCIES[id] || []; for (const dep of staticDeps) { if (!resolved.has(dep) && !EXTERNAL_DEPENDENCIES.has(dep)) { addDependencies(dep, depth + 1); } } // Add optional dependencies if requested if (includeOptional) { const optionalDeps = OPTIONAL_DEPENDENCIES[id] || []; for (const dep of optionalDeps) { if (!EXTERNAL_DEPENDENCIES.has(dep) && !resolved.has(dep)) { addDependencies(dep, depth + 1); } } } // Add detected dependencies from component metadata const component = registry.components.get(id); if (component) { for (const dep of component.metadata.dependencies) { if (!EXTERNAL_DEPENDENCIES.has(dep) && !resolved.has(dep)) { addDependencies(dep, depth + 1); } } } visiting.delete(id); resolved.add(id); required.add(id); } // Add all dependencies for (const id of componentIds) { addDependencies(id); } // Sort by installation order (topological sort) return resolveDependencyOrder(Array.from(required), registry); } /** * Resolve component dependencies in topological order * Handles circular dependencies gracefully */ export function resolveDependencyOrder( componentIds: string[], registry: ComponentRegistry ): string[] { const visited = new Set<string>(); const visiting = new Set<string>(); const result: string[] = []; const cycles = new Set<string>(); function visit(id: string, path: string[] = []): void { if (visited.has(id)) { return; } if (visiting.has(id)) { // Circular dependency detected const cycleStart = path.indexOf(id); if (cycleStart !== -1) { const cycle = path.slice(cycleStart); console.warn(`Circular dependency detected: ${cycle.join(' -> ')} -> ${id}`); cycles.add(id); } return; } visiting.add(id); path.push(id); // Get all dependencies (static + detected) const allDeps = new Set<string>(); // Add static dependencies const staticDeps = COMPONENT_DEPENDENCIES[id] || []; staticDeps.forEach((dep) => allDeps.add(dep)); // Add component dependencies const component = registry.components.get(id); if (component) { component.metadata.dependencies.forEach((dep) => allDeps.add(dep)); } // Visit dependencies first (skip external dependencies) for (const dep of allDeps) { if (componentIds.includes(dep) && !cycles.has(dep) && !EXTERNAL_DEPENDENCIES.has(dep)) { visit(dep, [...path]); } } path.pop(); visiting.delete(id); visited.add(id); // Only add if not part of a cycle or if it's the root of a cycle if (!cycles.has(id) || !Array.from(allDeps).some((dep) => cycles.has(dep))) { result.push(id); } } // Visit all components for (const id of componentIds) { visit(id); } // Throw error if circular dependencies were detected if (cycles.size > 0) { const cycleList = Array.from(cycles); throw new Error(`Circular dependency detected involving: ${cycleList.join(', ')}`); } return result; } // ============================================================================ // Main Discovery API // ============================================================================ /** * Discover all components in specified directories */ export async function discoverComponents( baseDir: string, options: ScanOptions = {} ): Promise<ComponentRegistry> { const startTime = Date.now(); // Don't validate path - just handle non-existent directories if (!(await pathExists(baseDir))) { // Return empty registry for non-existent directories const registry: ComponentRegistry = { components: new Map(), dependencies: new Map(), dependents: new Map(), categories: new Map(), lastScan: new Date(), cacheValid: true, }; return registry; } // Get or create registry const registry = getOrCreateRegistry(baseDir); // Return cached if valid and not forcing refresh if (registry.cacheValid === true && options.forceRefresh !== true) { return registry; } // Clear existing data registry.components.clear(); registry.dependencies.clear(); registry.dependents.clear(); registry.categories.clear(); const commandsDir = path.join(baseDir, 'commands'); const agentsDir = path.join(baseDir, 'agents'); // Scan for command and agent components const commandFiles = await scanDirectory(commandsDir, 'command'); const agentFiles = await scanDirectory(agentsDir, 'agent'); // Use predefined embedded hooks, but scan for actual agent files const allComponents = [...commandFiles, ...agentFiles, ...EMBEDDED_HOOK_COMPONENTS]; // Filter components based on options let filteredComponents = allComponents; // Filter by enabled status first if (options.includeDisabled === false) { filteredComponents = filteredComponents.filter((c) => c.metadata.enabled !== false); } if (options.filterByType !== undefined && options.filterByType.length > 0) { filteredComponents = filteredComponents.filter( (c) => options.filterByType?.includes(c.type) ?? false ); } if (options.filterByCategory !== undefined && options.filterByCategory.length > 0) { filteredComponents = filteredComponents.filter( (c) => options.filterByCategory?.includes(c.metadata.category) ?? false ); } // Populate registry for (const component of filteredComponents) { registry.components.set(component.metadata.id, component); // Group by category const categorySet = registry.categories.get(component.metadata.category) || new Set(); categorySet.add(component.metadata.id); registry.categories.set(component.metadata.category, categorySet); } // Build dependency graphs const { dependencies, dependents } = buildDependencyGraphs(filteredComponents); registry.dependencies = dependencies; registry.dependents = dependents; // Mark cache as valid registry.cacheValid = true; registry.lastScan = new Date(); const duration = Date.now() - startTime; console.debug( `Component discovery completed in ${duration}ms. Found ${filteredComponents.length} components.` ); return registry; } /** * Get component by ID */ export function getComponent(id: string, registry: ComponentRegistry): ComponentFile | undefined { return registry.components.get(id); } /** * Get components by category */ export function getComponentsByCategory( category: ComponentCategory, registry: ComponentRegistry ): ComponentFile[] { const componentIds = registry.categories.get(category) || new Set(); return Array.from(componentIds) .map((id) => registry.components.get(id)) .filter((comp): comp is ComponentFile => comp !== undefined); } /** * Get components by type */ export function getComponentsByType( type: ComponentType, registry: ComponentRegistry ): ComponentFile[] { return Array.from(registry.components.values()).filter((c) => c.type === type); } /** * Find components that depend on a given component */ export function getDependents(componentId: string, registry: ComponentRegistry): ComponentFile[] { const dependentIds = registry.dependents.get(componentId) || new Set(); return Array.from(dependentIds) .map((id) => registry.components.get(id)) .filter((comp): comp is ComponentFile => comp !== undefined); } /** * Find components that a given component depends on */ export function getDependencies(componentId: string, registry: ComponentRegistry): ComponentFile[] { const allDeps = new Set<string>(); // Add static dependencies const staticDeps = COMPONENT_DEPENDENCIES[componentId] || []; staticDeps.forEach((dep) => allDeps.add(dep)); // Add component metadata dependencies const component = registry.components.get(componentId); if (component) { component.metadata.dependencies.forEach((dep) => allDeps.add(dep)); } // Return only components that exist in registry return Array.from(allDeps) .map((id) => registry.components.get(id)) .filter((comp): comp is ComponentFile => comp !== undefined); } /** * Get transitive dependencies for a component */ export function getTransitiveDependencies( componentId: string, registry: ComponentRegistry, maxDepth = 10 ): ComponentFile[] { const visited = new Set<string>(); const dependencies: ComponentFile[] = []; function collectDeps(id: string, depth = 0): void { if (depth > maxDepth || visited.has(id)) { return; } visited.add(id); const deps = getDependencies(id, registry); for (const dep of deps) { if (!visited.has(dep.metadata.id)) { dependencies.push(dep); collectDeps(dep.metadata.id, depth + 1); } } } collectDeps(componentId); return dependencies; } /** * Check if adding a dependency would create a circular dependency */ export function wouldCreateCircularDependency( componentId: string, newDependencyId: string, registry: ComponentRegistry ): boolean { // Self-reference always creates a cycle if (componentId === newDependencyId) { return true; } // Check if newDependencyId already depends on componentId const visited = new Set<string>(); function hasDependency(fromId: string, toId: string): boolean { if (fromId === toId) { return true; } if (visited.has(fromId)) { return false; } visited.add(fromId); const deps = getDependencies(fromId, registry); for (const dep of deps) { if (hasDependency(dep.metadata.id, toId)) { return true; } } return false; } return hasDependency(newDependencyId, componentId); } /** * Get missing dependencies for a set of components */ export function getMissingDependencies( componentIds: string[], registry: ComponentRegistry ): string[] { const selected = new Set(componentIds); const missing = new Set<string>(); for (const id of componentIds) { // Check static dependencies const staticDeps = COMPONENT_DEPENDENCIES[id] || []; for (const dep of staticDeps) { if (!selected.has(dep) && !EXTERNAL_DEPENDENCIES.has(dep)) { missing.add(dep); } } // Check component dependencies const component = registry.components.get(id); if (component) { for (const dep of component.metadata.dependencies) { if (!selected.has(dep) && !EXTERNAL_DEPENDENCIES.has(dep)) { missing.add(dep); } } } } return Array.from(missing); } /** * Search components by name or description */ export function searchComponents( query: string, registry: ComponentRegistry, options: { fuzzy?: boolean; includeDescription?: boolean } = {} ): ComponentFile[] { const normalizedQuery = query.toLowerCase(); const results: ComponentFile[] = []; for (const component of registry.components.values()) { const nameMatch = component.metadata.name.toLowerCase().includes(normalizedQuery); const descMatch = options.includeDescription === true && component.metadata.description.toLowerCase().includes(normalizedQuery); if (nameMatch === true || descMatch === true) { results.push(component); } } // Sort by relevance (name matches first, then by length) return results.sort((a, b) => { const aNameMatch = a.metadata.name.toLowerCase().includes(normalizedQuery); const bNameMatch = b.metadata.name.toLowerCase().includes(normalizedQuery); if (aNameMatch && !bNameMatch) { return -1; } if (!aNameMatch && bNameMatch) { return 1; } return a.metadata.name.length - b.metadata.name.length; }); } /** * Convert registry to a format compatible with the Component type */ export function registryToComponents(registry: ComponentRegistry): Component[] { return Array.from(registry.components.values()).map((componentFile) => ({ id: componentFile.metadata.id, type: componentFile.type, name: componentFile.metadata.name, description: componentFile.metadata.description, path: componentFile.path, dependencies: componentFile.metadata.dependencies, platforms: [], category: componentFile.metadata.category, version: componentFile.metadata.version, author: componentFile.metadata.author, enabled: componentFile.metadata.enabled ?? true, config: { allowedTools: componentFile.metadata.allowedTools, argumentHint: componentFile.metadata.argumentHint, shellOptions: componentFile.metadata.shellOptions, timeout: componentFile.metadata.timeout, retries: componentFile.metadata.retries, }, createdAt: componentFile.lastModified, updatedAt: componentFile.lastModified, })); } /** * Get performance statistics for the discovery system */ export function getDiscoveryStats(registry: ComponentRegistry): { totalComponents: number; commandCount: number; hookCount: number; categoryCounts: Record<ComponentCategory, number>; dependencyCount: number; lastScanDuration: number; cacheStatus: 'valid' | 'invalid' | 'expired'; } { const now = new Date(); const cacheAge = now.getTime() - registry.lastScan.getTime(); let cacheStatus: 'valid' | 'invalid' | 'expired' = 'invalid'; if (registry.cacheValid) { cacheStatus = cacheAge < CACHE_DURATION ? 'valid' : 'expired'; } const categoryCounts: Partial<Record<ComponentCategory, number>> = {}; for (const [category, componentIds] of registry.categories) { categoryCounts[category] = componentIds.size; } return { totalComponents: registry.components.size, commandCount: getComponentsByType('command', registry).length, hookCount: getComponentsByType('hook', registry).length, categoryCounts: categoryCounts as Record<ComponentCategory, number>, dependencyCount: Array.from(registry.dependencies.values()).reduce( (sum, deps) => sum + deps.size, 0 ), lastScanDuration: cacheAge, cacheStatus, }; } // ============================================================================ // Component Recommendation Engine // ============================================================================ /** * Recommendation weight scores for different factors */ const RECOMMENDATION_WEIGHTS = { directMatch: 100, // Direct tool/framework match categoryMatch: 50, // Category relevance dependencyMatch: 30, // Has required dependencies commonPattern: 20, // Common project patterns optional: 10, // Nice-to-have components }; /** * Component recommendation with reasoning */ export interface ComponentRecommendation { component: ComponentFile; score: number; reasons: string[]; dependencies: string[]; isRequired: boolean; } /** * Recommendation result with grouped components */ export interface RecommendationResult { essential: ComponentRecommendation[]; recommended: ComponentRecommendation[]; optional: ComponentRecommendation[]; totalScore: number; } /** * Analyze project and recommend components * * @param projectInfo - Detected project information * @param registry - Component registry * @param options - Recommendation options * @returns Grouped component recommendations */ export async function recommendComponents( projectInfo: ProjectInfo, registry: ComponentRegistry, options: { includeOptional?: boolean; excludeCategories?: ComponentCategory[]; maxRecommendations?: number; } = {} ): Promise<RecommendationResult> { const { includeOptional = true, excludeCategories = [], maxRecommendations = 20 } = options; const recommendations = new Map<string, ComponentRecommendation>(); const processedDependencies = new Set<string>(); // Analyze each component for relevance for (const [id, component] of registry.components) { // Skip excluded categories if (excludeCategories.includes(component.metadata.category)) { continue; } const score = calculateRecommendationScore(component, projectInfo); const reasons = generateRecommendationReasons(component, projectInfo); if (score > 0) { // Resolve dependencies for this component const dependencies = resolveComponentDependencies(id, registry, processedDependencies); recommendations.set(id, { component, score, reasons, dependencies, isRequired: score >= RECOMMENDATION_WEIGHTS.directMatch, }); } } // Sort by score and categorize const sortedRecommendations = Array.from(recommendations.values()) .sort((a, b) => b.score - a.score) .slice(0, maxRecommendations); // Categorize recommendations const essential: ComponentRecommendation[] = []; const recommended: ComponentRecommendation[] = []; const optional: ComponentRecommendation[] = []; for (const rec of sortedRecommendations) { if (rec.score >= RECOMMENDATION_WEIGHTS.directMatch) { essential.push(rec); } else if (rec.score >= RECOMMENDATION_WEIGHTS.categoryMatch) { recommended.push(rec); } else if (includeOptional) { optional.push(rec); } } // Auto-include dependencies for essential components const allDependencies = new Set<string>(); for (const rec of essential) { rec.dependencies.forEach((dep) => allDependencies.add(dep)); } // Add dependency components if not already included for (const depId of allDependencies) { if (!recommendations.has(depId)) { const depComponent = registry.components.get(depId); if (depComponent && !excludeCategories.includes(depComponent.metadata.category)) { recommended.push({ component: depComponent, score: RECOMMENDATION_WEIGHTS.dependencyMatch, reasons: ['Required dependency for recommended components'], dependencies: [], isRequired: true, }); } } } const totalScore = [...essential, ...recommended, ...optional].reduce( (sum, rec) => sum + rec.score, 0 ); return { essential, recommended, optional, totalScore, }; } /** * Calculate recommendation score for a component */ function calculateRecommendationScore(component: ComponentFile, projectInfo: ProjectInfo): number { let score = 0; // TypeScript project checks if (projectInfo.hasTypeScript) { if ( component.metadata.id === 'typecheck-changed' || component.metadata.name.toLowerCase().includes('typescript') ) { score += RECOMMENDATION_WEIGHTS.directMatch; } if (component.metadata.category === 'validation') { score += RECOMMENDATION_WEIGHTS.categoryMatch / 2; } } // ESLint project checks if (projectInfo.hasESLint) { if ( component.metadata.id === 'lint-changed' || component.metadata.name.toLowerCase().includes('eslint') ) { score += RECOMMENDATION_WEIGHTS.directMatch; } } // Prettier checks if (projectInfo.hasPrettier === true) { if ( component.metadata.id === 'prettier' || component.metadata.name.toLowerCase().includes('prettier') ) { score += RECOMMENDATION_WEIGHTS.directMatch; } } // Testing framework checks if (projectInfo.hasJest === true || projectInfo.hasVitest === true) { if (component.metadata.id === 'test-changed' || component.metadata.category === 'testing') { score += RECOMMENDATION_WEIGHTS.directMatch; } } // Git repository checks if (projectInfo.isGitRepository === true) { if (component.metadata.category === 'git') { score += RECOMMENDATION_WEIGHTS.categoryMatch; } // Auto-checkpoint is highly recommended for git projects if (component.metadata.id === 'create-checkpoint') { score += RECOMMENDATION_WEIGHTS.directMatch; } } // Framework-specific recommendations if (projectInfo.frameworks !== undefined && projectInfo.frameworks.length > 0) { for (const framework of projectInfo.frameworks) { const frameworkLower = framework.toLowerCase(); const componentNameLower = component.metadata.name.toLowerCase(); const componentDescLower = component.metadata.description.toLowerCase(); if ( componentNameLower.includes(frameworkLower) || componentDescLower.includes(frameworkLower) ) { score += RECOMMENDATION_WEIGHTS.categoryMatch; } } } // Common patterns // All projects benefit from validation hooks if (component.metadata.category === 'validation' && !component.metadata.id.includes('lib')) { score += RECOMMENDATION_WEIGHTS.commonPattern; } // All projects benefit from development tools if (component.metadata.category === 'development') { score += RECOMMENDATION_WEIGHTS.optional; } // Project management tools are optional but useful if (component.metadata.category === 'project-management') { score += RECOMMENDATION_WEIGHTS.optional; } // Claude setup tools are optional but useful if (component.metadata.category === 'claude-setup') { score += RECOMMENDATION_WEIGHTS.optional; } return score; } /** * Generate human-readable reasons for recommendation */ function generateRecommendationReasons( component: ComponentFile, projectInfo: ProjectInfo ): string[] { const reasons: string[] = []; // TypeScript reasons if ( projectInfo.hasTypeScript && (component.metadata.id === 'typecheck-changed' || component.metadata.name.toLowerCase().includes('typescript')) ) { reasons.push('TypeScript detected - type checking recommended'); } // ESLint reasons if ( projectInfo.hasESLint && (component.metadata.id === 'lint-changed' || component.metadata.name.toLowerCase().includes('eslint')) ) { reasons.push('ESLint configuration found - linting automation recommended'); } // Prettier reasons if ( projectInfo.hasPrettier === true && (component.metadata.id === 'prettier' || component.metadata.name.toLowerCase().includes('prettier')) ) { reasons.push('Prettier configuration found - formatti