Browse Source

initial commit for Michel PHP Console v1

michelphp 1 day ago
commit
a9b22f28af

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+/vendor/
+/.idea
+composer.lock

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 MICHEL
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 1245 - 0
README.md

@@ -0,0 +1,1245 @@
+# Michel PHP Console
+
+A lightweight PHP library designed to simplify command handling in console applications. This library is dependency-free and focused on providing a streamlined and efficient solution for building PHP CLI tools.
+## Installation
+
+You can install this library via [Composer](https://getcomposer.org/). Ensure your project meets the minimum PHP version requirement of 7.4.
+
+```bash
+composer require michel/console
+```
+## Requirements
+
+- PHP version 7.4 or higher
+
+## Table of Contents
+
+1. [Setup in a PHP Application](#setup-in-a-php-application)
+2. [Creating a Command](#creating-a-command)
+3. [Defining Arguments and Options](#defining-arguments-and-options)
+4. [Handling Different Output Types](#handling-different-output-types)
+
+
+I attempted to rewrite the chapter "Setup in a PHP Application" in English while updating the document, but the update failed due to an issue with the pattern-matching process. Let me fix the issue manually. Here's the rewritten content in English:
+
+---
+
+## Setup in a PHP Application
+
+To use this library in any PHP application (Symfony, Laravel, Slim, or others), first create a `bin` directory in your project. Then, add a script file, for example, `bin/console`, with the following content:
+
+```php
+#!/usr/bin/php
+<?php
+
+use Michel\Console\CommandParser;
+use Michel\Console\CommandRunner;
+use Michel\Console\Output;
+
+set_time_limit(0);
+
+if (file_exists(dirname(__DIR__) . '/../../autoload.php')) {
+    require dirname(__DIR__) . '/../../autoload.php';
+} elseif (file_exists(dirname(__DIR__) . '/vendor/autoload.php')) {
+    require dirname(__DIR__) . '/vendor/autoload.php';
+} else {
+    die(
+        'You need to set up the project dependencies using the following commands:' . PHP_EOL .
+        'curl -sS https://getcomposer.org/installer | php' . PHP_EOL .
+        'php composer.phar install' . PHP_EOL
+    );
+}
+
+
+// For modern frameworks using containers and bootstrapping (e.g., Kernel or App classes),
+// make sure to retrieve the CommandRunner from the container after booting the application.
+// Example for Symfony:
+//
+// $kernel = new Kernel('dev', true);
+// $kernel->boot();
+// $container = $kernel->getContainer();
+// $app = $container->get(CommandRunner::class);
+
+
+$app = new CommandRunner([]);
+$exitCode = $app->run(new CommandParser(), new Output());
+exit($exitCode);
+```
+
+By convention, the file is named `bin/console`, but you can choose any name you prefer. This script serves as the main entry point for your CLI commands. Make sure the file is executable by running the following command:
+
+```bash
+chmod +x bin/console
+```
+
+## Creating a Command
+
+To add a command to your application, you need to create a class that implements the `CommandInterface` interface. Here is an example implementation for a command called `send-email`:
+
+```php
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\OutputInterface;
+
+class SendEmailCommand implements CommandInterface
+{
+    public function getName(): string
+    {
+        return 'send-email';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Sends an email to the specified recipient with an optional subject.';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            new CommandOption('subject', 's', 'The subject of the email', false),
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [
+            new CommandArgument('recipient', true, null, 'The email address of the recipient'),
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        // Validate and retrieve the recipient email
+        if (!$input->hasArgument('recipient')) {
+            $output->writeln('Error: The recipient email is required.');
+            return;
+        }
+        $recipient = $input->getArgumentValue('recipient');
+
+        // Validate email format
+        if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
+            $output->writeln('Error: The provided email address is not valid.');
+            return;
+        }
+
+        // Retrieve the subject option (if provided)
+        $subject = $input->hasOption('subject') ? $input->getOptionValue('subject') : 'No subject';
+
+        // Simulate email sending
+        $output->writeln('Sending email...');
+        $output->writeln('Recipient: ' . $recipient);
+        $output->writeln('Subject: ' . $subject);
+        $output->writeln('Email sent successfully!');
+    }
+}
+```
+
+###  Registering the Command in `CommandRunner`
+
+After creating your command, you need to register it in the `CommandRunner` so that it can be executed. Here is an example of how to register the `SendEmailCommand`:
+
+```php
+use Michel\Console\CommandRunner;
+
+$app = new CommandRunner([
+    new SendEmailCommand()
+]);
+
+```
+
+The `CommandRunner` takes an array of commands as its parameter. Each command should be an instance of a class that implements the `CommandInterface`. Once registered, the command can be called from the console.
+
+### Example Usage in the Terminal
+
+1. **Command without a subject option:**
+
+   ```bash
+   bin/console send-email john.doe@example.com
+   ```
+
+   **Output:**
+   ```
+   Sending email...
+   Recipient: john.doe@example.com
+   Subject: No subject
+   Email sent successfully!
+   ```
+
+2. **Command with a subject option:**
+
+   ```bash
+   bin/console send-email john.doe@example.com --subject "Meeting Reminder"
+   ```
+
+   **Output:**
+   ```
+   Sending email...
+   Recipient: john.doe@example.com
+   Subject: Meeting Reminder
+   Email sent successfully!
+   ```
+
+3. **Command with an invalid email format:**
+
+   ```bash
+   bin/console send-email invalid-email
+   ```
+
+   **Output:**
+   ```
+   Error: The provided email address is not valid.
+   ```
+
+4. **Command without the required argument:**
+
+   ```bash
+   bin/console send-email
+   ```
+
+   **Output:**
+   ```
+   Error: The recipient email is required.
+   ```
+
+---
+
+### Explanation of the Features Used
+
+1. **Required Arguments**:
+    - The `recipient` argument is mandatory. If missing, the command displays an error.
+
+2. **Optional Options**:
+    - The `--subject` option allows defining the subject of the email. If not specified, a default value ("No subject") is used.
+
+3. **Simple Validation**:
+    - The email address is validated using `filter_var` to ensure it has a valid format.
+
+4. **User Feedback**:
+    - Clear and simple messages are displayed to guide the user during the command execution.
+
+## Defining Arguments and Options
+
+Arguments and options allow developers to customize the behavior of a command based on the parameters passed to it. These concepts are managed by the `CommandArgument` and `CommandOption` classes, respectively.
+
+---
+
+### 1 Arguments
+
+An **argument** is a positional parameter passed to a command. For example, in the following command:
+
+```bash
+bin/console send-email recipient@example.com
+```
+
+`recipient@example.com` is an argument. Arguments are defined using the `CommandArgument` class. Here are the main properties of an argument:
+
+- **Name (`name`)**: The unique name of the argument.
+- **Required (`isRequired`)**: Indicates whether the argument is mandatory.
+- **Default Value (`defaultValue`)**: The value used if no argument is provided.
+- **Description (`description`)**: A brief description of the argument, useful for help messages.
+
+##### Example of defining an argument:
+
+```php
+use Michel\Console\Argument\CommandArgument;
+
+new CommandArgument(
+    'recipient', // The name of the argument
+    true,        // The argument is required
+    null,        // No default value
+    'The email address of the recipient' // Description
+);
+```
+
+If a required argument is not provided, an exception is thrown.
+
+---
+
+### 2 Options
+
+An **option** is a named parameter, often prefixed with a double dash (`--`) or a shortcut (`-`). For example, in the following command:
+
+```bash
+bin/console send-email recipient@example.com --subject "Meeting Reminder"
+```
+
+`--subject` is an option. Options are defined using the `CommandOption` class. Here are the main properties of an option:
+
+- **Name (`name`)**: The full name of the option, used with `--`.
+- **Shortcut (`shortcut`)**: A short alias, used with a single dash (`-`).
+- **Description (`description`)**: A brief description of the option, useful for help messages.
+- **Flag (`isFlag`)**: Indicates whether the option is a simple flag (present or absent) or if it accepts a value.
+
+##### Example of defining an option:
+
+```php
+use Michel\Console\Option\CommandOption;
+
+new CommandOption(
+    'subject',    // The name of the option
+    's',          // Shortcut
+    'The subject of the email', // Description
+    false         // Not a flag, expects a value
+);
+
+new CommandOption(
+    'verbose',    // The name of the option
+    'v',          // Shortcut
+    'Enable verbose output', // Description
+    true          // This is a flag
+);
+```
+
+An option with a flag does not accept a value; its mere presence indicates that it is enabled.
+
+---
+
+### 3 Usage in a Command
+
+In a command, arguments and options are defined by overriding the `getArguments()` and `getOptions()` methods from the `CommandInterface`.
+
+##### Example:
+
+```php
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Option\CommandOption;
+
+public function getArguments(): array
+{
+    return [
+        new CommandArgument('recipient', true, null, 'The email address of the recipient'),
+    ];
+}
+
+public function getOptions(): array
+{
+    return [
+        new CommandOption('subject', 's', 'The subject of the email', false),
+        new CommandOption('verbose', 'v', 'Enable verbose output', true),
+    ];
+}
+```
+
+In this example:
+- `recipient` is a required argument.
+- `--subject` (or `-s`) is an option that expects a value.
+- `--verbose` (or `-v`) is a flag option.
+
+---
+
+### 4 Validation and Management
+
+Arguments and options are automatically validated when the command is executed. For instance, if a required argument is missing or an attempt is made to access an undefined option, an exception will be thrown.
+
+The `InputInterface` allows you to retrieve these parameters in the `execute` method:
+- Arguments: `$input->getArgumentValue('recipient')`
+- Options: `$input->getOptionValue('subject')` or `$input->hasOption('verbose')`
+
+This ensures clear and consistent management of the parameters passed within your PHP project.
+
+## Handling Different Output Types
+
+Output management provides clear and useful information during command execution. Below is a practical example demonstrating output functionalities.
+
+### Example: Command `UserReportCommand`
+
+This command generates a report for a specific user and uses various output features.
+
+```php
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\Output\ConsoleOutput;
+use Michel\Console\OutputInterface;
+
+class UserReportCommand implements CommandInterface
+{
+    public function getName(): string
+    {
+        return 'user:report';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Generates a detailed report for a specific user.';
+    }
+
+    public function getArguments(): array
+    {
+        return [
+            new CommandArgument('user_id', true, null, 'The ID of the user to generate the report for'),
+        ];
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            new CommandOption('verbose', 'v', 'Enable verbose output', true),
+            new CommandOption('export', 'e', 'Export the report to a file', false),
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $console = new ConsoleOutput($output);
+
+        // Main title
+        $console->title('User Report Generation');
+
+        // Arguments and options
+        $userId = $input->getArgumentValue('user_id');
+        $verbose = $input->hasOption('verbose');
+        $export = $input->hasOption('export') ? $input->getOptionValue('export') : null;
+
+        $console->info("Generating report for User ID: $userId");
+
+        // Simulating user data retrieval
+        $console->spinner();
+        $userData = [
+            'name' => 'John Doe',
+            'email' => 'john.doe@example.com',
+            'active' => true,
+            'roles' => ['admin', 'user']
+        ];
+
+        if ($verbose) {
+            $console->success('User data retrieved successfully.');
+        }
+
+        // Displaying user data
+        $console->json($userData);
+
+        // Displaying user roles as a list
+        $console->listKeyValues([
+            'Name' => $userData['name'],
+            'Email' => $userData['email'],
+            'Active' => $userData['active'] ? 'Yes' : 'No',
+        ]);
+        $console->list($userData['roles']);
+
+        // Table of recent activities
+        $headers = ['ID', 'Activity', 'Timestamp'];
+        $rows = [
+            ['1', 'Login', '2024-12-22 12:00:00'],
+            ['2', 'Update Profile', '2024-12-22 12:30:00'],
+        ];
+        $console->table($headers, $rows);
+
+        // Progress bar
+        for ($i = 0; $i <= 100; $i += 20) {
+            $console->progressBar(100, $i);
+            usleep(500000);
+        }
+
+        // Final result
+        if ($export) {
+            $console->success("Report exported to: $export");
+```php
+use Michel\Console\Argument\CommandArgument;
+
+// Using the constructor
+new CommandArgument(
+    'recipient', // The name of the argument
+    true,        // The argument is required
+    null,        // No default value
+    'The email address of the recipient' // Description
+);
+
+// Using static methods (Recommended)
+CommandArgument::required('recipient', 'The email address of the recipient');
+CommandArgument::optional('recipient', null, 'The email address of the recipient');
+```
+
+If a required argument is not provided, an exception is thrown.
+
+---
+
+### 2 Options
+
+An **option** is a named parameter, often prefixed with a double dash (`--`) or a shortcut (`-`). For example, in the following command:
+
+```bash
+bin/console send-email recipient@example.com --subject "Meeting Reminder"
+```
+
+`--subject` is an option. Options are defined using the `CommandOption` class. Here are the main properties of an option:
+
+##### Example of defining an option:
+
+```php
+use Michel\Console\Option\CommandOption;
+
+new CommandOption(
+    'subject',    // The name of the option
+    's',          // Shortcut
+    'The subject of the email', // Description
+    false         // Not a flag, expects a value
+);
+```
+
+An option with a flag does not accept a value; its mere presence indicates that it is enabled.
+
+---
+
+### 3 Usage in a Command
+
+In a command, arguments and options are defined by overriding the `getArguments()` and `getOptions()` methods from the `CommandInterface`.
+
+##### Example:
+
+```php
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Option\CommandOption;
+
+public function getArguments(): array
+{
+    return [
+        new CommandArgument('recipient', true, null, 'The email address of the recipient'),
+    ];
+}
+
+public function getOptions(): array
+{
+    return [
+        new CommandOption('subject', 's', 'The subject of the email', false),
+    ];
+}
+```
+
+In this example:
+- `recipient` is a required argument.
+- `--subject` (or `-s`) is an option that expects a value.
+
+> [!NOTE]
+> **Global Options**: The options `--help` (`-h`) and `--verbose` (`-v`) are **globally available** for all commands. You **must not** define them manually in your commands, as they are automatically handled by the application.
+
+---
+
+### 4 Validation and Management
+
+Arguments and options are automatically validated when the command is executed. For instance, if a required argument is missing or an attempt is made to access an undefined option, an exception will be thrown.
+
+The `InputInterface` allows you to retrieve these parameters in the `execute` method:
+- Arguments: `$input->getArgumentValue('recipient')`
+- Options: `$input->getOptionValue('subject')` or `$input->hasOption('verbose')`
+
+This ensures clear and consistent management of the parameters passed within your PHP project.
+
+## Handling Different Output Types
+
+Output management provides clear and useful information during command execution. Below is a practical example demonstrating output functionalities.
+
+### Example: Command `UserReportCommand`
+
+This command generates a report for a specific user and uses various output features.
+
+```php
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\Output\ConsoleOutput;
+use Michel\Console\OutputInterface;
+
+class UserReportCommand implements CommandInterface
+{
+    public function getName(): string
+    {
+        return 'user:report';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Generates a detailed report for a specific user.';
+    }
+
+    public function getArguments(): array
+    {
+        return [
+            new CommandArgument('user_id', true, null, 'The ID of the user to generate the report for'),
+        ];
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            new CommandOption('export', 'e', 'Export the report to a file', false),
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $console = new ConsoleOutput($output);
+
+        // Main title
+        $console->title('User Report Generation');
+
+        // Arguments and options
+        $userId = $input->getArgumentValue('user_id');
+        $verbose = $input->hasOption('verbose'); // Available globally
+        $export = $input->hasOption('export') ? $input->getOptionValue('export') : null;
+
+        $console->info("Generating report for User ID: $userId");
+
+        // Simulating user data retrieval
+        $console->spinner();
+        $userData = [
+            'name' => 'John Doe',
+            'email' => 'john.doe@example.com',
+            'active' => true,
+            'roles' => ['admin', 'user']
+        ];
+
+        if ($verbose) {
+            $console->debug('User data retrieved successfully.');
+        }
+
+        // Displaying user data
+        $console->json($userData);
+
+        // Displaying user roles as a list
+        $console->listKeyValues([
+            'Name' => $userData['name'],
+            'Email' => $userData['email'],
+            'Active' => $userData['active'] ? 'Yes' : 'No',
+        ]);
+        $console->list($userData['roles']);
+
+        // Table of recent activities
+        $headers = ['ID', 'Activity', 'Timestamp'];
+        $rows = [
+            ['1', 'Login', '2024-12-22 12:00:00'],
+            ['2', 'Update Profile', '2024-12-22 12:30:00'],
+        ];
+        $console->table($headers, $rows);
+
+        // Progress bar
+        for ($i = 0; $i <= 100; $i += 20) {
+            $console->progressBar(100, $i);
+            usleep(500000);
+        }
+
+        // Final result
+        if ($export) {
+            $console->success("Report exported to: $export");
+```php
+use Michel\Console\Argument\CommandArgument;
+
+// Using the constructor
+new CommandArgument(
+    'recipient', // The name of the argument
+    true,        // The argument is required
+    null,        // No default value
+    'The email address of the recipient' // Description
+);
+
+// Using static methods (Recommended)
+CommandArgument::required('recipient', 'The email address of the recipient');
+CommandArgument::optional('recipient', null, 'The email address of the recipient');
+```
+
+If a required argument is not provided, an exception is thrown.
+
+---
+
+### 2 Options
+
+An **option** is a named parameter, often prefixed with a double dash (`--`) or a shortcut (`-`). For example, in the following command:
+
+```bash
+bin/console send-email recipient@example.com --subject "Meeting Reminder"
+```
+
+`--subject` is an option. Options are defined using the `CommandOption` class. Here are the main properties of an option:
+
+-   **Name (`name`)**: The full name of the option, used with `--`.
+-   **Shortcut (`shortcut`)**: A short alias, used with a single dash (`-`).
+-   **Description (`description`)**: A brief description of the option, useful for help messages.
+-   **Flag (`isFlag`)**: Indicates whether the option is a simple flag (present or absent) or if it accepts a value.
+
+##### Example of defining an option:
+
+```php
+use Michel\Console\Option\CommandOption;
+
+// Using the constructor
+new CommandOption(
+    'subject',    // The name of the option
+    's',          // Shortcut
+    'The subject of the email', // Description
+    false         // Not a flag, expects a value
+);
+
+// Using static methods (Recommended)
+CommandOption::withValue('subject', 's', 'The subject of the email');
+```
+
+An option with a flag does not accept a value; its mere presence indicates that it is enabled.
+
+---
+
+### 3 Usage in a Command
+
+In a command, arguments and options are defined by overriding the `getArguments()` and `getOptions()` methods from the `CommandInterface`.
+
+##### Example:
+
+```php
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Option\CommandOption;
+
+public function getArguments(): array
+{
+    return [
+        CommandArgument::required('recipient', 'The email address of the recipient'),
+    ];
+}
+
+public function getOptions(): array
+{
+    return [
+        CommandOption::withValue('subject', 's', 'The subject of the email'),
+    ];
+}
+```
+
+In this example:
+- `recipient` is a required argument.
+- `--subject` (or `-s`) is an option that expects a value.
+
+> [!NOTE]
+> **Global Options**: The options `--help` (`-h`) and `--verbose` (`-v`) are **globally available** for all commands. You **must not** define them manually in your commands, as they are automatically handled by the application.
+
+---
+
+### 4 Validation and Management
+
+Arguments and options are automatically validated when the command is executed. For instance, if a required argument is missing, an exception will be thrown.
+
+The `InputInterface` allows you to retrieve these parameters in the `execute` method:
+- Arguments: `$input->getArgumentValue('recipient')` (Throws exception if missing and required)
+- Options: `$input->getOptionValue('subject')` (Returns `null` if not present) or `$input->hasOption('verbose')`
+
+This ensures clear and consistent management of the parameters passed within your PHP project.
+
+## Handling Different Output Types
+
+Output management provides clear and useful information during command execution. Below is a practical example demonstrating output functionalities.
+
+### Example: Command `UserReportCommand`
+
+This command generates a report for a specific user and uses various output features.
+
+```php
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\Output\ConsoleOutput;
+use Michel\Console\OutputInterface;
+
+class UserReportCommand implements CommandInterface
+{
+    public function getName(): string
+    {
+        return 'user:report';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Generates a detailed report for a specific user.';
+    }
+
+    public function getArguments(): array
+    {
+        return [
+            CommandArgument::required('user_id', 'The ID of the user to generate the report for'),
+        ];
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            CommandOption::withValue('export', 'e', 'Export the report to a file'),
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $console = new ConsoleOutput($output);
+
+        // Main title
+        $console->title('User Report Generation');
+
+        // Arguments and options
+        $userId = $input->getArgumentValue('user_id');
+        $verbose = $input->hasOption('verbose'); // Available globally
+        $export = $input->getOptionValue('export'); // Returns null if not set
+
+        $console->info("Generating report for User ID: $userId");
+
+        // Simulating user data retrieval
+        $console->spinner();
+        $userData = [
+            'name' => 'John Doe',
+            'email' => 'john.doe@example.com',
+            'active' => true,
+            'roles' => ['admin', 'user']
+        ];
+
+        if ($verbose) {
+            $console->debug('User data retrieved successfully.');
+        }
+
+        // Displaying user data
+        $console->json($userData);
+
+        // Displaying user roles as a list
+        $console->listKeyValues([
+            'Name' => $userData['name'],
+            'Email' => $userData['email'],
+            'Active' => $userData['active'] ? 'Yes' : 'No',
+        ]);
+        $console->list($userData['roles']);
+
+        // Table of recent activities
+        $headers = ['ID', 'Activity', 'Timestamp'];
+        $rows = [
+            ['1', 'Login', '2024-12-22 12:00:00'],
+            ['2', 'Update Profile', '2024-12-22 12:30:00'],
+        ];
+        $console->table($headers, $rows);
+
+        // Progress bar
+        for ($i = 0; $i <= 100; $i += 20) {
+            $console->progressBar(100, $i);
+            usleep(500000);
+        }
+
+        // Final result
+        if ($export) {
+            $console->success("Report exported to: $export");
+        } else {
+            $console->success('Report generated successfully!');
+        }
+
+        // Confirmation for deletion
+        if ($console->confirm('Do you want to delete this user?')) {
+            $console->success('User deleted successfully.');
+        } else {
+            $console->warning('User deletion canceled.');
+        }
+    }
+}
+```
+
+#### Features Used
+
+1.  **Rich Messages**:
+    *   `success($message)`: Displays a success message.
+    *   `warning($message)`: Displays a warning message.
+    *   `error($message)`: Displays a critical error message.
+    *   `info($message)`: Displays an informational message.
+    *   `debug($message)`: Displays a debug message (only visible with `-v`).
+    *   `title($title)`: Displays a main title.
+
+2.  **Structured Output**:
+    *   `json($data)`: Displays data in JSON format.
+    *   `list($items)`: Displays a simple list.
+    *   `listKeyValues($data)`: Displays key-value pairs.
+    *   `table($headers, $rows)`: Displays tabular data.
+
+3.  **Progression and Interactivity**:
+    *   `progressBar($total, $current)`: Displays a progress bar.
+    *   `spinner()`: Displays a loading spinner.
+    *   `confirm($question)`: Prompts for user confirmation.
+
+### Verbosity and Error Handling
+
+-   **Global Verbosity**: The `-v` or `--verbose` flag is **globally available** for all commands. You do not need to define it. Passing it will enable `debug()` messages.
+-   **Error Handling**: The `error()` method writes messages to `STDERR`, allowing you to separate error output from standard output (useful for piping).
+
+---
+
+# Documentation en Français
+
+Une bibliothèque PHP légère conçue pour simplifier la gestion des commandes dans les applications console. Cette bibliothèque est sans dépendance et se concentre sur une solution rationalisée et efficace pour créer des outils CLI en PHP.
+
+## Installation
+
+Vous pouvez installer cette bibliothèque via [Composer](https://getcomposer.org/). Assurez-vous que votre projet respecte la version minimale de PHP requise (7.4).
+
+```bash
+composer require michel/console
+```
+
+## Prérequis
+
+-   Version PHP 7.4 ou supérieure
+
+## Table des Matières
+
+1.  [Configuration dans une Application PHP](#configuration-dans-une-application-php)
+2.  [Créer une Commande](#créer-une-commande)
+3.  [Définir des Arguments et des Options](#définir-des-arguments-et-des-options)
+4.  [Gérer les Différents Types de Sortie](#gérer-les-différents-types-de-sortie)
+
+---
+
+## Configuration dans une Application PHP
+
+Pour utiliser cette bibliothèque dans n'importe quelle application PHP (Symfony, Laravel, Slim ou autres), créez d'abord un répertoire `bin` dans votre projet. Ensuite, ajoutez un fichier de script, par exemple `bin/console`, avec le contenu suivant :
+
+```php
+#!/usr/bin/php
+<?php
+
+use Michel\Console\CommandParser;
+use Michel\Console\CommandRunner;
+use Michel\Console\Output;
+
+set_time_limit(0);
+
+if (file_exists(dirname(__DIR__) . '/../../autoload.php')) {
+    require dirname(__DIR__) . '/../../autoload.php';
+} elseif (file_exists(dirname(__DIR__) . '/vendor/autoload.php')) {
+    require dirname(__DIR__) . '/vendor/autoload.php';
+} else {
+    die(
+        'Vous devez installer les dépendances du projet avec les commandes suivantes :' . PHP_EOL .
+        'curl -sS https://getcomposer.org/installer | php' . PHP_EOL .
+        'php composer.phar install' . PHP_EOL
+    );
+}
+
+// Pour les frameworks modernes utilisant des conteneurs (ex: Kernel ou App classes),
+// assurez-vous de récupérer le CommandRunner depuis le conteneur après le démarrage de l'application.
+// Exemple pour Symfony :
+//
+// $kernel = new Kernel('dev', true);
+// $kernel->boot();
+// $container = $kernel->getContainer();
+// $app = $container->get(CommandRunner::class);
+
+
+$app = new CommandRunner([]);
+$exitCode = $app->run(new CommandParser(), new Output());
+exit($exitCode);
+```
+
+Par convention, le fichier est nommé `bin/console`, mais vous pouvez choisir le nom que vous préférez. Ce script sert de point d'entrée principal pour vos commandes CLI. Assurez-vous que le fichier est exécutable en lançant la commande suivante :
+
+```bash
+chmod +x bin/console
+```
+
+## Créer une Commande
+
+Pour ajouter une commande à votre application, vous devez créer une classe qui implémente l'interface `CommandInterface`. Voici un exemple d'implémentation pour une commande appelée `send-email` :
+
+```php
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\OutputInterface;
+
+class SendEmailCommand implements CommandInterface
+{
+    public function getName(): string
+    {
+        return 'send-email';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Envoie un email au destinataire spécifié avec un sujet optionnel.';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            CommandOption::withValue('subject', 's', "Le sujet de l'email"),
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [
+            CommandArgument::required('recipient', "L'adresse email du destinataire"),
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        // Valider et récupérer l'email du destinataire
+        if (!$input->hasArgument('recipient')) {
+            $output->writeln("Erreur : L'email du destinataire est requis.");
+            return;
+        }
+        $recipient = $input->getArgumentValue('recipient');
+
+        // Valider le format de l'email
+        if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
+            $output->writeln("Erreur : L'adresse email fournie n'est pas valide.");
+            return;
+        }
+
+        // Récupérer l'option sujet (si fournie)
+        $subject = $input->getOptionValue('subject') ?? 'Pas de sujet';
+
+        // Simuler l'envoi de l'email
+        $output->writeln("Envoi de l'email...");
+        $output->writeln('Destinataire : ' . $recipient);
+        $output->writeln('Sujet : ' . $subject);
+        $output->writeln('Email envoyé avec succès !');
+    }
+}
+```
+
+### Enregistrer la Commande dans `CommandRunner`
+
+Après avoir créé votre commande, vous devez l'enregistrer dans le `CommandRunner` pour qu'elle puisse être exécutée. Voici un exemple :
+
+```php
+use Michel\Console\CommandRunner;
+
+$app = new CommandRunner([
+    new SendEmailCommand()
+]);
+```
+
+Le `CommandRunner` prend un tableau de commandes en paramètre. Chaque commande doit être une instance d'une classe implémentant `CommandInterface`. Une fois enregistrée, la commande peut être appelée depuis la console.
+
+### Exemple d'Utilisation dans le Terminal
+
+1.  **Commande sans option sujet :**
+
+    ```bash
+    bin/console send-email john.doe@example.com
+    ```
+
+    **Sortie :**
+    ```
+    Envoi de l'email...
+    Destinataire : john.doe@example.com
+    Sujet : Pas de sujet
+    Email envoyé avec succès !
+    ```
+
+2.  **Commande avec option sujet :**
+
+    ```bash
+    bin/console send-email john.doe@example.com --subject "Rappel de Réunion"
+    ```
+
+    **Sortie :**
+    ```
+    Envoi de l'email...
+    Destinataire : john.doe@example.com
+    Sujet : Rappel de Réunion
+    Email envoyé avec succès !
+    ```
+
+3.  **Commande avec format d'email invalide :**
+
+    ```bash
+    bin/console send-email invalid-email
+    ```
+
+    **Sortie :**
+    ```
+    Erreur : L'adresse email fournie n'est pas valide.
+    ```
+
+## Définir des Arguments et des Options
+
+Les arguments et les options permettent aux développeurs de personnaliser le comportement d'une commande en fonction des paramètres passés. Ces concepts sont gérés respectivement par les classes `CommandArgument` et `CommandOption`.
+
+---
+
+### 1 Arguments
+
+Un **argument** est un paramètre positionnel passé à une commande. Par exemple :
+
+```bash
+bin/console send-email recipient@example.com
+```
+
+`recipient@example.com` est un argument. Les arguments sont définis via la classe `CommandArgument`. Voici les propriétés principales :
+
+-   **Nom (`name`)** : Le nom unique de l'argument.
+-   **Requis (`isRequired`)** : Indique si l'argument est obligatoire.
+-   **Valeur par défaut (`defaultValue`)** : La valeur utilisée si aucun argument n'est fourni.
+-   **Description (`description`)** : Une brève description de l'argument.
+
+##### Exemple de définition d'un argument :
+
+```php
+use Michel\Console\Argument\CommandArgument;
+
+// Via le constructeur
+new CommandArgument(
+    'recipient', // Le nom de l'argument
+    true,        // L'argument est requis
+    null,        // Pas de valeur par défaut
+    "L'adresse email du destinataire" // Description
+);
+
+// Via les méthodes statiques (Recommandé)
+CommandArgument::required('recipient', "L'adresse email du destinataire");
+CommandArgument::optional('recipient', null, "L'adresse email du destinataire");
+```
+
+---
+
+### 2 Options
+
+Une **option** est un paramètre nommé, souvent préfixé par un double tiret (`--`) ou un raccourci (`-`). Par exemple :
+
+```bash
+bin/console send-email recipient@example.com --subject "Rappel de Réunion"
+```
+
+`--subject` est une option. Les options sont définies via la classe `CommandOption`. Voici les propriétés principales :
+
+-   **Nom (`name`)** : Le nom complet de l'option.
+-   **Raccourci (`shortcut`)** : Un alias court.
+-   **Description (`description`)** : Une brève description.
+-   **Drapeau (`isFlag`)** : Indique si l'option est un simple drapeau (présent ou absent) ou si elle attend une valeur.
+
+##### Exemple de définition d'une option :
+
+```php
+use Michel\Console\Option\CommandOption;
+
+// Via le constructeur
+new CommandOption(
+    'subject',    // Le nom de l'option
+    's',          // Raccourci
+    "Le sujet de l'email", // Description
+    false         // Ce n'est pas un drapeau, attend une valeur
+);
+
+// Via les méthodes statiques (Recommandé)
+CommandOption::withValue('subject', 's', "Le sujet de l'email");
+```
+
+Une option avec un drapeau n'accepte pas de valeur ; sa simple présence indique qu'elle est activée.
+
+---
+
+### 3 Utilisation dans une Commande
+
+Dans une commande, les arguments et options sont définis en surchargeant les méthodes `getArguments()` et `getOptions()` de l'interface `CommandInterface`.
+
+##### Exemple :
+
+```php
+public function getArguments(): array
+{
+    return [
+        CommandArgument::required('recipient', "L'adresse email du destinataire"),
+    ];
+}
+
+public function getOptions(): array
+{
+    return [
+        CommandOption::withValue('subject', 's', "Le sujet de l'email"),
+    ];
+}
+```
+
+Dans cet exemple :
+- `recipient` est un argument requis.
+- `--subject` (ou `-s`) est une option qui attend une valeur.
+
+> [!NOTE]
+> **Options Globales** : Les options `--help` (`-h`) et `--verbose` (`-v`) sont **disponibles globalement** pour toutes les commandes. Vous **ne devez pas** les définir manuellement dans vos commandes, car elles sont gérées automatiquement par l'application.
+
+---
+
+### 4 Validation et Gestion
+
+Les arguments et options sont automatiquement validés lors de l'exécution de la commande. L'`InputInterface` permet de récupérer ces paramètres dans la méthode `execute` :
+-   Arguments : `$input->getArgumentValue('recipient')` (Lève une exception si manquant et requis)
+-   Options : `$input->getOptionValue('subject')` (Retourne `null` si absent) ou `$input->hasOption('verbose')`
+
+## Gérer les Différents Types de Sortie
+
+La gestion de la sortie fournit des informations claires et utiles pendant l'exécution de la commande.
+
+### Exemple : Commande `UserReportCommand`
+
+```php
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\Output\ConsoleOutput;
+use Michel\Console\OutputInterface;
+
+class UserReportCommand implements CommandInterface
+{
+    // ... (méthodes getName, getDescription, getArguments, getOptions comme ci-dessus)
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $console = new ConsoleOutput($output);
+
+        // Titre principal
+        $console->title('Génération du Rapport Utilisateur');
+
+        $userId = $input->getArgumentValue('user_id');
+        $verbose = $input->hasOption('verbose'); // Disponible globalement
+        $export = $input->getOptionValue('export'); // Retourne null si non défini
+
+        $console->info("Génération du rapport pour l'ID Utilisateur : $userId");
+
+        // Spinner
+        $console->spinner();
+
+        if ($verbose) {
+            $console->debug('Données utilisateur récupérées avec succès.');
+        }
+
+        // Affichage des données
+        $console->success('Rapport généré avec succès !');
+    }
+}
+```
+
+#### Fonctionnalités Utilisées
+
+1.  **Messages Riches** :
+    *   `success($message)` : Affiche un message de succès.
+    *   `warning($message)` : Affiche un message d'avertissement.
+    *   `error($message)` : Affiche un message d'erreur critique (sur STDERR).
+    *   `info($message)` : Affiche un message d'information.
+    *   `debug($message)` : Affiche un message de debug (visible uniquement avec `-v`).
+    *   `title($title)` : Affiche un titre principal.
+
+2.  **Sortie Structurée** :
+    *   `json($data)` : Affiche des données au format JSON.
+    *   `list($items)` : Affiche une liste simple.
+    *   `listKeyValues($data)` : Affiche des paires clé-valeur.
+    *   `table($headers, $rows)` : Affiche des données tabulaires.
+
+3.  **Progression et Interactivité** :
+    *   `progressBar($total, $current)` : Affiche une barre de progression.
+    *   `spinner()` : Affiche un spinner de chargement.
+    *   `confirm($question)` : Demande une confirmation à l'utilisateur.
+
+### Verbosité et Gestion des Erreurs
+
+-   **Verbosité Globale** : Le drapeau `-v` ou `--verbose` est **disponible globalement** pour toutes les commandes. Vous n'avez pas besoin de le définir. L'utiliser activera les messages `debug()`.(This line is already `debug`, so no change needed here, but I'll double check the context).
+-   **Gestion des Erreurs** : La méthode `error()` écrit les messages sur `STDERR`, ce qui permet de séparer la sortie d'erreur de la sortie standard (utile pour les pipes).
+
+---
+## Licence
+
+Cette bibliothèque est un logiciel open-source sous licence [MIT](LICENSE).

+ 26 - 0
composer.json

@@ -0,0 +1,26 @@
+{
+  "name": "michel/console",
+  "description": "A lightweight PHP library designed to simplify command handling in console applications.",
+  "type": "package",
+  "autoload": {
+    "psr-4": {
+      "Michel\\Console\\": "src",
+      "Test\\Michel\\Console\\": "tests"
+    }
+  },
+  "require": {
+    "php": ">=7.4",
+    "ext-mbstring": "*",
+    "ext-json": "*",
+    "ext-ctype": "*"
+  },
+  "require-dev": {
+    "michel/unitester": "^1.0.0"
+  },
+  "license": "MIT",
+  "authors": [
+    {
+      "name": "F. Michel"
+    }
+  ]
+}

+ 38 - 0
examples/commands.php

@@ -0,0 +1,38 @@
+<?php
+
+use Michel\Console\CommandParser;
+use Michel\Console\CommandRunner;
+use Michel\Console\Output;
+use Test\Michel\Console\Command\FooCommand;
+
+set_time_limit(0);
+
+if (file_exists(dirname(__DIR__) . '/../../autoload.php')) {
+    require dirname(__DIR__) . '/../../autoload.php';
+} elseif (file_exists(dirname(__DIR__) . '/vendor/autoload.php')) {
+    require dirname(__DIR__) . '/vendor/autoload.php';
+} else {
+    die(
+        'You need to set up the project dependencies using the following commands:' . PHP_EOL .
+        'curl -sS https://getcomposer.org/installer | php' . PHP_EOL .
+        'php composer.phar install' . PHP_EOL
+    );
+}
+
+// For modern frameworks using containers and bootstrapping (e.g., Kernel or App classes),
+// make sure to retrieve the CommandRunner from the container after booting the application.
+// Example for Symfony:
+//
+// $kernel = new Kernel('dev', true);
+// $kernel->boot();
+// $container = $kernel->getContainer();
+// $app = $container->get(CommandRunner::class);
+
+$runner = new CommandRunner([
+    new FooCommand(),
+]);
+$exitCode = $runner->run(new CommandParser(), new Output());
+exit($exitCode);
+
+
+

+ 85 - 0
examples/output.php

@@ -0,0 +1,85 @@
+<?php
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+use Michel\Console\Output;
+
+$output = new Output();
+$console = new Output\ConsoleOutput($output);
+
+$data = [
+    'name' => 'John Doe',
+    'email' => 'john.doe@example.com',
+    'active' => true,
+    'roles' => ['admin', 'user']
+];
+
+$console->json($data);
+//
+$console->spinner();
+
+$message = "This is a long message that needs to be automatically wrapped within the box, so it fits neatly without manual line breaks.";
+$console->boxed($message);
+$console->boxed($message, '.', 2);
+
+$console->success('The deployment was successful! All application components have been updated and the server is running the latest version. You can access the application and check if all services are functioning correctly.');
+$console->success('The deployment was successful! All application components have been updated and the server is running the latest version.');
+
+$console->warning('Warning: The connection to the remote server is unstable! Several attempts to establish a stable connection have failed, and data integrity cannot be guaranteed. Please check the network connection.');
+$console->warning('Warning: You are attempting to run a script with elevated privileges!');
+//
+
+$console->info('Info: Data export was completed successfully! All requested records have been exported to the specified format and location. You can now download the file or access it from the application dashboard.');
+$console->info('Info: Data export was completed successfully!');
+
+$console->error('Critical error encountered! The server encountered an unexpected condition that prevented it from fulfilling the request.');
+
+$console->title('My Application Title');
+
+$items = ['First item', 'Second item', 'Third item'];
+$console->list($items);
+$console->numberedList($items);
+
+
+$items = [
+    'Main Item 1',
+    ['Sub-item 1.1', 'Sub-item 1.2'],
+    'Main Item 2',
+    ['Sub-item 2.1', ['Sub-sub-item 2.1.1']]
+];
+$console->indentedList($items);
+$console->writeln('');
+$options = [
+    'Username' => 'admin',
+    'Password' => '******',
+    'Server' => 'localhost',
+    'Port' => '3306'
+];
+$console->listKeyValues($options);
+
+$headers = ['ID', 'Name', 'Status'];
+$rows = [
+    ['1', 'John Doe', 'Active'],
+    ['2', 'Jane Smith', 'Inactive'],
+    ['3', 'Emily Johnson', 'Active']
+];
+$console->table($headers, $rows);
+
+for ($i = 0; $i <= 100; $i++) {
+    $console->progressBar(100, $i);
+    usleep(8000); // Simulate some work being done
+}
+
+if ($console->confirm('Are you sure you want to proceed ?')) {
+    $console->success('Action confirmed.');
+} else {
+    $console->error('Action canceled.');
+}
+
+$name = $console->ask('What is your name ?');
+$console->success("Hello, $name!");
+
+$password = $console->ask('Enter your demo password', true, true);
+$console->success("Your password is: $password");
+
+$console->success('Done!');

+ 70 - 0
src/Argument/CommandArgument.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace Michel\Console\Argument;
+
+final class CommandArgument
+{
+    private string $name;
+    private bool $isRequired;
+    private $defaultValue;
+    private ?string $description;
+
+    public  function __construct(string $name, bool $isRequired = false, $defaultValue = null, ?string $description = null)
+    {
+        if ($name === '') {
+            throw new \InvalidArgumentException("Option name cannot be empty.");
+        }
+        if (!ctype_alpha($name)) {
+            throw new \InvalidArgumentException("Option name must contain only letters. '$name' is invalid.");
+        }
+
+        if ($isRequired && $defaultValue !== null) {
+            throw new \LogicException("Argument '$name' cannot be required and have a default value.");
+        }
+
+        $this->name = strtolower($name);
+        $this->isRequired = $isRequired;
+        $this->defaultValue = $defaultValue;
+        $this->description = $description;
+    }
+
+    public function validate($value): void
+    {
+        if ($this->isRequired && empty($value)) {
+            throw new \InvalidArgumentException(sprintf('The required argument "%s" was not provided.', $this->name));
+        }
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function isRequired(): bool
+    {
+        return $this->isRequired;
+    }
+
+    /**
+     * @return mixed|null
+     */
+    public function getDefaultValue()
+    {
+        return $this->defaultValue;
+    }
+
+    public function getDescription(): ?string
+    {
+        return $this->description;
+    }
+
+    public static function required(string $name, ?string $description = null): self
+    {
+        return new self($name, true, null, $description);
+    }
+
+    public static function optional(string $name, $default = null, ?string $description = null): self
+    {
+        return new self($name, false, $default, $description);
+    }
+}

+ 50 - 0
src/Command/CommandInterface.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace Michel\Console\Command;
+
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\OutputInterface;
+
+interface CommandInterface
+{
+    /**
+     * Returns the name of the command.
+     *
+     * @return string The name of the command.
+     */
+    public function getName(): string;
+
+    /**
+     * Returns the description of the command.
+     *
+     * @return string The description of the command.
+     */
+    public function getDescription(): string;
+
+    /**
+     * Returns the list of available options for the command.
+     *
+     * @return array<CommandOption> An array of CommandOption.
+     */
+    public function getOptions(): array;
+
+    /**
+     * Returns the list of required arguments for the command.
+     *
+     * @return array<CommandArgument> An array of CommandArgument.
+     */
+    public function getArguments(): array;
+
+    /**
+     * Executes the command with the provided inputs.
+     *
+     * @param InputInterface $input The inputs for the command.
+     * @param OutputInterface $output
+     * @return void
+     * @throws \InvalidArgumentException If arguments or options are invalid.
+     * @throws \RuntimeException If an error occurs during execution.
+     */
+    public function execute(InputInterface $input, OutputInterface $output): void;
+}

+ 83 - 0
src/Command/HelpCommand.php

@@ -0,0 +1,83 @@
+<?php
+
+
+namespace Michel\Console\Command;
+
+
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\Output;
+use Michel\Console\OutputInterface;
+
+class HelpCommand implements CommandInterface
+{
+    /**
+     * @var CommandInterface[]
+     */
+    private array $commands;
+
+    public function getName(): string
+    {
+        return 'help';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Displays a list of available commands and their descriptions.';
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $io = new Output\ConsoleOutput($output);
+        $io->title('Michel Console - A PHP Console Application');
+
+        $io->writeColor('Usage:', 'yellow');
+        $io->write(\PHP_EOL);
+        $io->write('  command [options] [arguments]');
+        $io->write(\PHP_EOL);
+        $io->write(\PHP_EOL);
+
+
+        $io->writeColor('List of Available Commands:', 'yellow');
+        $io->write(\PHP_EOL);
+        $commands = [];
+        foreach ($this->commands as $command) {
+            $commands[$command->getName()] = $command->getDescription();
+        }
+        $io->listKeyValues($commands, true);
+
+        $io->writeColor('Options:', 'yellow');
+        $io->write(PHP_EOL);
+        $options = [];
+        foreach ([new CommandOption('help', 'h', 'Display this help message.', true), new CommandOption('verbose', 'v', 'Enable verbose output', true)] as $option) {
+            $name = sprintf('--%s', $option->getName());
+            if ($option->getShortcut() !== null) {
+                $name = sprintf('-%s, --%s', $option->getShortcut(), $option->getName());
+            }
+
+            if (!$option->isFlag()) {
+                $name = sprintf('%s=VALUE', $name);
+            }
+            $options[$name] = $option->getDescription();
+        }
+        $io->listKeyValues($options, true);
+    }
+
+    /**
+     * @param CommandInterface[] $commands
+     */
+    public function setCommands(array $commands)
+    {
+        $this->commands = $commands;
+    }
+
+    public function getOptions(): array
+    {
+        return [];
+    }
+
+    public function getArguments(): array
+    {
+        return [];
+    }
+}

+ 100 - 0
src/CommandParser.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace Michel\Console;
+
+use InvalidArgumentException;
+use function strlen;
+use function strncmp;
+
+final class CommandParser
+{
+    /**
+     * @var string|null
+     */
+    private ?string $cmdName;
+    private array $options = [];
+    private array $arguments = [];
+
+    public function __construct(?array $argv = null)
+    {
+        $argv = $argv ?? $_SERVER['argv'] ?? [];
+        array_shift($argv);
+        $this->cmdName = $argv[0] ?? null;
+
+        $ignoreKeys = [0];
+        foreach ($argv as $key => $value) {
+            if (in_array($key, $ignoreKeys, true)) {
+                continue;
+            }
+
+            if (self::startsWith($value, '--')) {
+                $it = explode("=", ltrim($value, '-'), 2);
+                $optionName = $it[0];
+                $optionValue = $it[1] ?? true;
+                $this->options[$optionName] = $optionValue;
+            } elseif (self::startsWith($value, '-')) {
+                $optionName = ltrim($value, '-');
+                if (strlen($optionName) > 1) {
+                    $options = str_split($optionName);
+                    foreach ($options as $option) {
+                        $this->options[$option] = true;
+                    }
+                } else {
+                    $this->options[$optionName] = true;
+                    if (isset($argv[$key + 1]) && !self::startsWith($argv[$key + 1], '-')) {
+                        $ignoreKeys[] = $key + 1;
+                        $this->options[$optionName] = $argv[$key + 1];
+                    }
+                }
+            } else {
+                $this->arguments[] = $value;
+            }
+        }
+    }
+
+    public function getCommandName(): ?string
+    {
+        return $this->cmdName;
+    }
+
+    public function getOptions(): array
+    {
+        return $this->options;
+    }
+
+    public function getArguments(): array
+    {
+        return $this->arguments;
+    }
+
+    public function hasOption(string $name): bool
+    {
+        return array_key_exists($name, $this->options);
+    }
+
+    public function getOptionValue(string $name)
+    {
+        if (!$this->hasOption($name)) {
+            throw new InvalidArgumentException(sprintf('Option "%s" is not defined.', $name));
+        }
+        return $this->options[$name];
+    }
+
+    public function getArgumentValue(string $name)
+    {
+        if (!$this->hasArgument($name)) {
+            throw new InvalidArgumentException(sprintf('Argument "%s" is not defined.', $name));
+        }
+        return $this->arguments[$name];
+    }
+
+    public function hasArgument(string $name): bool
+    {
+        return array_key_exists($name, $this->arguments);
+    }
+
+    private static function startsWith(string $haystack, string $needle): bool
+    {
+        return strncmp($haystack, $needle, strlen($needle)) === 0;
+    }
+}

+ 228 - 0
src/CommandRunner.php

@@ -0,0 +1,228 @@
+<?php
+
+namespace Michel\Console;
+
+use InvalidArgumentException;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\Command\HelpCommand;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\Output\ConsoleOutput;
+use Throwable;
+use const PHP_EOL;
+
+final class CommandRunner
+{
+    const CLI_ERROR = 1;
+    const CLI_SUCCESS = 0;
+    public const CLI_COMMAND_NOT_FOUND = 10;    // Aucune commande trouvée
+    public const CLI_INVALID_ARGUMENTS = 11;    // Arguments invalides
+    public const CLI_AMBIGUOUS_COMMAND = 12;    // Plusieurs correspondances possibles
+
+    /**
+     * @var CommandInterface[]
+     */
+    private array $commands = [];
+
+    private HelpCommand $defaultCommand;
+
+    /**
+     * Application constructor.
+     * @param CommandInterface[] $commands
+     */
+    public function __construct(array $commands)
+    {
+        $this->defaultCommand = new HelpCommand();
+        foreach ($commands as $command) {
+            if (!is_subclass_of($command, CommandInterface::class)) {
+                $commandName = is_object($command) ? get_class($command) : $command;
+                throw new InvalidArgumentException(sprintf('Command "%s" must implement "%s".', $commandName, CommandInterface::class));
+            }
+        }
+        $this->commands = array_merge($commands, [$this->defaultCommand]);
+        $this->defaultCommand->setCommands($this->commands);
+    }
+
+    public function run(CommandParser $commandParser, OutputInterface $output): int
+    {
+        if ($commandParser->hasOption('verbose') || $commandParser->hasOption('v')) {
+            $output->setVerbose(true);
+        }
+
+        try {
+
+            if ($commandParser->getCommandName() === null || $commandParser->getCommandName() === '--help') {
+                $this->defaultCommand->execute(new Input($this->defaultCommand->getName(), [], []), $output);
+                return self::CLI_SUCCESS;
+            }
+
+            $commands = [];
+            foreach ($this->commands as $currentCommand) {
+                if (self::stringStartsWith($currentCommand->getName(), $commandParser->getCommandName())) {
+                    $commands[] = $currentCommand;
+                }
+            }
+
+            if (empty($commands)) {
+                throw new InvalidArgumentException(sprintf('Command "%s" is not defined.', $commandParser->getCommandName()), self::CLI_COMMAND_NOT_FOUND);
+            }
+
+            if (count($commands) > 1) {
+                $names = [];
+                foreach ($commands as $command) {
+                    $names[$command->getName()] = $command->getDescription();
+                }
+                $consoleOutput = new ConsoleOutput($output);
+                $consoleOutput->error(sprintf('Command "%s" is ambiguous.', $commandParser->getCommandName()));
+                $consoleOutput->listKeyValues($names, true);
+                return self::CLI_AMBIGUOUS_COMMAND;
+            }
+
+            $command = $commands[0];
+            if ($commandParser->hasOption('help')) {
+                $this->showCommandHelp($command, $output);
+                return self::CLI_SUCCESS;
+            }
+
+            $this->execute($command, $commandParser, $output);
+
+            return self::CLI_SUCCESS;
+
+        } catch (Throwable $e) {
+            (new ConsoleOutput($output))->error($e->getMessage());
+            return in_array($e->getCode(), [self::CLI_COMMAND_NOT_FOUND, self::CLI_INVALID_ARGUMENTS]) ? $e->getCode() : self::CLI_ERROR;
+        }
+
+    }
+
+    private function execute(CommandInterface $command, CommandParser $commandParser, OutputInterface $output)
+    {
+        $argvOptions = [];
+        $options = $command->getOptions();
+        $forbidden = ['help', 'h', 'verbose', 'v'];
+        foreach ($options as $option) {
+            $name     = $option->getName();
+            $shortcut = $option->getShortcut();
+            if (in_array($name, $forbidden, true) || ($shortcut !== null && in_array($shortcut, $forbidden, true))) {
+                $invalid = in_array($name, $forbidden, true) ? $name : $shortcut;
+                throw new \InvalidArgumentException(
+                    sprintf(
+                        'The option "%s" is reserved and cannot be used with the "%s" command.',
+                        $invalid,
+                        $command->getName()
+                    )
+                );
+            }
+        }
+
+        $options[] = CommandOption::flag('verbose', 'v', 'Enable verbose output');
+        foreach ($options as $option) {
+            if ($option->isFlag()) {
+                $argvOptions["--{$option->getName()}"] = false;
+            }elseif ($option->getDefaultValue() !== null) {
+                $argvOptions["--{$option->getName()}"] = $option->getDefaultValue();
+            }
+        }
+        foreach ($commandParser->getOptions() as $name => $value) {
+            $hasOption = false;
+            foreach ($options as $option) {
+                if ($option->getName() === $name || $option->getShortcut() === $name) {
+                    $hasOption = true;
+                    if (!$option->isFlag() && ($value === true || empty($value))) {
+                        throw new InvalidArgumentException(sprintf('Option "%s" requires a value for command "%s".', $name, $command->getName()));
+                    }
+                    $argvOptions["--{$option->getName()}"] = $value;
+                    break;
+                }
+            }
+            if (!$hasOption) {
+                throw new InvalidArgumentException(sprintf('Option "%s" is not defined for command "%s".', $name, $command->getName()));
+            }
+        }
+
+        $argv = [];
+
+        $arguments = $command->getArguments();
+        foreach ($arguments as $key => $argument) {
+            $keyArg = $argument->getName();
+            if ($argument->isRequired() && (!$commandParser->hasArgument($key) || empty($commandParser->getArgumentValue($key)))) {
+                throw new InvalidArgumentException(sprintf('Argument "%s" is required for command "%s".', $argument->getName(), $command->getName()));
+            }
+            if ($commandParser->hasArgument($key)) {
+                $argv["--{$keyArg}"] = $commandParser->getArgumentValue($key);
+            }else {
+                $argv["--{$keyArg}"] = $argument->getDefaultValue();
+            }
+        }
+
+        if (count($commandParser->getArguments()) > count($arguments)) {
+            throw new InvalidArgumentException(sprintf('Too many arguments for command "%s". Expected %d, got %d.', $command->getName(), count($arguments), count($commandParser->getArguments())));
+        }
+
+        $startTime = microtime(true);
+        $input = new Input($commandParser->getCommandName(), $argvOptions, $argv);
+        $command->execute($input, $output);
+        $endTime    = microtime(true);
+        $peakMemoryBytes            = memory_get_peak_usage(true);
+        $peakMemoryMB               = round($peakMemoryBytes / 1024 / 1024, 2);
+        $duration                   = round($endTime - $startTime, 2);
+        if ($output->isVerbose()) {
+            $output->writeln(sprintf(
+                'Execution time: %.2fs; Peak memory usage: %.2f MB',
+                $duration,
+                $peakMemoryMB
+            ));
+        }
+    }
+
+    private function showCommandHelp(CommandInterface $selectedCommand, OutputInterface $output): void
+    {
+        $consoleOutput = new ConsoleOutput($output);
+        $consoleOutput->writeColor('Description:', 'yellow');
+        $consoleOutput->write(PHP_EOL);
+        $consoleOutput->writeln($selectedCommand->getDescription());
+        $consoleOutput->write(PHP_EOL);
+
+        $consoleOutput->writeColor('Arguments:', 'yellow');
+        $consoleOutput->write(PHP_EOL);
+        $arguments = [];
+        foreach ($selectedCommand->getArguments() as $argument) {
+            $arguments[$argument->getName()] = $argument->getDescription();
+        }
+        $consoleOutput->listKeyValues($arguments, true);
+
+        $consoleOutput->writeColor('Options:', 'yellow');
+        $consoleOutput->write(PHP_EOL);
+        $options = [];
+        foreach ($selectedCommand->getOptions() as $option) {
+            $name = sprintf('--%s', $option->getName());
+            if ($option->getShortcut() !== null) {
+                $name = sprintf('-%s, --%s', $option->getShortcut(), $option->getName());
+            }
+
+            if (!$option->isFlag()) {
+                $name = sprintf('%s=VALUE', $name);
+            }
+            if ($option->getDefaultValue() !== null) {
+                $name = sprintf('%s (default: %s)', $name, $option->getDefaultValue());
+            }
+
+            $options[$name] = $option->getDescription();
+        }
+        $consoleOutput->listKeyValues($options, true);
+    }
+
+    private static function stringStartsWith(string $command, string $input): bool
+    {
+        $commandParts = explode(':', $command);
+        $inputParts = explode(':', $input);
+        foreach ($inputParts as $i => $inPart) {
+            $cmdPart = $commandParts[$i] ?? null;
+
+            if ($cmdPart === null || strpos($cmdPart, $inPart) !== 0) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+}

+ 80 - 0
src/Input.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace Michel\Console;
+
+
+final class Input implements InputInterface
+{
+    /**
+     * @var string|null
+     */
+    private ?string $cmdName;
+    private array $options = [];
+    private array $arguments = [];
+
+    public function __construct(string $cmdName, array $options = [], array $arguments = [])
+    {
+        $this->cmdName = $cmdName;
+        $this->options = $options;
+        $this->arguments = $arguments;
+    }
+
+    public function getCommandName(): ?string
+    {
+        return $this->cmdName;
+    }
+
+    public function getOptions(): array
+    {
+        return $this->options;
+    }
+
+    public function getArguments(): array
+    {
+        return $this->arguments;
+    }
+
+    public function hasOption(string $name): bool
+    {
+        if (!self::startsWith($name, '--')) {
+            $name = "--$name";
+        }
+        return array_key_exists($name, $this->options);
+    }
+
+    public function getOptionValue(string $name)
+    {
+        if (!self::startsWith($name, '--')) {
+            $name = "--$name";
+        }
+
+        if (!$this->hasOption($name)) {
+            return null;
+        }
+        return $this->options[$name];
+    }
+
+    public function getArgumentValue(string $name)
+    {
+        if (!self::startsWith($name, '--')) {
+            $name = "--$name";
+        }
+        if (!$this->hasArgument($name)) {
+            throw new \InvalidArgumentException(sprintf('Argument "%s" is not defined.', $name));
+        }
+        return $this->arguments[$name];
+    }
+
+    public function hasArgument(string $name): bool
+    {
+        if (!self::startsWith($name, '--')) {
+            $name = "--$name";
+        }
+        return array_key_exists($name, $this->arguments);
+    }
+
+    private static function startsWith(string $haystack, string $needle): bool
+    {
+        return strncmp($haystack, $needle, strlen($needle)) === 0;
+    }
+}

+ 14 - 0
src/InputInterface.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace Michel\Console;
+
+interface InputInterface
+{
+    public function getCommandName(): ?string;
+    public function getOptions(): array;
+    public function getArguments(): array;
+    public function hasOption(string $name): bool;
+    public function getOptionValue(string $name);
+    public function getArgumentValue(string $name);
+    public function hasArgument(string $name): bool;
+}

+ 89 - 0
src/Option/CommandOption.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace Michel\Console\Option;
+
+final class CommandOption
+{
+    private string $name;
+    private ?string $shortcut;
+    private ?string $description;
+    private bool $isFlag;
+
+    /**
+     * @var bool|float|int|string
+     */
+    private $default = null;
+
+    public function __construct(
+        string  $name,
+        ?string $shortcut = null,
+        ?string $description = null,
+        bool    $isFlag = false,
+        $default = null
+    )
+    {
+
+        if ($name === '') {
+            throw new \InvalidArgumentException("Option name cannot be empty.");
+        }
+
+        foreach (explode('-', $name) as $part) {
+            if ($part === '' || !ctype_alpha($part)) {
+                throw new \InvalidArgumentException("Option name must contain only letters and dashes. '$name' is invalid.");
+            }
+        }
+
+        if ($shortcut !== null && (strlen($shortcut) !== 1 || !ctype_alpha($shortcut))) {
+            throw new \InvalidArgumentException('Shortcut must be a single character and contain only letters. "' . $shortcut . '" is invalid.');
+        }
+
+        if (!is_null($default) && !is_scalar($default)) {
+            throw new \InvalidArgumentException(
+                sprintf(
+                    'Invalid default value: expected a scalar (int, float, string, or bool), got "%s".',
+                    gettype($default)
+                )
+            );
+        }
+
+        $this->name = strtolower($name);
+        $this->shortcut = $shortcut !== null ? strtolower($shortcut) : null;
+        $this->description = $description;
+        $this->isFlag = $isFlag;
+        $this->default = $default;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function getShortcut(): ?string
+    {
+        return $this->shortcut;
+    }
+
+    public function getDescription(): ?string
+    {
+        return $this->description;
+    }
+
+    public function isFlag(): bool
+    {
+        return $this->isFlag;
+    }
+
+    public function getDefaultValue()
+    {
+        return $this->default;
+    }
+
+    public static function flag(string $name, ?string $shortcut = null, ?string $description = null): self {
+        return new self($name, $shortcut, $description, true, false);
+    }
+    
+    public static function withValue(string $name, ?string $shortcut = null, ?string $description = null, $default = null): self {
+        return new self($name, $shortcut, $description, false, $default);
+    }
+
+}

+ 51 - 0
src/Output.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Michel\Console;
+
+use RuntimeException;
+use const PHP_EOL;
+
+final class Output implements OutputInterface
+{
+    /**
+     * @var callable
+     */
+    private $output;
+
+    public function __construct(callable $output = null)
+    {
+        if ($output === null) {
+            $output = function ($message) {
+                fwrite(STDOUT, $message);
+            };
+        }
+        $this->output = $output;
+    }
+
+    /**
+     * @var bool
+     */
+    private bool $verbose = false;
+
+    public function write(string $message): void
+    {
+        $output = $this->output;
+        $output($message);
+    }
+
+    public function writeln(string $message): void
+    {
+        $this->write($message);
+        $this->write(PHP_EOL);
+    }
+
+    public function setVerbose(bool $verbose): void
+    {
+        $this->verbose = $verbose;
+    }
+
+    public function isVerbose(): bool
+    {
+        return $this->verbose;
+    }
+}

+ 418 - 0
src/Output/ConsoleOutput.php

@@ -0,0 +1,418 @@
+<?php
+
+namespace Michel\Console\Output;
+
+
+use InvalidArgumentException;
+use Michel\Console\OutputInterface;
+use RuntimeException;
+
+final class ConsoleOutput implements OutputInterface
+{
+    const FOREGROUND_COLORS = [
+        'black' => '0;30',
+        'dark_gray' => '1;30',
+        'green' => '0;32',
+        'light_green' => '1;32',
+        'red' => '0;31',
+        'light_red' => '1;31',
+        'yellow' => '0;33',
+        'light_yellow' => '1;33',
+        'blue' => '0;34',
+        'dark_blue' => '0;34',
+        'light_blue' => '1;34',
+        'purple' => '0;35',
+        'light_purple' => '1;35',
+        'cyan' => '0;36',
+        'light_cyan' => '1;36',
+        'light_gray' => '0;37',
+        'white' => '1;37',
+    ];
+
+    const BG_COLORS = [
+        'black' => '40',
+        'red' => '41',
+        'green' => '42',
+        'yellow' => '43',
+        'blue' => '44',
+        'magenta' => '45',
+        'cyan' => '46',
+        'light_gray' => '47',
+    ];
+
+    private OutputInterface $output;
+
+    public static function create(OutputInterface $output): ConsoleOutput
+    {
+        return new self($output);
+    }
+
+    public function __construct(OutputInterface $output)
+    {
+        $this->output = $output;
+    }
+
+    public function success(string $message): void
+    {
+        [$formattedMessage, $lineLength, $color] = $this->formatMessage('OK', $message, 'green');
+        $this->outputMessage($formattedMessage, $lineLength, $color);
+    }
+
+    public function error(string $message): void
+    {
+        [$formattedMessage, $lineLength, $color] = $this->formatMessage('ERROR', $message, 'red');
+        $this->outputMessage($formattedMessage, $lineLength, $color, true);
+    }
+
+    public function warning(string $message): void
+    {
+        [$formattedMessage, $lineLength, $color] = $this->formatMessage('WARNING', $message, 'yellow');
+        $this->outputMessage($formattedMessage, $lineLength, $color);
+    }
+
+    public function info(string $message): void
+    {
+        [$formattedMessage, $lineLength, $color] = $this->formatMessage('INFO', $message, 'blue');
+        $this->outputMessage($formattedMessage, $lineLength, $color);
+    }
+
+    public function debug(string $message): void
+    {
+        if (!$this->output->isVerbose()) {
+            return;
+        }
+        [$formattedMessage, $lineLength, $color] = $this->formatMessage('DEBUG', $message, 'cyan');
+        $this->outputMessage($formattedMessage, $lineLength, $color);
+    }
+
+
+    public function title(string $message): void
+    {
+        $consoleWidth = $this->geTerminalWidth();
+        $titleLength = mb_strlen($message);
+        $underline = str_repeat('=', min($consoleWidth, $titleLength));
+
+        $this->write(PHP_EOL);
+        $this->write($message);
+        $this->write(PHP_EOL);
+        $this->write($underline);
+        $this->write(PHP_EOL);
+    }
+
+    public function list(array $items): void
+    {
+        foreach ($items as $item) {
+            $item = $this->variableToString($item);
+            $this->write('- ' . $item);
+            $this->write(PHP_EOL);
+        }
+        $this->write(PHP_EOL);
+    }
+
+    public function listKeyValues(array $items, bool $inlined = false): void
+    {
+        $maxKeyLength = 0;
+        if ($inlined) {
+            foreach ($items as $key => $value) {
+                $keyLength = mb_strlen($key);
+                if ($keyLength > $maxKeyLength) {
+                    $maxKeyLength = $keyLength;
+                }
+            }
+        }
+
+        foreach ($items as $key => $value) {
+            $value = $this->variableToString($value);
+            $value = implode(' ', explode(PHP_EOL, $value));
+            $key = str_pad($key, $maxKeyLength, ' ', STR_PAD_RIGHT);
+            $this->writeColor($key, 'green');
+            $this->write(' : ');
+            $this->writeColor($value, 'white');
+            $this->write(PHP_EOL);
+        }
+        $this->write(PHP_EOL);
+    }
+
+    public function indentedList(array $items, int $indentLevel = 1): void
+    {
+        if ($indentLevel == 1) {
+            $this->write(PHP_EOL);
+        }
+
+        foreach ($items as $item) {
+            if (is_array($item)) {
+                $this->indentedList($item, $indentLevel + 1);
+            } else {
+                $indentation = '';
+                if ($indentLevel > 1) {
+                    $indentation = str_repeat('  ', $indentLevel); // Indent with spaces
+                }
+                $this->writeColor($indentation . '- ', 'red');
+                $this->writeColor($item, 'white');
+                $this->write(PHP_EOL);
+            }
+        }
+
+        if ($indentLevel == 1) {
+            $this->write(PHP_EOL);
+        }
+    }
+
+    public function numberedList(array $items)
+    {
+        foreach ($items as $index => $item) {
+            $this->writeColor(($index + 1) . '. ', 'white');
+            $this->writeColor($item, 'green');
+            $this->write(PHP_EOL);
+        }
+        $this->write(PHP_EOL);
+    }
+
+    public function table(array $headers, array $rows): void
+    {
+        $maxWidth = $this->geTerminalWidth();
+        $maxWidthPerColumn = $maxWidth / count($headers);
+        $columnWidths = array_map(function ($header) {
+            return mb_strlen($header);
+        }, $headers);
+
+        $processedRows = [];
+        foreach ($rows as $row) {
+            $row = array_values($row);
+            $processedRow = [];
+            foreach ($row as $index => $column) {
+                $column = $this->variableToString($column);
+                $lines = explode(PHP_EOL, trim($column));
+                foreach ($lines as $i => $line) {
+                    $maxWidth = max($columnWidths[$index], mb_strlen($line));
+                    if ($maxWidth > $maxWidthPerColumn) {
+                        $maxWidth = $maxWidthPerColumn;
+                    }
+                    $columnWidths[$index] = $maxWidth;
+                    if (mb_strlen($line) > $maxWidth) {
+                        $lines[$i] = mb_substr($line, 0, $maxWidth) . '...';
+                    }
+                }
+                $processedRow[$index] = $lines;
+            }
+            $processedRows[] = $processedRow;
+        }
+
+        foreach ($headers as $index => $header) {
+            $this->write(str_pad($header, $columnWidths[$index] + 2));
+        }
+        $this->write(PHP_EOL);
+        $this->write(str_repeat('-', array_sum($columnWidths) + count($columnWidths) * 2));
+        $this->write(PHP_EOL);
+
+        foreach ($processedRows as $row) {
+            $maxLines = max(array_map('count', $row));
+            for ($lineIndex = 0; $lineIndex < $maxLines; $lineIndex++) {
+                foreach ($row as $index => $lines) {
+                    $line = $lines[$lineIndex] ?? ''; // Récupère la ligne actuelle ou une chaîne vide
+                    $this->write(str_pad($line, $columnWidths[$index] + 2));
+                }
+                $this->write(PHP_EOL);
+            }
+        }
+    }
+
+    public function progressBar(int $total, int $current): void
+    {
+        $barWidth = 50;
+        $progress = ($current / $total) * $barWidth;
+        $bar = str_repeat('#', (int)$progress) . str_repeat(' ', $barWidth - (int)$progress);
+        $this->write(sprintf("\r[%s] %d%%", $bar, ($current / $total) * 100));
+        if ($current === $total) {
+            $this->write(PHP_EOL);
+        }
+    }
+
+    public function confirm(string $message): bool
+    {
+        $this->writeColor($message . ' [y/n]: ', 'yellow');
+
+        $handle = fopen('php://stdin', 'r');
+        $input = trim(fgets($handle));
+        fclose($handle);
+
+        return in_array($input, ['y', 'yes', 'Y', 'YES'], true);
+    }
+
+    public function ask(string $question, bool $hidden = false, bool $required = false): string
+    {
+        $this->writeColor($question . ': ', 'cyan');
+
+        if ($hidden) {
+            if (strncasecmp(PHP_OS, 'WIN', 3) == 0) {
+                throw new RuntimeException('Windows platform is not supported for hidden input');
+            } else {
+                system('stty -echo');
+                $input = trim(fgets(STDIN));
+                system('stty echo');
+                $this->write(PHP_EOL);
+            }
+        } else {
+            $handle = fopen('php://stdin', 'r');
+            $input = trim(fgets($handle));
+            fclose($handle);
+        }
+
+        if ($required && empty($input)) {
+            throw new InvalidArgumentException('Response cannot be empty');
+        }
+
+        return $input;
+    }
+
+    public function spinner(int $duration = 3): void
+    {
+        $spinnerChars = ['|', '/', '-', '\\'];
+        $time = microtime(true);
+        while ((microtime(true) - $time) < $duration) {
+            foreach ($spinnerChars as $char) {
+                $this->write("\r$char");
+                usleep(100000);
+            }
+        }
+        $this->write("\r");
+    }
+
+    public function json(array $data): void
+    {
+        $jsonOutput = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+
+        if (json_last_error() !== JSON_ERROR_NONE) {
+            $this->writeColor("Error encoding JSON: " . json_last_error_msg() . PHP_EOL, 'red');
+            return;
+        }
+        $this->write($jsonOutput . PHP_EOL);
+    }
+
+    public function boxed(string $message, string $borderChar = '*', int $padding = 1): void
+    {
+        $this->write(PHP_EOL);
+        $lineLength = mb_strlen($message);
+        $boxWidth = $this->geTerminalWidth();
+        if ($lineLength > $boxWidth) {
+            $lineLength = $boxWidth - ($padding * 2) - 2;
+        }
+        $lines = explode('|', wordwrap($message, $lineLength, '|', true));
+        $border = str_repeat($borderChar, $lineLength + ($padding * 2) + 2);
+
+        $this->write($border . PHP_EOL);
+        foreach ($lines as $line) {
+            $strPad = str_repeat(' ', $padding);
+            $this->write($borderChar . $strPad . str_pad($line, $lineLength) . $strPad . $borderChar . PHP_EOL);
+        }
+        $this->write($border . PHP_EOL);
+        $this->write(PHP_EOL);
+    }
+
+    public function writeColor(string $message, ?string $color = null, ?string $background = null, bool $isError = false): void
+    {
+
+        $formattedMessage = '';
+
+        if ($color) {
+            $formattedMessage .= "\033[" . self::FOREGROUND_COLORS[$color] . 'm';
+        }
+        if ($background) {
+            $formattedMessage .= "\033[" . self::BG_COLORS[$background] . 'm';
+        }
+
+        $formattedMessage .= $message . "\033[0m";
+
+        $this->write($formattedMessage, $isError);
+    }
+
+    public function write(string $message, bool $isError = false): void
+    {
+        if ($isError) {
+            fwrite(STDERR, $message);
+            return;
+        }
+        $this->output->write($message);
+    }
+
+    public function writeln(string $message): void
+    {
+        $this->output->writeln($message);
+    }
+
+    private function outputMessage($formattedMessage, int $lineLength, string $color, bool $isError = false): void
+    {
+        $this->write(PHP_EOL, $isError);
+        $this->writeColor(str_repeat(' ', $lineLength), 'white', $color, $isError);
+        $this->write(PHP_EOL, $isError);
+
+        if (is_string($formattedMessage)) {
+            $formattedMessage = [$formattedMessage];
+        }
+
+        foreach ($formattedMessage as $line) {
+            $line = str_pad($line, $lineLength).PHP_EOL;
+            $this->writeColor($line, 'white', $color, $isError);
+        }
+
+        $this->writeColor(str_repeat(' ', $lineLength), 'white', $color, $isError);
+        $this->write(PHP_EOL, $isError);
+        $this->write(PHP_EOL, $isError);
+    }
+
+    private function formatMessage(string $prefix, string $message, string $color): array
+    {
+        $formattedMessage = sprintf('[%s] %s', $prefix, trim($message));
+        $lineLength = mb_strlen($formattedMessage);
+        $consoleWidth = $this->geTerminalWidth();
+
+        if ($lineLength > $consoleWidth) {
+            $lineLength = $consoleWidth;
+            $lines = explode('|', wordwrap($formattedMessage, $lineLength, '|', true));
+            $formattedMessage = array_map(function ($line) use ($lineLength) {
+                return str_pad($line, $lineLength);
+            }, $lines);
+        }
+        return [$formattedMessage, $lineLength, $color];
+    }
+
+    private function geTerminalWidth(): int
+    {
+        $width = 85;
+        if (getenv('TERM')) {
+            $width = ((int) @exec('tput cols') ?: $width);
+        }
+        return $width - 5;
+    }
+
+    private function variableToString($variable): string
+    {
+        if (is_object($variable)) {
+            return 'Object: ' . get_class($variable);
+        } elseif (is_array($variable)) {
+            $variables = [];
+            foreach ($variable as $item) {
+                $variables[] = $this->variableToString($item);
+            }
+            return var_export($variables, true);
+        } elseif (is_resource($variable)) {
+            return (string)$variable;
+        } elseif (is_null($variable)) {
+            return 'NULL';
+        } elseif (is_string($variable)) {
+            return $variable;
+        }
+
+        return var_export($variable, true);
+    }
+
+    public function setVerbose(bool $verbose): void
+    {
+        $this->output->setVerbose($verbose);
+    }
+
+    public function isVerbose(): bool
+    {
+        return  $this->output->isVerbose();
+    }
+}

+ 11 - 0
src/OutputInterface.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace Michel\Console;
+
+interface OutputInterface
+{
+    public function write(string $message): void;
+    public function writeln(string $message): void;
+    public function setVerbose(bool $verbose): void;
+    public function isVerbose(): bool;
+}

+ 37 - 0
tests/Command/CacheClearCommand.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace Test\Michel\Console\Command;
+
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\OutputInterface;
+
+class CacheClearCommand implements CommandInterface
+{
+    public function getName(): string
+    {
+        return 'cache:clear';
+    }
+
+    public function getDescription(): string
+    {
+        return 'TEST : Clear cache';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $output->write('Test OK : Clear cache');
+    }
+}

+ 44 - 0
tests/Command/FooCommand.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace Test\Michel\Console\Command;
+
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\OutputInterface;
+
+class FooCommand implements CommandInterface
+{
+    public function getName(): string
+    {
+        return 'foo';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Performs the foo operation with optional parameters.';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            new CommandOption('verbose', 'v', 'Enable verbose output', true),
+            new CommandOption('output', 'o', 'Specify output file', false)
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [
+            new CommandArgument('input', false, 'none', 'The input file for the foo operation')
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $output->writeln('Test OK');
+        $output->writeln('ARGUMENTS: ' . json_encode($input->getArguments()));
+        $output->writeln('OPTIONS: ' . json_encode($input->getOptions()));
+    }
+}

+ 39 - 0
tests/Command/MakeControllerCommand.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace Test\Michel\Console\Command;
+
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\OutputInterface;
+
+class MakeControllerCommand implements CommandInterface
+{
+    public function getName(): string
+    {
+        return 'make:controller';
+    }
+
+    public function getDescription(): string
+    {
+        return 'TEST : Make a new controller';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $output->write('Test OK : Make a new controller');
+    }
+}

+ 37 - 0
tests/Command/MakeEntityCommand.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace Test\Michel\Console\Command;
+
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\OutputInterface;
+
+class MakeEntityCommand implements CommandInterface
+{
+    public function getName(): string
+    {
+        return 'make:controller';
+    }
+
+    public function getDescription(): string
+    {
+        return 'TEST : Make a new Entity';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $output->write('Test OK : Make a new entity');
+    }
+}

+ 37 - 0
tests/Command/UserCreateCommand.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace Test\Michel\Console\Command;
+
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\OutputInterface;
+
+class UserCreateCommand implements CommandInterface
+{
+    public function getName(): string
+    {
+        return 'app:user:create';
+    }
+
+    public function getDescription(): string
+    {
+        return 'TEST : User create';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $output->write('Test OK : User create');
+    }
+}

+ 37 - 0
tests/Command/UserDisabledCommand.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace Test\Michel\Console\Command;
+
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\OutputInterface;
+
+class UserDisabledCommand implements CommandInterface
+{
+    public function getName(): string
+    {
+        return 'app:user:disabled';
+    }
+
+    public function getDescription(): string
+    {
+        return 'TEST : User disabled';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $output->write('Test OK : User disabled');
+    }
+}

+ 40 - 0
tests/Command/UserListCommand.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace Test\Michel\Console\Command;
+
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\OutputInterface;
+
+class UserListCommand implements CommandInterface
+{
+    public function getName(): string
+    {
+        return 'app:user:list';
+    }
+
+    public function getDescription(): string
+    {
+        return 'TEST : User list';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            CommandOption::withValue('limit', 'l', 'Limit the number of results', 100),
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $output->write('Test OK : User list');
+        $output->write(sprintf('LIMIT : %d', $input->getOptionValue('limit')));
+    }
+}

+ 37 - 0
tests/Command/UserResetPasswordCommand.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace Test\Michel\Console\Command;
+
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\OutputInterface;
+
+class UserResetPasswordCommand implements CommandInterface
+{
+    public function getName(): string
+    {
+        return 'app:user:reset-password';
+    }
+
+    public function getDescription(): string
+    {
+        return 'TEST : User reset password';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $output->write('Test OK : User reset password');
+    }
+}

+ 93 - 0
tests/CommandArgumentTest.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace Test\Michel\Console;
+
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Output;
+use Michel\UniTester\TestCase;
+
+class CommandArgumentTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testValidateThrowsExceptionIfRequiredAndValueIsEmpty();
+        $this->testValidateDoesNotThrowExceptionIfNotRequiredAndValueIsEmpty();
+        $this->testValidateDoesNotThrowExceptionIfRequiredAndValueIsNotEmpty();
+        $this->testGetNameReturnsCorrectValue();
+        $this->testIsRequiredReturnsCorrectValue();
+        $this->testGetDefaultValueReturnsCorrectValue();
+        $this->testGetDescriptionReturnsCorrectValue();
+    }
+
+    public function testValidateThrowsExceptionIfRequiredAndValueIsEmpty()
+    {
+        $arg = new CommandArgument('test', true);
+
+        $this->expectException(\InvalidArgumentException::class, function () use ($arg) {
+            $arg->validate('');
+        }, 'The required argument "test" was not provided.');
+
+    }
+
+    public function testValidateDoesNotThrowExceptionIfNotRequiredAndValueIsEmpty()
+    {
+        $arg = new CommandArgument('test');
+
+        $arg->validate('');
+
+        $this->assertTrue(true);
+    }
+
+    public function testValidateDoesNotThrowExceptionIfRequiredAndValueIsNotEmpty()
+    {
+        $arg = new CommandArgument('test', true);
+
+        $arg->validate('value');
+
+        $this->assertTrue(true);
+    }
+
+    public function testGetNameReturnsCorrectValue()
+    {
+        $arg = new CommandArgument('test');
+
+        $this->assertEquals('test', $arg->getName());
+    }
+
+    public function testIsRequiredReturnsCorrectValue()
+    {
+        $arg = new CommandArgument('test', true);
+
+        $this->assertTrue($arg->isRequired());
+
+        $arg = new CommandArgument('test');
+
+        $this->assertFalse($arg->isRequired());
+    }
+
+    public function testGetDefaultValueReturnsCorrectValue()
+    {
+        $arg = new CommandArgument('test', false, 'default');
+
+        $this->assertEquals('default', $arg->getDefaultValue());
+    }
+
+    public function testGetDescriptionReturnsCorrectValue()
+    {
+        $arg = new CommandArgument('test', false, null, 'description');
+
+        $this->assertEquals('description', $arg->getDescription());
+    }
+
+}

+ 65 - 0
tests/CommandOptionTest.php

@@ -0,0 +1,65 @@
+<?php
+
+namespace Test\Michel\Console;
+
+use Michel\Console\Option\CommandOption;
+use Michel\Console\Output;
+use Michel\UniTester\TestCase;
+
+class CommandOptionTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testConstructor();
+        $this->testGetName();
+        $this->testGetShortcut();
+        $this->testGetDescription();
+        $this->testIsFlag();
+    }
+
+    public function testConstructor(): void
+    {
+        $option = new CommandOption('foo', 'f', 'description', true);
+
+        $this->assertEquals('foo', $option->getName());
+        $this->assertEquals('f', $option->getShortcut());
+        $this->assertEquals('description', $option->getDescription());
+        $this->assertTrue($option->isFlag());
+    }
+
+    public function testGetName(): void
+    {
+        $option = new CommandOption('foo');
+        $this->assertEquals('foo', $option->getName());
+    }
+
+    public function testGetShortcut(): void
+    {
+        $option = new CommandOption('foo', 'f');
+        $this->assertEquals('f', $option->getShortcut());
+    }
+
+    public function testGetDescription(): void
+    {
+        $option = new CommandOption('foo', null, 'description');
+        $this->assertEquals('description', $option->getDescription());
+    }
+
+    public function testIsFlag(): void
+    {
+        $option = new CommandOption('foo', null, null, true);
+        $this->assertTrue($option->isFlag());
+    }
+
+}

+ 81 - 0
tests/CommandParserTest.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace Test\Michel\Console;
+
+use Michel\Console\CommandParser;
+use Michel\UniTester\TestCase;
+
+class CommandParserTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testCommandName();
+        $this->testOptions();
+        $this->testArguments();
+        $this->testHasOption();
+        $this->testGetOptionValue();
+        $this->testGetArgumentValue();
+    }
+
+    public function testCommandName()
+    {
+        $parser = new CommandParser(self::createArgv(['foo', '--bar=baz']));
+        $this->assertEquals('foo', $parser->getCommandName());
+    }
+
+    public function testOptions()
+    {
+        $parser = new CommandParser(self::createArgv(['foo', '--bar=baz', '--qux']));
+        $this->assertEquals(['bar' => 'baz', 'qux' => true], $parser->getOptions());
+    }
+
+    public function testArguments()
+    {
+        $parser = new CommandParser(self::createArgv(['foo', 'bar', 'baz']));
+        $this->assertEquals(['bar', 'baz'], $parser->getArguments());
+    }
+
+    public function testHasOption()
+    {
+        $parser = new CommandParser(self::createArgv(['foo', '--bar=baz']));
+        $this->assertTrue($parser->hasOption('bar'));
+        $this->assertFalse($parser->hasOption('qux'));
+    }
+
+    public function testGetOptionValue()
+    {
+        $parser = new CommandParser(self::createArgv(['foo', '--bar=baz']));
+        $this->assertEquals('baz', $parser->getOptionValue('bar'));
+    }
+
+    public function testGetArgumentValue()
+    {
+        $parser = new CommandParser(self::createArgv(['foo', 'bar', 'baz']));
+        $this->assertEquals('bar', $parser->getArgumentValue(0));
+        $this->assertEquals('baz', $parser->getArgumentValue(1));
+    }
+
+    public function testHasArgument()
+    {
+        $parser = new CommandParser(self::createArgv(['foo', 'bar', 'baz']));
+        $this->assertTrue($parser->hasArgument(0));
+        $this->assertTrue($parser->hasArgument(1));
+        $this->assertFalse($parser->hasArgument(2));
+    }
+
+    private static function createArgv(array $argv): array
+    {
+        return array_merge(['bin/console'], $argv);
+    }
+}

+ 81 - 0
tests/CommandRunnerTest.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace Test\Michel\Console;
+
+use Michel\Console\CommandParser;
+use Michel\Console\CommandRunner;
+use Michel\Console\Output;
+use Michel\UniTester\TestCase;
+use Test\Michel\Console\Command\CacheClearCommand;
+use Test\Michel\Console\Command\MakeControllerCommand;
+use Test\Michel\Console\Command\MakeEntityCommand;
+use Test\Michel\Console\Command\UserCreateCommand;
+use Test\Michel\Console\Command\UserDisabledCommand;
+use Test\Michel\Console\Command\UserListCommand;
+use Test\Michel\Console\Command\UserResetPasswordCommand;
+
+class CommandRunnerTest extends TestCase
+{
+    private CommandRunner $commandRunner;
+
+    protected function setUp(): void
+    {
+        $this->commandRunner = new CommandRunner([
+            new CacheClearCommand(),
+            new MakeControllerCommand(),
+            new MakeEntityCommand(),
+            new UserCreateCommand(),
+            new UserDisabledCommand(),
+            new UserResetPasswordCommand(),
+            new UserListCommand(),
+        ]);
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $exitCode = $this->commandRunner->run(new CommandParser(self::createArgv(['c:c'])), new Output(function ($message) {
+            $this->assertEquals('Test OK : Clear cache', $message);
+            })
+        );
+        $this->assertEquals(0, $exitCode);
+
+        $message = '';
+        $exitCode = $this->commandRunner->run(new CommandParser(self::createArgv(['c:c', '--verbose'])), new Output(function ($outputMessage) use (&$message) {
+                $message .= $outputMessage;
+            })
+        );
+        $this->assertStringContains( $message, 'Test OK : Clear cache');
+        $this->assertStringContains( $message, 'Execution time:');
+        $this->assertStringContains( $message, 'Peak memory usage:');
+        $this->assertEquals(0, $exitCode);
+
+
+        $messages = [];
+        $exitCode = $this->commandRunner->run(new CommandParser(self::createArgv(['app:user:list'])), new Output(function ($message) use(&$messages){
+                $messages[] = $message;
+            })
+        );
+        $this->assertEquals(0, $exitCode);
+        $this->assertEquals('Test OK : User list', $messages[0]);
+        $this->assertEquals('LIMIT : 100', $messages[1]);
+
+        $messages = [];
+        $exitCode = $this->commandRunner->run(new CommandParser(self::createArgv(['app:user:list', '-l', '1000'])), new Output(function ($message) use(&$messages){
+                $messages[] = $message;
+            })
+        );
+        $this->assertEquals(0, $exitCode);
+        $this->assertEquals('Test OK : User list', $messages[0]);
+        $this->assertEquals('LIMIT : 1000', $messages[1]);
+    }
+
+    private static function createArgv(array $argv): array
+    {
+        return array_merge(['bin/console'], $argv);
+    }
+}

+ 96 - 0
tests/CommandTest.php

@@ -0,0 +1,96 @@
+<?php
+
+namespace Test\Michel\Console;
+
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Input;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\Output;
+use Michel\UniTester\TestCase;
+use Test\Michel\Console\Command\FooCommand;
+
+class CommandTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testGetName();
+        $this->testGetDescription();
+        $this->testGetOptions();
+        $this->testGetArguments();
+        $this->testExecute();
+    }
+
+    public function testGetName(): void
+    {
+        $command = new FooCommand();
+        $this->assertEquals('foo', $command->getName());
+    }
+
+    public function testGetDescription(): void
+    {
+        $command = new FooCommand();
+        $this->assertEquals('Performs the foo operation with optional parameters.', $command->getDescription());
+    }
+
+    public function testGetOptions(): void
+    {
+        $command = new FooCommand();
+        $options = $command->getOptions();
+        $this->assertEquals(2, count($options));
+
+        $this->assertInstanceOf(CommandOption::class, $options[0]);
+        $this->assertEquals('verbose', $options[0]->getName());
+        $this->assertEquals('v', $options[0]->getShortcut());
+        $this->assertEquals('Enable verbose output', $options[0]->getDescription());
+        $this->assertTrue($options[0]->isFlag());
+
+        $this->assertInstanceOf(CommandOption::class, $options[1]);
+        $this->assertEquals('output', $options[1]->getName());
+        $this->assertEquals('o', $options[1]->getShortcut());
+        $this->assertEquals('Specify output file', $options[1]->getDescription());
+        $this->assertFalse($options[1]->isFlag());
+    }
+
+    public function testGetArguments(): void
+    {
+        $command = new FooCommand();
+        $arguments = $command->getArguments();
+        $this->assertEquals(1, count($arguments));
+        $this->assertInstanceOf(CommandArgument::class, $arguments[0]);
+        $this->assertEquals('input', $arguments[0]->getName());
+        $this->assertFalse($arguments[0]->isRequired());
+    }
+
+    public function testExecute(): void
+    {
+        $input = new Input('foo', ['verbose' => true, 'output' => 'output.txt'], ['input' => 'foo']);
+        $lines = 0;
+        $output = new Output(function (string $message) use (&$lines) {
+            if ($lines === 0) {
+                $this->assertEquals('Test OK', $message);
+            }
+            if ($lines === 2) {
+                $this->assertEquals('ARGUMENTS: {"input":"foo"}', $message);
+            }
+            if ($lines === 4) {
+                $this->assertEquals('OPTIONS: {"verbose":true,"output":"output.txt"}', $message);
+            }
+            $lines++;
+        });
+        $command = new FooCommand();
+        $command->execute($input, $output);
+
+        $this->assertEquals(6, $lines);
+    }
+}

+ 84 - 0
tests/InputTest.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace Test\Michel\Console;
+
+use Michel\Console\Input;
+use Michel\UniTester\TestCase;
+
+class InputTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testGetCommandName();
+        $this->testGetOptions();
+        $this->testGetArguments();
+        $this->testHasOption();
+        $this->testHasArgument();
+        $this->testGetOptionValue();
+        $this->testGetArgumentValue();
+
+    }
+
+    public function testGetCommandName()
+    {
+        $input = new Input('test', [], []);
+        $this->assertEquals('test', $input->getCommandName());
+    }
+
+    public function testGetOptions()
+    {
+        $options = ['--option1' => 'value1', '--option2' => 'value2'];
+        $input = new Input('test', $options, []);
+        $this->assertEquals($options, $input->getOptions());
+    }
+
+    public function testGetArguments()
+    {
+        $arguments = ['argument1' => 'value1', 'argument2' => 'value2'];
+        $input = new Input('test', [], $arguments);
+        $this->assertEquals($arguments, $input->getArguments());
+    }
+
+    public function testHasOption()
+    {
+        $input = new Input('test', ['--option' => 'value'], []);
+        $this->assertTrue($input->hasOption('option'));
+        $this->assertTrue($input->hasOption('--option'));
+        $this->assertFalse($input->hasOption('invalid'));
+    }
+
+    public function testGetOptionValue()
+    {
+        $input = new Input('test', ['--option' => 'value'], []);
+        $this->assertEquals('value', $input->getOptionValue('option'));
+        $this->assertEquals('value', $input->getOptionValue('--option'));
+        $this->assertEquals(null, $input->getOptionValue('invalid'));
+    }
+
+    public function testGetArgumentValue()
+    {
+        $input = new Input('test', [], ['--argument' => 'value']);
+        $this->assertEquals('value', $input->getArgumentValue('argument'));
+        $this->expectException(\InvalidArgumentException::class, function () use ($input) {
+            $input->getArgumentValue('invalid');
+        });
+    }
+
+    public function testHasArgument()
+    {
+        $input = new Input('test', [], ['--argument' => 'value']);
+        $this->assertTrue($input->hasArgument('argument'));
+        $this->assertFalse($input->hasArgument('invalid'));
+    }
+}

+ 51 - 0
tests/OutputTest.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Test\Michel\Console;
+
+use Michel\Console\Output;
+use Michel\UniTester\TestCase;
+class OutputTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testWrite();
+        $this->testWriteln();
+    }
+
+    public function testWrite()
+    {
+        $output = new Output(function ($message) {
+            $this->assertEquals('Hello, world!', $message);
+        });
+
+        $output->write('Hello, world!');
+    }
+
+    public function testWriteln()
+    {
+        $lines = 0;
+        $output = new Output(function ($message) use(&$lines) {
+            if ($lines === 0) {
+                $this->assertEquals('Hello, world!', $message);
+            }
+            if ($lines === 1) {
+                $this->assertEquals(PHP_EOL, $message);
+            }
+            $lines++;
+        });
+
+        $output->writeln('Hello, world!');
+        $this->assertEquals(2, $lines);
+    }
+}