jimkit-cli
v0.1.0
Published
Declarative, type-safe CLI framework built on Commander.js. Define your CLI as a plain config object and get fully inferred action handler types, shell completions (bash/zsh/fish), and structured error handling.
Readme
jimkit-cli
Declarative, type-safe CLI framework built on Commander.js. Define your CLI as a plain config object and get fully inferred action handler types, shell completions (bash/zsh/fish), and structured error handling.
Install
pnpm add jimkit-cliQuick start
import { defineCLI, ok, err, builtins } from 'jimkit-cli';
const completionsBuiltin = builtins.completions();
const cli = defineCLI({
name: 'my-tool',
version: '1.0.0',
description: 'A task manager',
defaultCommand: 'list',
completers: {
static: {
priorities: ['low', 'medium', 'high'],
...completionsBuiltin.completers?.static,
},
dynamic: {
tasks: { cmd: 'my-tool list --ids-only', label: 'tasks' },
},
},
globalOptions: {
verbose: { type: 'boolean', description: 'Verbose output' },
noColor: { type: 'boolean', negate: true, description: 'Disable color' },
},
commands: {
add: {
description: 'Add a task',
args: {
title: { type: 'string', required: true },
},
options: {
priority: { type: 'string', short: 'p', completeWith: 'priorities' },
},
},
list: {
description: 'List tasks',
options: {
status: { type: 'string', default: 'pending' },
},
},
done: {
description: 'Mark a task as done',
args: {
id: { type: 'string', required: true, completeWith: 'tasks' },
},
},
tag: {
description: 'Manage tags',
subcommands: {
add: {
description: 'Add a tag',
args: {
id: { type: 'string', required: true, completeWith: 'tasks' },
tag: { type: 'string', required: true },
},
},
remove: {
description: 'Remove a tag',
args: {
id: { type: 'string', required: true, completeWith: 'tasks' },
tag: { type: 'string', required: true },
},
},
},
},
completions: completionsBuiltin.command,
},
});
// Action handlers are fully typed from the config above.
// TypeScript enforces that every leaf command has a handler,
// and that args/opts/globals match the declared types.
cli.run(process.argv, {
add: (args, opts, globals) => {
// args: { title: string }
// opts: { priority?: string }
// globals: { verbose?: boolean, noColor: boolean }
console.log(`Adding: ${args.title}`);
return ok(undefined);
},
list: (args, opts, globals) => {
// opts: { status: string } (required — has default)
console.log(`Listing ${opts.status} tasks`);
return ok(undefined);
},
done: (args, opts, globals) => {
// args: { id: string }
const found = lookupTask(args.id);
if (!found) return err(`Task ${args.id} not found`, 2);
return ok(undefined);
},
'tag.add': (args, opts, globals) => {
return ok(undefined);
},
'tag.remove': (args, opts, globals) => {
return ok(undefined);
},
completions: (args) => {
console.log(cli.completion(args.shell as 'bash' | 'zsh' | 'fish'));
return ok(undefined);
},
});Config
Commands
Commands are defined as a record. Keys become command names. Commands are either leaf (have args/options) or parent (have subcommands) — never both.
commands: {
// Leaf command
add: {
description: 'Add an item',
args: {
title: { type: 'string', required: true },
note: { type: 'string' }, // optional
},
options: {
tag: { type: 'string', short: 't' },
verbose: { type: 'boolean' },
},
},
// Parent command with subcommands (recursive)
config: {
description: 'Manage config',
subcommands: {
set: {
description: 'Set a value',
args: { key: { type: 'string', required: true }, value: { type: 'string', required: true } },
},
get: {
description: 'Get a value',
args: { key: { type: 'string', required: true } },
},
},
},
}Subcommand actions use dot-notation keys: 'config.set', 'config.get'.
Args
| Field | Type | Description |
|-------|------|-------------|
| type | 'string' \| 'boolean' \| 'number' \| 'string[]' | Value type |
| required | boolean | Required arg (default: false) |
| variadic | boolean | Collects remaining args as string[] (last arg only) |
| description | string | Help text |
| completeWith | string | Key into completers for shell completion |
Options
| Field | Type | Description |
|-------|------|-------------|
| type | 'string' \| 'boolean' \| 'number' \| 'string[]' | Value type |
| short | string | Single-char alias (e.g. 'p' for -p) |
| default | matches type | Default value (makes option non-optional in handler) |
| negate | boolean | Generates --no-{name} flag |
| argName | string | Display name in help (e.g. 'count' for --limit <count>) |
| description | string | Help text |
| completeWith | string | Key into completers for shell completion |
Global options
Same as options, plus zshCompleter for native zsh completion (e.g. '_files -/'). Applied to all commands.
Completers
completers: {
dynamic: {
tasks: { cmd: 'my-tool list --ids-only', label: 'tasks' },
},
static: {
priorities: ['low', 'medium', 'high'],
},
}Completer keys are type-checked — completeWith: 'typo' errors at compile time.
Hooks
hooks: {
preAction: async ({ globals, command }) => {
// Runs before every action. globals is typed.
},
postAction: async ({ globals, command, result }) => {
// Runs after every action, even on errors.
},
},Default command
defaultCommand: 'list'If the first argument isn't a known command, list is prepended. So my-tool pending becomes my-tool list pending.
Type inference
All action handler types are inferred from the config literal:
- Args: required if
required: true, optional otherwise. Variadic args are alwaysstring[]. - Options: required if they have a
defaultornegate: true(Commander always provides a value). Optional otherwise. - Globals: same rules as options.
- Action keys: dot-notation for subcommands. Every leaf command must have a handler.
completeWith: constrained to actual completer keys.default: must match declaredtype.short: must be a single character.
Extracting types
import type { InferActionMap, InferGlobals, InferCommandKeys } from 'jimkit-cli';
type Actions = InferActionMap<typeof config>;
type Globals = InferGlobals<typeof config>;
type Keys = InferCommandKeys<typeof config>; // 'add' | 'list' | 'done' | 'tag.add' | ...Typed return values
By default, actions return Result<unknown>. Optionally parameterize run for typed results:
cli.run<{ add: { id: number }; list: Task[] }>(process.argv, {
add: (args, opts, globals) => ok({ id: 123 }), // must be Result<{ id: number }>
list: (args, opts, globals) => ok([task1, task2]), // must be Result<Task[]>
done: (args, opts, globals) => ok(undefined), // Result<unknown> (not in Returns map)
// ...
});Result type
Actions return Result<T> — a discriminated union:
import { ok, err, ExitCode } from 'jimkit-cli';
// Success
return ok({ id: 123 });
// Error with default exit code (1)
return err('Something went wrong');
// Error with custom exit code
return err('Not found', 2);On error, the framework prints to stderr and exits with the given code.
Shell completions
Built-in command
const completionsBuiltin = builtins.completions();
const cli = defineCLI({
commands: {
completions: completionsBuiltin.command,
// ...
},
completers: {
static: { ...completionsBuiltin.completers?.static },
},
});Programmatic
const script = cli.completion('zsh'); // or 'bash', 'fish'Shell setup
# bash (~/.bashrc)
eval "$(my-tool completions bash)"
# zsh (~/.zshrc)
eval "$(my-tool completions zsh)"
# fish (~/.config/fish/completions/my-tool.fish)
my-tool completions fish > ~/.config/fish/completions/my-tool.fishEscape hatch
Access the underlying Commander.js program:
const cli = defineCLI(config);
cli.program; // Commander Command instance