Skip to content

Latest commit

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

ordinary/cli

A PHP 8.5 CLI argument parser and subcommand router. Declare options and commands with typed builders or PHP attributes, receive a typed ParsedInput in every handler, and get auto-generated help text — with zero framework dependencies.

Key design principles

  • Declare once, validate always — options and positional arguments are declared upfront; the parser errors on anything undeclared.
  • Positional option scoping — options before a subcommand token belong to the parent; options after belong to the subcommand. Global options propagate down automatically.
  • Space-separated values only--config value and -c value are the only supported syntaxes. --config=value is intentionally not supported; this keeps the grammar unambiguous and the parser simple.
  • Flag counts — flags record how many times they appear: -vvv yields flag('verbose') === 3.
  • Typed parsed results — handlers receive ParsedInputInterface with flag(), value(), values(), argument(), and arguments(); no mixed returns, no casting.
  • Compile once, run oncecompile() validates the command tree at build time and returns an immutable dispatcher.
  • No magic — zero autowiring, no convention scanning, no hidden behavior.

Installation

composer require ordinary/cli

Requires PHP ≥ 8.5. Zero runtime dependencies.

Quick start

<?phpdeclare(strict_types=1);
useOrdinary\Cli\CommandRouter;
useOrdinary\Cli\Console;
useOrdinary\Cli\ExitCode;
useOrdinary\Cli\Input\ParsedInputInterface;
$router = newCommandRouter(name: 'myapp', version: '1.0.0');
// Global option — available to every command at any depth$router->option('verbose', 'v')
->flag()
->global()
->description('Enable verbose output');
// Root-level option — only valid before any subcommand$router->option('config', 'c')
->value()
->description('Path to config file')
->default('/etc/myapp.conf');
// Simple command$router->command('version')
->description('Print version and exit')
->handler(fn(ParsedInputInterface$i, Console$io): int => (
$io->output->writeln('myapp 1.0.0') ?? ExitCode::Success->value
));
// Nested commands$db = $router->command('db')->description('Database utilities');
$db->command('migrate')
->description('Apply pending migrations')
->option('dry-run', 'd')->flag()->description('Preview without writing')
->option('batch', 'b')->value()->description('Batch size')->default('all')
->handler(function (ParsedInputInterface$input, Console$io): int {
if ($input->flag('verbose')) { // global — available everywhere$io->output->writeln('[verbose] starting migrate');
}
$io->output->writeln('dry-run: ' . ($input->flag('dry-run') ? 'yes' : 'no'));
return ExitCode::Success->value;
});
$db->command('seed')
->description('Seed the database')
->argument('table')->optional()->description('Limit to one table')
->handler(fn(ParsedInputInterface$i, Console$io): int => ExitCode::Success->value);
$dispatcher = $router->compile();
exit($dispatcher->run($_SERVER['argv'], Console::fromStreams()));

Option types

Flags

Flags record the number of occurrences. They never consume a value token.

$router->option('verbose', 'v')->flag()->global();
// CLI: myapp -vvv migrate// $input->flag('verbose') === 3// if ($input->flag('verbose')) — truthy check

Value options

Value options consume the next token as their string value.

$cmd->option('config', 'c')->value()->description('Config file');
// CLI: myapp --config /etc/app.conf// CLI: myapp -c /etc/app.conf// $input->value('config') === '/etc/app.conf'

Note: The --option=value syntax is not supported. Only space-separated values are accepted (--option value). This is a deliberate design decision for parser simplicity. Document this constraint in your application's help text or man page if your users are accustomed to the = form.

Repeated value options

// Store last (default): subsequent occurrences overwrite earlier ones$cmd->option('env', 'e')->value()->description('Environment');
// Store first: first occurrence wins, rest are consumed and discarded$cmd->option('env', 'e')->value()->repeat(OptionRepeat::StoreFirst);
// Store list: collect all occurrences$cmd->option('file', 'f')->value()->repeat(OptionRepeat::StoreList)->description('Files');
// CLI: myapp process --file a.txt --file b.txt --file c.txt// $input->values('file') === ['a.txt', 'b.txt', 'c.txt']

Option scoping

Options declared with ->global() are available to all descendant commands without redeclaration. The handler receives them identically to locally-declared options — no separate accessor needed.

$router->option('verbose', 'v')->flag()->global();
// CLI: myapp --verbose db migrate// Inside the migrate handler: $input->flag('verbose') === 1

Options without ->global() are local — they are only valid before the command that declares them.

Subcommands

Commands can be nested to any depth. Options before a subcommand token are parsed by the parent; options after belong to the subcommand.

$db = $router->command('db');
$migrate = $db->command('migrate');
$seed = $db->command('seed');
// CLI: myapp db migrate --dry-run// CLI: myapp --verbose db migrate (--verbose is global, before db is fine)// CLI: myapp db --unknown-flag Error: unknown option

A command may have either subcommands or positional arguments — not both. This is enforced at compile() time.

Positional arguments

$cmd->argument('environment')
->required()
->enum(Environment::class) // validated against all case values
->description('Target environment');
$cmd->argument('target')
->optional()
->description('Migration target version');
$cmd->argument('files')
->variadic() // consumes all remaining tokens; must be last
->description('Files to process');

ParsedInputInterface::argument('name') returns ?string. For enum-constrained arguments, BackedEnum::from($input->argument('env')) is guaranteed safe after parsing.

-- terminator

Everything after -- is treated as a positional argument, regardless of leading dashes:

myapp process -- --not-an-option positional-arg

Built-in flags

--help / -h and --version / -V are auto-wired:

  • --help anywhere in the argument vector prints help for the matched command level and exits 0.
  • --version prints <appname> <version> (when a version was provided) and exits 0.

Testing

Handlers are closures that receive ParsedInputInterface and Console. Use BufferedOutput and BufferedInput in tests — no mocking required:

useOrdinary\Cli\Console;
useOrdinary\Cli\Io\BufferedInput;
useOrdinary\Cli\Io\BufferedOutput;
$out = newBufferedOutput();
$err = newBufferedOutput();
$console = newConsole(newBufferedInput(), $out, $err);
$exitCode = $dispatcher->run(['myapp', 'migrate', '--dry-run'], $console);
assertSame(0, $exitCode);
assertStringContainsString('dry-run: yes', $out->content());

Attribute-based registration

useOrdinary\Cli\Attribute\Command;
useOrdinary\Cli\Attribute\CommandArgument;
useOrdinary\Cli\Attribute\CommandOption;
useOrdinary\Cli\Argument\ArgumentMode;
useOrdinary\Cli\Option\OptionType;
// Class-level: represents the 'db' command; methods become subcommands
#[Command(name: 'db', description: 'Database utilities')]
#[CommandOption(long: 'connection', short: 'C', type: OptionType::Value, description: 'DB connection')]
finalclass DatabaseCommands
{
#[Command(name: 'migrate', description: 'Apply pending migrations')]
#[CommandOption(long: 'dry-run', short: 'd', type: OptionType::Flag)]
publicfunctionmigrate(ParsedInputInterface$input, Console$console): int
{
return0;
}
#[Command(name: 'seed', description: 'Seed the database')]
#[CommandArgument(name: 'table', mode: ArgumentMode::Optional)]
publicfunctionseed(ParsedInputInterface$input, Console$console): int
{
return0;
}
}
// Standalone function
#[Command(name: 'ping', description: 'Check connectivity')]
functionpingCommand(ParsedInputInterface$input, Console$console): int { return0; }
useOrdinary\Cli\Attribute\AttributeCommandLoader;
$loader = newAttributeCommandLoader($router);
$loader->loadClass(DatabaseCommands::class);
$loader->loadFunction('pingCommand');
$loader->loadDirectory(__DIR__ . '/Commands'); // token-parses, no execution$dispatcher = $router->compile();

Exception handling

Every exception thrown by this package implements Ordinary\Cli\Exception\ExceptionInterface:

useOrdinary\Cli\Exception\ExceptionInterface;
useOrdinary\Cli\Exception\LogicException; // compile-time wiring errorsuseOrdinary\Cli\Exception\ParseException; // never surfaces from run()try {
$dispatcher = $router->compile(); // LogicException on bad wiring
} catch (LogicException$e) {
// missing handler, subcommands+args on same command, duplicate short names
}

Parse errors (unknown option, missing required, unknown command) are handled internally by CommandDispatcher::run(): they write to $console->error, print help text, and return exit code 2. They never surface to the caller.

Exit codes

useOrdinary\Cli\ExitCode;
ExitCode::Success->value // 0ExitCode::Error->value // 1ExitCode::Usage->value// 2 (parse errors, returned automatically)

About

PHP 8.5 CLI argument parser and subcommand router: declare options and commands, receive typed ParsedInput, zero framework dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages