From c19fa5be23260447253e324a4faa7a10abe57d3d Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Sat, 21 Feb 2026 21:35:56 -0500 Subject: [PATCH 1/2] feat(cli): adopt createCLI() for program bootstrap Replace `new Command()` with `createCLI()` from `@outfitter/cli` which provides structured error/exit handling, --json flag with OUTFITTER_JSON env bridge, and exitOverride. Add duck-type checking for CommanderError across bundled Commander copies. Patch version flag from -V to -v to match waymark convention. --- packages/cli/src/program.ts | 128 ++++++++++++++++++++++++++++-------- 1 file changed, 99 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 7db5d01..312a51e 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -1,6 +1,7 @@ // tldr ::: waymark CLI program builder and command routing import tab from "@bomb.sh/tab/commander"; +import { createCLI } from "@outfitter/cli/command"; import type { WaymarkConfig } from "@waymarks/core"; import { Command, CommanderError, Option } from "commander"; import simpleUpdateNotifier from "simple-update-notifier"; @@ -111,7 +112,10 @@ function resolveGlobalOptions(program: Command): GlobalOptions { }; } -function resolveCommanderExitCode(error: CommanderError): ExitCode { +function resolveCommanderExitCode(error: { + exitCode: number; + code: string; +}): ExitCode { if (error.exitCode === 0) { return ExitCode.success; } @@ -121,6 +125,39 @@ function resolveCommanderExitCode(error: CommanderError): ExitCode { return (error.exitCode ?? ExitCode.failure) as ExitCode; } +// about ::: duck-type check for CommanderError since createCLI() may bundle its own Commander copy +function isCommanderLikeError( + error: unknown +): error is { exitCode: number; code: string } { + return ( + error !== null && + typeof error === "object" && + "exitCode" in error && + typeof (error as { exitCode: unknown }).exitCode === "number" && + "code" in error && + typeof (error as { code: unknown }).code === "string" && + (error as { code: string }).code.startsWith("commander.") + ); +} + +// note ::: Commander already writes help/version to stdout before throwing; +// suppress duplicate output in runMain() and runCli() error handlers +function isCommanderOutputError(error: unknown): boolean { + if (error instanceof CommanderError) { + return ( + error.code === "commander.helpDisplayed" || + error.code === "commander.version" + ); + } + if (isCommanderLikeError(error)) { + return ( + error.code === "commander.helpDisplayed" || + error.code === "commander.version" + ); + } + return false; +} + function resolveExitCode(error: unknown): ExitCode { if (error instanceof CliError) { return error.exitCode; @@ -128,6 +165,10 @@ function resolveExitCode(error: unknown): ExitCode { if (error instanceof CommanderError) { return resolveCommanderExitCode(error); } + // Handle CommanderError from a different Commander copy (e.g., via createCLI bundle) + if (isCommanderLikeError(error)) { + return resolveCommanderExitCode(error); + } // Check for @outfitter/contracts tagged errors with a category property if ( error && @@ -168,7 +209,7 @@ function resolveCommandOptions(command: Command): T { } function handleCommandError(program: Command, error: unknown): never { - if (error instanceof CommanderError) { + if (error instanceof CommanderError || isCommanderLikeError(error)) { throw error; } const message = resolveErrorMessage(error); @@ -1145,9 +1186,11 @@ function buildCustomHelpFormatter() { } /** - -- Build a Commander program with all CLI commands registered. -- @returns Configured Commander program instance. + * Build a CLI instance with all commands registered. + * Uses createCLI() from @outfitter/cli for program bootstrap, error handling, + * and --json env-var bridging. Returns both the CLI wrapper and the underlying + * Commander program for backward compatibility. + * @returns CLI instance and the underlying Commander program. */ export async function createProgram(): Promise { // Read version from package.json @@ -1160,13 +1203,38 @@ export async function createProgram(): Promise { shouldNotifyInNpmScript: true, }); - const program = new Command(); - program.exitOverride((error) => { - const exitCode = resolveCommanderExitCode(error); - process.exit(exitCode); + // about ::: createCLI provides: name, version, --json flag with OUTFITTER_JSON bridge, + // exitOverride, and structured error/exit handling via onError/onExit callbacks + const cli = createCLI({ + name: "wm", + version, + description: + "Waymark CLI - scan, filter, format, and manage waymarks\n\n" + + "Quick Start:\n" + + " wm [paths...] Scan and filter waymarks (default: current directory)\n" + + " wm find --graph Show dependency graph\n" + + " wm fmt --write Format waymarks in file\n" + + " wm rm --write Remove waymark from file\n" + + " wm init Initialize waymark configuration", + onError: (error) => { + const message = resolveErrorMessage(error); + writeStderr(message); + }, + onExit: (code) => { + process.exit(code); + }, }); - const jsonOption = new Option("--json", "Output as JSON array"); + const program = cli.program; + + // note ::: createCLI() already adds --json; configure conflicts and add --jsonl, --text + const existingJsonOption = program.options.find( + (opt) => opt.long === "--json" + ); + if (existingJsonOption) { + existingJsonOption.conflicts("jsonl"); + existingJsonOption.conflicts("text"); + } const jsonlOption = new Option( "--jsonl", "Output as JSON Lines (newline-delimited)" @@ -1175,25 +1243,25 @@ export async function createProgram(): Promise { "--text", "Output as human-readable formatted text" ); - jsonOption.conflicts("jsonl"); - jsonOption.conflicts("text"); jsonlOption.conflicts("json"); jsonlOption.conflicts("text"); textOption.conflicts("json"); textOption.conflicts("jsonl"); + // note ::: createCLI() already calls .version() with default -V flag; + // patch the short flag to -v (lowercase) to match waymark convention + const existingVersionOption = program.options.find( + (opt) => opt.long === "--version" + ); + if (existingVersionOption) { + // biome-ignore lint/suspicious/noExplicitAny: patching Commander option internals to change -V to -v + (existingVersionOption as any).short = "-v"; + // biome-ignore lint/suspicious/noExplicitAny: patching Commander option internals for display + (existingVersionOption as any).flags = "-v, --version"; + existingVersionOption.description = "output the current version"; + } + program - .name("wm") - .description( - "Waymark CLI - scan, filter, format, and manage waymarks\n\n" + - "Quick Start:\n" + - " wm [paths...] Scan and filter waymarks (default: current directory)\n" + - " wm find --graph Show dependency graph\n" + - " wm fmt --write Format waymarks in file\n" + - " wm rm --write Remove waymark from file\n" + - " wm init Initialize waymark configuration" - ) - .version(version, "--version, -v", "output the current version") .helpOption("--help, -h", "display help for command") .addHelpCommand(false) // Disable default help command, we'll add custom one .configureHelp({ @@ -1211,7 +1279,6 @@ export async function createProgram(): Promise { .option("--verbose", "enable verbose logging (info level)") .option("--debug", "enable debug logging") .option("--quiet, -q", "only show errors") - .addOption(jsonOption) .addOption(jsonlOption) .addOption(textOption) .option("--no-color", "disable ANSI colors") @@ -1281,18 +1348,21 @@ Note: For agent-facing documentation, use "wm skill". } /** - -- Run the CLI using process.argv when invoked as a script. Exits the process with appropriate exit code. -- @returns No return value; process exits after execution. + * Run the CLI using process.argv when invoked as a script. + * Exits the process with appropriate exit code. + * @returns No return value; process exits after execution. */ export function runMain(): void { registerSignalHandlers(); createProgram() .then((program) => program.parseAsync(process.argv)) .catch((error) => { - const message = resolveErrorMessage(error); const exitCode = resolveExitCode(error); - writeStderr(message); + // note ::: Commander already printed help/version to stdout; don't echo again + if (!isCommanderOutputError(error)) { + const message = resolveErrorMessage(error); + writeStderr(message); + } process.exit(exitCode); }); } From 3be3f7a2b87852254dd4c07359c54c6a525b1447 Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Sat, 21 Feb 2026 21:43:13 -0500 Subject: [PATCH 2/2] refactor(cli): delete CliError and manual exit code plumbing Replace CliError with @outfitter/contracts error types (ValidationError, InternalError, CancelledError) and use getExitCode() for category-based exit code resolution. Delete exit-codes.ts, errors.ts, and utils/command-runner.ts. Exit codes now follow the contracts exitCodeMap (validation:1, not_found:2, internal:8, cancelled:130). --- README.md | 10 ++- packages/cli/src/commands/config.ts | 7 +- packages/cli/src/commands/init.ts | 10 +-- packages/cli/src/commands/register.ts | 4 +- packages/cli/src/commands/skill.ts | 17 ++-- packages/cli/src/errors.ts | 31 ------- packages/cli/src/exit-codes.ts | 11 --- packages/cli/src/index.test.ts | 6 +- packages/cli/src/program.ts | 106 ++++++++++++++--------- packages/cli/src/utils/command-runner.ts | 36 -------- packages/cli/src/utils/context.ts | 4 +- packages/cli/src/utils/prompts.ts | 10 +-- 12 files changed, 99 insertions(+), 153 deletions(-) delete mode 100644 packages/cli/src/errors.ts delete mode 100644 packages/cli/src/exit-codes.ts delete mode 100644 packages/cli/src/utils/command-runner.ts diff --git a/README.md b/README.md index 6dffb77..f53f448 100644 --- a/README.md +++ b/README.md @@ -144,10 +144,12 @@ available programmatically via `@waymarks/core` (see `WaymarkCache`), and | Code | Meaning | | --- | --- | | 0 | Success | -| 1 | Waymark error (lint failures, parse errors) | -| 2 | Usage error (invalid arguments) | -| 3 | Configuration error | -| 4 | I/O error (file not found, permission denied) | +| 1 | Validation error (invalid flags, arguments, or waymark syntax) | +| 2 | Not found (file, waymark, or resource missing) | +| 3 | Conflict (concurrent modification) | +| 4 | Permission error | +| 8 | Internal error (unexpected failure) | +| 130 | Cancelled (user interrupted) | #### Shell Completions diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 42ad25e..1310168 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -2,8 +2,7 @@ import type { WaymarkConfig } from "@waymarks/core"; -import { createUsageError } from "../errors.ts"; -import { ExitCode } from "../exit-codes.ts"; +import { ValidationError } from "@outfitter/contracts"; import type { CommandContext } from "../types.ts"; export type ConfigCommandOptions = { @@ -37,7 +36,7 @@ export function runConfigCommand( options: ConfigCommandOptions = {} ): ConfigCommandResult { if (!options.print) { - throw createUsageError("Config command requires --print."); + throw ValidationError.fromMessage("Config command requires --print."); } const output = serializeConfig(context.config, { @@ -46,6 +45,6 @@ export function runConfigCommand( return { output, - exitCode: ExitCode.success, + exitCode: 0, }; } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index cb0c855..9930913 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -4,9 +4,7 @@ import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import { InternalError, Result } from "@outfitter/contracts"; -import { CliError } from "../errors.ts"; -import { ExitCode } from "../exit-codes.ts"; +import { CancelledError, InternalError, Result } from "@outfitter/contracts"; import { promptSelect } from "../utils/clack-prompts.ts"; import { logger } from "../utils/logger.ts"; import { assertPromptAllowed } from "../utils/prompts.ts"; @@ -71,7 +69,7 @@ async function runInitCommandInner( initialValue: "yaml" as ConfigFormat, }); if (formatResult.isErr()) { - throw new CliError("Init cancelled", ExitCode.usageError); + throw CancelledError.create("Init cancelled"); } format = formatResult.value; @@ -81,7 +79,7 @@ async function runInitCommandInner( initialValue: "full" as ConfigPreset, }); if (presetResult.isErr()) { - throw new CliError("Init cancelled", ExitCode.usageError); + throw CancelledError.create("Init cancelled"); } preset = presetResult.value; @@ -91,7 +89,7 @@ async function runInitCommandInner( initialValue: "project" as ConfigScope, }); if (scopeResult.isErr()) { - throw new CliError("Init cancelled", ExitCode.usageError); + throw CancelledError.create("Init cancelled"); } scope = scopeResult.value; diff --git a/packages/cli/src/commands/register.ts b/packages/cli/src/commands/register.ts index 523b598..4714bdf 100644 --- a/packages/cli/src/commands/register.ts +++ b/packages/cli/src/commands/register.ts @@ -1,7 +1,7 @@ // tldr ::: commander command registration for waymark CLI import { Command, InvalidArgumentError } from "commander"; -import { createUsageError } from "../errors.ts"; +import { ValidationError } from "@outfitter/contracts"; import type { ModifyCliOptions } from "../types.ts"; import { parsePropertyEntry } from "../utils/properties.ts"; import type { CheckCommandOptions } from "./check.ts"; @@ -135,7 +135,7 @@ export function registerCommands( if (helpTopicNames.length > 0) { writeStdout(`Available topics: ${helpTopicNames.join(", ")}`); } - throw createUsageError(`Unknown command or topic: ${commandName}`); + throw ValidationError.fromMessage(`Unknown command or topic: ${commandName}`); } catch (error) { handleCommandError(program, error); } diff --git a/packages/cli/src/commands/skill.ts b/packages/cli/src/commands/skill.ts index b990a24..fc6f3fd 100644 --- a/packages/cli/src/commands/skill.ts +++ b/packages/cli/src/commands/skill.ts @@ -1,8 +1,7 @@ // tldr ::: implement the wm skill command outputs [[cli/skill-command]] import { resolve } from "node:path"; -import { createUsageError } from "../errors.ts"; -import { ExitCode } from "../exit-codes.ts"; +import { ValidationError } from "@outfitter/contracts"; import { listSkillSections, loadSkillData, @@ -55,7 +54,7 @@ export async function runSkillCommand( const skillData = await loadSkillData(skillDir); return { output: formatJsonOutput(skillData), - exitCode: ExitCode.success, + exitCode: 0, }; } @@ -63,7 +62,7 @@ export async function runSkillCommand( return { output: core.content, - exitCode: ExitCode.success, + exitCode: 0, }; } @@ -140,7 +139,7 @@ export async function runSkillShowCommand( const resolved = resolveSkillSection(manifest, section); if (!resolved) { - throw createUsageError(`Unknown skill section: ${section}`); + throw ValidationError.fromMessage(`Unknown skill section: ${section}`); } const content = await loadSkillSection( @@ -153,13 +152,13 @@ export async function runSkillShowCommand( if (options.json) { return { output: formatJsonOutput(content), - exitCode: ExitCode.success, + exitCode: 0, }; } return { output: content.content, - exitCode: ExitCode.success, + exitCode: 0, }; } @@ -207,7 +206,7 @@ export async function runSkillListCommand( return { output: lines.join("\n"), - exitCode: ExitCode.success, + exitCode: 0, }; } @@ -222,6 +221,6 @@ export function runSkillPathCommand( const skillDir = resolveSkillDirectory(overrides); return { output: skillDir, - exitCode: ExitCode.success, + exitCode: 0, }; } diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts deleted file mode 100644 index 69fb351..0000000 --- a/packages/cli/src/errors.ts +++ /dev/null @@ -1,31 +0,0 @@ -// tldr ::: CLI error helpers for consistent exit codes [[cli/errors]] - -import { ExitCode } from "./exit-codes.ts"; - -export class CliError extends Error { - exitCode: ExitCode; - - constructor(message: string, exitCode: ExitCode) { - super(message); - this.name = "CliError"; - this.exitCode = exitCode; - } -} - -/** - * Create a CLI error with a usage exit code. - * @param message - Error message to display. - * @returns CLI error instance. - */ -export function createUsageError(message: string): CliError { - return new CliError(message, ExitCode.usageError); -} - -/** - * Create a CLI error with a config exit code. - * @param message - Error message to display. - * @returns CLI error instance. - */ -export function createConfigError(message: string): CliError { - return new CliError(message, ExitCode.configError); -} diff --git a/packages/cli/src/exit-codes.ts b/packages/cli/src/exit-codes.ts deleted file mode 100644 index 4a0fcd7..0000000 --- a/packages/cli/src/exit-codes.ts +++ /dev/null @@ -1,11 +0,0 @@ -// tldr ::: standardized CLI exit codes for waymark commands [[cli/exit-codes]] - -export const ExitCode = { - success: 0, - failure: 1, - usageError: 2, - configError: 3, - ioError: 4, -} as const; - -export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode]; diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index c716f01..c8aeec0 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -19,7 +19,7 @@ import { } from "./commands/unified/index"; import { parseUnifiedArgs } from "./commands/unified/parser"; import type { UnifiedCommandOptions } from "./commands/unified/types"; -import { ExitCode } from "./exit-codes"; +import { getExitCode } from "@outfitter/contracts"; import { runCli } from "./index"; import type { CommandContext } from "./types"; import { renderRecords } from "./utils/output"; @@ -1302,7 +1302,7 @@ describe("Commander integration", () => { test("unknown option returns a usage error exit code", async () => { const result = await runCliCaptured(["find", "--definitely-not-a-flag"]); - expect(result.exitCode).toBe(ExitCode.usageError); + expect(result.exitCode).toBe(getExitCode("validation")); }); test("no-input fails when format requires confirmation", async () => { @@ -1315,7 +1315,7 @@ describe("Commander integration", () => { "--write", "--no-input", ]); - expect(result.exitCode).toBe(ExitCode.usageError); + expect(result.exitCode).toBe(getExitCode("validation")); } finally { await cleanup(); } diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 312a51e..d9179a1 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -3,7 +3,7 @@ import tab from "@bomb.sh/tab/commander"; import { createCLI } from "@outfitter/cli/command"; import type { WaymarkConfig } from "@waymarks/core"; -import { Command, CommanderError, Option } from "commander"; +import { type Command, CommanderError, Option } from "commander"; import simpleUpdateNotifier from "simple-update-notifier"; import { type AddCommandInput, @@ -56,14 +56,18 @@ import { runUpdateCommand, type UpdateCommandOptions, } from "./commands/update.ts"; -import { CliError, createUsageError } from "./errors.ts"; -import { ExitCode } from "./exit-codes.ts"; +import { + type AnyKitError, + type ErrorCategory, + InternalError, + ValidationError, + getExitCode, +} from "@outfitter/contracts"; import type { CommandContext, GlobalOptions, ModifyCliOptions, } from "./types.ts"; -import { mapErrorToExitCode, runCommand } from "./utils/command-runner.ts"; import { createContext } from "./utils/context.ts"; import { logger } from "./utils/logger.ts"; import { normalizeScope } from "./utils/options.ts"; @@ -115,14 +119,14 @@ function resolveGlobalOptions(program: Command): GlobalOptions { function resolveCommanderExitCode(error: { exitCode: number; code: string; -}): ExitCode { +}): number { if (error.exitCode === 0) { - return ExitCode.success; + return 0; } if (error.code.startsWith("commander.")) { - return ExitCode.usageError; + return getExitCode("validation"); } - return (error.exitCode ?? ExitCode.failure) as ExitCode; + return error.exitCode ?? 1; } // about ::: duck-type check for CommanderError since createCLI() may bundle its own Commander copy @@ -158,10 +162,8 @@ function isCommanderOutputError(error: unknown): boolean { return false; } -function resolveExitCode(error: unknown): ExitCode { - if (error instanceof CliError) { - return error.exitCode; - } +// about ::: maps thrown errors to CLI exit codes using @outfitter/contracts categories +function resolveExitCode(error: unknown): number { if (error instanceof CommanderError) { return resolveCommanderExitCode(error); } @@ -176,9 +178,8 @@ function resolveExitCode(error: unknown): ExitCode { "category" in error && typeof (error as { category: unknown }).category === "string" ) { - return mapErrorToExitCode( - (error as { category: import("@outfitter/contracts").ErrorCategory }) - .category + return getExitCode( + (error as { category: ErrorCategory }).category, ); } if ( @@ -187,9 +188,16 @@ function resolveExitCode(error: unknown): ExitCode { "code" in error && typeof (error as NodeJS.ErrnoException).code === "string" ) { - return ExitCode.ioError; + const errnoCode = (error as NodeJS.ErrnoException).code; + if (errnoCode === "ENOENT") { + return getExitCode("not_found"); + } + if (errnoCode === "EACCES" || errnoCode === "EPERM") { + return getExitCode("permission"); + } + return getExitCode("internal"); } - return ExitCode.failure; + return 1; } function resolveErrorMessage(error: unknown): string { @@ -214,8 +222,12 @@ function handleCommandError(program: Command, error: unknown): never { } const message = resolveErrorMessage(error); const exitCode = resolveExitCode(error); - const code = - exitCode === ExitCode.usageError ? "WAYMARK_USAGE" : "WAYMARK_ERROR"; + const isValidation = + error && + typeof error === "object" && + "category" in error && + (error as { category: string }).category === "validation"; + const code = isValidation ? "WAYMARK_USAGE" : "WAYMARK_ERROR"; return program.error(message, { exitCode, code }); } @@ -236,6 +248,20 @@ function registerSignalHandlers(): void { }); } +/** + * Unwrap a Result, throwing the contracts error on failure. + * Bridges Result-returning core functions to the throw-based CLI error flow. + */ +async function runCommand( + fn: () => Promise>, +): Promise { + const result = await fn(); + if (result.isErr()) { + throw result.error; + } + return result.value; +} + // Command handlers extracted for complexity management async function handleFormatCommand( program: Command, @@ -324,7 +350,7 @@ async function handleLintCommand( ).length; if (errorCount > 0) { - throw new CliError("Lint errors detected", ExitCode.failure); + throw InternalError.create("Lint errors detected"); } } @@ -352,7 +378,7 @@ async function handleAddCommand( parsed = buildAddArgs(addInput); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw createUsageError(message); + throw ValidationError.fromMessage(message); } const result = await runCommand(() => runAddCommand(parsed, context)); @@ -362,7 +388,7 @@ async function handleAddCommand( } if (result.summary.failed > 0) { - process.exitCode = ExitCode.failure; + process.exitCode = 1; } } @@ -379,7 +405,7 @@ async function handleRemoveCommand( parsedArgs = buildRemoveArgs({ targets, options }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw createUsageError(message); + throw ValidationError.fromMessage(message); } const preview = await runCommand(() => runRemoveCommand(parsedArgs, context, { writeOverride: false }) @@ -388,7 +414,7 @@ async function handleRemoveCommand( if (parsedArgs.options.write) { if (preview.summary.failed > 0) { outputRemovalPreview(preview); - process.exitCode = ExitCode.failure; + process.exitCode = 1; return; } await executeRemovalWriteFlow(preview, parsedArgs, context); @@ -427,16 +453,16 @@ async function resolveInteractiveTarget( ): Promise<{ target: string; id?: string | undefined }> { const scanResult = await scanRecords([workspaceRoot], config, scanOptions); if (scanResult.isErr()) { - throw new CliError(scanResult.error.message, ExitCode.failure); + throw InternalError.create(scanResult.error.message); } const records = scanResult.value; if (records.length === 0) { - throw new CliError("No waymarks found to edit.", ExitCode.failure); + throw InternalError.create("No waymarks found to edit."); } const selected = await selectWaymark({ records }); if (!selected) { - throw new CliError("No waymark selected.", ExitCode.failure); + throw InternalError.create("No waymark selected."); } const target = `${selected.file}:${selected.startLine}`; @@ -534,7 +560,7 @@ async function handleModifyCommand( rawOptions: ModifyCliOptions ): Promise { if (rawOptions.json && rawOptions.jsonl) { - throw createUsageError("--json and --jsonl cannot be used together"); + throw ValidationError.fromMessage("--json and --jsonl cannot be used together"); } const interactiveOverride = determineInteractiveOverride( @@ -642,7 +668,7 @@ async function handleConfigCommand( } if (result.exitCode !== 0) { - throw new CliError("Config command failed", ExitCode.failure); + throw InternalError.create("Config command failed"); } } @@ -654,7 +680,7 @@ function handleSkillResult( writeStdout(result.output); } if (result.exitCode !== 0) { - throw new CliError(failureMessage, ExitCode.failure); + throw InternalError.create(failureMessage); } } @@ -839,7 +865,7 @@ async function handleDoctorCommand( // Exit with appropriate code if (!report.healthy) { - throw new CliError("Doctor found issues", ExitCode.failure); + throw InternalError.create("Doctor found issues"); } } @@ -878,7 +904,7 @@ async function handleCheckCommand( // Exit with appropriate code if (!report.passed) { - throw new CliError("Check found issues", ExitCode.failure); + throw InternalError.create("Check found issues"); } } @@ -905,7 +931,7 @@ async function handleSeedCommand( } if (result.exitCode !== 0) { - throw new CliError("Seed command failed", ExitCode.failure); + throw InternalError.create("Seed command failed"); } } @@ -916,7 +942,7 @@ function parseUnifiedOptions( return parseUnifiedArgs(args); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw createUsageError(message); + throw ValidationError.fromMessage(message); } } @@ -1287,10 +1313,12 @@ export async function createProgram(): Promise { ` Exit Codes: 0 Success - 1 Waymark error - 2 Usage error (invalid flags or arguments) - 3 Configuration error - 4 I/O error (file not found, permission denied) + 1 Validation error (invalid flags, arguments, or waymark syntax) + 2 Not found (file, waymark, or resource missing) + 3 Conflict (concurrent modification or merge conflict) + 4 Permission error + 8 Internal error (unexpected failure) + 130 Cancelled (user interrupted) Note: For agent-facing documentation, use "wm skill". ` @@ -1407,7 +1435,7 @@ export async function runCli(argv: string[]): Promise<{ process.stdout.write = capture(stdoutChunks) as typeof process.stdout.write; process.stderr.write = capture(stderrChunks) as typeof process.stderr.write; - let exitCode: ExitCode = ExitCode.success; + let exitCode = 0; try { const program = await createProgram(); program.exitOverride((error) => { diff --git a/packages/cli/src/utils/command-runner.ts b/packages/cli/src/utils/command-runner.ts deleted file mode 100644 index 76cff69..0000000 --- a/packages/cli/src/utils/command-runner.ts +++ /dev/null @@ -1,36 +0,0 @@ -// tldr ::: centralized Result-to-exit-code mapping for CLI command handlers - -import type { AnyKitError, ErrorCategory } from "@outfitter/contracts"; -import { CliError } from "../errors.ts"; -import { ExitCode } from "../exit-codes.ts"; - -/** Map an @outfitter/contracts error category to a CLI exit code. */ -export function mapErrorToExitCode(category: ErrorCategory): ExitCode { - switch (category) { - case "validation": - case "not_found": - case "conflict": - return ExitCode.failure; - case "internal": - return ExitCode.failure; - case "cancelled": - return ExitCode.success; - default: - return ExitCode.failure; - } -} - -/** - * Unwrap a Result, throwing a CliError on failure. - * Use in program.ts handlers to bridge Result -> exit code flow. - */ -export async function runCommand( - fn: () => Promise> -): Promise { - const result = await fn(); - if (result.isErr()) { - const exitCode = mapErrorToExitCode(result.error.category); - throw new CliError(result.error.message, exitCode); - } - return result.value; -} diff --git a/packages/cli/src/utils/context.ts b/packages/cli/src/utils/context.ts index ec802b7..b019b1e 100644 --- a/packages/cli/src/utils/context.ts +++ b/packages/cli/src/utils/context.ts @@ -2,7 +2,7 @@ import type { WaymarkConfig } from "@waymarks/core"; import { loadConfigFromDisk } from "@waymarks/core"; -import { createConfigError } from "../errors.ts"; +import { InternalError } from "@outfitter/contracts"; import type { CommandContext, GlobalOptions } from "../types.ts"; import { resolveWorkspaceRoot } from "./workspace.ts"; @@ -25,7 +25,7 @@ export async function createContext( const result = await loadConfigFromDisk(loadOptions); if (result.isErr()) { - throw createConfigError(result.error.message); + throw InternalError.create(result.error.message); } let config: WaymarkConfig = result.value; diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 452b7d5..ab7caa0 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -1,8 +1,7 @@ // tldr ::: interactive prompts using @outfitter/cli for CLI confirmations and selection import type { WaymarkRecord } from "@waymarks/grammar"; -import { CliError } from "../errors.ts"; -import { ExitCode } from "../exit-codes.ts"; +import { CancelledError, ValidationError } from "@outfitter/contracts"; import { promptConfirm, promptSelect } from "./clack-prompts.ts"; import { canPrompt } from "./terminal.ts"; @@ -54,9 +53,8 @@ export function assertPromptAllowed(action: string): void { ? "because --no-input was specified" : "because the terminal is not interactive"; - throw new CliError( + throw ValidationError.fromMessage( `Cannot prompt for ${action} ${details}.`, - ExitCode.usageError ); } @@ -77,7 +75,7 @@ export async function confirm(options: ConfirmOptions): Promise { initialValue: options.default ?? true, }); if (result.isErr()) { - throw new CliError("Operation cancelled", ExitCode.usageError); + throw CancelledError.create("Operation cancelled"); } return result.value; } @@ -153,7 +151,7 @@ export async function selectWaymark( }); if (result.isErr()) { - throw new CliError("Selection cancelled", ExitCode.usageError); + throw CancelledError.create("Selection cancelled"); } return result.value;