Skip to main content

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 routing
  • apps/xec/src/utils/command-manifest.ts — names, descriptions and aliases of the built-in commands
  • apps/xec/src/utils/command-base.ts — BaseCommand, SubcommandBase, option plumbing
  • apps/xec/src/commands/*.ts — one module per built-in command
  • apps/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 a ConfigurationManager (honouring --config and profile), a TargetResolver and a TaskManager.
  • getCommandDefaults() — the command's own defaults from the commands.<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: local returns the global $, ssh/docker/kubernetes return $.ssh(...), $.docker(...), $.k8s(...) built from the target's config (including SSH host-key policy and per-target env).

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:

  1. A lone --version/-V prints the version and exits — no command loading, no config read.
  2. -e <code> / --eval <code> anywhere in argv evaluates the code.
  3. --repl anywhere in argv starts the REPL.
  4. A first argument that is a file (by .js/.ts/.mjs extension, or an existing file path) runs as a script.
  5. A first argument matching a configured task (and not a command name) runs that task, with --key=value / --key value parsed as task parameters.
  6. A first argument that isDirectCommand() accepts executes as a direct command (xec echo hi, target-prefixed forms).
  7. Everything else goes to commander: built-in and dynamic commands, resolved by name or alias. An unknown command prints a did-you-mean suggestion (checkForCommandTypo from @xec-sh/core) and exits 1.
  8. No arguments at all shows help.