UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

473 lines 21.1 kB
/** * Turns raw `process.argv` into the fully-resolved ParsedCLI object that command dispatch consumes. * It owns the whole front door: positional command detection, per-flag validation, per-command * positional grammars (quality/skill/events/hooks each have their own arity rules), and cross-flag * checks that strict parseArgs can't express. The deliberate contract is fail-fast for malformed * commands, flags, values, or combinations, throwing CLIError with exit code 2 (usage error) and a * human-readable message, so the entry point can print it and exit without a stack trace. Path * positionals are resolved to absolute paths here so downstream handlers never see relative input. */ import { parseArgs } from "node:util"; import { join, resolve } from "node:path"; import { QUALITY_MODES } from "./quality/schema.js"; import { validAgents, validAgentFlags, validAgentList, } from "./cli-agent-options.js"; import { CLIError } from "./cli-error.js"; import { COMMANDS, HOOK_SUBCOMMANDS, REMOVED_COMMANDS, VALID_FORMATS, } from "./cli-types.js"; /** Parse the positional subcommand from raw CLI args; throws CLIError for removed commands with migration help. */ function parseCommand(argv) { const filteredArgs = [...argv]; if (filteredArgs.length === 0) { return { command: "menu", filteredArgs }; } const first = filteredArgs[0]; if (first !== undefined && Object.hasOwn(REMOVED_COMMANDS, first)) { const message = REMOVED_COMMANDS[first]; if (message !== undefined) throw new CLIError(message, 2); } if (filteredArgs.length > 0 && COMMANDS.includes(filteredArgs[0])) { return { command: filteredArgs.shift(), filteredArgs }; } return { command: "audit", filteredArgs }; } /** Parse the `--format` flag; throws CLIError for invalid values before command dispatch. */ function parseFormatArg(value) { const defaultFormat = process.stdout.isTTY ? "text" : "json"; if (!value) return defaultFormat; if (!VALID_FORMATS.includes(value)) { throw new CLIError(`Invalid format: ${value}. Use: json, text, markdown, sarif`, 2); } return value; } /** Parse the `--agent` flag; throws CLIError for invalid or deprecated aggregate values. */ function parseAgentArg(value) { if (!value) return null; if (value === "all") { throw new CLIError(`--agent all is no longer supported. Run setup separately for each agent: ${validAgentFlags()}`, 2); } if (!validAgents().includes(value)) { throw new CLIError(`Invalid agent: ${value}. Use: ${validAgentList()}`, 2); } return value; } /** Parse the quality-history/diff mode filter; throws CLIError for invalid modes. */ function parseQualityModeArg(value) { if (!value) return null; if (!QUALITY_MODES.includes(value)) { throw new CLIError(`Invalid quality mode: ${value}. Use: ${QUALITY_MODES.join(", ")}`, 2); } return value; } /** Resolve `--output`, defaulting bare file names into `.goat-flow/` under the target repo. */ function resolveOutputPath(output, projectRoot) { if (!output) return null; return resolve(output.includes("/") || output.includes("\\") ? output : join(projectRoot, ".goat-flow", output)); } /** Parse quality subcommand positionals; throws CLIError for invalid subcommand arity. */ // eslint-disable-next-line complexity -- intentional because each quality positional error reports in CLI order function parseQualityPositionals(positionals, draftFlag) { const [first, second, ...rest] = positionals; if (first === "capture") { throw new CLIError('"quality capture" was removed in v1.2.0. Agents now write reports directly to `.goat-flow/logs/quality/`; no capture step is needed.', 2); } if (first === "history") { if (rest.length > 0) { throw new CLIError("quality history accepts at most one positional project path.", 2); } return { qualitySubcommand: "history", projectPath: second !== undefined ? resolve(second) : resolve("."), qualityDiffPair: null, qualityValidatePath: null, candidacyInput: null, }; } if (first === "candidacy") { if (draftFlag !== null) { if (second !== undefined || rest.length > 0) { throw new CLIError("quality candidacy: pass either --draft <path> OR a description, not both.", 2); } return { qualitySubcommand: "candidacy", projectPath: resolve("."), qualityDiffPair: null, qualityValidatePath: null, candidacyInput: { mode: "draft", value: resolve(draftFlag) }, }; } const description = [second, ...rest] .filter((part) => typeof part === "string" && part.length > 0) .join(" "); if (description.length === 0) { throw new CLIError("quality candidacy: pass --draft <path> or a description string.", 2); } return { qualitySubcommand: "candidacy", projectPath: resolve("."), qualityDiffPair: null, qualityValidatePath: null, candidacyInput: { mode: "description", value: description }, }; } if (first === "diff") { if (rest.length > 0) { throw new CLIError("quality diff accepts at most one positional pair in the form <from-id>:<to-id>.", 2); } return { qualitySubcommand: "diff", projectPath: resolve("."), qualityDiffPair: second ?? null, qualityValidatePath: null, candidacyInput: null, }; } if (first === "validate") { if (second === undefined || rest.length > 0) { throw new CLIError("quality validate requires exactly one positional <path-to-report>.", 2); } return { qualitySubcommand: "validate", projectPath: resolve("."), qualityDiffPair: null, qualityValidatePath: resolve(second), candidacyInput: null, }; } return { qualitySubcommand: "prompt", projectPath: resolve(first ?? "."), qualityDiffPair: null, qualityValidatePath: null, candidacyInput: null, }; } /** Parse events subcommand positionals; throws CLIError for unsupported subcommands or arity. */ function parseEventsPositionals(positionals) { const [first, second, ...rest] = positionals; if (first !== "tail") { throw new CLIError('events requires subcommand "tail".', 2); } if (rest.length > 0) { throw new CLIError("events tail accepts at most one positional project path.", 2); } return { eventsSubcommand: "tail", projectPath: resolve(second ?? "."), }; } /** Parse hooks subcommand positionals; throws CLIError for unsupported subcommands or arity. */ function parseHooksPositionals(positionals) { const [first, second, third, ...rest] = positionals; if (!first || !HOOK_SUBCOMMANDS.has(first)) { throw new CLIError('hooks requires subcommand "list", "enable", "disable", or "sync".', 2); } const subcommand = first; if (subcommand === "enable" || subcommand === "disable") return parseHookTogglePositionals(subcommand, second, third, rest); if (third !== undefined || rest.length > 0) { throw new CLIError(`hooks ${subcommand} accepts at most one project path.`, 2); } return { hookSubcommand: subcommand, hookId: null, projectPath: resolve(second ?? "."), }; } function parseHookTogglePositionals(subcommand, hookId, projectPath, rest) { if (hookId === undefined || rest.length > 0) { throw new CLIError(`hooks ${subcommand} requires <hook-id> [project-path].`, 2); } return { hookSubcommand: subcommand, hookId, projectPath: resolve(projectPath ?? "."), }; } /** Return the project path and quality-specific positionals for a command. */ function parseCommandPositionals(command, positionals, draftFlag) { if (command === "quality") return parseQualityPositionals(positionals, draftFlag); if (command === "skill") return { qualitySubcommand: "prompt", projectPath: parseSkillPositionals(positionals).projectPath, qualityDiffPair: null, qualityValidatePath: null, candidacyInput: null, }; if (command === "hooks") return { qualitySubcommand: "prompt", projectPath: parseHooksPositionals(positionals).projectPath, qualityDiffPair: null, qualityValidatePath: null, candidacyInput: null, }; return { qualitySubcommand: "prompt", projectPath: resolve(positionals[0] ?? "."), qualityDiffPair: null, qualityValidatePath: null, candidacyInput: null, }; } /** Detect positional skill project paths so descriptions do not accidentally consume them. */ function isPathShapedSkillProject(value) { const normalized = value.replace(/\\/gu, "/"); return (value === "." || value === ".." || normalized.startsWith("./") || normalized.startsWith("../") || normalized.startsWith("/") || /^[a-zA-Z]:[\\/]/u.test(value) || value.startsWith("\\\\")); } /** Join free-form skill description parts after dropping absent argv values. */ function parseSkillDescription(parts) { const description = parts .filter((part) => typeof part === "string" && part.length > 0) .join(" "); return description.length > 0 ? description : null; } /** Parse `skill [project-path] new [project-path] [description...]`; throws CLIError for unknown subcommands. */ function parseSkillPositionals(positionals) { const [first, second, ...rest] = positionals; if (first === undefined) { return { skillSubcommand: null, skillDescription: null, projectPath: resolve("."), }; } if (first === "new") { const descriptionParts = second !== undefined && isPathShapedSkillProject(second) ? rest : positionals.slice(1); return { skillSubcommand: "new", skillDescription: parseSkillDescription(descriptionParts), projectPath: second !== undefined && isPathShapedSkillProject(second) ? resolve(second) : resolve("."), }; } if (second === "new") { return { skillSubcommand: "new", skillDescription: parseSkillDescription(rest), projectPath: resolve(first), }; } throw new CLIError(`unknown skill subcommand "${first}". Supported: new`, 2); } /** Validate flags shared across commands. */ function rejectFlagOutsideCommand(command, expectedCommand, flag, isSet) { if (command === expectedCommand || !isSet) return; throw new CLIError(`${flag} is only valid for the ${expectedCommand} command.`, 2); } /** Return whether a raw `parseArgs` boolean flag was explicitly set. */ function parsedFlag(values, name) { return values[name] === true; } /** Return a raw `parseArgs` string value without trusting the option map shape. */ function parsedString(values, name) { const value = values[name]; return typeof value === "string" ? value : undefined; } /** Reject shared flags when they are attached to commands that do not support them. */ function validateCommonFlags(command, values) { rejectFlagOutsideCommand(command, "audit", "--format sarif", parsedString(values, "format") === "sarif"); rejectFlagOutsideCommand(command, "quality", "--all", parsedFlag(values, "all")); rejectFlagOutsideCommand(command, "quality", "--mode", parsedString(values, "mode") !== undefined); rejectFlagOutsideCommand(command, "events", "--limit", parsedString(values, "limit") !== undefined); rejectFlagOutsideCommand(command, "audit", "--no-audit-details", parsedFlag(values, "no-audit-details")); } /** Returns true when the command resolves to a deterministic install/apply path. */ function isInstallCommand(command, values) { return (command === "install" || (command === "setup" && parsedFlag(values, "apply"))); } /** Validate deterministic install/setup flags; throws CLIError when flags target the wrong command. */ function validateInstallFlags(command, values) { if (command !== "setup" && parsedFlag(values, "apply")) { throw new CLIError("--apply is only valid for the setup command.", 2); } const installOnly = [ ["--force", parsedFlag(values, "force")], ["--update-config-version", parsedFlag(values, "update-config-version")], ["--clean-deprecated", parsedFlag(values, "clean-deprecated")], ]; for (const [flag, set] of installOnly) { if (set === true && !isInstallCommand(command, values)) { throw new CLIError(`${flag} is only valid for install or setup --apply.`, 2); } } } /** Validate quality mode flags against the selected quality subcommand. */ // eslint-disable-next-line complexity -- intentional because one validator preserves the cross-command error contract function validateQualityFlags(command, values, qualitySubcommand) { if (command === "quality" && parsedString(values, "mode") !== undefined && !["prompt", "history", "diff"].includes(qualitySubcommand)) { throw new CLIError("--mode is only valid for quality prompt, quality history, and quality diff.", 2); } if (parsedString(values, "draft") !== undefined && !((command === "quality" && qualitySubcommand === "candidacy") || command === "skill")) { throw new CLIError("--draft is only valid for quality candidacy and skill new.", 2); } if (parsedFlag(values, "interactive") && command !== "skill") { throw new CLIError("--interactive is only valid for skill new.", 2); } if (parsedString(values, "name") !== undefined && command !== "skill") { throw new CLIError("--name is only valid for skill new.", 2); } if (parsedFlag(values, "yes") && command !== "skill") { throw new CLIError("--yes is only valid for skill new.", 2); } } /** Validate flag combinations after strict parseArgs accepts their shapes. */ function validateFlagCombinations(command, values, qualitySubcommand) { validateCommonFlags(command, values); validateInstallFlags(command, values); validateQualityFlags(command, values, qualitySubcommand); } /** Parse the events tail limit; throws CLIError for invalid values before clamping to the display cap. */ function parseEventsLimitArg(value) { if (value === undefined) return 20; const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0 || String(parsed) !== value) { throw new CLIError("--limit must be a positive integer.", 2); } return Math.min(parsed, 500); } function buildSkillCLIFields(command, values, positionals) { const isSkillCommand = command === "skill"; const skillDraftValue = isSkillCommand ? parsedString(values, "draft") : undefined; return { skillSubcommand: positionals.skillSubcommand, skillDescription: positionals.skillDescription, skillDraftPath: skillDraftValue === undefined ? null : resolve(skillDraftValue), skillName: isSkillCommand ? (parsedString(values, "name") ?? null) : null, skillInteractive: isSkillCommand && parsedFlag(values, "interactive"), skillSkipConfirm: isSkillCommand && parsedFlag(values, "yes"), }; } /** * Parse raw CLI argv into structured command options. * Throws CLIError when a command, flag, positional, or value combination is invalid. * * @param argv - raw CLI arguments after the executable and script path * @returns normalized options consumed by command dispatch */ export function parseCLIArgs(argv) { const { command, filteredArgs } = parseCommand(argv); /** Destructured parseArgs result containing option values and positional arguments */ const { values, positionals } = parseArgs({ args: filteredArgs, options: { format: { type: "string" }, agent: { type: "string" }, mode: { type: "string" }, verbose: { type: "boolean", default: false }, output: { type: "string", short: "o" }, all: { type: "boolean", default: false }, limit: { type: "string" }, harness: { type: "boolean", default: false }, "check-drift": { type: "boolean", default: false }, "check-content": { type: "boolean", default: false }, "untrusted-target": { type: "boolean", default: false }, "no-audit-details": { type: "boolean", default: false }, check: { type: "boolean", default: false }, apply: { type: "boolean", default: false }, force: { type: "boolean", default: false }, "update-config-version": { type: "boolean", default: false }, "clean-deprecated": { type: "boolean", default: false }, dev: { type: "boolean", default: false }, draft: { type: "string" }, interactive: { type: "boolean", default: false }, name: { type: "string" }, yes: { type: "boolean", short: "y", default: false }, json: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, version: { type: "boolean", short: "v", default: false }, }, allowPositionals: true, strict: true, }); const parsedValues = values; const qualityPositionals = parseCommandPositionals(command, positionals, parsedString(parsedValues, "draft") ?? null); const eventsPositionals = command === "events" ? parseEventsPositionals(positionals) : { eventsSubcommand: null, projectPath: qualityPositionals.projectPath }; const hooksPositionals = command === "hooks" ? parseHooksPositionals(positionals) : { hookSubcommand: null, hookId: null, projectPath: qualityPositionals.projectPath, }; const projectPath = command === "events" ? eventsPositionals.projectPath : command === "hooks" ? hooksPositionals.projectPath : qualityPositionals.projectPath; const skillPositionals = command === "skill" ? parseSkillPositionals(positionals) : { skillSubcommand: null, skillDescription: null, projectPath, }; const skillFields = buildSkillCLIFields(command, parsedValues, skillPositionals); validateFlagCombinations(command, parsedValues, qualityPositionals.qualitySubcommand); return { command, projectPath, format: parseFormatArg(parsedFlag(parsedValues, "json") ? "json" : parsedString(parsedValues, "format")), agent: parseAgentArg(parsedString(parsedValues, "agent")), isVerbose: parsedFlag(parsedValues, "verbose"), output: resolveOutputPath(parsedString(parsedValues, "output"), projectPath), includeHarness: parsedFlag(parsedValues, "harness"), checkDrift: parsedFlag(parsedValues, "check-drift"), checkContent: parsedFlag(parsedValues, "check-content"), untrustedTarget: parsedFlag(parsedValues, "untrusted-target"), auditDetails: !parsedFlag(parsedValues, "no-audit-details"), shouldCheck: parsedFlag(parsedValues, "check"), shouldApply: parsedFlag(parsedValues, "apply"), shouldForce: parsedFlag(parsedValues, "force"), updateConfigVersion: parsedFlag(parsedValues, "update-config-version"), cleanDeprecated: parsedFlag(parsedValues, "clean-deprecated"), qualitySubcommand: qualityPositionals.qualitySubcommand, qualityDiffPair: qualityPositionals.qualityDiffPair, qualityValidatePath: qualityPositionals.qualityValidatePath, qualityMode: parseQualityModeArg(parsedString(parsedValues, "mode")), candidacyInput: qualityPositionals.candidacyInput, ...skillFields, eventsSubcommand: eventsPositionals.eventsSubcommand, eventsLimit: parseEventsLimitArg(parsedString(parsedValues, "limit")), hookSubcommand: hooksPositionals.hookSubcommand, hookId: hooksPositionals.hookId, includeAll: parsedFlag(parsedValues, "all"), isDevMode: parsedFlag(parsedValues, "dev"), showHelp: parsedFlag(parsedValues, "help"), showVersion: parsedFlag(parsedValues, "version"), }; } /** Remove heavy per-check detail payloads from compact JSON audit output. */ //# sourceMappingURL=cli-parser.js.map