Skip to content

Defining commands

Citadel commands are hierarchical. In the DSL, you write a dot-delimited path such as user.show. In the UI, Citadel expands short, unambiguous prefixes into the full command path. For user.show, the user can type us and Citadel expands it to user show.

Start with the DSL

The main authoring API is the command DSL:

tsx
import {
  Citadel,
  bool,
  command,
  createCommandRegistry,
  image,
  json,
  text,
} from 'citadel_cli';

const commandRegistry = createCommandRegistry([
  command('user.show')
    .describe('Show one user record')
    .arg('userId', (arg) => arg.describe('The user id to load'))
    .handle(async ({ namedArgs }) =>
      json({
        id: namedArgs.userId,
        name: 'Ada Lovelace',
      }),
    ),

  command('note.add')
    .describe('Create a note')
    .arg('title', (arg) => arg.describe('Short title'))
    .arg('body', (arg) => arg.describe('Longer note body'))
    .handle(async ({ namedArgs, rawArgs, commandPath }) =>
      text(
        `Saved ${commandPath} with title "${namedArgs.title}" and ${rawArgs.length} arguments.`,
      ),
    ),

  command('system.status')
    .describe('Check whether the system is healthy')
    .handle(async () => bool(true, 'healthy', 'unhealthy')),

  command('avatar.random')
    .describe('Show a placeholder image')
    .handle(async () => image('https://picsum.photos/160')),
]);

export function CommandExamples() {
  return <Citadel commandRegistry={commandRegistry} />;
}

When users run those commands, they usually enter prefixes rather than the full expanded command text. For example:

  • user.show can be entered as us
  • note.add can be entered as na
  • system.status can be entered as ss

Command paths

A path is a sequence of literal words.

  • command('hello') becomes hello
  • command('user.show') becomes user show
  • command('team.member.remove') becomes team member remove

Use short, specific words. Auto-expansion works best when sibling commands diverge early.

When an argument can only take a few known values, model those values as command words instead of a free-text argument so they participate in expansion:

tsx
// Three keystrokes: u f a
command('users.filter.admin')
  .describe('Show only admins')
  .handle(async () => text('Filtered to admins'));

// Versus making the user type "admin" out by hand:
command('users.filter')
  .arg('role', (arg) => arg.describe('One of: admin, editor, viewer'))
  .handle(async ({ namedArgs }) => text(`Filtered to ${namedArgs.role}`));

Reserve .arg() for genuinely free-form values such as IDs, names, and messages.

How users enter commands

Users usually do not type the full command text.

  • For hello, typing h expands to hello
  • For user.show, typing us expands to user show
  • For team.member.remove, the user types the shortest unambiguous prefix for each segment

Think of the DSL path as the canonical command definition. In the UI, the user typically enters the shortest prefix that uniquely identifies that path.

Arguments

Add arguments with .arg(name).

tsx
command('user.show')
  .arg('userId', (arg) => arg.describe('The user id to load'))
  .handle(async ({ namedArgs }) => json({ id: namedArgs.userId }));

The argument description is shown in help output.

Arguments can be quoted when they contain spaces. These examples show the fully expanded command text after Citadel has resolved the command prefix:

  • note add "Sprint retro" "Capture follow-up items"
  • note add 'Sprint retro' 'Capture follow-up items'

Optional arguments

Mark trailing arguments optional with .optional(). The command can then be executed without them. Declare a default and the handler receives it in place of the omitted value:

tsx
command('seed.users')
  .arg('count', (arg) =>
    arg.describe('How many users to seed (default: 10)').optional({ default: '10' })
  )
  .handle(async ({ namedArgs }) => {
    const count = parseInt(namedArgs.count ?? '10', 10);
    return text(`Seeded ${count} users.`);
  });

Without a declared default (.optional()), the handler receives undefined for the omitted argument and applies its own fallback.

Optional arguments must come after all required ones — .arg() throws otherwise. Help output renders them in square brackets ([count]) instead of angle brackets (<count>).

Handler context

Each handler receives one object:

  • rawArgs: positional arguments in order
  • namedArgs: argument values keyed by the names you declared
  • commandPath: the original dot-delimited path string

Example:

tsx
command('note.add')
  .arg('title')
  .arg('body')
  .handle(async ({ rawArgs, namedArgs, commandPath }) =>
    text(`${commandPath}: ${namedArgs.title} (${rawArgs.length} args)`),
  );

Result helpers

Handlers should return one of Citadel's result types. These are what is used to determine what is shown in the output console when the handler is done executing. Each handle must return one of these:

  • text(value)
  • json(value)
  • image(url, altText?)
  • error(message)
  • bool(value, trueText?, falseText?)

Example:

tsx
command('deploy.check')
  .handle(async () => bool(true, 'ready', 'blocked'));

Default Help Command

By default, Citadel injects a built-in help command into the registry. It lists available commands and argument descriptions.

If you want to disable the default help command, set config.includeHelpCommand to false. See Configuring Citadel and command history.


Previous: Installing Citadel in an existing React app

Next: Embedding Citadel and choosing a display mode