Commands
Zelt provides CLI command support with dependency injection through @zeltjs/core.
See the command input schema contract for required options, variadic positional arguments, mutual exclusion through schema unions, rejection of undeclared input, and help generation. These additions are not included in @zeltjs/core@0.11.0.
Creating a Command
Define the schema with cliSchema() as a module-level constant, then pass it to args() in the @Command decorated class for type-safe CLI commands:
import { Command, cliSchema, args } from '@zeltjs/core';
const greetSchema = cliSchema({
args: [{ name: 'name', type: 'string' }],
});
@Command({
name: 'greet',
description: 'Greet a user',
})
export class GreetCommand {
run(ctx = args(greetSchema)) {
console.log(`Hello, ${ctx.name}!`);
}
}
Configuration
Create a src/cli.ts entry point for your CLI:
const app = createApp([command([GreetCommand])]);
const nodeApp = await onNode(app);
await nodeApp.command.execCommand([...nodeApp.args]);
Then configure cli.entry in your zelt.config.ts:
// @filename: src/app.ts
import { createApp, command } from '@zeltjs/core';
export const app = createApp([command([])]);
// @filename: zelt.config.ts
import { defineConfig } from '@zeltjs/cli';
export default defineConfig({
app: () => import('./src/app').then((m) => m.app),
cli: { entry: './src/cli.ts' },
});
Running Commands
Use zelt run to execute commands:
# Run a command
zelt run greet Alice
# With custom config
zelt run -c ./config/zelt.config.ts greet Alice
Schema Definition
The cliSchema() function defines typed arguments and options. The resulting schema is a plain value: declare it in its own module (for example greet-schema.lib.ts), import it into the command, and pass it to args(). The argv is parsed and validated when args(schema) is called. Undeclared options and excess positional arguments are rejected; input errors prevent entry into the run() body. Commands that never call args() are not automatically validated.
Positional Arguments
const copySchema = cliSchema({
args: [
{ name: 'source', type: 'string' },
{ name: 'destination', type: 'string' },
],
});
@Command({ name: 'copy' })
export class CopyCommand {
run(ctx = args(copySchema)) {
console.log(`Copying ${ctx.source} to ${ctx.destination}`);
}
}
Options (Flags)
const buildSchema = cliSchema({
options: [
{ name: 'watch', type: 'boolean', alias: 'w' },
{ name: 'outDir', type: 'string', alias: 'o', default: 'dist' },
],
});
@Command({ name: 'build' })
export class BuildCommand {
run(ctx = args(buildSchema)) {
if (ctx.watch) {
console.log('Watching for changes...');
}
console.log(`Output directory: ${ctx.outDir}`);
}
}
# Usage
zelt run build --watch --outDir=out
zelt run build -w -o out
Combined Arguments and Options
const deploySchema = cliSchema({
args: [
{ name: 'environment', type: 'string' },
],
options: [
{ name: 'dryRun', type: 'boolean' },
{ name: 'tag', type: 'string' },
],
});
@Command({ name: 'deploy' })
export class DeployCommand {
run(ctx = args(deploySchema)) {
const { environment, dryRun, tag } = ctx;
if (dryRun) {
console.log(`[DRY RUN] Would deploy to ${environment}`);
} else {
console.log(`Deploying ${tag ?? 'latest'} to ${environment}`);
}
}
}
Input Contracts and Help
An option with required: true requires explicit input and removes undefined from its inferred string or number type. A positional argument with variadic: { min, max } collects the remaining values into an array.
import { Command, args, cliSchema, formatCommandHelp } from '@zeltjs/core';
const analyzeSchema = cliSchema({
args: [{ name: 'directories', type: 'string', variadic: { min: 1 } }],
options: [{ name: 'guide', type: 'string', required: true }],
});
@Command({ name: 'analyze' })
class AnalyzeCommand {
run(input = args(analyzeSchema)) {
// directories: string[], guide: string
console.log(input.directories, input.guide);
}
}
console.log(formatCommandHelp('analyze', analyzeSchema));
cliUnion([schemaA, schemaB, ...]) declares valid input alternatives. Exactly one schema must accept the entire input, and the inferred input type is also a union. See the input schema contract for exclusion examples, count boundaries, input errors, and help requirements.
Schema Types
Argument Types
| Type | Description |
|---|---|
string | String value |
number | Numeric value (automatically parsed) |
Arguments can be marked as optional:
const schema = cliSchema({
args: [
{ name: 'file', type: 'string' },
{ name: 'count', type: 'number', optional: true },
],
});
Option Types
| Type | Description |
|---|---|
string | String option |
number | Numeric option (automatically parsed) |
boolean | Boolean flag |
Options can have defaults:
const schema = cliSchema({
options: [
{ name: 'port', type: 'number', default: 3000 },
{ name: 'verbose', type: 'boolean' }, // defaults to false
],
});
Transient Scope
Commands are registered as transient — a new instance is created for each execution. This ensures:
- Clean state for each command run
- No shared mutable state between executions
- Dependencies injected via
inject()remain singletons
@Command({ name: 'process' })
export class ProcessCommand {
private startTime = Date.now(); // Fresh for each execution
constructor(private db = inject(DatabaseService)) {} // Singleton, shared
run() {
console.log(`Started at: ${this.startTime}`);
}
}
Dependency Injection
Commands support dependency injection:
const migrateSchema = cliSchema({
options: [
{ name: 'force', type: 'boolean' },
],
});
@Command({ name: 'migrate' })
export class MigrateCommand {
constructor(private readonly db = inject(DatabaseService)) {}
async run(ctx = args(migrateSchema)) {
if (ctx.force) {
console.log('Force migration enabled');
}
await this.db.runMigrations();
console.log('Migrations completed');
}
}
Where to Call inject() and args()
Call inject() in the constructor and args() as a default parameter of run(). The command context that args() reads from only exists while run() is executing, so calling args() in the constructor throws ZeltContextNotAvailableError.
const greetSchema = cliSchema({ args: [{ name: 'name', type: 'string' }] });
@Command({ name: 'greet' })
export class GreetCommand {
// ❌ No command context yet — throws ZeltContextNotAvailableError
constructor(private readonly ctx = args(greetSchema)) {}
run() {
console.log(`Hello, ${this.ctx.name}!`);
}
}
const greetSchema = cliSchema({ args: [{ name: 'name', type: 'string' }] });
@Command({ name: 'greet' })
export class GreetCommand {
// ✅ run() executes inside the command context
run(ctx = args(greetSchema)) {
console.log(`Hello, ${ctx.name}!`);
}
}
Programmatic Execution
Commands can be executed programmatically using onNode():
const app = createApp([command([MigrateCommand])]);
const nodeApp = await onNode(app);
const result = await nodeApp.command.execCommand(['migrate', '--force']);
console.log(`Exit code: ${result.exitCode}`);
Async Commands
Commands can be async:
@Command({ name: 'sync' })
export class SyncCommand {
async run() {
console.log('Starting sync...');
await this.fetchData();
await this.processData();
console.log('Sync completed');
}
private async fetchData() {
// ...
}
private async processData() {
// ...
}
}