/** * Command-line argument parser * * Policy functions (Rule 10.1) - parsing and validation only */ import type { CommandDef } from '@celilo/core'; import type { ParsedCommand } from './types'; /** * Parse command-line arguments * * Expected format: * celilo [subcommand] [args...] [--flags] * * Examples: * celilo module import ./path/to/module * celilo module list * celilo module config set homebridge hostname myhost * celilo module generate homebridge * celilo secret set homebridge api_key mykey123 * * @param argv - Process argv array (usually process.argv) * @returns Parsed command structure */ export function parseArguments(argv: string[]): ParsedCommand { // Skip node/bun executable and script name (first 2 args) const args = argv.slice(2); if (args.length === 0) { return { command: 'help', args: [], flags: {}, }; } // Handle --help or -h as first argument const firstArg = args[0]; if (firstArg === '--help' || firstArg === '-h') { return { command: 'help', args: [], flags: { help: true }, }; } // Handle --version or -v as first argument. Without this, the // top-level loop in index.ts would never see the flag — `--version` // would land as the command name (`Unknown command: --version`). if (firstArg === '--version' || firstArg === '-v') { return { command: 'version', args: [], flags: { version: true }, }; } // Handle --get-completions as special flag (for shell completion) if (firstArg === '--get-completions') { return { command: 'completion', args: args.slice(1), flags: { 'get-completions': true }, }; } const command = firstArg ?? 'help'; const remaining = args.slice(1); // Check if second argument is a subcommand (not a flag) const hasSubcommand = remaining.length > 0 && !remaining[0]?.startsWith('-'); const subcommand = hasSubcommand ? remaining[0] : undefined; const restArgs = hasSubcommand ? remaining.slice(1) : remaining; // Parse flags and arguments const parsedArgs: string[] = []; const flags: Record = {}; for (let i = 0; i < restArgs.length; i++) { const arg = restArgs[i]; if (!arg) continue; if (arg.startsWith('--')) { const body = arg.slice(2); const eqIndex = body.indexOf('='); // Inline-value form: --flag=value (everything after the first `=`). if (eqIndex >= 0) { flags[body.slice(0, eqIndex)] = body.slice(eqIndex + 1); continue; } // Space-separated form: --flag value const flagName = body; const nextArg = restArgs[i + 1]; // Check if next arg is a value (not another flag, not a key=value pair) if (nextArg && !nextArg.startsWith('-') && !nextArg.includes('=')) { flags[flagName] = nextArg; i++; // Skip next arg } else { flags[flagName] = true; } } else if (arg.startsWith('-') && arg.length > 1) { // Short flag (-h, -v, etc.) const flagName = arg.slice(1); flags[flagName] = true; } else { // Positional argument parsedArgs.push(arg); } } return { command, subcommand, args: parsedArgs, flags, }; } /** * Validate that required positional arguments are provided * * @param args - Parsed arguments * @param required - Number of required arguments * @returns Error message or null if valid */ export function validateRequiredArgs(args: string[], required: number): string | null { if (args.length < required) { return `Missing required arguments. Expected ${required}, got ${args.length}`; } return null; } /** * Get argument by index with type-safe access * * @param args - Arguments array * @param index - Index to access * @returns Argument value or undefined */ export function getArg(args: string[], index: number): string | undefined { return args[index]; } /** * Get flag value with default * * @param flags - Flags object * @param name - Flag name * @param defaultValue - Default value if flag not present * @returns Flag value or default */ export function getFlag( flags: Record, name: string, defaultValue = '', ): string { const value = flags[name]; if (typeof value === 'string') { return value; } return defaultValue; } /** * Check if boolean flag is set * * @param flags - Flags object * @param name - Flag name * @returns True if flag is set */ export function hasFlag(flags: Record, name: string): boolean { return flags[name] === true; } /** * Validate that all flags are recognized for the given command. * Returns an error message if unknown flags are found, or null if valid. * * @param flags - Parsed flags from command line * @param commandDef - Command definition from registry * @returns Error message or null if valid */ export function validateFlags( flags: Record, commandDef: CommandDef, ): string | null { // Universal flags accepted by all commands const universalFlags = new Set(['help', 'h']); const allowedFlags = new Set([ ...universalFlags, ...(commandDef.flags?.map((f) => f.name) ?? []), ]); const unknownFlags = Object.keys(flags).filter((f) => !allowedFlags.has(f)); if (unknownFlags.length === 0) return null; const flagList = unknownFlags.map((f) => `--${f}`).join(', '); const lines = [`Unknown flag${unknownFlags.length > 1 ? 's' : ''}: ${flagList}`]; if (commandDef.flags && commandDef.flags.length > 0) { lines.push(''); lines.push('Available flags:'); for (const f of commandDef.flags) { lines.push(` --${f.name}${f.takesValue ? ' ' : ''} ${f.description}`); } } else { lines.push(`Command '${commandDef.name}' does not accept any flags.`); } // Suggest close matches for (const unknown of unknownFlags) { const suggestion = findClosestFlag(unknown, allowedFlags); if (suggestion) { lines.push(''); lines.push(`Did you mean --${suggestion}?`); } } return lines.join('\n'); } /** * Find the closest matching flag name using simple edit distance. */ function findClosestFlag(input: string, allowed: Set): string | null { let best: string | null = null; let bestDistance = Number.POSITIVE_INFINITY; for (const flag of allowed) { const d = levenshtein(input, flag); if (d < bestDistance && d <= 3) { bestDistance = d; best = flag; } } return best; } function levenshtein(a: string, b: string): number { const m = a.length; const n = b.length; const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); } } return dp[m][n]; }