UNPKG

claudekit

Version:

CLI tools for Claude Code development workflow

588 lines (519 loc) 17.5 kB
import { Logger } from '../utils/logger.js'; import { loadConfig, loadUserConfig } from '../utils/config.js'; import type { Config } from '../types/config.js'; import { progress } from '../utils/progress.js'; import * as path from 'node:path'; import fs from 'fs-extra'; import { Colors } from '../utils/colors.js'; import { HOOK_REGISTRY } from '../hooks/registry.js'; import { CommandLoader } from '../lib/loaders/command-loader.js'; import { AgentLoader } from '../lib/loaders/agent-loader.js'; interface ListOptions { format?: 'table' | 'json'; filter?: string; verbose?: boolean; quiet?: boolean; } /** * List hooks, commands, or settings */ export async function list(type: string = 'all', options: ListOptions = {}): Promise<void> { const logger = new Logger(); if (options.verbose === true) { logger.setLevel('debug'); } else if (options.quiet === true) { logger.setLevel('error'); } logger.debug(`Listing ${type} with options:`, options); const validTypes = ['all', 'hooks', 'commands', 'agents', 'config']; if (!validTypes.includes(type)) { throw new Error(`Invalid type "${type}". Must be one of: ${validTypes.join(', ')}`); } type OperationResult = HookInfo[] | CommandInfo[] | AgentInfo[] | Record<string, unknown>; const operations: Array<{ name: string; operation: () => Promise<OperationResult> }> = []; // Prepare operations based on type if (type === 'all' || type === 'hooks') { operations.push({ name: 'Listing hooks', operation: () => listHooks(options) as Promise<OperationResult>, }); } if (type === 'all' || type === 'commands') { operations.push({ name: 'Listing commands', operation: () => listCommands(options) as Promise<OperationResult>, }); } if (type === 'all' || type === 'agents') { operations.push({ name: 'Listing agents', operation: () => listAgents(options) as Promise<OperationResult>, }); } if (type === 'all' || type === 'config') { operations.push({ name: 'Listing configuration', operation: () => listConfig(options) as Promise<OperationResult>, }); } // Execute operations with progress const operationResults = await progress.withSteps(operations, { quiet: options.quiet === true || options.format === 'json', // Suppress progress output for JSON format verbose: options.verbose, }); // Map results back to expected structure const results: ListResults = {}; let resultIndex = 0; if (type === 'all' || type === 'hooks') { const hooksResult = operationResults[resultIndex++]; if ( Array.isArray(hooksResult) && hooksResult.every((item): item is HookInfo => 'executable' in item) ) { results.hooks = hooksResult; } } if (type === 'all' || type === 'commands') { const commandsResult = operationResults[resultIndex++]; if ( Array.isArray(commandsResult) && commandsResult.every( (item): item is CommandInfo => 'description' in item && !('category' in item) ) ) { results.commands = commandsResult; } } if (type === 'all' || type === 'agents') { const agentsResult = operationResults[resultIndex++]; if ( Array.isArray(agentsResult) && agentsResult.every((item): item is AgentInfo => 'category' in item) ) { results.agents = agentsResult; } } if (type === 'all' || type === 'config') { const configResult = operationResults[resultIndex++]; if (configResult && typeof configResult === 'object' && !Array.isArray(configResult)) { results.config = configResult as Record<string, unknown>; } } // Output results if (options.format === 'json') { console.log(JSON.stringify(results, null, 2)); } else { displayTable(results, type); } } interface HookInfo { name: string; type: string; path: string; executable: boolean; size: number; modified: Date; source: string; events: string[]; } async function listHooks(options: ListOptions): Promise<HookInfo[]> { const hooks: HookInfo[] = []; const pattern = options.filter !== undefined && options.filter !== '' ? new RegExp(options.filter, 'i') : null; // Load project and user configurations let projectConfig: Config; try { projectConfig = await loadConfig(process.cwd()); } catch { projectConfig = { hooks: {} }; } const userConfig = await loadUserConfig(); // Extract hook configurations from both sources const configuredHooks = new Map<string, { source: string; events: string[] }>(); // Process project hooks first (higher priority) processHookConfigurations(projectConfig, configuredHooks, 'project'); // Process user hooks second (lower priority) processHookConfigurations(userConfig, configuredHooks, 'user'); // List embedded hooks from the registry for (const [name] of Object.entries(HOOK_REGISTRY)) { if (pattern !== null && !pattern.test(name)) { continue; } const configured = configuredHooks.get(name); const source = configured?.source ?? 'not installed'; const events = configured?.events ?? []; hooks.push({ name, type: 'embedded-hook', path: `embedded:${name}`, executable: true, // Embedded hooks are always executable size: 0, // Size not applicable for embedded hooks modified: new Date(), // Use current date for embedded hooks source, events, }); } return hooks; } /** * Process hook configurations from a config object and update the configuredHooks Map * @param config - Configuration object containing hooks * @param configuredHooks - Map to store hook configurations * @param source - Source type ('project' or 'user') */ function processHookConfigurations( config: Config, configuredHooks: Map<string, { source: string; events: string[] }>, source: 'project' | 'user' ): void { if (config.hooks !== undefined && Object.keys(config.hooks).length > 0) { for (const [eventType, matchers] of Object.entries(config.hooks)) { if (matchers !== undefined && Array.isArray(matchers) && matchers.length > 0) { for (const matcher of matchers) { for (const hook of matcher.hooks) { const hookName = extractHookName(hook.command); if (hookName !== null && hookName !== '') { const existing = configuredHooks.get(hookName); if (existing !== undefined) { // Add event to existing hook if not already present if (!existing.events.includes(eventType)) { existing.events.push(eventType); } // Don't override project source with user source (project has priority) if (existing.source !== 'project') { existing.source = source; } } else { // Create new hook configuration configuredHooks.set(hookName, { source, events: [eventType], }); } } } } } } } } /** * Extract hook name from command string * e.g., "claudekit-hooks run typecheck-changed" -> "typecheck-changed" */ function extractHookName(command: string): string | null { const match = command.match(/claudekit-hooks\s+run\s+([\w-]+)/); return match?.[1] ?? null; } interface CommandInfo { name: string; type: string; path: string; description: string; source: string; namespace: string; size: number; tokens: number; modified: Date; } interface AgentInfo { name: string; type: string; path: string; description: string; source: string; category: string; size: number; tokens: number; modified: Date; } async function listCommands(options: ListOptions): Promise<CommandInfo[]> { const loader = new CommandLoader(); const allCommands = await loader.getAllCommands(); const commands: CommandInfo[] = []; const pattern = options.filter !== undefined && options.filter !== '' ? new RegExp(options.filter, 'i') : null; for (const { id, source, path: cmdPath } of allCommands) { if (pattern !== null && !pattern.test(id)) { continue; } try { const stats = await fs.stat(cmdPath); // Extract frontmatter data const { frontmatter, tokens } = await extractFrontmatter(cmdPath); const description = frontmatter.description ?? ''; // Determine namespace from command ID (e.g., "spec:create" -> "spec") const namespace = id.includes(':') ? (id.split(':')[0] ?? 'general') : 'general'; commands.push({ name: id, type: 'command', path: cmdPath, description, source, namespace, size: stats.size, tokens, modified: stats.mtime, }); } catch { // File might not be readable } } return commands; } async function listAgents(options: ListOptions): Promise<AgentInfo[]> { const loader = new AgentLoader(); const allAgents = await loader.getAllAgents(); const agents: AgentInfo[] = []; const pattern = options.filter !== undefined && options.filter !== '' ? new RegExp(options.filter, 'i') : null; for (const { id, source, path: agentPath } of allAgents) { try { const stats = await fs.stat(agentPath); // Extract frontmatter data const { frontmatter, tokens } = await extractFrontmatter(agentPath); const displayName = frontmatter.name ?? id; // Filter by display name, not ID if (pattern !== null && !pattern.test(displayName)) { continue; } const description = frontmatter.description ?? ''; // Use category from frontmatter, fallback to path-based detection const category = typeof frontmatter.category === 'string' && frontmatter.category !== '' ? frontmatter.category : ((): string => { const pathParts = agentPath.split(path.sep); const agentsIndex = pathParts.lastIndexOf('agents'); const nextPart = pathParts[agentsIndex + 1]; return agentsIndex >= 0 && nextPart !== undefined && nextPart !== `${id}.md` ? nextPart : 'general'; })(); agents.push({ name: displayName, type: 'agent', path: agentPath, description, source, category, size: stats.size, tokens, modified: stats.mtime, }); } catch { // File might not be readable } } return agents; } async function listConfig(options: ListOptions): Promise<Record<string, unknown>> { try { const config = await loadConfig(process.cwd()); const pattern = options.filter !== undefined && options.filter !== '' ? new RegExp(options.filter, 'i') : null; if (pattern !== null) { // Filter config keys const filtered: Record<string, unknown> = {}; for (const [key, value] of Object.entries(config)) { if (pattern.test(key)) { filtered[key] = value; } } return filtered; } return config; } catch { return {}; } } // Interface needs to be defined before usage interface ListResults { hooks?: Array<{ name: string; type: string; path: string; executable: boolean; size: number; modified: Date; source: string; events: string[]; }>; commands?: Array<{ name: string; type: string; path: string; description: string; source: string; namespace: string; size: number; tokens: number; modified: Date; }>; agents?: Array<{ name: string; type: string; path: string; description: string; source: string; category: string; size: number; tokens: number; modified: Date; }>; config?: Record<string, unknown>; } function displayTable(results: ListResults, type: string): void { // Display hooks if (results.hooks !== undefined && results.hooks.length > 0) { console.log(Colors.bold('\nHooks:')); console.log(Colors.dim('─'.repeat(80))); for (const hook of results.hooks) { const source = Colors.dim(`[${hook.source}]`); const events = hook.events.length > 0 ? hook.events.join(', ') : ''; const eventsFormatted = events ? Colors.dim(events) : ''; console.log( ` ${Colors.accent(hook.name.padEnd(30))} ${source.padEnd(18)} ${eventsFormatted}` ); } } else if (type === 'hooks' || type === 'all') { console.log(Colors.dim('\nNo hooks found')); } // Display commands if (results.commands !== undefined && results.commands.length > 0) { console.log(Colors.bold('\nCommands:')); console.log(Colors.dim('─'.repeat(80))); // Group commands by namespace const grouped = results.commands.reduce( (acc, cmd) => { if (!acc[cmd.namespace]) { acc[cmd.namespace] = []; } const namespaceCommands = acc[cmd.namespace]; if (namespaceCommands !== undefined) { namespaceCommands.push(cmd); } return acc; }, {} as Record<string, typeof results.commands> ); // Display by namespace for (const [namespace, namespaceCommands] of Object.entries(grouped)) { if (namespaceCommands !== undefined && namespaceCommands.length > 0) { console.log(` ${Colors.dim(`${namespace}:`)}`); for (const cmd of namespaceCommands) { const tokens = formatTokens(cmd.tokens ?? estimateTokens('')); const source = Colors.dim(`[${cmd.source}]`); console.log( ` ${Colors.accent(cmd.name.padEnd(30))} ${source.padEnd(12)} ${tokens.padStart(12)}` ); } } } } else if (type === 'commands' || type === 'all') { console.log(Colors.dim('\nNo commands found')); } // Display agents if (results.agents !== undefined && results.agents.length > 0) { console.log(Colors.bold('\nAgents:')); console.log(Colors.dim('─'.repeat(80))); // Group agents by category const grouped = results.agents.reduce( (acc, agent) => { if (!acc[agent.category]) { acc[agent.category] = []; } const categoryAgents = acc[agent.category]; if (categoryAgents !== undefined) { categoryAgents.push(agent); } return acc; }, {} as Record<string, typeof results.agents> ); // Display by category for (const [category, categoryAgents] of Object.entries(grouped)) { if (categoryAgents !== undefined && categoryAgents.length > 0) { console.log(` ${Colors.dim(`${category}:`)}`); for (const agent of categoryAgents) { const tokens = formatTokens(agent.tokens); const source = Colors.dim(`[${agent.source}]`); console.log( ` ${Colors.accent(agent.name.padEnd(30))} ${source.padEnd(12)} ${tokens.padStart(12)}` ); } } } } else if (type === 'agents' || type === 'all') { console.log(Colors.dim('\nNo agents found')); } // Display config if (results.config !== undefined && Object.keys(results.config).length > 0) { console.log(Colors.bold('\nConfiguration:')); console.log(Colors.dim('─'.repeat(60))); const configStr = JSON.stringify(results.config, null, 2); const lines = configStr.split('\n'); for (const line of lines) { console.log(` ${line}`); } } else if (type === 'config' || type === 'all') { console.log(Colors.dim('\nNo configuration found')); } } interface FrontmatterData { name?: string; description?: string; category?: string; [key: string]: unknown; } /** * Extract frontmatter data from a markdown file */ async function extractFrontmatter(filePath: string): Promise<{ content: string; frontmatter: FrontmatterData; tokens: number; }> { try { const content = await fs.readFile(filePath, 'utf8'); const tokens = estimateTokens(content); const frontmatter: FrontmatterData = {}; const match = content.match(/^---\s*\n([\s\S]*?)\n---/); if (match !== null && match[1] !== undefined && match[1] !== '') { const frontmatterText = match[1]; // Extract common fields const nameMatch = frontmatterText.match(/name:\s*(.+)/); if (nameMatch !== null && nameMatch[1] !== undefined && nameMatch[1] !== '') { frontmatter.name = nameMatch[1].trim(); } const descMatch = frontmatterText.match(/description:\s*(.+)/); if (descMatch !== null && descMatch[1] !== undefined && descMatch[1] !== '') { frontmatter.description = descMatch[1].trim(); } const categoryMatch = frontmatterText.match(/category:\s*(.+)/); if (categoryMatch !== null && categoryMatch[1] !== undefined && categoryMatch[1] !== '') { frontmatter.category = categoryMatch[1].trim(); } } return { content, frontmatter, tokens }; } catch { return { content: '', frontmatter: {}, tokens: 0, }; } } function estimateTokens(text: string): number { // Rough estimation: ~1 token per 4 characters for English text // This is a simplified heuristic that works reasonably well for markdown/code return Math.ceil(text.length / 4); } function formatTokens(tokens: number): string { if (tokens < 1000) { return `${tokens} tokens`; } return `${(tokens / 1000).toFixed(1)}k tokens`; }