Building CLI Tools with Mini
This tutorial shows how to build robust command-line tools using Mini's CLI features.
Quick Start
A minimal CLI script:
#!/usr/bin/env php
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use function mini\args;
args(args()
->withFlag('v', 'verbose')
->withRequiredValue('o', 'output')
);
if (args()->getUnparsedArgs()) {
fwrite(STDERR, "Unknown: " . implode(', ', args()->getUnparsedArgs()) . "\n");
exit(1);
}
if (args()->getFlag('verbose')) {
echo "Verbose mode enabled\n";
}
$output = args()->getOption('output');
echo "Output file: " . ($output ?: 'none') . "\n";
Make it executable: chmod +x bin/mytool
Project Structure
For CLI projects, a clean structure separates concerns:
myproject/
├── bin/ # Executable scripts
│ ├── main-command # Primary entry point
│ └── sub-command # Additional commands
├── src/ # PHP classes (PSR-4 autoloaded)
│ └── MyService.php
├── bootstrap.php # Global setup, helper functions
├── composer.json
└── data/ # Runtime data (state, config, etc.)
Composer Setup
{
"name": "vendor/myproject",
"require": {
"php": "^8.2",
"fubber/mini": "dev-main"
},
"autoload": {
"psr-4": {
"MyProject\\": "src/"
},
"files": ["bootstrap.php"]
},
"minimum-stability": "dev",
"prefer-stable": true
}
Bootstrap Pattern
The bootstrap.php file runs on every autoload. Use it for:
- Constants and paths
- Singleton accessor functions
- Configuration loader
<?php
declare(strict_types=1);
use MyProject\StateManager;
use MyProject\AlertWriter;
define('PROJECT_ROOT', __DIR__);
define('PROJECT_DATA', PROJECT_ROOT . '/data');
// Singleton accessors using static variables
function state_manager(): StateManager
{
static $instance = null;
return $instance ??= new StateManager(PROJECT_DATA . '/state.json');
}
function alert_writer(): AlertWriter
{
static $instance = null;
return $instance ??= new AlertWriter(PROJECT_DATA . '/alerts');
}
// Configuration loader
function config(): array
{
static $config = null;
if ($config === null) {
$path = PROJECT_DATA . '/config.json';
$config = file_exists($path) ? json_decode(file_get_contents($path), true) : [];
}
return $config;
}
Configuring the Logger
CLI tools typically output to stderr by default, with optional file logging. Configure this after parsing arguments in your entry point:
use mini\Mini;
use mini\Lifetime;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
/**
* Configure logger based on verbosity and optional log file
*
* @param int $verbosity 0=errors only, 1=warnings, 2=info, 3=debug
* @param string|null $logFile Optional file path for logging
*/
function configure_logger(int $verbosity, ?string $logFile = null): void
{
Mini::$mini->addService(LoggerInterface::class, Lifetime::Singleton,
function() use ($verbosity, $logFile) {
return new class($verbosity, $logFile) implements LoggerInterface {
use \Psr\Log\LoggerTrait;
private const LEVELS = [
LogLevel::EMERGENCY => 0,
LogLevel::ALERT => 0,
LogLevel::CRITICAL => 0,
LogLevel::ERROR => 0,
LogLevel::WARNING => 1,
LogLevel::NOTICE => 2,
LogLevel::INFO => 2,
LogLevel::DEBUG => 3,
];
public function __construct(
private int $verbosity,
private ?string $logFile
) {}
public function log($level, string|\Stringable $message, array $context = []): void
{
$ts = date('Y-m-d H:i:s');
$levelStr = strtoupper((string) $level);
$line = "[$ts] [$levelStr] $message\n";
// Always write to log file if configured
if ($this->logFile !== null) {
file_put_contents($this->logFile, $line, FILE_APPEND);
}
// Write to stderr based on verbosity
$requiredVerbosity = self::LEVELS[$level] ?? 0;
if ($this->verbosity >= $requiredVerbosity) {
fwrite(STDERR, $line);
}
}
};
}
);
}
Argument Parsing
Declaring Options
use function mini\args;
args(args()
->withFlag('v', 'verbose') // -v or --verbose (countable)
->withFlag('h', null) // -h only
->withFlag(null, 'dry-run') // --dry-run only
->withRequiredValue('c', 'config') // -c FILE or --config=FILE
->withOptionalValue('l', 'log') // --log or --log=FILE
->withOptionalValue('o', 'out', '/dev/stdout') // with default
->withSubcommand('run', 'build', 'test') // declared subcommands
);
Reading Values
// Flags return count (0 if not present)
$verbose = args()->getFlag('verbose'); // 0, 1, 2, etc.
$veryVerbose = args()->getFlag('verbose') >= 2;
// Options return string|array|false|null
$config = args()->getOption('config');
// null = not present
// false = --config without value (if optional)
// "value" = --config=value
// ["a", "b"] = --config=a --config=b
// Check presence
if (args()->hasOption('config')) {
// --config was passed (with or without value)
}
// Unparsed args (positional or unknown)
$files = args()->getUnparsedArgs(); // ['file1.txt', 'file2.txt']
Error Handling
Always check for unexpected arguments:
if (args()->getUnparsedArgs()) {
fwrite(STDERR, "Unexpected: " . implode(', ', args()->getUnparsedArgs()) . "\n");
exit(1);
}
Subcommands
ArgManager handles subcommands by stopping at the first declared subcommand it encounters. The pattern is recursive: each level declares its own flags/options and valid subcommands.
How it works:
- Configure the root ArgManager with global flags and declare valid subcommands
- Check
getUnparsedArgs()- any unknown arguments indicate a syntax error - Call
nextCommand()to get a fresh ArgManager positioned at the subcommand - Configure the new ArgManager with subcommand-specific options
- Repeat check for unparsed args
Main Command
#!/usr/bin/env php
<?php
// bin/myapp
require_once __DIR__ . '/../vendor/autoload.php';
use function mini\args;
// Step 1: Configure root with global flags and valid subcommands
args(args()
->withFlag('v', 'verbose')
->withOptionalValue('l', 'log')
->withSubcommand('check', 'list', 'dismiss')
);
// Step 2: Check for unknown options/arguments at this level
if (args()->getUnparsedArgs()) {
fwrite(STDERR, "Unknown: " . implode(', ', args()->getUnparsedArgs()) . "\n");
exit(1);
}
// Extract global options before handing off
$verbosity = args()->getFlag('verbose');
$logFile = args()->getOption('log') ?: null;
configure_logger($verbosity, $logFile);
// Step 3: Get subcommand and hand off
if ($sub = args()->nextCommand()) {
args($sub); // Replace global args() with subcommand context
match (args()->getCommand()) {
'check' => require __DIR__ . '/commands/check.php',
'list' => require __DIR__ . '/commands/list.php',
'dismiss' => require __DIR__ . '/commands/dismiss.php',
};
} else {
echo "Usage: myapp [-v] [--log=FILE] <check|list|dismiss> [options]\n";
}
Subcommand File
The subcommand file receives a fresh ArgManager context. It declares its own options and validates them independently:
<?php
// bin/commands/check.php
use function mini\args;
// Step 4: Configure subcommand-specific options
args(args()
->withFlag(null, 'dry-run')
->withRequiredValue('r', 'repo')
);
// Step 5: Check for unknown options at subcommand level
if (args()->getUnparsedArgs()) {
fwrite(STDERR, "Unknown: " . implode(', ', args()->getUnparsedArgs()) . "\n");
exit(1);
}
$dryRun = args()->getFlag('dry-run') > 0;
$repo = args()->getOption('repo');
if (!$repo) {
fwrite(STDERR, "Error: --repo is required\n");
exit(1);
}
\mini\log()->info("Checking repo: {repo}", ['repo' => $repo]);
echo "Checking repo: $repo\n";
if ($dryRun) {
echo "[dry-run mode]\n";
}
Example invocations:
myapp check --repo=foo # verbosity=0, no log file
myapp -v check --repo=foo # verbosity=1, warnings to stderr
myapp -vv check --repo=foo # verbosity=2, info to stderr
myapp -vvv --log=app.log check # verbosity=3, debug to stderr + file
Output Formatting
TTY Table Output
Mini includes a TTY class for formatted output:
use mini\CLI\TTY;
$data = [
['name' => 'Alice', 'score' => 95, 'grade' => 'A'],
['name' => 'Bob', 'score' => 87, 'grade' => 'B'],
['name' => 'Carol', 'score' => 92, 'grade' => 'A'],
];
// Automatic table formatting
echo TTY::table($data);
// JSON output
echo TTY::json($data, pretty: true);
// CSV output
echo TTY::csv($data);
ANSI Colors
For simple coloring without dependencies:
$red = "\033[31m";
$green = "\033[32m";
$yellow = "\033[33m";
$reset = "\033[0m";
echo "{$red}Error:{$reset} Something went wrong\n";
echo "{$green}Success:{$reset} Operation completed\n";
echo "{$yellow}[warning]{$reset} Check your config\n";
Complete Example
Here's a monitoring tool that demonstrates verbosity levels and optional file logging:
#!/usr/bin/env php
<?php
/**
* Repository Monitor
*
* Usage: ./bin/monitor [-v|-vv|-vvv] [--log=FILE] [--dry-run] [--repo=OWNER/REPO]
*
* Verbosity:
* (none) Show errors only
* -v Also show warnings
* -vv Also show info messages
* -vvv Also show debug output
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use function mini\args;
// Parse arguments
args(args()
->withFlag('v', 'verbose')
->withFlag(null, 'dry-run')
->withOptionalValue('l', 'log')
->withOptionalValue('r', 'repo')
);
if (args()->getUnparsedArgs()) {
fwrite(STDERR, "Unknown: " . implode(', ', args()->getUnparsedArgs()) . "\n");
exit(1);
}
// Extract options
$verbosity = args()->getFlag('verbose'); // 0, 1, 2, or 3
$dryRun = args()->getFlag('dry-run') > 0;
$logFile = args()->getOption('log') ?: null;
$targetRepo = args()->getOption('repo') ?: null;
// Configure logger based on verbosity
configure_logger($verbosity, $logFile);
// Log start - only visible at verbosity >= 2
\mini\log()->info('Monitor started' . ($dryRun ? ' (dry-run)' : ''));
// Get configured repos
$repos = config()['repos'] ?? [];
if (empty($repos)) {
\mini\log()->error("No repos configured");
fwrite(STDERR, "No repos configured in data/config.json\n");
exit(1);
}
// Filter to specific repo if requested
if ($targetRepo) {
$repos = array_filter($repos, fn($r) => $r['name'] === $targetRepo);
if (empty($repos)) {
\mini\log()->error("Repo not found: {repo}", ['repo' => $targetRepo]);
fwrite(STDERR, "Repo not found: $targetRepo\n");
exit(1);
}
}
$total = 0;
$alerts = 0;
foreach ($repos as $repo) {
\mini\log()->debug("Processing repo: {name}", ['name' => $repo['name']]);
echo "Checking {$repo['name']}...\n";
// ... check logic here ...
$found = 5;
$needsAttention = 2;
\mini\log()->info("Found {found} items, {attention} need attention", [
'found' => $found,
'attention' => $needsAttention
]);
if ($needsAttention > 0) {
\mini\log()->warning("Repo {name} has items needing attention", [
'name' => $repo['name']
]);
}
if (!$dryRun) {
// ... create alerts, update state ...
\mini\log()->debug("Created alerts for {name}", ['name' => $repo['name']]);
}
$total += $found;
$alerts += $needsAttention;
}
echo "\nDone. Checked $total items, created $alerts alerts.\n";
\mini\log()->info("Monitor complete: {total} checked, {alerts} alerts", [
'total' => $total,
'alerts' => $alerts
]);
Example output at different verbosity levels:
# Silent except errors
$ ./bin/monitor
Checking repo-a...
Checking repo-b...
Done. Checked 10 items, created 4 alerts.
# With warnings (-v)
$ ./bin/monitor -v
Checking repo-a...
[2025-01-15 10:30:00] [WARNING] Repo repo-a has items needing attention
Checking repo-b...
[2025-01-15 10:30:00] [WARNING] Repo repo-b has items needing attention
Done. Checked 10 items, created 4 alerts.
# With info (-vv) and log file
$ ./bin/monitor -vv --log=monitor.log
[2025-01-15 10:30:00] [INFO] Monitor started
Checking repo-a...
[2025-01-15 10:30:00] [INFO] Found 5 items, 2 need attention
[2025-01-15 10:30:00] [WARNING] Repo repo-a has items needing attention
...
Best Practices
-
Always validate unparsed args - Catch typos and unknown options early. An unknown option is a syntax error.
-
Use dry-run for destructive operations - Let users preview what will happen.
-
Support verbosity levels - Use
-v/-vv/-vvvto control log output to stderr. Errors always show; warnings at-v; info at-vv; debug at-vvv. -
Make file logging opt-in - Use
--log=FILEfor persistent logs. By default, CLI tools should only output to stderr. -
Exit with proper codes -
exit(0)for success,exit(1)for errors. -
Write to stderr for errors and logs - Keep stdout clean for data output that can be piped.
-
Use
mini\log()with levels -error()for failures,warning()for issues,info()for operations,debug()for troubleshooting. The verbosity flag controls which levels appear on stderr. -
Use singleton accessors - The static variable pattern avoids global state while maintaining single instances.
-
Keep bin scripts thin - Put logic in src/ classes, keep bin/ as glue code.