UNPKG

aiwg

Version:

Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.

459 lines (397 loc) 11.2 kB
/** * CLI Analyzer for MCPsmith * * Parses CLI tool help output to extract tool definitions. * * @architecture @.aiwg/architecture/mcpsmith-architecture.md - Section 5.1 * @implements @.aiwg/architecture/decisions/ADR-014-mcpsmith-mcp-server-generator.md */ import { spawn } from 'child_process'; import type { AnalyzerResult, CLIAnalyzerOptions, MCPToolDefinition, ParsedCLIHelp, ParsedSubcommand, ParsedFlag } from '../types.js'; /** * Analyze a CLI tool and extract MCP tool definitions */ export async function analyzeCLI(options: CLIAnalyzerOptions): Promise<AnalyzerResult> { const { command, includeSubcommands = true, // maxDepth and parseManPage reserved for future use timeout = 5000 } = options; // Get command version const version = await getCommandVersion(command, timeout); // Get help output const helpText = await getHelpOutput(command, timeout); // Parse help text const parsed = parseHelpText(helpText, command); // Extract tool definitions const tools: MCPToolDefinition[] = []; // If there are subcommands, generate tools for each if (includeSubcommands && parsed.subcommands.length > 0) { for (const subcommand of parsed.subcommands) { const tool = subcommandToTool(command, subcommand, parsed.globalFlags); tools.push(tool); } } else { // Generate single tool for the command const tool = commandToTool(command, parsed); tools.push(tool); } return { tools, metadata: { sourceType: 'cli', sourceName: command, sourceVersion: version, discoveredAt: new Date().toISOString(), toolCount: tools.length } }; } /** * Get command version */ async function getCommandVersion(command: string, timeout: number): Promise<string | undefined> { try { // Try common version flags const versionFlags = ['--version', '-v', 'version', '-V']; for (const flag of versionFlags) { try { const output = await executeCommand(command, [flag], timeout); if (output && output.trim().length > 0) { // Extract version number from output const versionMatch = output.match(/\d+\.\d+(\.\d+)?/); if (versionMatch) { return versionMatch[0]; } // Return first line if no version number found return output.split('\n')[0].trim(); } } catch { continue; } } return undefined; } catch { return undefined; } } /** * Get help output for command */ async function getHelpOutput(command: string, timeout: number): Promise<string> { const helpFlags = ['--help', '-h', 'help']; for (const flag of helpFlags) { try { const output = await executeCommand(command, [flag], timeout); if (output && output.trim().length > 0) { return output; } } catch { continue; } } throw new Error(`Could not get help output for command: ${command}`); } /** * Execute command and capture output */ function executeCommand(command: string, args: string[], timeout: number): Promise<string> { return new Promise((resolve, reject) => { const proc = spawn(command, args, { timeout, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.on('close', (code) => { // Many commands output help to stderr const output = stdout || stderr; if (output.trim().length > 0) { resolve(output); } else { reject(new Error(`Command exited with code ${code}`)); } }); proc.on('error', reject); }); } /** * Parse help text into structured format */ function parseHelpText(helpText: string, command: string): ParsedCLIHelp { const lines = helpText.split('\n'); // Extract description (usually first non-empty line or after "NAME:") const description = extractDescription(lines); // Extract subcommands const subcommands = extractSubcommands(lines); // Extract global flags/options const globalFlags = extractFlags(lines); return { command, description, subcommands, globalFlags }; } /** * Extract description from help text */ function extractDescription(lines: string[]): string { // Look for description after NAME: or DESCRIPTION: for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (/^(NAME|DESCRIPTION):/i.test(line)) { // Next line is usually the description if (i + 1 < lines.length) { return lines[i + 1].trim(); } } } // Otherwise use first non-empty line for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('-') && !trimmed.startsWith('Usage:')) { return trimmed; } } return 'CLI tool'; } /** * Extract subcommands from help text */ function extractSubcommands(lines: string[]): ParsedSubcommand[] { const subcommands: ParsedSubcommand[] = []; let inSubcommandsSection = false; // Patterns for subcommand sections const sectionPatterns = /^(COMMANDS|SUBCOMMANDS|Available commands):/i; // Pattern for subcommand line: " command description" const subcommandPattern = /^ ([a-z][a-z0-9-_]*)\s{2,}(.+)$/i; for (const line of lines) { // Check if we're entering subcommands section if (sectionPatterns.test(line)) { inSubcommandsSection = true; continue; } // Check if we're leaving section (next section header or empty line) if (inSubcommandsSection && (/^[A-Z\s]+:/.test(line) || line.trim() === '')) { if (/^[A-Z\s]+:/.test(line)) { inSubcommandsSection = false; } continue; } // Parse subcommand line if (inSubcommandsSection) { const match = line.match(subcommandPattern); if (match) { subcommands.push({ name: match[1], description: match[2].trim(), flags: [], arguments: [] }); } } } return subcommands; } /** * Extract flags/options from help text */ function extractFlags(lines: string[]): ParsedFlag[] { const flags: ParsedFlag[] = []; let inOptionsSection = false; // Patterns for options section const sectionPatterns = /^(OPTIONS|FLAGS):/i; // Pattern for flag line: " -s, --long <arg> description" const flagPattern = /^\s+(-[a-z])?,?\s*(--[a-z][a-z0-9-_]*)?(?:\s+<([^>]+)>|\s+\[([^\]]+)\])?\s{2,}(.+)$/i; for (const line of lines) { // Check if we're entering options section if (sectionPatterns.test(line)) { inOptionsSection = true; continue; } // Check if we're leaving section if (inOptionsSection && /^[A-Z\s]+:/.test(line)) { inOptionsSection = false; continue; } // Parse flag line if (inOptionsSection) { const match = line.match(flagPattern); if (match) { const [, short, long, requiredArg, optionalArg, description] = match; const hasArg = !!(requiredArg || optionalArg); flags.push({ short, long, description: description.trim(), type: hasArg ? 'string' : 'boolean', required: !!requiredArg }); } } } return flags; } /** * Convert subcommand to MCP tool definition */ function subcommandToTool( command: string, subcommand: ParsedSubcommand, globalFlags: ParsedFlag[] ): MCPToolDefinition { const toolName = `${command}-${subcommand.name}`; const mapping: Record<string, any> = {}; const properties: Record<string, any> = {}; const required: string[] = []; // Add flags to schema const allFlags = [...globalFlags, ...subcommand.flags]; for (const flag of allFlags) { const paramName = (flag.long || flag.short || '').replace(/^-+/, ''); if (!paramName) continue; mapping[paramName] = { flag: flag.long || flag.short, type: flag.type }; properties[paramName] = { type: flag.type, description: flag.description }; if (flag.required) { required.push(paramName); } if (flag.default) { properties[paramName].default = flag.default; } } // Add arguments to schema for (const arg of subcommand.arguments) { mapping[arg.name] = { position: arg.position, type: arg.type }; properties[arg.name] = { type: arg.type, description: arg.description }; if (arg.required) { required.push(arg.name); } } return { name: toolName, title: `${command} ${subcommand.name}`, description: subcommand.description, source: { type: 'cli', command, subcommand: subcommand.name, mapping }, inputSchema: { type: 'object', properties, required: required.length > 0 ? required : undefined }, examples: [], metadata: { dangerous: isDangerousCommand(command, subcommand.name), requiresConfirmation: requiresConfirmation(command, subcommand.name), retryable: true } }; } /** * Convert command to MCP tool definition (for commands without subcommands) */ function commandToTool(command: string, parsed: ParsedCLIHelp): MCPToolDefinition { const mapping: Record<string, any> = {}; const properties: Record<string, any> = {}; const required: string[] = []; // Add flags to schema for (const flag of parsed.globalFlags) { const paramName = (flag.long || flag.short || '').replace(/^-+/, ''); if (!paramName) continue; mapping[paramName] = { flag: flag.long || flag.short, type: flag.type }; properties[paramName] = { type: flag.type, description: flag.description }; if (flag.required) { required.push(paramName); } if (flag.default) { properties[paramName].default = flag.default; } } return { name: command, title: command, description: parsed.description, source: { type: 'cli', command, mapping }, inputSchema: { type: 'object', properties, required: required.length > 0 ? required : undefined }, examples: [], metadata: { dangerous: isDangerousCommand(command), requiresConfirmation: requiresConfirmation(command), retryable: true } }; } /** * Check if command/subcommand is dangerous */ function isDangerousCommand(command: string, subcommand?: string): boolean { const dangerousPatterns = [ /rm/i, /delete/i, /remove/i, /format/i, /erase/i, /destroy/i, /force/i, /hard/i ]; const fullCommand = subcommand ? `${command}-${subcommand}` : command; return dangerousPatterns.some(pattern => pattern.test(fullCommand)); } /** * Check if command requires confirmation */ function requiresConfirmation(command: string, subcommand?: string): boolean { const confirmationPatterns = [ /push/i, /deploy/i, /publish/i, /reset/i, /rebase/i ]; const fullCommand = subcommand ? `${command}-${subcommand}` : command; return confirmationPatterns.some(pattern => pattern.test(fullCommand)); }