KB LabsDocs

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

TypeScript
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

TypeScript
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

TypeScript
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 declared TInput.
  • cleanup — optional teardown callback. Runs after execute regardless 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:

TypeScript
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.

TypeScript
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

TypeScript
type FlagType = 'string' | 'number' | 'boolean' | 'array';
  • string — any string. --name=Alice'Alice'.
  • number — parsed with Number(). --count=55. Invalid numbers produce parsing errors.
  • boolean — presence is truthy. --verbosetrue. No --verbose=false form; use --no-verbose if you need to flip a defaulted-true flag.
  • array — repeatable string values. --tags=a --tags=b['a', 'b'].

FlagSchema

TypeScript
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
}
  • default is used when the flag is omitted. Its type must match type.
  • alias is the single-letter short form (-v for --verbose). One character only.
  • required: true makes the CLI fail with a usage error if the flag is missing.
  • choices restricts a string flag 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:

TypeScript
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:

TypeScript
defineCommand<unknown, CLIInput<MyFlags>, MyResult>({ ... });

defineCommandFlags

TypeScript
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

TypeScript
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:

Bash
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:

TypeScript
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:

TypeScript
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. 0 is 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.exitCode

cleanup 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:

TypeScript
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

TypeScript
// 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.

Commands — KB Labs Docs