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