Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json

# Finder (MacOS) folder config
.DS_Store

# local tool caches
.impeccable
38 changes: 31 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ const username = validatePattern('user123', /^[a-z0-9]+$/, 'Username', 'alphanum
### Errors Module

Throw structured errors with machine-readable codes and render them consistently
for agents to parse:
for agents to parse. Built on [`@vercel/error`](https://github.com/vercel-labs/error),
so every error can answer _what_ happened (`message`), _why_ (`reason`), _what
could help_ (`hint`), _how to fix it_ (`fix`), and _where to learn more_ (`link`):

```typescript
import {
Expand All @@ -132,17 +134,38 @@ import {
import { outputData } from '@pleaseai/cli-toolkit/output'

try {
if (!issue)
throw new CliError('Issue #999 not found', 'NOT_FOUND', ['Run `gh-please issue list`'])
if (!issue) {
throw new CliError('Issue #999 not found', {
code: 'NOT_FOUND',
fix: 'Run `gh-please issue list` to see open issues',
})
}
}
catch (error) {
// Render a stable { error, code, help } payload (JSON or TOON)
// Render a stable { error, code, reason?, hint?, fix?, link? } payload (JSON or TOON)
outputData(toErrorOutput(error), 'toon')
// Map to a process exit code (2 for validation errors, otherwise 1)
process.exitCode = exitCodeForError(error)
}
```

For tools with many error sites, `createErrors` (re-exported from
`@vercel/error`) builds a factory that injects a shared scope and derives docs
links from error codes:

```typescript
import { CliError, createErrors } from '@pleaseai/cli-toolkit/errors'

const errors = createErrors({
scope: 'gh-please',
ErrorClass: CliError,
docsBaseUrl: 'https://example.com/docs/errors',
})

errors.raise('Issue #999 not found', { code: 'NOT_FOUND' })
// throws a CliError with link https://example.com/docs/errors/NOT_FOUND
```

The built-in validators already throw `CliError` tagged with `VALIDATION_ERROR`
(exit code `2`), so wrapping a command in the `try/catch` above gives you
consistent structured errors and exit codes for free.
Expand All @@ -158,7 +181,7 @@ consistent structured errors and exit codes for free.
import { isCliError } from '@pleaseai/cli-toolkit/errors'

if (isCliError(error)) {
// error.code / error.suggestions are safe to read here
// error.code / error.reason / error.hint / error.fix are safe to read here
}
```

Expand Down Expand Up @@ -223,12 +246,13 @@ collapseHomeDirectory('/Users/alice/project/bin', '/Users/alice') // '~/project/

### Errors Module

- `CliError` - Structured error with `code` and `suggestions`
- `CliError` - Structured error extending `VercelError`; carries `code` (always set) plus `reason`, `hint`, `fix`, `link`, `metadata`, `cause`, ...
- `isCliError(error)` - Type guard for `CliError`, robust across subpath/version boundaries (prefer over `instanceof`)
- `exitCodeForError(error)` - Map an error to a process exit code (`2` for validation, else `1`)
- `errorOutput(message, code, suggestions?)` - Build a `{ error, code, help? }` payload
- `errorOutput(message, code, details?)` - Build a `{ error, code, reason?, hint?, fix?, link? }` payload
- `toErrorOutput(error)` - Convert any thrown value into a structured payload
- `VALIDATION_ERROR` / `UNKNOWN_ERROR` - Built-in error code constants
- Re-exported from `@vercel/error`: `VercelError`, `createErrors`, `isVercelError`, `isError`, `isErrorLike`, `hasCode`, `getMessage`, `getRootCause`

## TypeScript Support

Expand Down
4 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import antfu from '@antfu/eslint-config'

export default antfu({
type: 'lib',
ignores: ['.impeccable'],
typescript: true,
formatters: true,
stylistic: {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
},
"dependencies": {
"@byjohann/toon": "^0.3.0",
"@vercel/error": "^0.0.2",
"commander": "^12.1.0",
"es-toolkit": "^1.31.0"
},
Expand Down
67 changes: 46 additions & 21 deletions src/errors/error.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { VercelErrorOptions } from '@vercel/error'
import { hasCode, VercelError } from '@vercel/error'

/**
* Error code for validation failures (bad/invalid user input).
*
Expand All @@ -13,37 +16,54 @@ export const VALIDATION_ERROR = 'VALIDATION_ERROR'
*/
export const UNKNOWN_ERROR = 'UNKNOWN_ERROR'

/**
* Options accepted by {@link CliError}.
*
* Identical to `VercelErrorOptions` from `@vercel/error`, so a `CliError` can
* carry the full structured context (`reason`, `hint`, `fix`, `link`,
* `metadata`, `cause`, ...) and stays compatible with `createErrors()` via
* the `ErrorClass` option.
*/
export type CliErrorOptions = VercelErrorOptions

/**
* Structured error for CLI / agent-facing tools.
*
* Carries a machine-readable `code` and optional `suggestions` alongside the
* human message, so callers can render a consistent structured payload
* (`{ error, code, help }`) and map the error to a stable process exit code.
* Built on {@link VercelError}, so beyond the machine-readable `code` it can
* answer the full set of questions a human or agent needs: *what* happened
* (`message`), *why* (`reason`), *what could help* (`hint`), *how to fix it*
* (`fix`), and *where to learn more* (`link`).
*
* Unlike the base class, `code` is always set (defaulting to
* {@link UNKNOWN_ERROR}) so callers can rely on it when rendering the
* structured payload and mapping to a process exit code.
*
* Because it extends the built-in `Error`, existing `try/catch` and
* `expect(...).toThrow(message)` checks keep working unchanged.
* `expect(...).toThrow(message)` checks keep working unchanged. Its
* `toString()` is environment-aware: colored tree output on a TTY, plain
* indented text when piped (inherited from `VercelError`).
*
* @example
* ```typescript
* throw new CliError(
* 'Issue #999 not found',
* 'NOT_FOUND',
* ['Run `gh-please issue list` to see open issues'],
* )
* throw new CliError('Issue #999 not found', {
* code: 'NOT_FOUND',
* hint: 'The issue may have been closed or transferred.',
* fix: 'Run `gh-please issue list` to see open issues',
* })
* ```
*/
export class CliError extends Error {
export class CliError extends VercelError {
/** Always present; defaults to {@link UNKNOWN_ERROR}. */
declare readonly code: string

/**
* @param message - Human-readable error message
* @param code - Machine-readable error code (default: `'UNKNOWN_ERROR'`)
* @param suggestions - Optional actionable hints surfaced to the user/agent
* @param options - Structured context; `code` defaults to `'UNKNOWN_ERROR'`
*/
constructor(
message: string,
public readonly code: string = UNKNOWN_ERROR,
public readonly suggestions: string[] = [],
) {
super(message)
constructor(message: string, options: CliErrorOptions = {}) {
super(message, { ...options, code: options.code ?? UNKNOWN_ERROR })
// Hardcoded (not derived from `this.constructor.name`) so minified
// bundles and the structural check in `isCliError` stay reliable.
this.name = 'CliError'
}
}
Expand All @@ -56,8 +76,9 @@ export class CliError extends Error {
* (each bundled with its own copy), or two versions of the toolkit coexisting in
* a dependency tree. Each copy defines a structurally identical but
* reference-distinct class, so `instanceof` silently misclassifies the error and
* its `code`/`suggestions` are lost. Falling back to a structural `name`/`code`
* check keeps detection reliable across those boundaries.
* its structured fields are lost. Falling back to a structural `name`/`code`
* check keeps detection reliable across those boundaries (including errors
* from pre-`@vercel/error` versions of this toolkit).
*
* @param error - Any thrown value
* @returns `true` if the value is a `CliError` (or structurally equivalent)
Expand All @@ -80,6 +101,10 @@ export function isCliError(error: unknown): error is CliError {
* Validation errors return `2` (usage error), everything else returns `1`.
* Mirrors the common Unix convention where `2` signals misuse/invalid input.
*
* Uses `hasCode` from `@vercel/error`, so any error-like value tagged with
* {@link VALIDATION_ERROR} maps to `2` — `CliError`, a plain `VercelError`,
* or a structurally equivalent error from another module copy.
*
* @param error - Any thrown value
* @returns Exit code (`2` for validation errors, otherwise `1`)
*
Expand All @@ -93,7 +118,7 @@ export function isCliError(error: unknown): error is CliError {
* ```
*/
export function exitCodeForError(error: unknown): number {
if (isCliError(error) && error.code === VALIDATION_ERROR) {
if (hasCode(error, VALIDATION_ERROR)) {
return 2
}
return 1
Expand Down
34 changes: 30 additions & 4 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
/**
* Structured error utilities for CLI / agent-facing tools.
*
* Provides a structured error class with machine-readable codes and
* suggestions, exit-code mapping, and helpers to render errors as a stable
* `{ error, code, help }` payload.
* Built on `@vercel/error`: provides a structured error class with
* machine-readable codes and rich context (`reason`, `hint`, `fix`, `link`),
* exit-code mapping, and helpers to render errors as a stable flat payload.
*
* Core primitives from `@vercel/error` (`VercelError`, `createErrors`,
* guards, and cause-chain helpers) are re-exported so consumers don't need a
* direct dependency.
*
* @module errors
*/
Expand All @@ -17,6 +21,28 @@ export {
VALIDATION_ERROR,
} from './error.ts'

export type { CliErrorOptions } from './error.ts'

// Structured error output
export { errorOutput, toErrorOutput } from './output.ts'
export type { ErrorOutput } from './output.ts'
export type { ErrorOutput, ErrorOutputDetails } from './output.ts'

// Core primitives from @vercel/error
export {
createErrors,
getMessage,
getRootCause,
hasCode,
isError,
isErrorLike,
isVercelError,
VercelError,
} from '@vercel/error'
export type {
CreateErrorsOptions,
ErrorAttributes,
ErrorFactory,
ErrorLike,
ErrorMetadata,
VercelErrorOptions,
} from '@vercel/error'
Loading
Loading