siktec / just-cli
Command line interface library for PHP
Requires
- php: >=8.0
Requires (Dev)
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^9.0
README
Note
Just CLI is a fork of adhocore/php-cli which is an amazing CLI framework. This fork is for adding some features that may not be suitable for the original project. Some of the features are:
- Negative numbers in arguments.
- Support for multiple variadic arguments with a grouping option (e.g.
$ app cmd [arg11 arg12] --opt [val1 val2]
) - Add support for a
structured
CLI app that will auto register commands, views, layouts, etc. - Add support for Themes.
Framework agnostic Command Line Interface utilities and helpers for PHP. Build Console App with ease, fun and love.
- Zero dependency.
- For PHP 8.0+ only.
What's included
Core: Argv parser · Cli application · Shell
IO: Colorizer · Cursor manipulator · Progress bar · Stream writer · Stream reader
Other: Autocompletion
Installation
composer require siktec/just-Cli
Usage
Argv parser
$command = new JCli\Input\Command('rmdir', 'Remove dirs'); $command ->version('0.0.1-dev') // Arguments are separated by space // Format: `<name>` for required, `[name]` for optional // `[name:default]` for default value, `[name...]` for variadic (last argument) ->arguments('<dir> [dirs...]') // `-h --help`, `-V --version`, `-v --verbosity` options are already added by default. // Format: `<name>` for required, `[name]` for optional ->option('-s --with-subdir', 'Also delete sub-dirs (`with` means false by default)') ->option('-e,--no-empty', 'Delete empty (`no` means true by default)') // Specify sanitizer/callback as 3rd param, default value as 4th param ->option('-d|--depth [nestlevel]', 'How deep to process sub-dirs', 'intval', 5) ->parse(['thisfile.php', '-sev', 'dir', 'dir1', 'dir2', '-vv']) // `$_SERVER['argv']` ; // Print all values: print_r($command->values()); /*Array ( [help] => [version] => 0.0.1 [verbosity] => 3 [dir] => dir [dirs] => Array ( [0] => dir1 [1] => dir2 ) [subdir] => true [empty] => false [depth] => 5 )*/ // To get values for options except the default ones (help, version, verbosity) print_r($command->values(false)); // Pick a value by name $command->dir; // dir $command->dirs; // [dir1, dir2] $command->depth; // 5
Command help
It can be triggered manually with $command->showHelp()
or automatic when -h
or --help
option is passed to $command->parse()
.
For above example, the output would be:
Command version
It can be triggered manually with $command->showVersion()
or automatic when -V
or --version
option is passed to $command->parse()
.
For above example, the output would be:
0.0.1-dev
Console app
Here we simulate a git
app with limited functionality of add
, and checkout
.
You will see how intuitive, fluent and cheese building a console app is!
Git app
$app = new JCli\Application('git', '0.0.1'); $app // Register `add` command ->command('add', 'Stage changed files', 'a') // alias a // Set options and arguments for this command ->arguments('<path> [paths...]') ->option('-f --force', 'Force add ignored file', 'boolval', false) ->option('-N --intent-to-add', 'Add content later but index now', 'boolval', false) // Handler for this command: param names should match but order can be anything :) ->action(function ($path, $paths, $force, $intentToAdd) { array_unshift($paths, $path); echo ($intentToAdd ? 'Intent to add ' : 'Add ') . implode(', ', $paths) . ($force ? ' with force' : ''); // If you return integer from here, that will be taken as exit error code }) // Done setting up this command for now, tap() to retreat back so we can add another command ->tap() ->command('checkout', 'Switch branches', 'co') // alias co ->arguments('<branch>') ->option('-b --new-branch', 'Create a new branch and switch to it', false) ->option('-f --force', 'Checkout even if index differs', 'boolval', false) ->action(function ($branch, $newBranch, $force) { echo 'Checkout to ' . ($newBranch ? 'new ' . $branch : $branch) . ($force ? ' with force' : ''); }) ; // Parse only parses input but doesn't invoke action $app->parse(['git', 'add', 'path1', 'path2', 'path3', '-f']); // Handle will do both parse and invoke action. $app->handle(['git', 'add', 'path1', 'path2', 'path3', '-f']); // Will produce: Add path1, path2, path3 with force $app->handle(['git', 'co', '-b', 'master-2', '-f']); // Will produce: Checkout to new master-2 with force
Organized app
Instead of inline commands/actions, we define and add our own commands (having interact()
and execute()
) to the app:
class InitCommand extends JCli\Input\Command { public function __construct() { parent::__construct('init', 'Init something'); $this ->argument('<arrg>', 'The Arrg') ->argument('[arg2]', 'The Arg2') ->option('-a --apple', 'The Apple') ->option('-b --ball', 'The ball') // Usage examples: ->usage( // append details or explanation of given example with ` ## ` so they will be uniformly aligned when shown '<bold> init</end> <comment>--apple applet --ball ballon <arggg></end> ## details 1<eol/>' . // $0 will be interpolated to actual command name '<bold> $0</end> <comment>-a applet -b ballon <arggg> [arg2]</end> ## details 2<eol/>' ); } // This method is auto called before `self::execute()` and receives `Interactor $io` instance public function interact(JCli\IO\Interactor $io) : void { // Collect missing opts/args if (!$this->apple) { $this->set('apple', $io->prompt('Enter apple')); } if (!$this->ball) { $this->set('ball', $io->prompt('Enter ball')); } // ... } // When app->handle() locates `init` command it automatically calls `execute()` // with correct $ball and $apple values public function execute($ball, $apple) { $io = $this->app()->io(); $io->write('Apple ' . $apple, true); $io->write('Ball ' . $ball, true); // more codes ... // If you return integer from here, that will be taken as exit error code } } class OtherCommand extends JCli\Input\Command { public function __construct() { parent::__construct('other', 'Other something'); } public function execute() { $io = $this->app()->io(); $io->write('Other command'); // more codes ... // If you return integer from here, that will be taken as exit error code } } // Init App with name and version $app = new JCli\Application('App', 'v0.0.1'); // Add commands with optional aliases` $app->add(new InitCommand, 'i'); $app->add(new OtherCommand, 'o'); // Set logo $app->logo('Ascii art logo of your app'); $app->handle($_SERVER['argv']); // if argv[1] is `i` or `init` it executes InitCommand
Grouping commands
Grouped commands are listed together in commands list. Explicit grouping a command is optional.
By default if a command name has a colon :
then the part before it is taken as a group,
else *
is taken as a group.
Example: command name
app:env
has a default groupapp
, command nameappenv
has group*
.
// Add grouped commands: $app->group('Configuration', function ($app) { $app->add(new ConfigSetCommand); $app->add(new ConfigListCommand); }); // Alternatively, set group one by one in each commands: $app->add((new ConfigSetCommand)->inGroup('Config')); $app->add((new ConfigListCommand)->inGroup('Config')); ...
Exception handler
Set a custom exception handler as callback. The callback receives exception & exit code. The callback may rethrow exception or may exit the program or just log exception and do nothing else.
$app = new JCli\Application('App', 'v0.0.1'); $app->add(...); $app->onException(function (Throwable $e, int $exitCode) { // send to sentry // write to logs // optionally, exit with exit code: exit($exitCode); // or optionally rethrow, a re-thrown exception is propagated to top layer caller. throw $e; })->handle($argv);
App help
It can be triggered manually with $app->showHelp()
or automatic when -h
or --help
option is passed to $app->parse()
.
Note If you pass something like ['app', cmd', '-h']
to $app->parse()
it will automatically and instantly show you help of that cmd
and not the $app
.
For above example, the output would be:
App version
Same version number is passed to all attached Commands. So you can trigger version on any of the commands.
Shell
Very thin shell wrapper that provides convenience methods around proc_open()
.
Basic usage
$shell = new JCli\Helper\Shell($command = 'php -v', $rawInput = null); // Waits until proc finishes $shell->execute($async = false); // default false echo $shell->getOutput(); // PHP version string (often with zend/opcache info)
Advanced usage
$shell = new JCli\Helper\Shell('php /some/long/running/script.php'); // With async flag, doesn't wait for proc to finish! $shell->setOptions($workDir = '/home', $envVars = []) ->execute($async = true) ->isRunning(); // true // Force stop anytime (please check php.net/proc_close) $shell->stop(); // also closes pipes // Force kill anytime (please check php.net/proc_terminate) $shell->kill();
Timeout
$shell = new JCli\Helper\Shell('php /some/long/running/script.php'); // Wait for at most 10.5 seconds for proc to finish! // If it doesn't complete by then, throws exception $shell->setOptions($workDir, $envVars, $timeout = 10.5)->execute(); // And if it completes within timeout, you can access the stdout/stderr echo $shell->getOutput(); echo $shell->getErrorOutput();
Cli Interaction
You can perform user interaction like printing colored output, reading user input programatically and moving the cursors around with provided JCli\IO\Interactor
.
$interactor = new JCli\IO\Interactor; // For mocking io: $interactor = new JCli\IO\Interactor($inputPath, $outputPath);
Confirm
$confirm = $interactor->confirm('Are you happy?', 'n'); // Default: n (no) $confirm // is a boolean ? $interactor->greenBold('You are happy :)', true) // Output green bold text : $interactor->redBold('You are sad :(', true); // Output red bold text
Single choice
$fruits = ['a' => 'apple', 'b' => 'banana']; $choice = $interactor->choice('Select a fruit', $fruits, 'b'); $interactor->greenBold("You selected: {$fruits[$choice]}", true);
Multiple choices
$fruits = ['a' => 'apple', 'b' => 'banana', 'c' => 'cherry']; $choices = $interactor->choices('Select fruit(s)', $fruits, ['b', 'c']); $choices = \array_map(function ($c) use ($fruits) { return $fruits[$c]; }, $choices); $interactor->greenBold('You selected: ' . implode(', ', $choices), true);
Prompt free input
$any = $interactor->prompt('Anything', rand(1, 100)); // Random default $interactor->greenBold("Anything is: $any", true);
Prompt with validation
$nameValidator = function ($value) { if (\strlen($value) < 5) { throw new \InvalidArgumentException('Name should be at least 5 chars'); } return $value; }; // No default, Retry 5 more times $name = $interactor->prompt('Name', null, $nameValidator, 5); $interactor->greenBold("The name is: $name", true);
Prompt hidden
On windows platform, it may change the fontface which can be fixed.
$passValidator = function ($pass) { if (\strlen($pass) < 6) { throw new \InvalidArgumentException('Password too short'); } return $pass; }; $pass = $interactor->promptHidden('Password', $passValidator, 2);
IO Components
The interactor is composed of JCli\Input\Reader
and JCli\Output\Writer
while the Writer
itself is composed of JCli\Output\Color
. All these components can be used standalone.
Color
Color looks cool!
$color = new JCli\Output\Color;
Simple usage
echo $color->warn('This is warning'); echo $color->info('This is info'); echo $color->error('This is error'); echo $color->comment('This is comment'); echo $color->ok('This is ok msg');
Custom style
JCli\Output\Color::style('mystyle', [ 'bg' => JCli\Output\Color::CYAN, 'fg' => JCli\Output\Color::WHITE, 'bold' => 1, // You can experiment with 0, 1, 2, 3 ... as well ]); echo $color->mystyle('My text');
Cursor
Move cursor around, erase line up or down, clear screen.
$cursor = new JCli\Output\Cursor; echo $cursor->up(1) . $cursor->down(2) . $cursor->right(3) . $cursor->left(4) . $cursor->next(0) . $cursor->prev(2); . $cursor->eraseLine() . $cursor->clear() . $cursor->clearUp() . $cursor->clearDown() . $cursor->moveTo(5, 8); // x, y
Progress Bar
Easily add a progress bar to your output:
$progress = new JCli\Output\ProgressBar(100); for ($i = 0; $i <= 100; $i++) { $progress->current($i); // Simulate something happening usleep(80000); }
You can also manually advance the bar:
$progress = new JCli\Output\ProgressBar(100); // Do something $progress->advance(); // Adds 1 to the current progress // Do something $progress->advance(10); // Adds 10 to the current progress // Do something $progress->advance(5, 'Still going.'); // Adds 5, displays a label
You can override the progress bar options to customize it to your liking:
$progress = new JCli\Output\ProgressBar(100); $progress->option('pointer', '>>'); $progress->option('loader', '▩'); // You can set the progress fluently $progress->option('pointer', '>>')->option('loader', '▩'); // You can also use an associative array to set many options in one time $progress->option([ 'pointer' => '>>', 'loader' => '▩' ]); // Available options +------------+------------------------------+---------------+ | Option | Description | Default value | +------------+------------------------------+---------------+ | pointer | The progress bar head symbol | > | | loader | The loader symbol | = | | color | The color of progress bar | white | | labelColor | The text color of the label | white | +------------+------------------------------+---------------+
Writer
Write anything in style.
$writer = new JCli\Output\Writer; // All writes are forwarded to STDOUT // But if you specify error, then to STDERR $writer->errorBold('This is error');
Output formatting
You can call methods composed of any combinations:
'<colorName>', 'bold', 'bg', 'fg', 'warn', 'info', 'error', 'ok', 'comment'
... in any order (eg: bgRedFgBlack
, boldRed
, greenBold
, commentBgPurple
and so on ...)
$writer->bold->green->write('It is bold green'); $writer->boldGreen('It is bold green'); // Same as above $writer->comment('This is grayish comment', true); // True indicates append EOL character. $writer->bgPurpleBold('This is white on purple background');
Free style
Many colors with one single call: wrap text with tags <method>
and </end>
For NL/EOL just use <eol>
or </eol>
or <eol/>
.
Great for writing long colorful texts for example command usage info.
$writer->colors('<red>This is red</end><eol><bgGreen>This has bg Green</end>');
Raw output
$writer->raw('Enter name: ');
Tables
Just pass array of assoc arrays. The keys of first array will be taken as heading. Heading is auto inflected to human readable capitalized words (ucwords).
$writer->table([ ['a' => 'apple', 'b-c' => 'ball', 'c_d' => 'cat'], ['a' => 'applet', 'b-c' => 'bee', 'c_d' => 'cute'], ]);
Gives something like:
+--------+------+------+
| A | B C | C D |
+--------+------+------+
| apple | ball | cat |
| applet | bee | cute |
+--------+------+------+
Designing table look and feel
Just pass 2nd param $styles
:
$writer->table([ ['a' => 'apple', 'b-c' => 'ball', 'c_d' => 'cat'], ['a' => 'applet', 'b-c' => 'bee', 'c_d' => 'cute'], ], [ // for => styleName (anything that you would call in $writer instance) 'head' => 'boldGreen', // For the table heading 'odd' => 'bold', // For the odd rows (1st row is odd, then 3, 5 etc) 'even' => 'comment', // For the even rows (2nd row is even, then 4, 6 etc) ]); // 'head', 'odd', 'even' are all the styles for now // In future we may support styling a column by its name!
Reader
Read and pre process user input.
$reader = new JCli\Input\Reader; // No default, callback fn `ucwords()` $reader->read(null, 'ucwords'); // Default 'abc', callback `trim()` $reader->read('abc', 'trim'); // Read at most first 5 chars // (if ENTER is pressed before 5 chars then further read is aborted) $reader->read('', 'trim', 5); // Read but don't echo back the input $reader->readHidden($default, $callback); // Read from piped stream (or STDIN) if available without waiting $reader->readPiped(); // Pass in a callback for if STDIN is empty // The callback receives $reader instance and MUST return string $reader->readPiped(function ($reader) { // Wait to read a line! return $reader->read(); // Wait to read multi lines (until Ctrl+D pressed) return $reader->readAll(); });
Exceptions
Whenever an exception is caught by Application::handle()
, it will show a beautiful stack trace and exit with non 0 status code.
Autocompletion
Any console applications that are built on top of siktec/just-cli can entertain autocomplete of commands and options in zsh shell with oh-my-zsh.
All you have to do is add one line to the end of ~/.oh-my-zsh/custom/plugins/ahccli/jcli.plugin.zsh
:
compdef _jcli <appname>
Example: compdef _jcli your-cli-app
for your cli app named your-cli-app
That is cumbersome to perform manually, here's a complete command you can copy/paste/run:
One time setup
mkdir -p ~/.oh-my-zsh/custom/plugins/jcli && cd ~/.oh-my-zsh/custom/plugins/jcli [ -f ./jcli.plugin.zsh ] || curl -sSLo ./jcli.plugin.zsh https://raw.githubusercontent.com/siktec-lab/just-cli/master/jcli.plugin.zsh chmod 760 ./jcli.plugin.zsh && cd -
Load JCli plugin
This is also one time setup.
# Open .zshrc nano ~/.zshrc # locate plugins=(... ...) and add jcli plugins=(git ... ... jcli) # ... then save it (Ctrl + O)
Registering app
# replace appname with real name eg: phint echo compdef _jcli appname >> ~/.oh-my-zsh/custom/plugins/jcli/jcli.plugin.zsh
Of course you can add multiple apps, just change appname in above command
Then either restart the shell or source the plugin like so:
source ~/.oh-my-zsh/custom/plugins/jcli/jcli.plugin.zsh
Trigger autocomplete
appname <tab> # autocompletes commands appname subcommand <tab> # autocompletes options for subcommand