Shared oclif helpers for errors, logging, JSON support, etc.
npm install @charlie-labs/oclif-plugin-helpers
Shared helpers to standardize error handling and output behavior across multiple oclif CLIs.
- Shared error classes with stable static exit codes and string codes
- Robust unknown → exit-code mapper (errorToExitCode)
- A BaseCommand that centralizes --json gating, minimal error JSON shaping, stderr-only logging helpers, and a TSV printer
- Optional handle() for bin/run catch chains to conform exit codes
- Optional finally hook that conservatively sets process.exitCode when an error occurred
This package is publicly published on the npm registry as @charlie-labs/oclif-plugin-helpers and expects oclif v4.
- Public npm: npmjs.com/package/@charlie-labs/oclif-plugin-helpers
- Peer dependency: @oclif/core@^4.5.2
- Node: >=18 (per this package’s engines field)
``bashnpm
npm install @charlie-labs/oclif-plugin-helpers @oclif/core@^4.5.2
Overview / Mission
A reusable place to make multiple CLIs behave the same way when errors happen and when users request JSON:
- Error classes include a static
exitCode and a string code for stability across boundaries.
- errorToExitCode(err) maps any thrown value to a stable exit code using a resilient precedence order.
- BaseCommand is the primary integration point: it enables --json for all commands, shapes error JSON, suppresses logs under --json, and provides stderr-only logging helpers plus a TSV printer.
- handle() can be used in bin/run to conform exit codes for errors that bypass Command.catch().
- An optional finally hook exists to set process.exitCode only when no other layer has set it.Quick Start
Extend
BaseCommand and implement execute(ctx) (do not override run()). Destructure only what you need from ctx — { parsed }, { deps }, or { parsed, deps }. If you don’t need the context at all, accept it as an unused parameter: execute(_ctx). When your command defines flags, build a manifest with defineFlags(...) and install it via static override flags = super.registerManifest(manifest);. Commands without flags can skip that line entirely; they fall back to an empty manifest. Use logInfo/logWarn for stderr-only logs and printRows for TSV content. Under --json, logs are suppressed and printRows is a no-op. The BaseCommand generic is kwargs‑style and order‑free: supply a union of tags — CfgFlags — and specify only what you need.`ts
import { BaseCommand } from '@charlie-labs/oclif-plugin-helpers';
import type { Result } from '@charlie-labs/oclif-plugin-helpers';export default class Demo extends BaseCommand> {
//
--json is enabled for all subclasses via BaseCommand
protected async execute(_ctx) {
this.logInfo('Fetching record…'); // stderr-only; suppressed under --json // pretend we looked something up
const record = { id: 'rec_123' } as const;
// TSV output (stdout). No-op under --json
this.printRows([
['id', 'name'],
['123', 'example'],
]);
return record;
}
}
`JSON mode and error JSON shape
All subclasses of
BaseCommand automatically support oclif’s built-in --json flag. When --json is set:- Non-content logs are suppressed (
logInfo/logWarn don’t write)
- printRows() is a no-op
- Errors are rendered as minimal, stable JSON using toErrorJson(err)Exact error JSON shape:
`ts
{
error: {
type: string;
message: string;
exitCode: number;
meta?: {
code?: string;
status?: number;
retryable?: boolean;
};
};
}
`-
type is derived from err.name (or constructor name)
- message is concise and always present
- exitCode is computed by errorToExitCode
- meta includes optional hints when available: string error code, HTTP status, and retryable (based on common transport failures via isRetryableNetworkError)Exit code mapping
errorToExitCode(err) resolves a stable process exit code using this order:1.
err.exitCode (instance)
2. err.constructor.exitCode (static)
3. err.code (string)
4. err.name
5. instanceof checks
6. default 1Provided error classes and their static exit codes:
-
ValidationError → 2 (code: EVALIDATION)
- NotFoundError → 3 (code: ERESOURCE_NOT_FOUND)
- ConflictError → 4 (code: ECONFLICT)
- UnauthorizedError → 5 (code: EUNAUTHORIZED)
- RateLimitedError → 6 (code: ERATELIMIT)
- ServiceUnavailableError → 7 (code: ESVCUNAVAILABLE)
- CanceledError → 8 (code: ECANCELED)
- ApiRequestError → 1 (code: EAPI)Public API reference
All symbols below are exported from
@charlie-labs/oclif-plugin-helpers (via src/index.ts).$3
-
ValidationError
- NotFoundError
- ConflictError
- UnauthorizedError
- RateLimitedError
- ServiceUnavailableError
- CanceledError
- ApiRequestError$3
-
errorToExitCode(err: unknown): number
- isRetryableNetworkError(err: unknown): boolean$3
A tiny framework to define oclif flags alongside Zod schemas, compose them into a manifest, and parse the oclif flag bag into fully typed domain values with optional cross-flag validation.
Public surface (concise):
`ts
import { Flags, type Command } from '@oclif/core';
import { z } from 'zod';
import {
defineFlags,
type FlagSchema,
type FlagManifest,
zDateYYYYMMDD,
zStringList,
zPositiveInt,
zOrderDir,
zMultiEnum,
CommonFlags,
} from '@charlie-labs/oclif-plugin-helpers';// Build a manifest from inline flag schemas (preferred pattern)
const statusValues = ['started', 'completed', 'error'] as const;
export const manifest = defineFlags({
status: {
oclif: Flags.option({
options: statusValues,
multiple: true,
delimiter: ',',
description: 'Multi-select; repeats and/or comma-separated',
})(),
schema: zMultiEnum(statusValues),
},
start: {
oclif: Flags.string({ description: 'Inclusive (YYYY-MM-DD, UTC)' }),
schema: zDateYYYYMMDD.optional(),
},
end: {
oclif: Flags.string({ description: 'Exclusive (YYYY-MM-DD, UTC)' }),
schema: zDateYYYYMMDD.optional(),
},
limit: {
oclif: Flags.integer({
description: 'Positive integer (10000 max)',
default: 100,
}),
schema: zPositiveInt({ default: 100, max: 10_000 }),
},
})
.withValidation((schema) =>
schema.superRefine(({ start, end }, ctx) => {
if (start && end && !(start < end))
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'end must be after start',
path: ['end'],
});
})
)
.withPredicate(
'start < end',
({ start, end }) => !start || !end || start < end,
{
path: ['end'],
message: 'end must be after start',
}
);
// oclif command usage (preferred)
import { BaseCommand } from '@charlie-labs/oclif-plugin-helpers';
import type { CfgFlags, ExecCtxOf } from '@charlie-labs/oclif-plugin-helpers';
export class MyCmd extends BaseCommand> {
static override flags = super.registerManifest(manifest);
protected override async execute({ parsed }: ExecCtxOf) {
// use typed flags
}
}
`Included atoms:
-
zDateYYYYMMDD: parse YYYY-MM-DD into Date at UTC midnight
- zStringList: parse string | number | (string|number)[] | undefined (repeat/comma) → de-duplicated string[] (undefined normalizes to [])
- zMultiEnum(values): normalize repeat/comma inputs and validate against provided enum values
- zPositiveInt({ max?, default? })
- zOrderDir ('asc'|'desc')
- zDateComparator: parse a single comparator string like ">=2025-01-01" or "> 2025-01-01" to { op: 'gte'|'gt'|'lte'|'lt'|'eq', date: Date } (UTC midnight)
- zDateComparatorList: alias for zStringList.pipe(z.array(zDateComparator)) — accepts repeat/comma inputs, returns DateComparator[], and normalizes undefined to []Notes
- Inline schemas are the primary path. For optional dates, use
zDateYYYYMMDD.optional().
- When using a default with zPositiveInt({ default: N, ... }), define the same default on the oclif flag to keep behavior consistent between oclif and Zod parsing.Convenience:
CommonFlags exports ready-made start, end, limit, order, and a sample status multi-enum.$3
`ts
import { Flags } from '@oclif/core';
import {
defineFlags,
zDateComparator,
zDateComparatorList,
} from '@charlie-labs/oclif-plugin-helpers';export const manifest = defineFlags({
created: {
oclif: Flags.string({
description: '>, >=, <, <=, = followed by YYYY-MM-DD (UTC)',
}),
schema: zDateComparator.optional(),
},
updated: {
oclif: Flags.string({
multiple: true,
delimiter: ',',
description:
'>, >=, <, <=, = followed by YYYY-MM-DD (UTC). Repeat or comma-separate',
}),
schema: zDateComparatorList,
},
});
`$3
-
class BaseCommand where Cfg is a union of order‑free tags you pick from:
- CfgFlags — enables typed parsed flags in execute(...)
- Result — declares the return type of run()/execute() (defaults to unknown)
- Deps — declares the dependency object type used by DI helpers
- static enableJsonFlag = true
- static get manifest(): FlagManifest — returns the manifest registered on the concrete subclass (defaults to an empty manifest). Call static override flags = super.registerManifest(manifest); to install your manifest and expose its oclif flags to help output and parsing.
- protected execute(ctx) — pick only what you need by destructuring:
- execute({ parsed })
- execute({ deps })
- execute({ parsed, deps })
If you don’t need the context, accept it as execute(_ctx).
When no CfgFlags<> tag is present, ctx.parsed is typed as Record. When a Deps<> tag is present, ctx.deps is typed as D | undefined; without a Deps<> tag, ctx.deps is undefined.
- Dependency injection (resolution order in run()): test override → static buildDeps(parsed) → protected get deps()
- static buildDeps(parsed): D | Promise — override on your subclass to construct deps from flags. The override is fully typed to your Cfg via a polymorphic this parameter, so parsed is ParsedOf and the return type is your D. If you mark it async, the return type becomes Promise.
- protected get deps(): D | undefined — instance fallback when buildDeps isn’t used
- Note: this getter is typed to your Deps tag. If you previously returned a broader type, narrow it to D.
- static setTestDeps(deps: D) — test-only override used first when present (one‑shot; cleared after use)
- static clearTestDeps() — clears any previously set test override (useful in custom harnesses)
- async run(): Promise — provided by BaseCommand and final in practice (where T is the type you supplied via Result in Cfg)
- Helpers (no-op under --json):
- protected logInfo(msg: string): void — stderr-only
- protected logWarn(msg: string): void — stderr-only
- protected printRows(rows: (string | string[])[], options?: { header?: string[] }): void — stdout TSV
- Error shaping:
- protected toErrorJson(err: unknown) — returns the JSON shape shown above (used automatically by oclif in JSON mode)####
Cfg combinations (type-only examples)Below are short, type-only class declarations that show common ways to configure
BaseCommand using the CfgFlags<>, Result<>, and Deps<> tags. These intentionally omit method bodies and focus on the type surface.`ts
import { BaseCommand } from '@charlie-labs/oclif-plugin-helpers';
import type {
CfgFlags,
Result,
Deps,
FlagManifest,
Defs,
} from '@charlie-labs/oclif-plugin-helpers';
import type { ZodTypeAny } from 'zod';// For these standalone examples, use a type-only stub for
manifest with the correct generic shape.
declare const manifest: FlagManifest;// 1) Deps-only (no flags; implicit output type = unknown)
export class DepsOnly extends BaseCommand> {}
// 2) Result + Deps (no flags)
export class ResultAndDeps extends BaseCommand<
| Deps<{ client: { request: (s: string) => Promise } }>
| Result
> {}
// 3) Flags + Result (no deps)
export class FlagsAndResult extends BaseCommand<
CfgFlags | Result
> {
static override flags = super.registerManifest(manifest);
}
// 3a) Flags-only (no deps; implicit output type = unknown)
export class FlagsOnly extends BaseCommand> {
static override flags = super.registerManifest(manifest);
}
// 4) Flags + Deps + Result (all three)
export class FlagsDepsResult extends BaseCommand<
CfgFlags | Deps<{ db: unknown }> | Result
> {
static override flags = super.registerManifest(manifest);
}
`#### Notes
- When you omit
Result, the command’s output type defaults to unknown.
- When you omit CfgFlags<>, ctx.parsed in execute(ctx) is Record.
- When you omit Deps<>, ctx.deps is undefined.$3
-
handle(err: unknown): Promise — drop-in for bin/run catch chainsOptional integration points
$3
Use
handle() to ensure exit codes conform to the mapper even when errors bypass Command.catch():`ts
// bin/run (snippet)
import { handle } from '@charlie-labs/oclif-plugin-helpers';
import { run } from '@oclif/core';await run(void 0, import.meta.url).catch(handle);
`$3
This package includes an optional
finally hook at src/hooks/finally/report.ts that only sets process.exitCode if an error occurred and the exit code wasn’t set yet. It does not print anything. This is a conservative last resort layer.Examples
$3
`ts
import { BaseCommand } from '@charlie-labs/oclif-plugin-helpers';
import type { Result } from '@charlie-labs/oclif-plugin-helpers';export default class ListProjects extends BaseCommand> {
protected async execute(_ctx) {
this.logInfo('Listing projects');
this.printRows(
[
['p_123', 'Acme'],
['p_456', 'Beta'],
],
{ header: ['id', 'name'] }
);
}
}
`$3
`ts
import {
BaseCommand,
ValidationError,
} from '@charlie-labs/oclif-plugin-helpers';
import type { CfgFlags, Result } from '@charlie-labs/oclif-plugin-helpers';
import { defineFlags } from '@charlie-labs/oclif-plugin-helpers/flags';const noFlags = defineFlags({} as const);
export default class Create extends BaseCommand<
CfgFlags | Result
> {
protected async execute(_ctx) {
const name = '';
if (!name) throw new ValidationError('
name is required'); // exit 2
}
}
`$3
`ts
import { errorToExitCode } from '@charlie-labs/oclif-plugin-helpers';try {
// … your code …
} catch (err) {
process.exitCode = errorToExitCode(err);
}
`$3
`ts
import { handle } from '@charlie-labs/oclif-plugin-helpers';
import { run } from '@oclif/core';await run(void 0, import.meta.url).catch(handle);
`Compatibility and requirements
- Peer:
@oclif/core@^4.5.2
- Node: >=18Repository meta
- Package:
@charlie-labs/oclif-plugin-helpers
- Published: public on npm (publishConfig.access: "public")
- License: UNLICENSED`