diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..09230e0
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,32 @@
+name: CI
+
+on:
+ push:
+ branches: [ main, master ]
+ pull_request:
+
+jobs:
+ tasker-bridges:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: TaskerBridges
+ strategy:
+ matrix:
+ php: ['8.2', '8.3']
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: none
+ tools: phpstan, php-cs-fixer, phpunit
+ - name: Validate composer.json
+ run: php -v && composer validate --strict
+ - name: Install dependencies
+ run: composer install --no-interaction --no-progress --prefer-dist
+ - name: PHPUnit
+ run: vendor/bin/phpunit --configuration phpunit.xml --display-deprecations
+ - name: PHPStan
+ run: vendor/bin/phpstan analyse --no-progress --memory-limit=512M
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2550eb1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.idea/
+/vendor/
+/composer.lock
+/.phpunit.cache/
+/phpstan-baseline.neon
diff --git a/MILESTONES.md b/MILESTONES.md
index f8df0ba..272d6e8 100644
--- a/MILESTONES.md
+++ b/MILESTONES.md
@@ -3,25 +3,25 @@
This document defines the implementation milestones for the `getphred/tasker-bridges` package.
## 1. Bridge Infrastructure
-- [ ] Initialize repository with `getphred/console-contracts` dependency.
-- [ ] Set up testing environment for multi-toolkit validation (mocking Symfony/Laravel).
+- [x] Initialize repository with `getphred/console-contracts` dependency.
+- [x] Set up testing environment for multi-toolkit validation (mocking Symfony/Laravel).
## 2. Symfony Bridge
-- [ ] Implement `SymfonyCommandAdapter`.
-- [ ] Implement `SymfonyInputAdapter` and `SymfonyOutputAdapter`.
-- [ ] Implement helper adapters (Interaction, ProgressBar, Table).
-- [ ] Implement fixed markup translation (mapping to Symfony Formatter).
+- [x] Implement `SymfonyCommandAdapter`.
+- [x] Implement `SymfonyInputAdapter` and `SymfonyOutputAdapter`.
+- [x] Implement helper adapters (Interaction, ProgressBar, Table).
+- [x] Implement fixed markup translation (mapping to Symfony Formatter).
## 3. Laravel Bridge
-- [ ] Implement `LaravelCommandAdapter` and `LaravelServiceProvider`.
-- [ ] Integrate Symfony IO adapters within the Laravel context.
-- [ ] Implement native Laravel interaction helper mappings.
+- [x] Implement `LaravelCommandAdapter` and `LaravelServiceProvider`.
+- [x] Integrate Symfony IO adapters within the Laravel context.
+- [x] Implement native Laravel interaction helper mappings.
## 4. Phred (Native) Bridge
-- [ ] Implement ANSI-based `OutputInterface` with full markup support.
-- [ ] Implement lightweight interaction and progress helpers.
-- [ ] Implement regex-based Markdown-to-Markup converter.
+- [x] Implement ANSI-based `OutputInterface` with full markup support.
+- [x] Implement lightweight interaction and progress helpers.
+- [x] Implement regex-based Markdown-to-Markup converter.
## 5. Universal Integration
-- [ ] Implement universal exit code translation logic.
-- [ ] Implement global flag mapping (Verbosity, Decoration, Interactivity).
+- [x] Implement universal exit code translation logic.
+- [x] Implement global flag mapping (Verbosity, Decoration, Interactivity).
diff --git a/composer.json b/composer.json
index 19d91e7..fcf0bdd 100644
--- a/composer.json
+++ b/composer.json
@@ -7,6 +7,19 @@
"php": "^8.2",
"getphred/console-contracts": "dev-master"
},
+ "require-dev": {
+ "phpunit/phpunit": "^10.0",
+ "phpstan/phpstan": "^1.10",
+ "symfony/console": "^6.0 || ^7.0",
+ "illuminate/console": "^10.0 || ^11.0",
+ "illuminate/support": "^10.0 || ^11.0"
+ },
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../ConsoleContracts"
+ }
+ ],
"suggest": {
"getphred/tasker": "To use the PhredBridge with the Tasker runner",
"symfony/console": "To use the SymfonyBridge",
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..ac454e0
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,5 @@
+parameters:
+ level: 8
+ paths:
+ - src
+ treatPhpDocTypesAsCertain: false
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..739b75c
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,24 @@
+
+
+
+
+ tests
+
+
+
+
+
+
+
+ src
+
+
+
diff --git a/src/ExitCodeTranslator.php b/src/ExitCodeTranslator.php
new file mode 100644
index 0000000..365d1fd
--- /dev/null
+++ b/src/ExitCodeTranslator.php
@@ -0,0 +1,37 @@
+name = $phredCommand->getName();
+ $this->description = $phredCommand->getDescription();
+
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ foreach ($this->phredCommand->getArguments() as $name => $description) {
+ $this->addArgument($name, InputArgument::OPTIONAL, $description);
+ }
+
+ foreach ($this->phredCommand->getOptions() as $name => $description) {
+ $this->addOption($name, null, InputOption::VALUE_OPTIONAL, $description);
+ }
+ }
+
+ public function handle(): int
+ {
+ $phredInput = new SymfonyInputAdapter($this->input);
+ $phredOutput = new SymfonyOutputAdapter($this->output);
+
+ return $this->phredCommand->execute($phredInput, $phredOutput);
+ }
+}
diff --git a/src/Laravel/LaravelServiceProvider.php b/src/Laravel/LaravelServiceProvider.php
new file mode 100644
index 0000000..4ebb6e0
--- /dev/null
+++ b/src/Laravel/LaravelServiceProvider.php
@@ -0,0 +1,126 @@
+app->runningInConsole()) {
+ return;
+ }
+
+ $this->discoverCommands();
+ }
+
+ private function discoverCommands(): void
+ {
+ $basePath = function_exists('base_path') ? base_path() : getcwd();
+
+ // 1. Discover via composer.json (package-based/explicitly registered)
+ $this->discoverFromComposer($basePath);
+
+ // 2. Discover via app/Console/Commands (Laravel standard)
+ $this->discoverFromDirectory($basePath . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'Console' . DIRECTORY_SEPARATOR . 'Commands');
+ }
+
+ private function discoverFromComposer(string $basePath): void
+ {
+ $rootComposer = $basePath . DIRECTORY_SEPARATOR . 'composer.json';
+ if (!file_exists($rootComposer)) {
+ return;
+ }
+
+ $json = file_get_contents($rootComposer);
+ if ($json === false) {
+ return;
+ }
+
+ $composerData = json_decode($json, true);
+ if (!is_array($composerData)) {
+ return;
+ }
+
+ $commands = $composerData['extra']['phred-tasker']['commands'] ?? [];
+ $adapters = [];
+
+ foreach ((array)$commands as $commandClass) {
+ if (is_string($commandClass) && is_subclass_of($commandClass, CommandInterface::class)) {
+ $adapters[] = new LaravelCommandAdapter($this->app->make($commandClass));
+ }
+ }
+
+ if (!empty($adapters)) {
+ $this->commands($adapters);
+ }
+ }
+
+ private function discoverFromDirectory(string $path): void
+ {
+ if (!is_dir($path)) {
+ return;
+ }
+
+ $adapters = [];
+ $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path));
+
+ foreach ($files as $file) {
+ if (!$file->isFile() || $file->getExtension() !== 'php') {
+ continue;
+ }
+
+ $className = $this->resolveClassName($file->getPathname());
+
+ if ($className && is_subclass_of($className, CommandInterface::class)) {
+ $adapters[] = new LaravelCommandAdapter($this->app->make($className));
+ }
+ }
+
+ if (!empty($adapters)) {
+ $this->commands($adapters);
+ }
+ }
+
+ private function resolveClassName(string $path): ?string
+ {
+ $content = file_get_contents($path);
+ if ($content === false) {
+ return null;
+ }
+
+ $tokens = token_get_all($content);
+ $namespace = '';
+ $class = '';
+ $gettingNamespace = false;
+ $gettingClass = false;
+
+ foreach ($tokens as $index => $token) {
+ if (is_array($token)) {
+ if ($token[0] === T_NAMESPACE) {
+ $gettingNamespace = true;
+ } elseif ($token[0] === T_CLASS) {
+ $gettingClass = true;
+ } elseif ($gettingNamespace) {
+ if ($token[0] === T_STRING || $token[0] === T_NAME_QUALIFIED) {
+ $namespace .= $token[1];
+ } elseif ($token[1] === ';') {
+ $gettingNamespace = false;
+ }
+ } elseif ($gettingClass && $token[0] === T_STRING) {
+ $class = $token[1];
+ break;
+ }
+ }
+ }
+
+ return $namespace ? $namespace . '\\' . $class : $class;
+ }
+}
diff --git a/src/Phred/InteractionAdapter.php b/src/Phred/InteractionAdapter.php
new file mode 100644
index 0000000..67df093
--- /dev/null
+++ b/src/Phred/InteractionAdapter.php
@@ -0,0 +1,100 @@
+inputStream = $inputStream ?? STDIN;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function ask(string $question, ?string $default = null): string
+ {
+ $prompt = $question . ($default ? " [$default]" : '') . ': ';
+ echo $prompt;
+
+ $input = trim(fgets($this->inputStream) ?: '');
+
+ if ($input === '' && $default !== null) {
+ return $default;
+ }
+
+ return $input;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function confirm(string $question, bool $default = true): bool
+ {
+ $prompt = $question . ($default ? ' [Y/n]' : ' [y/N]') . ': ';
+ echo $prompt;
+
+ $input = strtolower(trim(fgets($this->inputStream) ?: ''));
+
+ if ($input === '') {
+ return $default;
+ }
+
+ return $input === 'y' || $input === 'yes';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function secret(string $question): string
+ {
+ echo $question . ': ';
+
+ // Hide input using stty if available
+ $sttyAvailable = shell_exec('stty 2>&1') !== null;
+
+ if ($sttyAvailable) {
+ shell_exec('stty -echo');
+ }
+
+ $input = trim(fgets($this->inputStream) ?: '');
+
+ if ($sttyAvailable) {
+ shell_exec('stty echo');
+ echo PHP_EOL;
+ }
+
+ return $input;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function choice(string $question, array $choices, mixed $default = null): mixed
+ {
+ echo $question . ':' . PHP_EOL;
+
+ foreach ($choices as $key => $choice) {
+ echo " [$key] $choice" . PHP_EOL;
+ }
+
+ $input = $this->ask('Choice', (string)$default);
+
+ return $choices[$input] ?? $default;
+ }
+}
diff --git a/src/Phred/MarkdownConverter.php b/src/Phred/MarkdownConverter.php
new file mode 100644
index 0000000..060712e
--- /dev/null
+++ b/src/Phred/MarkdownConverter.php
@@ -0,0 +1,150 @@
+$1', $result);
+ $result = (string)preg_replace('/^## (.*)$/m', '$1', $result);
+ $result = (string)preg_replace('/^### (.*)$/m', '$1', $result);
+
+ // Bold
+ $result = (string)preg_replace('/\*\*(.*?)\*\*/', '$1', $result);
+ $result = (string)preg_replace('/__(.*?)__/', '$1', $result);
+
+ // Italic
+ $result = (string)preg_replace('/\*(.*?)\*/', '$1', $result);
+ $result = (string)preg_replace('/_(.*?)_/', '$1', $result);
+
+ // Lists
+ $result = (string)preg_replace('/^[-*] (.*)$/m', ' • $1', $result);
+
+ // Code blocks
+ $result = (string)preg_replace('/```(.*?)```/s', '$1', $result);
+ $result = (string)preg_replace('/`(.*?)`/', '$1', $result);
+
+ // Tables
+ $result = $this->parseTables($result);
+
+ return $result;
+ }
+
+ private function parseTables(string $markdown): string
+ {
+ $lines = explode(PHP_EOL, $markdown);
+ $result = [];
+ $tableLines = [];
+ $inTable = false;
+
+ foreach ($lines as $line) {
+ $isSeparator = (bool)preg_match('/^\|?[:\s-]*\|[:\s-]*\|?/', trim($line));
+ $isRow = (bool)preg_match('/^\|.*\|$/', trim($line));
+
+ if ($isRow || ($inTable && $isSeparator)) {
+ $inTable = true;
+ $tableLines[] = $line;
+ } else {
+ if ($inTable) {
+ $result[] = $this->renderTable($tableLines);
+ $tableLines = [];
+ $inTable = false;
+ }
+ $result[] = $line;
+ }
+ }
+
+ if ($inTable) {
+ $result[] = $this->renderTable($tableLines);
+ }
+
+ return implode(PHP_EOL, $result);
+ }
+
+ /**
+ * @param array $lines
+ */
+ private function renderTable(array $lines): string
+ {
+ if (count($lines) < 2) {
+ return implode(PHP_EOL, $lines);
+ }
+
+ $headers = [];
+ $rows = [];
+ $alignments = [];
+ $headerLine = array_shift($lines);
+ $separatorLine = array_shift($lines);
+
+ // Parse Headers
+ $headers = array_map('trim', explode('|', trim($headerLine, '|')));
+
+ // Parse Alignments
+ if ($separatorLine !== null) {
+ $parts = explode('|', trim($separatorLine, '|'));
+ foreach ($parts as $index => $part) {
+ $part = trim($part);
+ if (str_starts_with($part, ':') && str_ends_with($part, ':')) {
+ $alignments[$index] = 'center';
+ } elseif (str_ends_with($part, ':')) {
+ $alignments[$index] = 'right';
+ } else {
+ $alignments[$index] = 'left';
+ }
+ }
+ }
+
+ // Parse Rows
+ foreach ($lines as $line) {
+ $rows[] = array_map('trim', explode('|', trim($line, '|')));
+ }
+
+ // Use TableAdapter to render
+ $table = new TableAdapter($this->output);
+ $table->setHeaders($headers);
+ $table->setColumnAlignments($alignments);
+ foreach ($rows as $row) {
+ $table->addRow($row);
+ }
+
+ ob_start();
+ $table->render();
+ return trim((string)ob_get_clean());
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function convertFile(string $path): string
+ {
+ if (!file_exists($path)) {
+ return '';
+ }
+
+ $content = file_get_contents($path);
+ if ($content === false) {
+ return '';
+ }
+
+ return $this->convert($content);
+ }
+}
diff --git a/src/Phred/ProgressBarAdapter.php b/src/Phred/ProgressBarAdapter.php
new file mode 100644
index 0000000..b54b7a6
--- /dev/null
+++ b/src/Phred/ProgressBarAdapter.php
@@ -0,0 +1,75 @@
+max = $max;
+ $this->current = 0;
+ $this->render();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function advance(int $step = 1): void
+ {
+ $this->current += $step;
+ if ($this->max > 0 && $this->current > $this->max) {
+ $this->current = $this->max;
+ }
+ $this->render();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function finish(): void
+ {
+ if ($this->max > 0) {
+ $this->current = $this->max;
+ }
+ $this->render();
+ $this->output->writeln('');
+ }
+
+ private function render(): void
+ {
+ $percent = $this->max > 0 ? ($this->current / $this->max) * 100 : 0;
+ $barWidth = 40;
+ $filledWidth = $this->max > 0 ? (int)round(($this->current / $this->max) * $barWidth) : 0;
+
+ $bar = str_repeat('█', $filledWidth) . str_repeat('_', $barWidth - $filledWidth);
+
+ $message = sprintf(
+ "\r [%s] %d%% (%d/%d)",
+ $bar,
+ $percent,
+ $this->current,
+ $this->max
+ );
+
+ $this->output->write($message);
+ }
+}
diff --git a/src/Phred/TableAdapter.php b/src/Phred/TableAdapter.php
new file mode 100644
index 0000000..ab0d6be
--- /dev/null
+++ b/src/Phred/TableAdapter.php
@@ -0,0 +1,137 @@
+
+ */
+ private array $headers = [];
+
+ /**
+ * @var array>
+ */
+ private array $rows = [];
+
+ /**
+ * @var array
+ */
+ private array $alignments = [];
+
+ public function __construct(private readonly OutputInterface $output)
+ {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setHeaders(array $headers): void
+ {
+ $this->headers = $headers;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function addRow(array $row): void
+ {
+ $this->rows[] = $row;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setColumnAlignments(array $alignments): void
+ {
+ $this->alignments = $alignments;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function render(): void
+ {
+ if (empty($this->headers) && empty($this->rows)) {
+ return;
+ }
+
+ $columnWidths = $this->calculateColumnWidths();
+
+ // Render headers
+ if (!empty($this->headers)) {
+ $this->renderRow($this->headers, $columnWidths);
+ $this->renderSeparator($columnWidths);
+ }
+
+ // Render rows
+ foreach ($this->rows as $row) {
+ $this->renderRow($row, $columnWidths);
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function calculateColumnWidths(): array
+ {
+ $widths = [];
+
+ foreach ($this->headers as $index => $header) {
+ $widths[$index] = mb_strlen((string)$header);
+ }
+
+ foreach ($this->rows as $row) {
+ foreach ($row as $index => $value) {
+ $width = mb_strlen((string)$value);
+ if (!isset($widths[$index]) || $width > $widths[$index]) {
+ $widths[$index] = $width;
+ }
+ }
+ }
+
+ return $widths;
+ }
+
+ /**
+ * @param array $row
+ * @param array $widths
+ */
+ private function renderRow(array $row, array $widths): void
+ {
+ $formatted = '|';
+ foreach ($widths as $index => $width) {
+ $value = (string)($row[$index] ?? '');
+ $alignment = $this->alignments[$index] ?? 'left';
+
+ $padType = match ($alignment) {
+ 'right' => STR_PAD_LEFT,
+ 'center' => STR_PAD_BOTH,
+ default => STR_PAD_RIGHT,
+ };
+
+ $formatted .= ' ' . str_pad($value, $width, ' ', $padType) . ' |';
+ }
+ $this->output->writeln($formatted);
+ }
+
+ /**
+ * @param array $widths
+ */
+ private function renderSeparator(array $widths): void
+ {
+ $separator = '+';
+ foreach ($widths as $width) {
+ $separator .= str_repeat('-', $width + 2) . '+';
+ }
+ $this->output->writeln($separator);
+ }
+}
diff --git a/src/Symfony/SymfonyCommandAdapter.php b/src/Symfony/SymfonyCommandAdapter.php
new file mode 100644
index 0000000..2652e2a
--- /dev/null
+++ b/src/Symfony/SymfonyCommandAdapter.php
@@ -0,0 +1,44 @@
+getName());
+ }
+
+ protected function configure(): void
+ {
+ $this->setDescription($this->phredCommand->getDescription());
+
+ foreach ($this->phredCommand->getArguments() as $name => $description) {
+ $this->addArgument($name, InputArgument::OPTIONAL, $description);
+ }
+
+ foreach ($this->phredCommand->getOptions() as $name => $description) {
+ $this->addOption($name, null, InputOption::VALUE_OPTIONAL, $description);
+ }
+ }
+
+ protected function execute(SymfonyInputInterface $input, SymfonyOutputInterface $output): int
+ {
+ $phredInput = new SymfonyInputAdapter($input);
+ $phredOutput = new SymfonyOutputAdapter($output);
+
+ return $this->phredCommand->execute($phredInput, $phredOutput);
+ }
+}
diff --git a/src/Symfony/SymfonyFormatter.php b/src/Symfony/SymfonyFormatter.php
new file mode 100644
index 0000000..f356a09
--- /dev/null
+++ b/src/Symfony/SymfonyFormatter.php
@@ -0,0 +1,39 @@
+getFormatter();
+
+ if (!$formatter->hasStyle('success')) {
+ $formatter->setStyle('success', new OutputFormatterStyle('green'));
+ }
+
+ if (!$formatter->hasStyle('info')) {
+ $formatter->setStyle('info', new OutputFormatterStyle('blue'));
+ }
+
+ if (!$formatter->hasStyle('warning')) {
+ $formatter->setStyle('warning', new OutputFormatterStyle('black', 'yellow'));
+ }
+
+ // Symfony already has and by default
+ }
+}
diff --git a/src/Symfony/SymfonyInputAdapter.php b/src/Symfony/SymfonyInputAdapter.php
new file mode 100644
index 0000000..80cabda
--- /dev/null
+++ b/src/Symfony/SymfonyInputAdapter.php
@@ -0,0 +1,42 @@
+symfonyInput->getArgument($name) ?? $default;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getOption(string $name, mixed $default = null): mixed
+ {
+ return $this->symfonyInput->getOption($name) ?? $default;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function hasOption(string $name): bool
+ {
+ return $this->symfonyInput->hasOption($name);
+ }
+}
diff --git a/src/Symfony/SymfonyInteractionAdapter.php b/src/Symfony/SymfonyInteractionAdapter.php
new file mode 100644
index 0000000..4091239
--- /dev/null
+++ b/src/Symfony/SymfonyInteractionAdapter.php
@@ -0,0 +1,64 @@
+helper->ask($this->input, $this->output, $q);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function confirm(string $question, bool $default = true): bool
+ {
+ $q = new ConfirmationQuestion($question, $default);
+ return (bool)$this->helper->ask($this->input, $this->output, $q);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function secret(string $question): string
+ {
+ $q = new Question($question);
+ $q->setHidden(true);
+ $q->setHiddenFallback(true);
+ return (string)$this->helper->ask($this->input, $this->output, $q);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function choice(string $question, array $choices, mixed $default = null): mixed
+ {
+ $q = new ChoiceQuestion($question, $choices, $default);
+ return $this->helper->ask($this->input, $this->output, $q);
+ }
+}
diff --git a/src/Symfony/SymfonyOutputAdapter.php b/src/Symfony/SymfonyOutputAdapter.php
new file mode 100644
index 0000000..e54eb13
--- /dev/null
+++ b/src/Symfony/SymfonyOutputAdapter.php
@@ -0,0 +1,108 @@
+symfonyOutput);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function write(string $message): void
+ {
+ $this->symfonyOutput->write($message);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function writeln(string $message): void
+ {
+ $this->symfonyOutput->writeln($message);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function success(string $message): void
+ {
+ $this->writeln("$message");
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function info(string $message): void
+ {
+ $this->writeln("$message");
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function error(string $message): void
+ {
+ $this->writeln("$message");
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function warning(string $message): void
+ {
+ $this->writeln("$message");
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function comment(string $message): void
+ {
+ $this->writeln("$message");
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setVerbosity(int $level): void
+ {
+ // Map Phred verbosity to Symfony verbosity
+ $symfonyLevel = match ($level) {
+ 0 => SymfonyOutputInterface::VERBOSITY_QUIET,
+ 1 => SymfonyOutputInterface::VERBOSITY_NORMAL,
+ 2 => SymfonyOutputInterface::VERBOSITY_VERBOSE,
+ 3 => SymfonyOutputInterface::VERBOSITY_VERY_VERBOSE,
+ 4 => SymfonyOutputInterface::VERBOSITY_DEBUG,
+ default => SymfonyOutputInterface::VERBOSITY_NORMAL,
+ };
+ $this->symfonyOutput->setVerbosity($symfonyLevel);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getVerbosity(): int
+ {
+ $symfonyLevel = $this->symfonyOutput->getVerbosity();
+ return match ($symfonyLevel) {
+ SymfonyOutputInterface::VERBOSITY_QUIET => 0,
+ SymfonyOutputInterface::VERBOSITY_NORMAL => 1,
+ SymfonyOutputInterface::VERBOSITY_VERBOSE => 2,
+ SymfonyOutputInterface::VERBOSITY_VERY_VERBOSE => 3,
+ SymfonyOutputInterface::VERBOSITY_DEBUG => 4,
+ default => 1,
+ };
+ }
+}
diff --git a/src/Symfony/SymfonyProgressBarAdapter.php b/src/Symfony/SymfonyProgressBarAdapter.php
new file mode 100644
index 0000000..da38341
--- /dev/null
+++ b/src/Symfony/SymfonyProgressBarAdapter.php
@@ -0,0 +1,42 @@
+progressBar->start($max);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function advance(int $step = 1): void
+ {
+ $this->progressBar->advance($step);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function finish(): void
+ {
+ $this->progressBar->finish();
+ }
+}
diff --git a/src/Symfony/SymfonyTableAdapter.php b/src/Symfony/SymfonyTableAdapter.php
new file mode 100644
index 0000000..9f05276
--- /dev/null
+++ b/src/Symfony/SymfonyTableAdapter.php
@@ -0,0 +1,54 @@
+table->setHeaders($headers);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function addRow(array $row): void
+ {
+ $this->table->addRow($row);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setColumnAlignments(array $alignments): void
+ {
+ // Symfony doesn't have a direct Index => Alignment method on the Table helper
+ // that works exactly like this, but we can set them individually
+ foreach ($alignments as $index => $alignment) {
+ $this->table->setColumnStyle($index, $alignment);
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function render(): void
+ {
+ $this->table->render();
+ }
+}
diff --git a/tests/LaravelBridgeTest.php b/tests/LaravelBridgeTest.php
new file mode 100644
index 0000000..956b9b5
--- /dev/null
+++ b/tests/LaravelBridgeTest.php
@@ -0,0 +1,51 @@
+ 'Name']; }
+ public function getOptions(): array { return []; }
+ public function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $output->info('Hello ' . $input->getArgument('name'));
+ return 0;
+ }
+ };
+
+ $adapter = new LaravelCommandAdapter($phredCommand);
+
+ // Mock Laravel components
+ $symfonyInput = new ArrayInput(['name' => 'John'], $adapter->getDefinition());
+ $symfonyOutput = new BufferedOutput();
+
+ // Use reflection to set protected properties as Laravel's constructor and run logic is complex
+ $refl = new \ReflectionClass($adapter);
+
+ $inputProp = $refl->getProperty('input');
+ $inputProp->setValue($adapter, $symfonyInput);
+
+ $outputProp = $refl->getProperty('output');
+ $outputProp->setValue($adapter, $symfonyOutput);
+
+ $exitCode = $adapter->handle();
+
+ $this->assertEquals(0, $exitCode);
+ $this->assertStringContainsString('Hello John', $symfonyOutput->fetch());
+ }
+}
diff --git a/tests/PhredBridgeTest.php b/tests/PhredBridgeTest.php
new file mode 100644
index 0000000..6a30505
--- /dev/null
+++ b/tests/PhredBridgeTest.php
@@ -0,0 +1,136 @@
+ 'val1'],
+ ['opt1' => 'val2']
+ );
+
+ $this->assertEquals('val1', $adapter->getArgument('arg1'));
+ $this->assertEquals('default', $adapter->getArgument('arg2', 'default'));
+ $this->assertEquals('val2', $adapter->getOption('opt1'));
+ $this->assertTrue($adapter->hasOption('opt1'));
+ $this->assertFalse($adapter->hasOption('opt2'));
+ }
+
+ public function testOutputAdapterFormatting(): void
+ {
+ $adapter = new OutputAdapter(true);
+
+ ob_start();
+ $adapter->info('test');
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString("\033[34mtest\033[0m", $output);
+
+ $adapter = new OutputAdapter(false);
+ ob_start();
+ $adapter->info('test');
+ $output = ob_get_clean();
+
+ $this->assertEquals("test" . PHP_EOL, $output);
+ }
+
+ public function testTableAdapter(): void
+ {
+ $output = new OutputAdapter(false);
+ $table = new TableAdapter($output);
+
+ $table->setHeaders(['ID', 'Name', 'Status']);
+ $table->setColumnAlignments([0 => 'right', 1 => 'left', 2 => 'center']);
+ $table->addRow([1, 'John', 'Active']);
+ $table->addRow([100, 'Jane', 'Inactive']);
+
+ ob_start();
+ $table->render();
+ $content = ob_get_clean();
+
+ // ID column (right aligned)
+ $this->assertStringContainsString('| 1 |', $content);
+ $this->assertStringContainsString('| 100 |', $content);
+
+ // Name column (left aligned)
+ $this->assertStringContainsString('| John |', $content);
+
+ // Status column (center aligned)
+ $this->assertStringContainsString('| Active |', $content);
+ }
+
+ public function testMarkdownConverter(): void
+ {
+ $output = new OutputAdapter(false);
+ $converter = new MarkdownConverter($output);
+
+ $this->assertEquals('Title', $converter->convert('# Title'));
+ $this->assertEquals('Bold', $converter->convert('**Bold**'));
+ $this->assertEquals('Italic', $converter->convert('*Italic*'));
+ $this->assertEquals('code', $converter->convert('`code`'));
+ }
+
+ public function testMarkdownTableConverter(): void
+ {
+ $output = new OutputAdapter(false);
+ $converter = new MarkdownConverter($output);
+
+ $markdown = <<convert($markdown);
+ $this->assertStringContainsString('| ID | Name |', $result);
+ $this->assertStringContainsString('| 1 | John |', $result);
+ }
+
+ public function testInteractionAdapter(): void
+ {
+ $input = fopen('php://memory', 'r+');
+ if ($input === false) {
+ $this->fail('Could not open memory stream');
+ }
+ fwrite($input, "John\ny\nsecret\n1\n");
+ rewind($input);
+
+ $adapter = new InteractionAdapter($input);
+
+ ob_start();
+ $this->assertEquals('John', $adapter->ask('Name'));
+ $this->assertTrue($adapter->confirm('Continue?'));
+ $this->assertEquals('secret', $adapter->secret('Password'));
+ $this->assertEquals('Choice 2', $adapter->choice('Select', ['0' => 'Choice 1', '1' => 'Choice 2']));
+ ob_end_clean();
+
+ fclose($input);
+ }
+
+ public function testProgressBarAdapter(): void
+ {
+ $output = new OutputAdapter(false);
+ $bar = new ProgressBarAdapter($output);
+
+ ob_start();
+ $bar->start(100);
+ $bar->advance(50);
+ $bar->finish();
+ $content = ob_get_clean();
+
+ $this->assertStringContainsString('50%', $content);
+ $this->assertStringContainsString('100%', $content);
+ }
+}
diff --git a/tests/SymfonyBridgeTest.php b/tests/SymfonyBridgeTest.php
new file mode 100644
index 0000000..47093eb
--- /dev/null
+++ b/tests/SymfonyBridgeTest.php
@@ -0,0 +1,78 @@
+ 'Name']; }
+ public function getOptions(): array { return []; }
+ public function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $output->info('Hello ' . $input->getArgument('name'));
+ return 0;
+ }
+ };
+
+ $adapter = new SymfonyCommandAdapter($phredCommand);
+ $app = new Application();
+ $app->add($adapter);
+ $app->setAutoExit(false);
+
+ $input = new ArrayInput(['test:phred', 'name' => 'John']);
+ $output = new BufferedOutput();
+
+ $exitCode = $app->run($input, $output);
+
+ $this->assertEquals(0, $exitCode);
+ $this->assertStringContainsString('Hello John', $output->fetch());
+ }
+
+ public function testVerbosityMapping(): void
+ {
+ $symfonyOutput = new BufferedOutput();
+ $adapter = new SymfonyOutputAdapter($symfonyOutput);
+
+ $adapter->setVerbosity(0);
+ $this->assertEquals(SymfonyOutputInterface::VERBOSITY_QUIET, $symfonyOutput->getVerbosity());
+
+ $adapter->setVerbosity(4);
+ $this->assertEquals(SymfonyOutputInterface::VERBOSITY_DEBUG, $symfonyOutput->getVerbosity());
+
+ $symfonyOutput->setVerbosity(SymfonyOutputInterface::VERBOSITY_VERBOSE);
+ $this->assertEquals(2, $adapter->getVerbosity());
+ }
+
+ public function testSymfonyHelpers(): void
+ {
+ $symfonyOutput = new BufferedOutput();
+ $table = new \Symfony\Component\Console\Helper\Table($symfonyOutput);
+ $adapter = new \Phred\TaskerBridges\Symfony\SymfonyTableAdapter($table);
+
+ $adapter->setHeaders(['ID', 'Name']);
+ $adapter->addRow([1, 'John']);
+ $adapter->render();
+
+ $output = $symfonyOutput->fetch();
+ $this->assertStringContainsString('ID', $output);
+ $this->assertStringContainsString('John', $output);
+ }
+}