Commands
Last updated April 7, 2026
defineCommand, defineFlags, CLIInput, host guards, and the command result shape.
This is the reference page for defineCommand and the surrounding SDK helpers for building CLI handlers. It's the API companion to Plugins → CLI Commands, which walks through the authoring flow end-to-end.
Everything here comes from @kb-labs/shared-command-kit/src/define-command.ts, re-exported via @kb-labs/sdk.
defineCommand
function defineCommand<TConfig = unknown, TInput = unknown, TResult = unknown>(
definition: CommandDefinition<TConfig, TInput, TResult>,
): CommandHandlerV3<TConfig, TInput, TResult>;The helper takes a definition object and returns a wrapped handler. The wrapping adds a host guard and preserves the cleanup callback for the runtime to call after execution.
CommandDefinition
interface CommandDefinition<TConfig, TInput, TResult> {
id: string; // matches manifest cli.commands[i].id
description?: string;
handler: CommandHandlerV3<TConfig, TInput, TResult>;
schema?: unknown; // reserved for future input validation
}CommandHandlerV3
interface CommandHandlerV3<TConfig, TInput, TResult> {
execute(
context: PluginContextV3<TConfig>,
input: TInput,
): Promise<CommandResult<TResult>> | CommandResult<TResult>;
cleanup?(): Promise<void> | void;
}execute— your handler logic. Can be sync or async. Receives the plugin context and the input shaped however you declaredTInput.cleanup— optional teardown callback. Runs afterexecuteregardless of whether it succeeded or threw. Use it for closing connections, removing temp files, canceling timers.
Host guard
defineCommand wraps your handler so it refuses to run from anything other than the 'cli' or 'workflow' host:
if (context.host !== 'cli' && context.host !== 'workflow') {
throw new Error(
`Command ${definition.id} can only run in CLI or workflow host (current: ${context.host})`
);
}This means the same command handler can be invoked from the CLI and as a workflow step — both contexts pass the guard. REST routes use a different helper (defineRoute), webhooks use defineWebhook, and so on. Each helper has its own host check, so you can't accidentally call a CLI command from a REST route.
defineFlags
The typed flag schema builder.
import { defineFlags } from '@kb-labs/sdk';
const myFlags = defineFlags({
name: {
type: 'string',
description: 'Who to greet',
},
count: {
type: 'number',
description: 'How many times',
default: 1,
},
verbose: {
type: 'boolean',
alias: 'v',
description: 'Extra output',
default: false,
},
tags: {
type: 'array',
description: 'Tags to apply (repeatable)',
},
});Flag types
type FlagType = 'string' | 'number' | 'boolean' | 'array';string— any string.--name=Alice→'Alice'.number— parsed withNumber().--count=5→5. Invalid numbers produce parsing errors.boolean— presence is truthy.--verbose→true. No--verbose=falseform; use--no-verboseif you need to flip a defaulted-true flag.array— repeatable string values.--tags=a --tags=b→['a', 'b'].
FlagSchema
interface FlagSchema {
type: FlagType;
description?: string;
default?: unknown; // must match the declared type
alias?: string; // short form, e.g. 'v' for '--verbose'
required?: boolean;
choices?: readonly string[]; // enum validation for string flags
}defaultis used when the flag is omitted. Its type must matchtype.aliasis the single-letter short form (-vfor--verbose). One character only.required: truemakes the CLI fail with a usage error if the flag is missing.choicesrestricts astringflag to a closed set of values.
InferFlags
The result of defineFlags carries its type. Use InferFlags<typeof myFlags> to read the flag type back out:
import type { InferFlags } from '@kb-labs/sdk';
type MyFlags = InferFlags<typeof myFlags>;
// {
// name: string | undefined;
// count: number;
// verbose: boolean;
// tags: string[] | undefined;
// }Flags without a default are nullable in the inferred type; flags with a default are non-nullable.
Use the inferred type as the generic for CLIInput<T> when declaring your handler:
defineCommand<unknown, CLIInput<MyFlags>, MyResult>({ ... });defineCommandFlags
import { defineCommandFlags } from '@kb-labs/sdk';
const flags = defineCommandFlags(myFlags);A thin wrapper around defineFlags specifically for use in the cli.commands[i].flags slot in the manifest. It returns the same object shape defineFlags produces, but with the specific shape the manifest type expects. Use it in the manifest; use defineFlags when building schemas separately to pass around.
CLIInput
interface CLIInput<TFlags = unknown> {
flags: TFlags;
argv: string[];
}The input shape for CLI handlers. The CLI runtime wraps parsed flags in CLIInput<YourFlagType> before calling execute. argv carries whatever positional arguments followed the command name:
pnpm kb hello:greet Alice Bob --count=2
# → input.argv === ['Alice', 'Bob']
# → input.flags === { count: 2, ... }Use CLIInput<T> as the second generic parameter to defineCommand so the handler is fully typed:
defineCommand<unknown, CLIInput<HelloFlags>, HelloResult>({
id: 'hello:greet',
handler: {
async execute(ctx, input) {
// input.flags is typed as HelloFlags
// input.argv is typed as string[]
}
}
});CommandResult
The return shape from execute:
interface CommandResult<T = unknown> {
exitCode: number;
result?: T;
meta?: Record<string, unknown>;
error?: {
message: string;
code?: string;
details?: Record<string, unknown>;
};
}exitCode— process exit code.0is success; anything else is failure. The CLI exits with this code after rendering the output.result— structured payload. The presenter renders it; in text mode it's formatted as a table/card/list based on shape; in JSON mode it's emitted as-is.meta— diagnostic metadata (timings, token counts, cache hit rates). Shown in a summary box by the text presenter, included in the JSON output by the JSON presenter.error— populated on failures. The presenter renders this as a user-facing error message.
Returning vs throwing
- Return
{ exitCode: 1, error }for handled failures — missing config, invalid input, rate limits. The user gets a clean error message with the declared code. - Throw for unexpected errors — runtime invariants, logic bugs, unreachable code paths. The CLI catches the exception, logs the stack, and maps it to a generic exit code via
mapCliErrorToExitCode.
The difference matters for UX. A returned error looks intentional ("The LLM is rate-limited, try again in 30s"); a thrown exception looks like a crash ("Internal error, see log"). Use the right path.
Lifecycle
1. defineCommand wraps your handler with the host guard
2. You export it as default from the handler file
3. Runtime imports the module via manifest.cli.commands[i].handler
4. Runtime calls wrappedHandler.execute(ctx, input)
5. Your execute() runs
6. Runtime calls wrappedHandler.cleanup?.() in a finally block
7. The CommandResult is rendered by the presenter
8. Process exits with result.exitCodecleanup always runs after execute, even if execute threw. Use it for side-effect teardown; don't rely on it for critical invariants (it can itself throw, in which case the original error takes precedence).
Host check helpers
Three type guards are exported for manual host checks inside handlers:
import { isCLIHost, isRESTHost, isWorkflowHost } from '@kb-labs/sdk';
async execute(ctx, input) {
if (isCLIHost(ctx.hostContext)) {
// ctx.hostContext is narrowed to the CLI shape
console.log('CLI version:', ctx.hostContext.cliVersion);
}
}These are rarely needed in practice — defineCommand already ensures the host is valid. They're here for the edge case where a handler wants to branch on which valid host it's running in.
A full example
// src/cli/commands/greet.ts
import {
defineCommand,
useLogger,
useLLM,
type CLIInput,
type InferFlags,
} from '@kb-labs/sdk';
import { greetFlags } from './flags';
type GreetFlags = InferFlags<typeof greetFlags>;
interface GreetResult {
greeting: string;
source: 'canned' | 'llm';
tokens?: number;
}
export default defineCommand<unknown, CLIInput<GreetFlags>, GreetResult>({
id: 'hello:greet',
description: 'Greet a user',
handler: {
async execute(ctx, input) {
const logger = useLogger();
const name = input.flags.name ?? 'world';
if (input.flags.fancy) {
const llm = useLLM();
if (!llm) {
return {
exitCode: 1,
error: {
code: 'LLM_UNAVAILABLE',
message: 'Fancy mode requires an LLM adapter',
},
};
}
try {
const response = await llm.complete(`Say hi to ${name}`);
return {
exitCode: 0,
result: {
greeting: response.content,
source: 'llm',
tokens: response.usage?.totalTokens,
},
meta: {
model: response.model,
},
};
} catch (err) {
logger.error('LLM call failed', { err });
return {
exitCode: 1,
error: {
code: 'LLM_FAILED',
message: err instanceof Error ? err.message : 'Unknown',
},
};
}
}
return {
exitCode: 0,
result: {
greeting: `Hello, ${name}!`,
source: 'canned',
},
};
},
async cleanup() {
// No-op here; use it for connection pools, temp files, etc.
},
},
});Every case returns a CommandResult; nothing throws; errors have codes that downstream callers can branch on.
What to read next
- Plugins → CLI Commands — the manifest-side walkthrough.
- SDK → Handler Context — everything in
ctx. - SDK → Hooks —
useLogger,useLLM, and the rest. - SDK → Routes —
defineRoutefor REST handlers. - SDK → Testing — unit-testing command handlers.