Command Structure
How the CLI's built-in commands are put together and how an invocation reaches them. This is internals documentation — nothing here is required to write your own commands; for that, see Creating Commands.
Source Layout
apps/xec/src/main.ts— program setup and invocation routingapps/xec/src/utils/command-manifest.ts— names, descriptions and aliases of the built-in commandsapps/xec/src/utils/command-base.ts—BaseCommand,SubcommandBase, option plumbingapps/xec/src/commands/*.ts— one module per built-in commandapps/xec/src/utils/cli-command-manager.ts— discovery and loading of dynamic commands from.xec/commands/
The Command Manifest
xec --help needs a name and one line of description per command — not the
implementations. Every command module statically imports @xec-sh/ops, so
importing all of them just to list their names cost ~140 ms on every
invocation. Instead, a manifest carries what help needs, and a command's
module is imported only when that command is actually invoked:
// apps/xec/src/utils/command-manifest.ts
export interface CommandManifestEntry {
name: string; // as typed
description: string; // as shown in `xec --help`
aliases: string[]; // alternative names
module: string; // module under ./commands/ that registers it
}
export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
{ name: 'config', description: 'Manage Xec configuration', aliases: ['conf', 'cfg'], module: 'config' },
{ name: 'copy', description: 'Copy files between targets', aliases: ['cp'], module: 'copy' },
// ... twelve entries in total
];
Drift between the manifest and the real modules is caught by
apps/xec/test/command-manifest.test.ts, which loads every module and fails
if the two disagree.
At startup, registerBuiltInCommands() (in main.ts) imports the one
module the first non-flag argument refers to (findCommand() resolves
names and aliases — xec cfg loads the config module). Every other
command is registered as a stub with just the manifest's name, description
and aliases. If a stub is nevertheless invoked — the requested command
wasn't recognisable up front, for instance xec --cwd /x config get where
the first non-flag token is /x — its action imports the real module into
a fresh program and re-parses argv.
Dynamic commands get the same treatment: their name, description and
literal .alias() strings are read out of the source text without
importing the file, and the module is imported only on invocation. That is
why a dynamic command's description and aliases appear in --help without
the file ever executing.
BaseCommand
Every built-in command is a class extending BaseCommand
(apps/xec/src/utils/command-base.ts). The constructor takes a declarative
config; create() turns it into a commander Command:
export interface CommandConfig {
name: string;
description: string;
aliases?: string[];
arguments?: string; // commander argument spec, e.g. '[fileOrTask]'
options?: Array<{
flags: string;
description: string;
defaultValue?: any;
}>;
examples?: Array<{ command: string; description: string }>;
validateOptions?: (options: any) => void;
}
export abstract class BaseCommand {
constructor(protected config: CommandConfig) { /* ... */ }
create(): Command { /* build the commander command */ }
abstract execute(args: any[]): Promise<void>;
}
create() adds three options to every command — -o, --output <format>
(text|json|yaml|csv), -c, --config <path> and --dry-run — plus the
declared arguments, aliases and custom options. -v/--verbose and
-q/--quiet are root-program options inherited by all commands, not
re-declared per command. Declared examples are appended to --help output.
From argv to execute()
The action handler create() installs unpacks commander's
(...positionals, options, command) call shape, merges command options
with the root program's verbose/quiet, runs the validateOptions hook
if declared, configures the output formatter, and finally calls:
await this.execute([...positionalArgs, this.options]);
So inside execute() the merged options object is the last element and
positionals come before it — the pattern every command follows:
// apps/xec/src/commands/run.ts
public async execute(args: any[]): Promise<void> {
const fileOrTask = args[0];
const options = args[args.length - 1] as RunOptions;
// ...
}
Anything thrown out of execute() is caught by the action handler and
routed to handleError() from @xec-sh/ops, which prints the enhanced
error and exits with the code from its getExitCode() mapping — the table
is in the CLI reference.
Registration
A command module default-exports a function that registers it:
// apps/xec/src/commands/run.ts
export default function registerCommand(program: Command): void {
const cmd = new RunCommand();
program.addCommand(cmd.create());
}
Configuration-aware helpers
BaseCommand carries the machinery a command needs to work with the
project configuration; all of it is initialized on demand by
initializeConfig():
initializeConfig(options)— builds aConfigurationManager(honouring--configand profile), aTargetResolverand aTaskManager.getCommandDefaults()— the command's own defaults from thecommands.<name>section of the configuration;applyDefaults()merges them under explicitly passed options.resolveTarget(spec)/findTargets(pattern)— configured-target lookup (hosts.web-1,containers.*).createTargetEngine(target)— maps a resolved target to an execution engine:localreturns the global$,ssh/docker/kubernetesreturn$.ssh(...),$.docker(...),$.k8s(...)built from the target's config (including SSH host-key policy and per-targetenv).
Output and interaction helpers
Uniform UX comes from the base class rather than each command:
output()/table() go through the OutputFormatter and respect
-o text|json|yaml|csv; startSpinner()/stopSpinner() and log() are
quiet-aware; confirm()/prompt()/select()/multiselect() wrap
@xec-sh/kit prompts and short-circuit to their defaults under --quiet
(a cancelled prompt also falls back instead of throwing);
isDryRun()/isVerbose()/isQuiet() read the merged options.
SubcommandBase
Commands that are groups of subcommands (docker is one) extend
SubcommandBase, implement setupSubcommands(command), and inherit an
execute() that prints help when no subcommand is given.
ConfigAwareCommand is a backward-compatibility alias for BaseCommand —
some modules still import it under that name.
Invocation Routing
run() in main.ts routes an invocation in this order — the first match
wins, and only the last step involves commander dispatch:
- A lone
--version/-Vprints the version and exits — no command loading, no config read. -e <code>/--eval <code>anywhere in argv evaluates the code.--replanywhere in argv starts the REPL.- A first argument that is a file (by
.js/.ts/.mjsextension, or an existing file path) runs as a script. - A first argument matching a configured task (and not a command name)
runs that task, with
--key=value/--key valueparsed as task parameters. - A first argument that
isDirectCommand()accepts executes as a direct command (xec echo hi, target-prefixed forms). - Everything else goes to commander: built-in and dynamic commands,
resolved by name or alias. An unknown command prints a
did-you-mean suggestion (
checkForCommandTypofrom@xec-sh/core) and exits1. - No arguments at all shows help.
Related Topics
- Creating Commands — writing dynamic commands in
.xec/commands/ - Command Testing — testing strategies
- CLI Reference — flags, environment variables, exit codes