Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .claude/agent-memory/fieldguides-engineer/MEMORY.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .outfitter/reports/upgrade.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,4 @@
"@outfitter/mcp"
]
}
}
}
5 changes: 3 additions & 2 deletions packages/cli/src/__tests__/fixtures/help/config-help.txt
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions packages/cli/src/__tests__/fixtures/help/find-help.txt
Original file line number Diff line number Diff line change
@@ -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 <types...>, -t filter by waymark type(s)
--tag <tags...> filter by tag(s)
Expand All @@ -27,4 +27,4 @@ Options:
--page <n> 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
50 changes: 25 additions & 25 deletions packages/cli/src/__tests__/fixtures/help/root-help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <scope>, -s config scope (default|project|user) (choices: "default", "project", "user", default: "default")
--config <path> load additional config file (JSON/YAML/TOML)
-v, --version output the current version
--no-input Disable interactive prompts (alias)
--scope <scope>, -s config scope (default|project|user) (choices: "default", "project", "user", default: "default")
--config <path> 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 <topic>' for syntax guides (syntax, tldr, todo, signals, tags)
Run 'wm help <topic>' for syntax guides (syntax, tldr, todo, signals, tags)
3 changes: 1 addition & 2 deletions packages/cli/src/commands/config.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/commands/register.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down
88 changes: 64 additions & 24 deletions packages/cli/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>): 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;
Comment on lines +107 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor --color always instead of falling back to auto

resolveNoColorFromOpts() only treats false and "never" as special cases, so opts.color === "always" is collapsed into the same path as auto mode. Downstream color checks still rely on TTY detection, which means commands like wm --color always ... | cat can lose ANSI output even though the CLI now documents --color [mode] with an always mode. This makes the new flag mode ineffective in non-TTY environments.

Useful? React with 👍 / 👎.

}
return false;
}

function resolveGlobalOptions(program: Command): GlobalOptions {
const opts = program.opts();
const scopeValue = typeof opts.scope === "string" ? opts.scope : "default";
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -253,7 +262,7 @@ function registerSignalHandlers(): void {
* Bridges Result-returning core functions to the throw-based CLI error flow.
*/
async function runCommand<T>(
fn: () => Promise<import("@outfitter/contracts").Result<T, AnyKitError>>,
fn: () => Promise<import("@outfitter/contracts").Result<T, AnyKitError>>
): Promise<T> {
const result = await fn();
if (result.isErr()) {
Expand Down Expand Up @@ -319,7 +328,7 @@ async function handleLintCommand(
structuredOutput: Boolean(options.json),
}),
text: "Linting waymarks...",
noColor: Boolean(programOpts.noColor),
noColor: resolveNoColorFromOpts(programOpts),
});

spinner.start();
Expand Down Expand Up @@ -560,7 +569,9 @@ async function handleModifyCommand(
rawOptions: ModifyCliOptions
): Promise<void> {
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(
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -755,6 +765,20 @@ function collectOptionValues(value: unknown): string[] {
return [String(value)];
}

// note ::: negatable flags that map to --no-<flag> when their condition is met
function appendNegatableArgs(
args: string[],
options: Record<string, unknown>
): 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<string, unknown>
Expand All @@ -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];
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1091,7 +1112,7 @@ const ROOT_OPTION_SECTIONS: OptionSection[] = [
},
{
title: "Color",
longs: ["--no-color"],
longs: ["--color", "--no-color"],
},
];

Expand Down Expand Up @@ -1301,13 +1322,11 @@ export async function createProgram(): Promise<Command> {
.option("--config <path>", "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",
`
Expand All @@ -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) {
Expand All @@ -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,
Expand Down
Loading