Skip to main content

Build commands and subcommands

Use Artisanal's command runner when your program has subcommands, options, generated help, or shell completion. It builds on package:args and connects each command to Console, so parsing and user-facing output follow the same conventions.

Quick start

import 'package:artisanal/args.dart';

class ServeCommand extends Command<void> {

String get name => 'serve';


String get description => 'Start the development server.';


Future<void> run() async {
io.title('Starting server...');
io.info('Listening on port 8080');
}
}

void main(List<String> args) async {
final runner = CommandRunner(
CommandRunner.detectExecutableName(),
'My Application',
);
runner.addCommand(ServeCommand());
await runner.run(args);
}

Shell Completion

Artisanal can automatically generate shell completion scripts for your CLI. This is enabled by default on every CommandRunner — no extra setup required. You do not need to add a completion command, read COMP_LINE, or call the completion parser from your app.

How It Works

When the shell invokes your executable with completion -- <args>, Artisanal detects the call, computes matching command names, option flags, and allowed values, prints them to stdout, and exits. Normal runs are completely unaffected.

Installing

Print the completion script from your compiled binary (or dart run) and source it in your shell rc file:

# One-shot evaluation (bash / zsh)
eval "$(./github_cli --completion-script)"

# Or for a dart-run script
eval "$(dart run bin/myapp.dart --completion-script)"

# Persistent: append to your rc file
./github_cli --completion-script >> ~/.bashrc # bash
./github_cli --completion-script >> ~/.zshrc # zsh

After adding to your rc file, restart your shell or source ~/.bashrc / source ~/.zshrc.

Most apps never call completion APIs directly. For installers or release tooling, ShellCompleter.generate('myapp') can produce the same script without constructing a runner.

Opting Out

Disable completion per runner if you need to:

final runner = CommandRunner('myapp', 'My Application', enableShellCompletion: false);

Unknown Command Fallbacks

Shim-style CLIs sometimes need to delegate unknown commands to another executable. Use unknownCommandFallback for that case instead of pre-parsing the argument list before CommandRunner.run.

final runner = CommandRunner<void>(
'flutter-cli',
'Flutter CLI shim',
unknownCommandFallback: (args) async {
await runExternalFlutter(args);
},
);

The fallback runs only after the runner has handled built-in completion requests, so completion -- ... and --completion-script remain automatic.

What Gets Completed

The completer walks your full command tree:

  • Top-level commands — names and aliases
  • Subcommands — recursively through nested addSubcommand chains
  • Option names--flag and --no-flag for negatable options
  • Allowed values — completion lists from argParser.addOption(..., allowed: [...])

All of the above respect the same namespaceSeparator grouping your help already uses.

Advanced Programmatic Access

import 'package:artisanal/args.dart';

final runner = CommandRunner(
CommandRunner.detectExecutableName(),
'My Application',
);
runner.addCommand(ServeCommand());

// Lazy-initialized completer bound to this runner's arg tree.
// Most applications do not need to call this directly.
final completer = runner.shellCompleter;

// Generate a standalone completion script
final script = runner.shellCompletionScript;

ShellCompleter is also re-exported directly so you can call ShellCompleter.generate('myapp') or ShellCompleter.generateAll(['myapp', 'myapp-dev']) without a runner instance.


CommandRunner

Creating a Runner

void main(List<String> args) async {
final runner = CommandRunner(
CommandRunner.detectExecutableName(),
'My CLI application',
)
..addCommand(ServeCommand())
..addCommand(DbCommand())
..addCommand(DbMigrateCommand());

await runner.run(args);
}

Global Flags

The runner automatically adds these global flags:

FlagDescription
--help, -hDisplay help information
--ansi / --no-ansiForce enable/disable ANSI colors
--quiet, -q / --silentSuppress all output
--no-interaction, -nDisable interactive prompts
--verbose, -v, -vv, -vvvIncrease verbosity (verbose, very verbose, debug)
--completion-scriptPrint a shell completion script to stdout

Auto-detecting the Executable Name

Pass CommandRunner.detectExecutableName() as the executable name to have the runner automatically pick up the correct name from the running script:

final runner = CommandRunner(
CommandRunner.detectExecutableName(),
'My CLI application',
);

When run as a compiled binary, the usage shows the binary's file name:

$ ./artisan --help
Usage: artisan <command> [arguments]
...

When run via dart run, the usage includes the script path:

$ dart run bin/myapp.dart --help
Usage: dart run bin/myapp.dart <command> [arguments]
...

You can always pass an explicit name if you prefer a fixed value:

// Always shows "myapp" regardless of how it's executed
final runner = CommandRunner('myapp', 'My CLI');

Constructor and I/O Hooks

CommandRunner supports dependency injection for tests and hosts:

final runner = CommandRunner(
'mycli',
'My CLI',
ansi: false,
namespaceSeparator: ':',
usageExitCode: 64,
out: (line) => output.add(line),
err: (line) => errors.add(line),
outRaw: (text) => rawOut.add(text),
errRaw: (text) => rawErr.add(text),
readLine: () => 'input',
setExitCode: (code) => exitCode = code,
usageLineLength: 80,
);

Important constructor options:

  • namespaceSeparator: custom command namespace separator (default :).
  • usageExitCode: exit code used for argument/usage errors (default 64).
  • ansi: force or disable ANSI behavior globally (null = auto-detect).
  • renderer: optional renderer override (StringRenderer, TerminalRenderer, etc.).
  • out, err, outRaw, errRaw: output redirection hooks.
  • readLine: custom stdin reader for prompts.
  • setExitCode: custom exit-code sink.

Argument / Option Accessors

Artisanal's Command provides Laravel-style convenience helpers so you don't need to reach for raw argResults! every time:

class GreetCommand extends Command<void> {
GreetCommand() {
argParser.addOption('name', abbr: 'n', help: 'Who to greet.');
argParser.addFlag('shout', help: 'SHOUT the greeting.');
}

String get name => 'greet';
String get description => 'Greet someone.';


void run() {
// --name value (or default)
final name = option('name') as String? ?? 'World';

// --shout flag (bool)
final shout = option('shout') as bool;

// First positional argument
final message = argument(0);

// All positional arguments
for (final arg in arguments) { ... }

// Check if --name was explicitly provided
if (hasOption('name')) { ... }

io.success('Hello, $name!');
}
}

Available Helpers

MethodReturnsDescription
option(name)Object?Value of a named option/flag (e.g., --name, --force)
hasOption(name)boolWhether the option was explicitly provided on the command line
argument(index)String?Positional argument at index (0-based), or null if not provided
argumentsList<String>All positional arguments
argumentCountintNumber of positional arguments

Command Namespaces

Commands can be grouped using the : separator:

class DbMigrateCommand extends Command<void> {

String get name => 'db:migrate';


String get description => 'Run database migrations';


void run() {
io.task('Running migrations', run: () async {
// ...
return TaskResult.success;
});
}
}

class DbSeedCommand extends Command<void> {

String get name => 'db:seed';


String get description => 'Seed the database';


void run() { ... }
}

final runner = CommandRunner('myapp', 'My Application')
..addCommand(DbMigrateCommand())
..addCommand(DbSeedCommand());

Run with:

dart run bin/myapp.dart db:migrate

Namespace Discovery (Symfony-style)

When a user types a namespace prefix (e.g., db where commands like db:migrate, db:seed exist), the runner displays the subcommands in that namespace instead of a "command not found" error:

$ dart run bin/myapp.dart db
Available commands for the "db" namespace:
db
db:migrate Run database migrations
db:seed Seed the database

Run "myapp db:<subcommand> --help" for more information about a command.

This works for nested namespaces too (e.g., cache:user lists cache:user:list, cache:user:evict).

Like Symfony Console, namespace-only invocations exit with code 1 (no command was actually executed).

Namespace APIs

The runner exposes Symfony Console-style APIs for programmatic namespace inspection:

// Get all unique namespace prefixes
final namespaces = runner.getNamespaces();
// -> ['cache', 'cache:user', 'db', 'help', 'project']

// Get all commands within a namespace
final dbCommands = runner.allCommandsInNamespace('db');
// -> {'db:migrate': ..., 'db:seed': ...}
  • getNamespaces() -- equivalent to Symfony's Application::getNamespaces()
  • allCommandsInNamespace(name) -- equivalent to Symfony's Application::all($namespace)

Help Output Grouping

Commands sharing a namespace prefix are automatically grouped under a heading in the help output:

Help Output

The runner generates beautiful, styled help output with namespaces grouped:

My Application

Usage: myapp <command> [arguments]

Options:
-h, --help Print this usage information.
--[no-]ansi Force (or disable with --no-ansi) ANSI output.
-q, --quiet Do not output any message.
--silent Alias for --quiet.
-n, --no-interaction Do not ask any interactive question.
-v, --verbose Increase verbosity of messages:
1 for verbose, 2 for very verbose, 3 for debug.

Available commands:
serve Start the development server

db
db:migrate Run database migrations
db:seed Seed the database

cache
cache:clear Clear the cache
cache:user:list List cached users

Run "myapp <command> --help" for more information about a command.

Commands without a namespace prefix appear first (e.g., serve), followed by grouped commands under their namespace headings (db, cache).

HelpColorScheme

HelpColorScheme controls the colors applied to different elements of help output. Pass it as the helpColorScheme argument to CommandRunner:

final runner = CommandRunner(
'myapp',
'My Application',
helpColorScheme: HelpColorScheme.dark(),
);

Built-in Presets

PresetDescription
HelpColorScheme.default_Default scheme (same amber/green palette, auto-adaptive)
HelpColorScheme.dark()Optimized for dark terminals (amber headings, green commands)
HelpColorScheme.light()Optimized for light terminals (darker shades)
HelpColorScheme.minimal(color)Single foreground color for all elements

Customizing Colors

Each field accepts any Artisanal Color (AdaptiveColor, BasicColor, AnsiColor, etc.). Use AdaptiveColor to supply different values for light and dark backgrounds:

final runner = CommandRunner(
'myapp',
'My Application',
helpColorScheme: HelpColorScheme(
heading: AdaptiveColor(
light: BasicColor('#7c3aed'),
dark: BasicColor('#a78bfa'),
),
command: AdaptiveColor(
light: BasicColor('#0369a1'),
dark: BasicColor('#38bdf8'),
),
option: AdaptiveColor(
light: BasicColor('#047857'),
dark: BasicColor('#34d399'),
),
error: AdaptiveColor(
light: BasicColor('#b91c1c'),
dark: BasicColor('#f87171'),
),
),
);

Use copyWith to extend an existing scheme without respecifying every field:

final myScheme = HelpColorScheme.dark().copyWith(
heading: const BasicColor('#ff9900'),
namespace: const BasicColor('#cc77ff'),
);

Color Fields

FieldApplies to
headingSection labels: Description:, Usage:, Options:, Available commands:
commandCommand names in the listing
optionOption flags: --port, -p
descriptionOption description text (defaults to no extra color)
errorError prefix in usage exceptions
emphasisQuoted items like "myapp <command> --help"
namespaceNamespace prefixes in grouped command listings (e.g., db:)

Command Listing Utilities

import 'package:artisanal/args.dart';

void main() {
final entries = [
CommandListingEntry(name: 'serve', description: 'Start the server'),
CommandListingEntry(name: 'db:migrate', description: 'Run migrations'),
];

final output = formatCommandListing(
entries,
namespaceSeparator: ':',
);

print(indentBlock(output, 2));
}

Command

Creating Commands

class BuildCommand extends Command<void> {

String get name => 'build';


String get description => 'Build the application';


String get summary => 'Compiles the application to executable code';

BuildCommand() {
argParser.addFlag(
'watch',
abbr: 'w',
help: 'Watch for changes and rebuild',
);
argParser.addOption(
'output',
abbr: 'o',
help: 'Output directory',
defaultsTo: 'build',
);
}


Future<void> run() async {
final watch = option('watch') as bool;
final output = option('output') as String? ?? 'build';

io.title('Building application');
io.text('Output directory: $output');

if (watch) {
io.info('Watching for changes...');
} else {
io.task('Compiling', run: () async {
// ...
return TaskResult.success;
});
}
}
}

Accessing Console

Every command has access to the io property for console output:


void run() {
io.title('My Command');
io.info('This is an info message');
io.success('Operation completed');
io.warn('This is a warning');
io.error('An error occurred');

// Advanced features
io.table([
['Name', 'Value'],
['Version', '1.0.0'],
['Status', 'Active'],
]);

final confirmed = io.confirm('Are you sure?');
if (confirmed) {
io.task('Processing', run: () async {
// ...
return TaskResult.success;
});
}
}

Command also exposes convenience forwarding methods (line, info, comment, question, warn, error, alert) when you only need lightweight formatting without touching the full io API.

Command Help

Commands can override formatUsage() to customize their help output:


String formatUsage({bool includeDescription = true}) {
final buffer = StringBuffer();

buffer.writeln('Custom help header');
buffer.writeln();

buffer.writeln(super.formatUsage(includeDescription: includeDescription));

return buffer.toString();
}

Command Structure

Command Lifecycle

class MyCommand extends Command<void> {

String get name => 'my-command';


String get description => 'My command description';


void run() {
// Main command logic here
}
}

Command Aliases

class ServeCommand extends Command<void> {
ServeCommand() : super(aliases: ['server', 's']);


String get name => 'serve';


String get description => 'Start the server';


void run() {
io.title('Starting server...');
}
}

Now you can run:

dart run bin/myapp.dart serve
dart run bin/myapp.dart server
dart run bin/myapp.dart s

Argument Parsing

Basic Arguments

class DeployCommand extends Command<void> {
DeployCommand() {
argParser.addFlag('dry-run', help: 'Simulate deployment');
argParser.addOption('environment',
abbr: 'e',
help: 'Target environment',
allowed: ['dev', 'staging', 'prod'],
defaultsTo: 'dev',
);
argParser.addMultiOption('tag',
help: 'Tags to apply',
);
}


void run() {
final dryRun = option('dry-run') as bool;
final environment = option('environment') as String;
final tags = option('tag') as List<String>? ?? [];

io.info('Deploying to $environment${dryRun ? ' (dry run)' : ''}');
if (tags.isNotEmpty) {
io.text('Tags: ${tags.join(', ')}');
}
}
}

Positional Arguments

package:args stores trailing positional values in argResults.rest. Use the convenience argument(index) and arguments helpers:

class HelloCommand extends Command<void> {
HelloCommand() {
argParser.addOption(
'name',
abbr: 'n',
help: 'Name to greet',
);
}


void run() {
// Named option value
final name = option('name') as String? ?? 'World';
// First positional argument (or null if not provided)
final greeting = argument(0);
io.success('Hello, $name!');
}
}

Checking if an Option Was Explicitly Provided

Use hasOption() to distinguish between "not provided" and "provided with default value":

if (hasOption('environment')) {
io.info('Environment explicitly set to ${option('environment')}');
} else {
io.info('Using default environment');
}

Re-exported Args Types

import 'package:artisanal/args.dart';

void main() {
final parser = ArgParser()..addFlag('verbose', abbr: 'v');
final results = parser.parse(['-v']);
final isVerbose = results['verbose'] as bool;

if (!isVerbose) {
throw UsageException('Missing -v', parser.usage);
}
}

Error Handling

Artisanal provides friendly error handling without stack traces:

try {
await runner.run(args);
} catch (e) {
io.error('Unhandled error: $e');
exit(1);
}

Symfony-style Error Blocks

The runner catches UsageException and formats errors as styled blocks (written to stderr), matching Symfony Console / Laravel Artisan conventions:

$ dart run bin/myapp.dart unknown-command

Could not find a command named "unknown-command".

$ echo $?
64
  • Errors go to stderr (matching Symfony's $output->getErrorOutput())
  • In ANSI mode, the message is rendered with white foreground on red background
  • In ASCII mode (--no-ansi), the message is padded with spaces
  • The block is surrounded by blank lines, matching Symfony's formatBlock(..., large: true)
  • Exit code is usageExitCode (default 64)

Usage Errors

For argument-level errors (e.g., missing required options, invalid values), the runner prints the error block to stderr without a stack trace:

$ dart run bin/myapp.dart serve --invalid-option

Could not find an option named "invalid-option".

Throwing Usage Errors from Commands

Commands can throw structured errors with the usageException() helper:


void run() {
final port = option('port') as String?;
if (port != null && int.tryParse(port) == null) {
usageException('The --port option must be a number.');
}
// ...
}

This produces the same styled error block output.


Integration with Console

Commands automatically get access to the Console through the io property:


void run() {
// Text output
io.title('My Command');
io.text('This is some text');

// Status messages
io.info('Processing files...');
io.success('Operation completed');
io.warn('This is a warning');
io.error('An error occurred');

// Interactive prompts
final name = io.ask('What is your name?');
final confirmed = io.confirm('Are you sure?');

// Tasks
final result = await io.task('Processing', run: () async {
await Future.delayed(Duration(seconds: 2));
return TaskResult.success;
});

// Tables
io.table([
['Name', 'Email'],
['John Doe', 'john@example.com'],
['Jane Smith', 'jane@example.com'],
]);
}

Advanced Usage

Custom Renderer

final runner = CommandRunner(
'myapp',
'My Application',
renderer: StringRenderer(colorProfile: ColorProfile.ansi256),
);

Custom I/O Callbacks

final runner = CommandRunner(
'myapp',
'My Application',
out: (line) => print('[OUT] $line'),
err: (line) => print('[ERR] $line'),
readLine: () => 'user input',
);

Testing

For testing, you can inject custom I/O callbacks:

void main() async {
final output = <String>[];
final runner = CommandRunner(
'myapp',
'My Application',
out: (line) => output.add(line),
err: (line) => output.add(line),
readLine: () => 'yes',
);

runner.addCommand(MyCommand());
await runner.run(['my-command']);

print(output.join('\n'));
}