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:
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.showcan be entered asusnote.addcan be entered asnasystem.statuscan be entered asss
Command paths
A path is a sequence of literal words.
command('hello')becomeshellocommand('user.show')becomesuser showcommand('team.member.remove')becomesteam 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:
// 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, typinghexpands tohello - For
user.show, typingusexpands touser 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).
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:
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 ordernamedArgs: argument values keyed by the names you declaredcommandPath: the original dot-delimited path string
Example:
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:
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.