claude-flow
Version:
Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration
544 lines (476 loc) • 16.4 kB
text/typescript
/**
* V3 CLI Command Parser
* Advanced argument parsing with validation and type coercion
*/
import type { Command, CommandOption, ParsedFlags, CommandContext, V3Config } from './types.js';
export interface ParseResult {
command: string[];
flags: ParsedFlags;
positional: string[];
raw: string[];
}
export interface ParserOptions {
stopAtFirstNonFlag?: boolean;
allowUnknownFlags?: boolean;
booleanFlags?: string[];
stringFlags?: string[];
arrayFlags?: string[];
aliases?: Record<string, string>;
defaults?: Record<string, unknown>;
}
export class CommandParser {
private options: ParserOptions;
private commands: Map<string, Command> = new Map();
private lazyCommandNames: Set<string> = new Set();
private globalOptions: CommandOption[] = [];
constructor(options: ParserOptions = {}) {
this.options = {
stopAtFirstNonFlag: false,
allowUnknownFlags: false,
...options
};
this.initializeGlobalOptions();
}
private initializeGlobalOptions(): void {
this.globalOptions = [
{
name: 'help',
short: 'h',
description: 'Show help information',
type: 'boolean',
default: false
},
{
name: 'version',
short: 'V',
description: 'Show version number',
type: 'boolean',
default: false
},
{
name: 'verbose',
short: 'v',
description: 'Enable verbose output',
type: 'boolean',
default: false
},
{
name: 'quiet',
short: 'Q',
description: 'Suppress non-essential output',
type: 'boolean',
default: false
},
{
name: 'config',
short: 'c',
description: 'Path to configuration file',
type: 'string'
},
{
name: 'format',
// Note: removed global short flag 'f' — it collides with 50+ subcommand
// flags (force, follow, file, feature, full) causing unpredictable behavior
// depending on parser resolution order (#1425). Use --format instead.
description: 'Output format (text, json, table)',
type: 'string',
default: 'text',
choices: ['text', 'json', 'table']
},
{
name: 'no-color',
description: 'Disable colored output',
type: 'boolean',
default: false
},
{
name: 'interactive',
short: 'i',
description: 'Enable interactive mode',
type: 'boolean',
default: true
}
];
}
registerCommand(command: Command): void {
this.commands.set(command.name, command);
if (command.aliases) {
for (const alias of command.aliases) {
this.commands.set(alias, command);
}
}
}
/**
* Register a lazy-loaded command's name so Pass 1/Pass 2 can recognize it as
* a command position even though its full definition hasn't been loaded yet.
* Fix for #1596: without this, lazy commands like `daemon start` were
* mis-routed because Pass 1 walked past `daemon` and greedy-matched `start`.
*/
registerLazyCommandName(name: string): void {
this.lazyCommandNames.add(name);
}
/**
* #1791.2 — true when `name` is a lazy command that hasn't been promoted
* to a fully registered Command yet. The CLI uses this to eagerly load
* the module before parsing so its subcommand flags (e.g. `-d` for
* `hive-mind task --description`) are scoped into the alias map. Without
* this, lazy commands' short flags silently fall through to global
* resolution and the action handler sees an empty `flags.description`.
*/
isLazyOnly(name: string): boolean {
return this.lazyCommandNames.has(name) && !this.commands.has(name);
}
private isKnownCommandName(name: string): boolean {
return this.commands.has(name) || this.lazyCommandNames.has(name);
}
getCommand(name: string): Command | undefined {
return this.commands.get(name);
}
getAllCommands(): Command[] {
// Return unique commands (filter out aliases)
const seen = new Set<Command>();
return Array.from(this.commands.values()).filter(cmd => {
if (seen.has(cmd)) return false;
seen.add(cmd);
return true;
});
}
parse(args: string[]): ParseResult {
const result: ParseResult = {
command: [],
flags: { _: [] },
positional: [],
raw: [...args]
};
// Pass 1: Identify command and subcommand (skip flags).
// Fix for #1596: the first non-flag positional is ALWAYS the command slot.
// If it's a known command (sync or lazy) we resolve it; otherwise we stop
// searching — we MUST NOT walk past it and greedy-match a later arg as the
// command, because that's what caused `daemon start` to resolve as `start`
// with `daemon` left as a positional.
let resolvedCmd: Command | undefined;
let resolvedSub: Command | undefined;
let sawFirstPositional = false;
for (const arg of args) {
if (arg.startsWith('-')) continue;
if (!sawFirstPositional) {
sawFirstPositional = true;
if (this.commands.has(arg)) {
resolvedCmd = this.commands.get(arg);
continue;
}
// Lazy command: we know its name but not its subcommands. Stop the
// walk here — we'll rely on Pass 2 to push it onto commandPath.
if (this.lazyCommandNames.has(arg)) {
break;
}
// Unknown first positional — not a command. Stop walking.
break;
}
if (resolvedCmd && !resolvedSub && resolvedCmd.subcommands) {
resolvedSub = resolvedCmd.subcommands.find(sc => sc.name === arg || sc.aliases?.includes(arg));
}
}
// Pass 2: Build aliases scoped to the resolved subcommand
// Subcommand-specific aliases take priority over global ones
const aliases = this.buildScopedAliases(resolvedSub || resolvedCmd);
const booleanFlags = this.getScopedBooleanFlags(resolvedSub || resolvedCmd);
let i = 0;
let parsingFlags = true;
while (i < args.length) {
const arg = args[i];
// Check for end of flags marker
if (arg === '--') {
parsingFlags = false;
i++;
continue;
}
// Handle flags
if (parsingFlags && arg.startsWith('-')) {
const parseResult = this.parseFlag(args, i, aliases, booleanFlags);
// Apply to result flags
Object.assign(result.flags, parseResult.flags);
i = parseResult.nextIndex;
continue;
}
// Handle positional arguments.
// Fix for #1596: treat lazy command names as commands here too so that
// downstream dispatch sees `commandPath = ['daemon', 'start']` instead of
// `commandPath = ['start'], positional = ['daemon']`.
if (result.command.length === 0 && this.isKnownCommandName(arg)) {
// This is a command
result.command.push(arg);
// Check for subcommand (level 1) — only possible for sync commands
// whose subcommand definitions are already loaded.
const cmd = this.commands.get(arg);
if (cmd?.subcommands && i + 1 < args.length) {
const nextArg = args[i + 1];
const subCmd = cmd.subcommands.find(sc => sc.name === nextArg || sc.aliases?.includes(nextArg));
if (subCmd) {
result.command.push(nextArg);
i++;
// Check for nested subcommand (level 2)
if (subCmd.subcommands && i + 1 < args.length) {
const nestedArg = args[i + 1];
const nestedCmd = subCmd.subcommands.find(sc => sc.name === nestedArg || sc.aliases?.includes(nestedArg));
if (nestedCmd) {
result.command.push(nestedArg);
i++;
// Check for deeply nested subcommand (level 3)
if (nestedCmd.subcommands && i + 1 < args.length) {
const deepArg = args[i + 1];
const deepCmd = nestedCmd.subcommands.find(sc => sc.name === deepArg || sc.aliases?.includes(deepArg));
if (deepCmd) {
result.command.push(deepArg);
i++;
}
}
}
}
}
}
} else {
// Positional argument
result.positional.push(arg);
result.flags._.push(arg);
}
i++;
}
// Apply defaults
this.applyDefaults(result.flags);
return result;
}
private parseFlag(
args: string[],
index: number,
aliases: Record<string, string>,
booleanFlags: Set<string>
): { flags: ParsedFlags; nextIndex: number } {
const flags: ParsedFlags = { _: [] };
const arg = args[index];
let nextIndex = index + 1;
if (arg.startsWith('--')) {
// Long flag
const equalIndex = arg.indexOf('=');
if (equalIndex !== -1) {
// --flag=value
const key = arg.slice(2, equalIndex);
const value = arg.slice(equalIndex + 1);
flags[this.normalizeKey(key)] = this.parseValue(value);
} else if (arg.startsWith('--no-')) {
// --no-flag (boolean negation)
const key = arg.slice(5);
flags[this.normalizeKey(key)] = false;
} else {
const key = arg.slice(2);
const normalizedKey = this.normalizeKey(key);
if (booleanFlags.has(normalizedKey)) {
flags[normalizedKey] = true;
} else if (nextIndex < args.length && !args[nextIndex].startsWith('-')) {
flags[normalizedKey] = this.parseValue(args[nextIndex]);
nextIndex++;
} else {
flags[normalizedKey] = true;
}
}
} else if (arg.startsWith('-')) {
// Short flag(s)
const chars = arg.slice(1);
if (chars.length === 1) {
// Single short flag
const key = aliases[chars] || chars;
const normalizedKey = this.normalizeKey(key);
if (booleanFlags.has(normalizedKey)) {
flags[normalizedKey] = true;
} else if (nextIndex < args.length && !args[nextIndex].startsWith('-')) {
flags[normalizedKey] = this.parseValue(args[nextIndex]);
nextIndex++;
} else {
flags[normalizedKey] = true;
}
} else {
// Multiple short flags combined (e.g., -abc)
for (const char of chars) {
const key = aliases[char] || char;
flags[this.normalizeKey(key)] = true;
}
}
}
return { flags, nextIndex };
}
private parseValue(value: string): string | number | boolean {
// Boolean
if (value.toLowerCase() === 'true') return true;
if (value.toLowerCase() === 'false') return false;
// Number
const num = Number(value);
if (!isNaN(num) && value.trim() !== '') return num;
// String
return value;
}
private normalizeKey(key: string): string {
// Convert kebab-case to camelCase
return key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
}
private buildAliases(): Record<string, string> {
const aliases: Record<string, string> = {};
for (const opt of this.globalOptions) {
if (opt.short) {
aliases[opt.short] = opt.name;
}
}
// Add aliases from all commands and subcommands
for (const cmd of this.commands.values()) {
if (cmd.options) {
for (const opt of cmd.options) {
if (opt.short) {
aliases[opt.short] = opt.name;
}
}
}
// Also include subcommands' options
if (cmd.subcommands) {
for (const sub of cmd.subcommands) {
if (sub.options) {
for (const opt of sub.options) {
if (opt.short) {
aliases[opt.short] = opt.name;
}
}
}
}
}
}
return { ...aliases, ...this.options.aliases };
}
/**
* Build aliases scoped to a specific command/subcommand.
* The resolved command's short flags take priority over global ones,
* fixing collisions where multiple subcommands use the same short flag (e.g. -t).
*/
private buildScopedAliases(resolvedCmd?: Command): Record<string, string> {
// Start with global aliases as base
const aliases = this.buildAliases();
// Override with the resolved command's own options (these take priority)
if (resolvedCmd?.options) {
for (const opt of resolvedCmd.options) {
if (opt.short) {
aliases[opt.short] = opt.name;
}
}
}
return aliases;
}
/**
* Get boolean flags scoped to a specific command/subcommand.
*/
private getScopedBooleanFlags(resolvedCmd?: Command): Set<string> {
const flags = this.getBooleanFlags();
if (resolvedCmd?.options) {
for (const opt of resolvedCmd.options) {
if (opt.type === 'boolean') {
flags.add(this.normalizeKey(opt.name));
}
}
}
return flags;
}
private getBooleanFlags(): Set<string> {
const flags = new Set<string>();
for (const opt of this.globalOptions) {
if (opt.type === 'boolean') {
flags.add(this.normalizeKey(opt.name));
}
}
// Add boolean flags from all commands and subcommands
for (const cmd of this.commands.values()) {
if (cmd.options) {
for (const opt of cmd.options) {
if (opt.type === 'boolean') {
flags.add(this.normalizeKey(opt.name));
}
}
}
// Also include subcommands' boolean flags
if (cmd.subcommands) {
for (const sub of cmd.subcommands) {
if (sub.options) {
for (const opt of sub.options) {
if (opt.type === 'boolean') {
flags.add(this.normalizeKey(opt.name));
}
}
}
}
}
}
if (this.options.booleanFlags) {
for (const flag of this.options.booleanFlags) {
flags.add(this.normalizeKey(flag));
}
}
return flags;
}
private applyDefaults(flags: ParsedFlags): void {
// Apply global option defaults
for (const opt of this.globalOptions) {
const key = this.normalizeKey(opt.name);
if (flags[key] === undefined && opt.default !== undefined) {
flags[key] = opt.default as string | boolean | number | string[];
}
}
// Apply custom defaults
if (this.options.defaults) {
for (const [key, value] of Object.entries(this.options.defaults)) {
const normalizedKey = this.normalizeKey(key);
if (flags[normalizedKey] === undefined) {
flags[normalizedKey] = value as string | boolean | number | string[];
}
}
}
}
validateFlags(flags: ParsedFlags, command?: Command): string[] {
const errors: string[] = [];
const allOptions = [...this.globalOptions];
if (command?.options) {
allOptions.push(...command.options);
}
// Check required flags
for (const opt of allOptions) {
const key = this.normalizeKey(opt.name);
if (opt.required && (flags[key] === undefined || flags[key] === '')) {
errors.push(`Required option missing: --${opt.name}`);
}
// Check choices
if (opt.choices && flags[key] !== undefined) {
const value = String(flags[key]);
if (!opt.choices.includes(value)) {
errors.push(`Invalid value for --${opt.name}: ${value}. Must be one of: ${opt.choices.join(', ')}`);
}
}
// Run custom validator
if (opt.validate && flags[key] !== undefined) {
const result = opt.validate(flags[key]);
if (result !== true) {
errors.push(typeof result === 'string' ? result : `Invalid value for --${opt.name}`);
}
}
}
// Check for unknown flags if not allowed
if (!this.options.allowUnknownFlags) {
const knownFlags = new Set(allOptions.map(opt => this.normalizeKey(opt.name)));
knownFlags.add('_'); // Positional args
for (const key of Object.keys(flags)) {
if (!knownFlags.has(key) && key !== '_') {
errors.push(`Unknown option: --${key}`);
}
}
}
return errors;
}
getGlobalOptions(): CommandOption[] {
return [...this.globalOptions];
}
}
// Export singleton parser instance
export const commandParser = new CommandParser({ allowUnknownFlags: true });