/** * Command Tree Parser * * Discovers all CLI commands by parsing --help output. * Used for automated completion testing to ensure 100% coverage. */ import type { CLIContext } from '../test-utils/cli-context'; /** * A node in the command tree */ export interface CommandNode { name: string; subcommands: CommandNode[]; dynamicArgs: string[]; // e.g., ['', ''] level: number; // depth in tree (0 = top-level) } /** * Parser that discovers command structure from help text */ export class CommandTreeParser { constructor(private cli: CLIContext) {} /** * Discover all commands by parsing help output * * @returns Map of command name to CommandNode */ async discover(): Promise> { // Get top-level commands const helpResult = await this.cli.run('--help'); const topLevelCommands = this.parseHelpText(helpResult.stdout); const tree = new Map(); // For each top-level command, discover its subcommands for (const cmdName of topLevelCommands) { try { const subHelpResult = await this.cli.run(`${cmdName} --help`); const subcommandNames = this.parseHelpText(subHelpResult.stdout); // Check if subcommands are actually the same as top-level commands // This happens when a command like 'help' just shows the main help again const topLevelSet = new Set(topLevelCommands); const allSubcommandsAreTopLevel = subcommandNames.every((name) => topLevelSet.has(name)); let subcommands: CommandNode[]; if (allSubcommandsAreTopLevel && subcommandNames.length > 1) { // Command doesn't have real subcommands, it just shows main help subcommands = []; } else { subcommands = subcommandNames.map((name) => ({ name, subcommands: [], dynamicArgs: [], level: 1, })); } tree.set(cmdName, { name: cmdName, subcommands, dynamicArgs: [], level: 0, }); } catch (_error) { // Command has no subcommands (or help failed) tree.set(cmdName, { name: cmdName, subcommands: [], dynamicArgs: [], level: 0, }); } } return tree; } /** * Parse help text to extract command names * * Matches lines like: * module Manage modules (import, generate, configure) * service Manage container services * help, --help, -h Show this help message * * @param helpText - Output from --help * @returns Array of command names */ private parseHelpText(helpText: string): string[] { const commands: string[] = []; // Strip ANSI color codes const cleanText = this.stripAnsiCodes(helpText); // Split into lines const lines = cleanText.split('\n'); // Track whether we're in a Commands/Subcommands section let inCommandSection = false; for (const line of lines) { // Check if we're entering a Commands section // Match " Commands:" or "│ Commands:" if (/^[│\s]*(Commands|Subcommands):\s*$/i.test(line)) { inCommandSection = true; continue; } // Check if we're leaving the Commands section // Look for major section headers that appear with little/no indentation // Examples: "Usage:", "Options:", "Description:", " For command-specific help:" // NOTE: Must NOT match nested " Options:" (4-space indent) inside subcommand listings if ( inCommandSection && /^[│\s]{0,3}(Usage|Options|Description|Examples|For|Enable|Related):/i.test(line) ) { inCommandSection = false; continue; } // Only parse commands if we're in a command section if (!inCommandSection) { continue; } // Match lines that may start with box-drawing chars, then whitespace, then a command name // Patterns to match: // "│ command-name Description" // "│ command-name Description" // "│ command-name subcommand Description" // " command-name Description" const match = line.match(/^[│\s]*([a-z][a-z0-9-]*)(?:\s{2,}|\s+[