diff --git a/.claude/agent-memory/fieldguides-engineer/MEMORY.md b/.claude/agent-memory/fieldguides-engineer/MEMORY.md new file mode 100644 index 00000000..d1bed472 --- /dev/null +++ b/.claude/agent-memory/fieldguides-engineer/MEMORY.md @@ -0,0 +1,38 @@ +# Waymark CLI - Engineering Memory + +## @outfitter/contracts Error API (v0.4.1) + +- `ValidationError.create(field, reason, context?)` -- field-specific validation +- `ValidationError.fromMessage(message, context?)` -- freeform validation +- `InternalError.create(message, context?)` -- unexpected errors +- `CancelledError.create(message)` -- user/signal cancellation +- `NotFoundError.create(resourceType, resourceId, context?)` +- `getExitCode(category)` -- maps ErrorCategory to exit code number +- Exit codes: validation:1, not_found:2, conflict:3, permission:4, timeout:5, rate_limit:6, network:7, internal:8, auth:9, cancelled:130 + +## Dual Commander Package Issue + +- `@outfitter/cli` bundles its own Commander at `node_modules/@outfitter/cli/node_modules/commander/` +- `instanceof CommanderError` fails across package boundaries +- Solution: duck-type check `isCommanderLikeError()` that checks `.exitCode` (number) + `.code` (string starting with "commander.") +- Commander already prints help/version to stdout before throwing -- must suppress duplicate output in catch handlers + +## createCLI() Integration Notes + +- `createCLI()` calls `.version()` and `.exitOverride()` internally +- Do NOT call `.version()` again -- patch the existing option in-place to change flags +- The `cli.parse()` method handles help/version exit codes; `program.parseAsync()` bypasses this +- When using `program.parseAsync()` directly, add `isCommanderOutputError()` check in catch to avoid double-printing + +## Biome Formatting + +- Formatter reorders imports alphabetically -- don't fight it +- `biome-ignore lint/suspicious/noExplicitAny:` needed when patching Commander internals +- Format hook runs on pre-commit and may fix multiple files + +## Graphite Workflow + +- `gt modify -cm "message"` stages only tracked changes + creates new commit +- `gt modify -acm "message"` stages ALL changes including untracked +- Without `-a`, must `git add` specific files first, then use `-cm` +- Graphite auto-restacks the entire branch stack after commits diff --git a/.outfitter/reports/upgrade.json b/.outfitter/reports/upgrade.json index f6057d7f..79e30d77 100644 --- a/.outfitter/reports/upgrade.json +++ b/.outfitter/reports/upgrade.json @@ -66,4 +66,4 @@ "@outfitter/mcp" ] } -} \ No newline at end of file +} diff --git a/packages/cli/src/__tests__/fixtures/help/config-help.txt b/packages/cli/src/__tests__/fixtures/help/config-help.txt index 31bb641a..7b104164 100644 --- a/packages/cli/src/__tests__/fixtures/help/config-help.txt +++ b/packages/cli/src/__tests__/fixtures/help/config-help.txt @@ -1,7 +1,8 @@ -wm config [options] +Usage: wm config [options] + print resolved configuration Options: --print print merged configuration (default: false) --json output compact JSON (default: false) - --help, -h display help for command + -h, --help display help for command diff --git a/packages/cli/src/__tests__/fixtures/help/find-help.txt b/packages/cli/src/__tests__/fixtures/help/find-help.txt index fa196938..3a3ef8d3 100644 --- a/packages/cli/src/__tests__/fixtures/help/find-help.txt +++ b/packages/cli/src/__tests__/fixtures/help/find-help.txt @@ -1,10 +1,10 @@ -wm find [options] [paths...] +Usage: wm find [options] [paths...] + scan and filter waymarks in files or directories Arguments: paths files or directories to scan - Options: --type , -t filter by waymark type(s) --tag filter by tag(s) @@ -27,4 +27,4 @@ Options: --page page number (with --limit) --interactive interactively select a waymark --pretty output as pretty-printed JSON - --help, -h display help for command + -h, --help display help for command diff --git a/packages/cli/src/__tests__/fixtures/help/root-help.txt b/packages/cli/src/__tests__/fixtures/help/root-help.txt index 85c4d525..3d519a60 100644 --- a/packages/cli/src/__tests__/fixtures/help/root-help.txt +++ b/packages/cli/src/__tests__/fixtures/help/root-help.txt @@ -9,43 +9,43 @@ Quick Start: wm init Initialize waymark configuration Global Options: - --version, -v output the current version - --no-input fail if interactive input required - --scope , -s config scope (default|project|user) (choices: "default", "project", "user", default: "default") - --config load additional config file (JSON/YAML/TOML) +-v, --version output the current version +--no-input Disable interactive prompts (alias) +--scope , -s config scope (default|project|user) (choices: "default", "project", "user", default: "default") +--config load additional config file (JSON/YAML/TOML) Logging: - --verbose enable verbose logging (info level) - --debug enable debug logging - --quiet, -q only show errors +--verbose enable verbose logging (info level) +--debug enable debug logging +--quiet, -q only show errors Output Formats: - --json Output as JSON array - --jsonl Output as JSON Lines (newline-delimited) - --text Output as human-readable formatted text +--json Output as JSON (default: false) +--jsonl Output as JSON Lines (newline-delimited) +--text Output as human-readable formatted text Color: - --no-color disable ANSI colors +--color [mode] Color output mode (auto, always, never) (default: "auto") +--no-color Disable color output Commands: - find scan and filter waymarks in files or directories - add add waymarks into files - edit edit existing waymarks - rm remove waymarks from files - fmt format and normalize waymark syntax in files - lint validate waymark structure and enforce quality rules - init initialize waymark configuration - config print resolved configuration - skill show agent-facing skill documentation - doctor run health checks and diagnostics - completions|complete Generate shell completion scripts - update check for and install CLI updates (npm global installs) - help display help for command +find scan and filter waymarks in files or directories +add add waymarks into files +edit edit existing waymarks +rm remove waymarks from files +check validate cross-file content integrity +init initialize waymark configuration +config print resolved configuration +skill show agent-facing skill documentation +doctor check tool and environment health +completions|complete Generate shell completion scripts +update check for and install CLI updates (npm global installs) +help display help for command Topics: - Run 'wm help ' for syntax guides (syntax, tldr, todo, signals, tags) +Run 'wm help ' for syntax guides (syntax, tldr, todo, signals, tags) diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 13101687..d336d90b 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -1,8 +1,7 @@ // tldr ::: config command helpers for printing resolved settings [[cli/config-print]] -import type { WaymarkConfig } from "@waymarks/core"; - import { ValidationError } from "@outfitter/contracts"; +import type { WaymarkConfig } from "@waymarks/core"; import type { CommandContext } from "../types.ts"; export type ConfigCommandOptions = { diff --git a/packages/cli/src/commands/register.ts b/packages/cli/src/commands/register.ts index 4714bdfc..48afc192 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 { ValidationError } from "@outfitter/contracts"; +import { Command, InvalidArgumentError } from "commander"; import type { ModifyCliOptions } from "../types.ts"; import { parsePropertyEntry } from "../utils/properties.ts"; import type { CheckCommandOptions } from "./check.ts"; @@ -135,7 +135,9 @@ export function registerCommands( if (helpTopicNames.length > 0) { writeStdout(`Available topics: ${helpTopicNames.join(", ")}`); } - throw ValidationError.fromMessage(`Unknown command or topic: ${commandName}`); + throw ValidationError.fromMessage( + `Unknown command or topic: ${commandName}` + ); } catch (error) { handleCommandError(program, error); } diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index c8aeec03..9c1ff4f2 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -4,7 +4,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Result } from "@outfitter/contracts"; +import { getExitCode, Result } from "@outfitter/contracts"; import { resolveConfig } from "@waymarks/core"; import type { Command } from "commander"; import { findRecords } from "./commands/find"; @@ -19,7 +19,6 @@ import { } from "./commands/unified/index"; import { parseUnifiedArgs } from "./commands/unified/parser"; import type { UnifiedCommandOptions } from "./commands/unified/types"; -import { getExitCode } from "@outfitter/contracts"; import { runCli } from "./index"; import type { CommandContext } from "./types"; import { renderRecords } from "./utils/output"; diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index d9179a18..680b59ae 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -2,6 +2,14 @@ import tab from "@bomb.sh/tab/commander"; import { createCLI } from "@outfitter/cli/command"; +import { colorPreset, interactionPreset } from "@outfitter/cli/flags"; +import { + type AnyKitError, + type ErrorCategory, + getExitCode, + InternalError, + ValidationError, +} from "@outfitter/contracts"; import type { WaymarkConfig } from "@waymarks/core"; import { type Command, CommanderError, Option } from "commander"; import simpleUpdateNotifier from "simple-update-notifier"; @@ -56,13 +64,6 @@ import { runUpdateCommand, type UpdateCommandOptions, } from "./commands/update.ts"; -import { - type AnyKitError, - type ErrorCategory, - InternalError, - ValidationError, - getExitCode, -} from "@outfitter/contracts"; import type { CommandContext, GlobalOptions, @@ -99,6 +100,16 @@ function shouldEnableSpinner(options: { return Boolean(process.stderr.isTTY); } +// about ::: resolves --color/--no-color preset output to a noColor boolean for spinner/terminal helpers +function resolveNoColorFromOpts(opts: Record): boolean { + const colorValue = opts.color; + // colorPreset resolves color to "auto"|"always"|"never"; Commander negation sets it to false + if (colorValue === false || colorValue === "never") { + return true; + } + return false; +} + function resolveGlobalOptions(program: Command): GlobalOptions { const opts = program.opts(); const scopeValue = typeof opts.scope === "string" ? opts.scope : "default"; @@ -178,9 +189,7 @@ function resolveExitCode(error: unknown): number { "category" in error && typeof (error as { category: unknown }).category === "string" ) { - return getExitCode( - (error as { category: ErrorCategory }).category, - ); + return getExitCode((error as { category: ErrorCategory }).category); } if ( error && @@ -253,7 +262,7 @@ function registerSignalHandlers(): void { * Bridges Result-returning core functions to the throw-based CLI error flow. */ async function runCommand( - fn: () => Promise>, + fn: () => Promise> ): Promise { const result = await fn(); if (result.isErr()) { @@ -319,7 +328,7 @@ async function handleLintCommand( structuredOutput: Boolean(options.json), }), text: "Linting waymarks...", - noColor: Boolean(programOpts.noColor), + noColor: resolveNoColorFromOpts(programOpts), }); spinner.start(); @@ -560,7 +569,9 @@ async function handleModifyCommand( rawOptions: ModifyCliOptions ): Promise { if (rawOptions.json && rawOptions.jsonl) { - throw ValidationError.fromMessage("--json and --jsonl cannot be used together"); + throw ValidationError.fromMessage( + "--json and --jsonl cannot be used together" + ); } const interactiveOverride = determineInteractiveOverride( @@ -727,7 +738,6 @@ const BOOLEAN_OPTION_FLAGS = [ { key: "tree", flag: "--tree" }, { key: "flat", flag: "--flat" }, { key: "compact", flag: "--compact" }, - { key: "noColor", flag: "--no-color" }, ] as const; const STRING_OPTION_FLAGS = [ @@ -755,6 +765,20 @@ function collectOptionValues(value: unknown): string[] { return [String(value)]; } +// note ::: negatable flags that map to --no- when their condition is met +function appendNegatableArgs( + args: string[], + options: Record +): void { + if (options.wrap === false) { + args.push("--no-wrap"); + } + // color preset: --no-color when color is "never" or false (Commander negation) + if (options.color === "never" || options.color === false) { + args.push("--no-color"); + } +} + function buildArgsFromOptions( paths: string[], options: Record @@ -774,10 +798,7 @@ function buildArgsFromOptions( } } - // Handle negatable flags explicitly - if (options.wrap === false) { - args.push("--no-wrap"); - } + appendNegatableArgs(args, options); for (const { key, flag } of STRING_OPTION_FLAGS) { const value = options[key]; @@ -844,7 +865,7 @@ async function handleDoctorCommand( structuredOutput: Boolean(options.json || programOpts.json), }), text: "Running diagnostics...", - noColor: Boolean(programOpts.noColor), + noColor: resolveNoColorFromOpts(programOpts), }); spinner.start(); @@ -883,7 +904,7 @@ async function handleCheckCommand( structuredOutput: Boolean(options.json || programOpts.json), }), text: "Checking content integrity...", - noColor: Boolean(programOpts.noColor), + noColor: resolveNoColorFromOpts(programOpts), }); spinner.start(); @@ -1091,7 +1112,7 @@ const ROOT_OPTION_SECTIONS: OptionSection[] = [ }, { title: "Color", - longs: ["--no-color"], + longs: ["--color", "--no-color"], }, ]; @@ -1301,13 +1322,11 @@ export async function createProgram(): Promise { .option("--config ", "load additional config file (JSON/YAML/TOML)") .option("--cache", "use scan cache for faster repeated runs") .option("--include-ignored", "include waymarks inside wm:ignore fences") - .option("--no-input", "fail if interactive input required") .option("--verbose", "enable verbose logging (info level)") .option("--debug", "enable debug logging") .option("--quiet, -q", "only show errors") .addOption(jsonlOption) .addOption(textOption) - .option("--no-color", "disable ANSI colors") .addHelpText( "afterAll", ` @@ -1326,7 +1345,8 @@ Note: For agent-facing documentation, use "wm skill". .hook("preAction", (thisCommand) => { // Configure logger based on flags const opts = thisCommand.opts(); - setPromptPolicy({ noInput: Boolean(opts.noInput) }); + const resolvedInteraction = interactionPreset().resolve(opts); + setPromptPolicy({ noInput: !resolvedInteraction.interactive }); if (opts.debug) { logger.level = "debug"; } else if (opts.verbose) { @@ -1336,6 +1356,26 @@ Note: For agent-facing documentation, use "wm skill". } }); + // note ::: apply flag presets to program — interactionPreset adds --non-interactive/--no-input/-y/--yes, + // colorPreset adds --color [mode]/--no-color; register after other options to preserve option order + const interactionOpts = interactionPreset().options; + const colorOpts = colorPreset().options; + + // hide options that are aliases or internal — we surface only the canonical flags in help sections + const hiddenInteractionFlags = new Set(["--non-interactive", "-y, --yes"]); + + for (const opt of [...interactionOpts, ...colorOpts]) { + if (opt.required === true) { + program.requiredOption(opt.flags, opt.description, opt.defaultValue); + } else { + program.option(opt.flags, opt.description, opt.defaultValue); + } + if (hiddenInteractionFlags.has(opt.flags)) { + const added = program.options.find((o) => o.flags === opt.flags); + added?.hideHelp(); + } + } + registerCommands(program, { handleCommandError, handleFormatCommand, diff --git a/packages/cli/src/utils/context.ts b/packages/cli/src/utils/context.ts index b019b1e5..97d1ff86 100644 --- a/packages/cli/src/utils/context.ts +++ b/packages/cli/src/utils/context.ts @@ -1,8 +1,8 @@ // tldr ::: context creation helpers for waymark CLI commands +import { InternalError } from "@outfitter/contracts"; import type { WaymarkConfig } from "@waymarks/core"; import { loadConfigFromDisk } from "@waymarks/core"; -import { InternalError } from "@outfitter/contracts"; import type { CommandContext, GlobalOptions } from "../types.ts"; import { resolveWorkspaceRoot } from "./workspace.ts"; diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index ab7caa02..de4d7a6c 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -1,7 +1,7 @@ // tldr ::: interactive prompts using @outfitter/cli for CLI confirmations and selection -import type { WaymarkRecord } from "@waymarks/grammar"; import { CancelledError, ValidationError } from "@outfitter/contracts"; +import type { WaymarkRecord } from "@waymarks/grammar"; import { promptConfirm, promptSelect } from "./clack-prompts.ts"; import { canPrompt } from "./terminal.ts"; @@ -53,9 +53,7 @@ export function assertPromptAllowed(action: string): void { ? "because --no-input was specified" : "because the terminal is not interactive"; - throw ValidationError.fromMessage( - `Cannot prompt for ${action} ${details}.`, - ); + throw ValidationError.fromMessage(`Cannot prompt for ${action} ${details}.`); } export type ConfirmOptions = {