anvilm / iva
CLI framework for PHP 8.4+ with a real command tree, strongly typed input DTOs, GNU/POSIX-style option parsing, and a section/progress-bar/spinner output model.
Requires
- php: ^8.4
- php-standard-library/ansi: ^6.0
- php-standard-library/async: ^6.0
- php-standard-library/date-time: ^6.0
- php-standard-library/foundation: ^6.0
- php-standard-library/io: ^6.0
- php-standard-library/os: ^6.0
- php-standard-library/str: ^6.0
- php-standard-library/type: ^6.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Iva
A modern, strongly-typed CLI framework for PHP 8.4+
Important
This entire library — every line of source code, every docblock, and this README — was written by an AI. No human has hand-written the implementation. Review the code carefully before relying on it in production, and please open an issue if you spot a bug or a design flaw.
Overview
Iva is a command-line application framework for PHP. It gives you a real, nestable command tree (think
git remote add), strongly-typed input DTOs instead of stringly-typed $input->getOption('name') lookups, a
GNU/POSIX-compliant option parser, and a rich output model with sections, progress bars, spinners, tables and
trees — all without reflection, attributes, or hidden global state.
Two instances of Application can coexist in the same process, commands are built and registered explicitly, and
every value you read back in execute() is read through the very same typed Argument/Option object you declared
in configure() — so a typo or a type mismatch is a static-analysis error, not a runtime surprise.
Features
- Real command tree — unlimited nesting (
app db migrate up), grouping nodes that just list their children (likegit remote), aliases, hidden commands, and automatic "did you mean...?" suggestions for typos. - Statically-typed input —
Argument::string(),Option::int(),Option::flag(), etc. return a typed handle that you keep as a property and read back with$input->argument($this->name)/$input->option($this->loud)— no magic strings, nomixed. - GNU/POSIX-style parsing — long options (
--verbose), short flags and their bundling (-vvv),--opt=value/--opt value, negatable flags (--no-foo), variadic arguments, the--end-of-options marker, and optional option-name abbreviation. - Rich output model — colored/styled text (
<error>,<comment>, custom styles), verbosity levels (-q,-v,-vv,-vvv), redrawable sections, progress bars (determinate & indeterminate), spinners, tables, and trees, with automatic ANSI/color-support detection. - Command lifecycle hooks —
PreRun/PostRunon a command, andPersistentPreRun/PersistentPostRunon any ancestor group, run like afinallyblock even when the command throws. - Auto-generated help — usage lines, argument/option tables and examples are generated from the same definitions
used to parse input, for the whole app, a group, or a single command (
--help/-h). - First-class testing utilities —
CommandTesterandApplicationTesterrun a command through the real lexer → parser → binder pipeline against aBufferedOutput, with no real process orexecinvolved. - Invocation trace —
$input->invocation()exposes the command line exactly as it was typed: the order of every option and positional, argv positions, raw per-occurrence values,-vvvclusters,--no-x, abbreviations and the--marker, with a chainable query API. - Aligned, wrapped help — descriptions are wrapped to the terminal width and every continuation line is aligned to the description column; items are separated by a blank line.
- Persistent (inherited) options — declare an option once on a parent group and every subcommand can read it back through the same typed handle.
Requirements
| Requirement | Version |
|---|---|
| PHP | ^8.4 |
Iva depends on the php-standard-library packages (foundation, type,
str, io, ansi, async, os, date-time) — installed automatically via Composer.
Installation
composer require anvilm/iva
Quick Start
<?php declare(strict_types=1); require __DIR__ . '/vendor/autoload.php'; use Iva\Application; use Iva\Command\Command; use Iva\Input\Argument; use Iva\Input\Input; use Iva\Input\Option; use Iva\Output\Output; final class GreetCommand extends Command { private Argument $name; private Option $loud; protected function configure(): void { $this->setName('greet'); $this->setDescription('Greets someone by name'); $this->name = $this->addArgument(Argument::string('name', 'Who to greet')); $this->loud = $this->addOption(Option::flag('loud', shortcut: 'l', description: 'Shout the greeting')); $this->addExample('greet Ada --loud', 'Greets Ada, loudly'); } public function execute(Input $input, Output $output): int { $name = $input->argument($this->name); $greeting = sprintf('Hello, %s!', $name); $output->writeln($input->option($this->loud) ? strtoupper($greeting) : $greeting); return 0; } } $app = new Application('greet-app', '1.0.0'); $app->command(new GreetCommand()); exit($app->run($argv));
$ php greet.php Ada --loud
HELLO, ADA!
Core Concepts
Commands
Every command extends Command, declares itself in configure(), and receives an already-parsed, already-typed
Input in execute():
final class DeployCommand extends Command { protected function configure(): void { $this->setName('deploy'); $this->setDescription('Deploys the application'); $this->setAliases(['d']); } public function execute(Input $input, Output $output): int { // ... return ExitCode::Ok->value; } }
Nested commands & groups
Build a subcommand tree with CommandBuilder::subcommand(). A CommandGroup has no behaviour of its own — running
it just lists its children, like git remote:
final class DbGroup extends CommandGroup { protected function configure(): void { $this->setName('db'); $this->setDescription('Database commands'); } } $app->command(new DbGroup()) ->subcommand(new MigrateCommand()) ->subcommand(new SeedCommand());
$ php app.php db migrate --help
Arguments & Options
Arguments and options are built through named constructors and kept as typed handles:
protected function configure(): void { $this->setName('copy'); $this->source = $this->addArgument(Argument::string('source')); $this->targets = $this->addArgument(Argument::string('targets', variadic: true)); $this->force = $this->addOption(Option::flag('force', shortcut: 'f', negatable: true)); $this->retries = $this->addOption(Option::int('retries', default: 3)); $this->tags = $this->addOption(Option::strings('tag')); }
Persistent options declared on a parent group are inherited by every subcommand and can be read back through the
same shared handle — see the docblock on Command::addOption() for the pattern.
Output
public function execute(Input $input, Output $output): int { $output->writeln('<comment>Starting...</comment>'); $bar = $output->progressBar(100); $bar->start(); for ($i = 0; $i < 100; $i++) { $bar->advance(); } $bar->finish(); $output->table() ->headers(['Name', 'Status']) ->rows([['web', 'running'], ['worker', 'stopped']]) ->render(); $spinner = $output->spinner('Connecting...'); $spinner->start(); // ... $spinner->finish(); return ExitCode::Ok->value; }
Verbosity is controlled by the built-in -q, -v, -vv, -vvv global flags, and $output->isVerbose() /
$output->isDebug() let a command adapt its own logging.
Lifecycle hooks
final class DbGroup extends CommandGroup implements PersistentPreRun, PersistentPostRun { public function persistentPreRun(Output $output): void { /* open a connection */ } public function persistentPostRun(Output $output, int $exitCode): void { /* always close it */ } }
PersistentPostRun runs like a finally block: it fires even if the subcommand throws.
Testing
use Iva\Testing\CommandTester; $tester = new CommandTester(new GreetCommand()); $tester->execute(['Ada', '--loud']); $tester->assertSuccessful(); $tester->assertOutputContains('HELLO, ADA!');
ApplicationTester does the same at the whole-application level, resolving the command path exactly as run()
would.
Invocation order
Values tell you what was passed; $input->invocation() tells you how and in which order. It returns an immutable
InvocationLog of ArgvEntry objects, one per option occurrence (--tag a --tag b is two, -vvv is three), per
positional token, and one for a -- marker.
public function execute(Input $input, Output $output): int { $log = $input->invocation(); // Typed order of related options: --include a --exclude b --include c foreach ($log->ofAny($this->include, $this->exclude) as $entry) { $rule = $entry->name === 'include' ? '+' : '-'; $output->writeln($rule . ' ' . $entry->value); } $log->precedes($this->force, $this->dryRun); // was --force typed before --dry-run? $log->last($this->color)?->isNegated(); // does the last --color/--no-color win? $log->options()->labels(); // ['--force', '--tag', '--tag'] $log->times('verbose'); // -vvv => 3 $log->afterTerminator()->values('file'); // tokens typed after `--` $input->provided($this->retries); // typed explicitly, or just the default? echo $log->describe(); // debug table }
A target is an Option/Argument handle (typed, only matches that kind) or a string matched against the entry name or
label ('tag', '--tag'). Every narrowing method returns a new log, so queries chain:
| Method | Purpose |
|---|---|
all(), count(), iteration |
every entry in typed order |
options(), arguments(), afterTerminator() |
narrow by kind |
of($t), ofAny(...$ts) |
narrow to one or several targets, interleaved as typed |
before($t), after($t), between($a, $b) |
slice around first occurrences |
has, first, last, at, times, values |
lookups |
positionOf, sequenceOf, precedes, follows |
positions and relative order |
labels(), names(), groupedByName(), map(), filter(), slice(), toArray(), describe() |
projections |
tokens(), offset() |
the raw argv tail the parser saw and how many tokens the command path used |
Each ArgvEntry carries kind, name, token (the raw token), value (raw value of that occurrence; true/false
for flags), position (0-based argv index after the script name, command path included), sequence, ordinal, form
(Long, LongNegated, LongAbbreviated, Short, Positional, Terminated, Terminator), cluster (letter index
inside -abc) and valuePosition (argv index of a separate value token, null when inline). A positional that no
declared Argument claims keeps an empty name. CommandTester fills the log too, from the tokens you pass.
Help layout
--help renders arguments, options and commands as a two-column table. Descriptions are wrapped to the terminal width
(COLUMNS, 80 when unknown, clamped to 40–120; $app->setHelpWidth(100) fixes it) and every continuation line is
indented to the start of the description column, with one blank line between items. A label wider than the label
column goes on its own line and the description starts below it, still in the description column. Notes such as
[default: 3] and [repeatable] are kept on one line when they fit. A \n in a description starts a new paragraph,
and a no-break space (U+00A0) keeps two words together.
Options:
-f, --force / --no-force Overwrite existing files in the target directories
without asking for confirmation first
--retries=RETRIES How many times a failed copy is retried before
giving up [default: 3]
-h, --help Show help for this command
Supported input styles
| Style | Example |
|---|---|
| Long option | --verbose |
| Long option with value | --timeout=30 / --timeout 30 |
| Short flag | -v |
| Bundled short flags | -vvv |
| Negatable flag | --no-force |
| Variadic argument | cmd file1 file2 file3 |
| End-of-options marker | cmd -- --not-an-option |
Project Structure
src/
├── Application.php # Entry point: parses argv, resolves & runs a command
├── ExitCode.php # Standard sysexits-style exit codes
├── Command/ # Command tree: Command, CommandGroup, CommandBuilder, lifecycle hooks
├── Input/ # Lexer, Parser, Definition, Argument, Option, typed InputType system
├── Output/ # Output, ConsoleOutput, sections, ProgressBar, Spinner, Table, Tree, formatter
├── Help/ # Auto-generated help & usage lines
└── Testing/ # CommandTester, ApplicationTester
License
The project is distributed under the MIT License. For details, refer to the LICENSE file.