php doesn't get a lot of attention as a command line scripting language. which is a shame, since php has a lot of features that make it a good choice for writing terminal apps.
in this series, we'll be going over writing interactive command line scripts using the macrame library. we'll be working through building an example project, a script that fetches a list of a mastodon user's followers, from start to end, and will cover topics such as getting and validating user input, building interactive menus, handling command line arguments, accessing files safely, styling output text, and running functions in the background while showing our users an animated spinner.
further information on macrame can be found on the documentation site.
the sample project
the project we will be working through is a simple command-line script that returns a list of a mastodon user's followers. running it looks like this:
the user selects the desired mastodon instance from a dynamic menu, enters the username as free text, and the script shows an animated spinner while fetching the data. the output is a nice, ascii-style table.
the complete source code for this project is available as a gist for anyone who wants to skip ahead.
the flyover
in this installment, we will go over how to:
- install macrame
- scaffold a blank script
- read command line arguments
- create a dynamic menu
- read a line of user input (with optional validation)
- styling output text
install macrame
macrame is installed via composer
composer require gbhorwood/macrame
scaffolding your script
once we have macrame installed, we can set up a basic 'hello world' script and use that as our starting boilerplate. although this scaffolding is technically not required, using it will make our script a little safer and more compliant. let's look at the code:
#!/usr/bin/env php
<?php
require __DIR__ . '/vendor/autoload.php';
use Gbhorwood\Macrame\Macrame;
// Instantiate a Macrame object.
// The argument is the name of the script as seen by ps(1)
$macrame = new Macrame("Example Macrame script");
// Enforce that the script only runs if executed on the command line
if($macrame->running()) {
// Validate that the host system can run Macrame scripts. Exit on failure
$macrame->preflight();
// output text to STDOUT
$macrame->text("hello world")->write();
// exit cleanly
$macrame->exit();
}
although this isn't a whole lot of lines, there's a lot happening here. let's go over it.
#!/usr/bin/env php
this line is the 'shebang'. basically, it tells our linux-like operating system which interpreter to use to run this script. this allows us to run our script without having to type php
first. the shebang must be the first line in the file, even before <?php
.
$macrame = new Macrame("Example Macrame script");
here, we create a Macrame
object that we will use throughout the rest of our script. pretty standard stuff. the only interesting part is the argument. this is the name that the operating system will give to our script. for instance, if we run ps
to show a list of running processes, our script will show up with this name.
if($macrame->running())
this statement ensures that all the code inside the block will only execute if the script has been run on the command line.
$macrame->preflight();
when we write php for the command line, we do not have as much control over the environment as we do on a webserver that we own and manage. the preflight()
call here tests the local php environment and, if it does not meet the minimum requirements, terminates the script with an error message. the minimum requirements are:
- php 7.4
-
posix
extension -
mbstring
extension
note: although macrame does run on php 7.4 and 8.0, due to changes in how php handles multibyte strings in 8.1, strings containing emojis may not be aligned properly in output on php pre-8.1.
$macrame->exit();
this exits the script cleanly, returning a success code of 0
. additionally, any temporary files created during execution will be automatically deleted. using macrame's exit()
function is preferable to php's die()
;
running hello world
once we have our basic 'hello world' script written, we can set its permissions to allow execution and run it on the command line.
chmod 755 ./examplescript.php
./examplescript.php
reading arguments
macrame provides a set of tools for parsing and reading command line arguments. let's start with something straightforward: getting the version number when the script is called with:
./examplescript.php --version
# or
./examplescript.php -v
for this case, all we need to do is check whether either of those arguments exist.
we can do this by calling the args()
method on our macrame
object, which returns an object containing all of our script's arguments and a suite of methods we can use to inspect them. to test if an argument exists, we can use the exists()
method like so:
if ($macrame->args('v')->exists() || $macrame->args('version')->exists()) {
$macrame->text('1.0')->write(); // output the version number
$macrame->exit();
}
the exists()
method returns a boolean.
🔎 macrame calls are designed to be chained.
command line arguments can also be used to assign values to variables. for instance, to set the username
value, we will probably want our users to be able to call the script like this:
./examplescript.php --username=ghorwood
to get the value of this argument, in our script we can use the first()
method provided by args()
like so:
$username = $macrame->args('username')->first();
as the name implies, the first()
method returns the value of the first occurrence of our argument. if we called our script like this:
./examplescript.php --username=firstuser --username=seconduser
then first()
would return the value 'firstuser'. if we want the last value, we can call last()
. if we want all the values as an array, we would use all()
.
putting this all together, our script now looks like this:
#!/usr/bin/env php
<?php
require __DIR__ . '/vendor/autoload.php';
use Gbhorwood\Macrame\Macrame;
$macrame = new Macrame("Example Macrame script");
if($macrame->running()) {
$macrame->preflight();
/**
* Handle the -v or --version arguments
*/
if ($macrame->args('v')->exists() || $macrame->args('version')->exists()) {
$macrame->text('1.0')->write();
$macrame->exit();
}
/**
* Accept first value of --instance=
*/
$instance = $macrame->args('instance')->first();
/**
* Accept first value of --username=
*/
$username = $macrame->args('username')->first();
$macrame->exit();
}
the full list of methods for handling command line args is covered in the macrame documentation on arguments
creating a dynamic menu
we're also going to want to give our users the option to use our script interactively. if they don't pass an argument on the command line, we will prompt them to input the data. for the mastodon instance value, we're going to use a menu.
macrame menus are dynamic; our users naivgate them by using the arrow keys to move up or down the list and then hit <RETURN>
to make their selection. let's write a function that displays a menu to the user and returns the selected value:
function menuInstance(Macrame $macrame): string
{
$header = "Select your instance";
// list of options in the menu
$instances = [
'phpc.social',
'mastodon.social',
'mstdn.ca',
];
// display the menu, return the selected text
return $macrame->menu()
->erase() // erase the menu after selection
->interactive($instances, $header);
}
the core functionality here is a call to:
$macrame->menu()->interactive(<array of options>, <header>);
we provide an array of strings to display as the menu options and an optional header text for the menu to menu()->interactive()
and the menu is automatically displayed to the user. the user's selection is returned as a string.
there is also the option to erase the menu from the screen after the user has made their selection by adding a call to erase()
to our chain. this method is optional, but does keep things clean.
once we have our menu function, we can modify how we get the mastodon instance. we will try reading it from the command line arguments and, if no value has be
as the name implies, the first()
method returns the value of the first occurrence of our argument. if we called our script like this:
menuInstance()
function.$instance = $macrame->args('instance')->first();
if (!$instance) {
$instance = menuInstance($macrame);
}
a side note on styling menus
out of the box, macrame uses the terminal's default style and colour for menus and sets the highlighted item in reverse. we can alter this if we want by adding a few extra methods to our chain. for instance, if we would prefer that our highlighted item was shown as bold, red text, we could write:
$macrame->menu()
->styleSelected('bold')
->colourSelected('red') // colorSelected() also works
->interactive(<array of options>, <header>);
there is a complete overview on the menu documentation page of all the methods available to customize the colour, style and alignment of menus.
reading a line of user input
next, we're going to modify how we get the username to also accept interactive input. in this case, we're going to read a sting of user input text using input()->readline()
. here's the function:
function inputUsername(string $instance, Macrame $macrame): string
{
// the prompt for the text input, with bold styling using tags
$prompt = $macrame->text("<!BOLD!>Username<!CLOSE!> (for $instance): ")
->get();
/* alternate method to apply bold styling:
$prompt = $macrame->text('Username ')
->style('bold')
->get()."(for $instance): ";
*/
// read one line of user input, return the text
return $macrame->input()
->readline($prompt);
}
the last line of this function is where we poll the user for input. the readline()
method accepts an optional $prompt
argument; the text we display to the user telling them what they should enter. the return value is the user input as a string.
a side note on input validation
users make mistakes. that's why input validation is important.
macrame comes with a number of pre-set methods to validate input. we can add as many of them as we want to our chain and if any of the validators fail, the user will be prompted to input again. the input()->readline()
function will not return a value until all validators pass.
let's look at an example:
return $macrame->input()
->isLengthMin(4, 'must be at least 4 characters long')
->doesNotContain('@', 'do not use the @ symbol')
->readline($prompt);
here, we apply two validation test: the text must be four or more characters and must not contain the '@' symbol. for both those validation methods, the second argument is the error message we will show to the user if the validation fails.
a full list of the pre-built validation functions is available on the macrame input documentation page. if we want to write our own custom validators, that is covered, too.
what about 'dots echo' output?
if our users are inputting sensitive data, like passwords, we'll probably not want to echo their keystrokes back to the terminal where nosey shoulder-surfers can read it.
to address this, macrame provides a 'dots echo' version of readline()
called readPassword()
.
$macrame->input()->readPassword("enter sensitive data: ");
each keystroke read by readPassword()
is echoed back as an asterisk.
styling text
in the example of how to read a line of user text, we saw a bunch of code for styling the prompt text. let's look at that in more detail.
macrame allows for styling text output to the terminal using ansi codes which allow us to apply both styles such as bold and italic, and colours to our text.
we can do this in our script one of two ways. there are methods such as style()
, and colour()
(or color()
), or we can use a basic tagging system with text.
let's look at the method approach first.
$prompt = $macrame->text('Username ')
->style('bold')
->colour('blue')
->get()."(for $instance): ";
here, we created a 'text' object using macrame's text()
method, then applied a style and colour before returning it as a string using get()
.
note that the style and colour methods are applied to all the text in the string. if we want to mix in styled and coloured text with plain text, we will have to create a number of substrings and concatenate them together. this can be cumbersome, especially if we're dealing with large amounts of text.
alternately, we can use macrame's tagging system to make styling our text a little easier. here's an example:
$prompt = $macrame->text("<!BOLD!>Username<!CLOSE!> (for $instance): ")
->get();
the text between the <!BOLD!>
and <!CLOSE!>
tags will, unsurprisingly, be bolded. there is a full list of all the tags in the documentation.
an important thing to note is that the <!CLOSE!>
tag closes all of the preceding tags. this is due to the behaviour of ANSI escape codes.
this means that nesting tags does not work the way we might expect. for instance, in this example, the first <!CLOSE!>
tag closes both the <!BOLD!>
and <!RED!>
tags:
"<!RED!>this is red <!BOLD!>this is red and bold<!CLOSE!> this is plain<!CLOSE!>";
the script so far
our example script so far looks like:
#!/usr/bin/env php
<?php
require __DIR__ . '/vendor/autoload.php';
use Gbhorwood\Macrame\Macrame;
// Instantiate a Macrame object.
// The argument is the name of the script as seen by ps(1)
$macrame = new Macrame("Example Macrame script");
// Enforce that the script only runs if executed on the command line
if ($macrame->running()) {
// Validate that the host system can run Macrame scripts. Exit on failure
$macrame->preflight();
/**
* Handle the -v or --version arguments
*/
if ($macrame->args('v')->exists() || $macrame->args('version')->exists()) {
$macrame->text('1.0')->write();
$macrame->exit();
}
/**
* Handle the --instance= argument if present, or poll user for instance
* with dynamic menu if not.
*/
$instance = $macrame->args('instance')->first();
if (!$instance) {
$instance = menuInstance($macrame);
}
/**
* Handle the --username= argument if present, or poll user for username
* with text input if not
*/
$username = $macrame->args('username')->first();
if (!$username) {
$username = inputUsername($instance, $macrame);
}
// exit cleanly
$macrame->exit();
}
/**
* Display a dynamic menu of instances to the user, return
* the selected text.
*
* @param Macrame $macrame
* @return string
*/
function menuInstance(Macrame $macrame): string
{
$header = "Select your instance";
// list of options in the menu
$instances = [
'phpc.social',
'mastodon.social',
'mstdn.ca',
];
// display the menu, return the selected text
return $macrame->menu()
->erase() // erase the menu after selection
->interactive($instances, $header);
}
/**
* Display a text input to the user, return the input text.
*
* @param string $instance
* @param Macrame $macrame
* @return string
*/
function inputUsername(string $instance, Macrame $macrame): string
{
// the prompt for the text input, with bold styling using tags
$prompt = $macrame->text("<!BOLD!>Username<!CLOSE!> (for $instance): ")
->get();
/* alternate method to apply bold styling:
$prompt = $macrame->text('Username ')
->style('bold')
->get()."(for $instance): ";
*/
// read one line of user input, return the text
return $macrame->input()
->doesNotContain('@', 'do not use the @ symbol')
->readline($prompt);
}
what's next
so far, we've covered reading command line arguments, getting user input from menus and as text, and doing some basic text styling for output. in the next post, we will go over:
- running a function in the background while we show users an animated spinner
- writing safely to files
- outputting array data as a nicely-formatted ascii table
- paging long output
- basic notice level output
🔎 this post originally appeared in the grant horwood technical blog
Top comments (1)
You put a lot of effort in the library.
But I can't see the benefit of the code over Symfony console or Laravel artisan. It is possible to run them as individual scripts if you want.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.