diff --git a/packages/devai/src/Commands/InstallCommand.php b/packages/devai/src/Commands/InstallCommand.php index 863eb73f..b138b199 100644 --- a/packages/devai/src/Commands/InstallCommand.php +++ b/packages/devai/src/Commands/InstallCommand.php @@ -60,7 +60,13 @@ public function execute( $this->maybeInstallDocsDriver($input, $output, $projectRoot); - $result = $this->orchestrator->install($context, $projectRoot); + $result = $this->orchestrator->install( + $context, + $projectRoot, + static function (string $message) use ($output): void { + $output->writeLine($message); + }, + ); if ($result['status'] === 'skipped') { $output->writeLine($result['message'] ?? ''); @@ -115,6 +121,7 @@ private function maybeInstallDocsDriver( return; } + $output->writeLine("Installing $pkg via composer (this may take a moment)…"); $result = $this->commandRunner->run('composer', ['require', '--dev', $pkg]); if ($result['exitCode'] !== 0) { diff --git a/packages/devai/src/Installation/InstallationOrchestrator.php b/packages/devai/src/Installation/InstallationOrchestrator.php index c436036f..55bb3c51 100644 --- a/packages/devai/src/Installation/InstallationOrchestrator.php +++ b/packages/devai/src/Installation/InstallationOrchestrator.php @@ -35,7 +35,9 @@ public function __construct( public function install( InstallationContext $ctx, string $projectRoot, + ?callable $onProgress = null, ): array { + $progress = $onProgress ?? static function (string $message): void {}; $marker = $projectRoot . '/.marko/devai.json'; if (is_file($marker) && !$ctx->force) { return [ @@ -65,8 +67,9 @@ public function install( continue; } + $progress('Configuring agent: ' . $agentName . '…'); $agents[$agentName]->install($installCtx, $projectRoot); - $this->log[] = "[$agentName] installed"; + $this->log[] = "[$agentName] configured"; } foreach (GuidelinesWriter::takeNotices() as $notice) { @@ -87,8 +90,8 @@ public function install( $this->updateGitignore($projectRoot); } - $this->warmFrameworkCaches($projectRoot, $markoBin); - $this->buildDocsIndex($projectRoot, $markoBin); + $this->warmFrameworkCaches($projectRoot, $markoBin, $progress); + $this->buildDocsIndex($projectRoot, $markoBin, $progress); return ['status' => 'installed', 'log' => $this->log]; } @@ -104,6 +107,7 @@ public function install( private function warmFrameworkCaches( string $projectRoot, string $markoBin, + callable $progress, ): void { $commands = [ 'discovery:cache' => '[discovery] compiled discovery cache', @@ -111,6 +115,7 @@ private function warmFrameworkCaches( ]; foreach ($commands as $command => $successMessage) { + $progress('Running marko ' . $command . '…'); $result = $this->runner->run($markoBin, [$command]); if (($result['exitCode'] ?? 1) === 0) { @@ -136,6 +141,7 @@ private function warmFrameworkCaches( private function buildDocsIndex( string $projectRoot, string $markoBin, + callable $progress, ): void { $package = $this->docsDriverResolver->installedDriver($projectRoot); @@ -149,6 +155,7 @@ private function buildDocsIndex( $command = $this->docsDriverResolver->buildCommand($package); $driver = substr($package, (int) strpos($package, '/') + 1); + $progress("Building docs search index ($driver)…"); $result = $this->runner->run($markoBin, [$command]); if (($result['exitCode'] ?? 1) === 0) { diff --git a/packages/devai/src/Process/StdinPrompter.php b/packages/devai/src/Process/StdinPrompter.php index 3fabe197..e16c4850 100644 --- a/packages/devai/src/Process/StdinPrompter.php +++ b/packages/devai/src/Process/StdinPrompter.php @@ -8,10 +8,12 @@ class StdinPrompter implements ConfirmationPrompterInterface { /** * @param resource $stream + * @param resource $output */ public function __construct( private $stream = STDIN, private readonly bool $noInteraction = false, + private $output = STDOUT, ) {} public function isInteractive(): bool @@ -19,8 +21,13 @@ public function isInteractive(): bool return !$this->noInteraction && stream_isatty($this->stream); } - public function confirm(string $question, bool $default): bool - { + public function confirm( + string $question, + bool $default, + ): bool { + $hint = $default ? '[Y/n]' : '[y/N]'; + fwrite($this->output, "$question $hint "); + $line = fgets($this->stream); $answer = strtolower(trim($line !== false ? $line : '')); diff --git a/packages/devai/tests/Unit/Commands/UpdateCommandTest.php b/packages/devai/tests/Unit/Commands/UpdateCommandTest.php index 7072db1b..995a7589 100644 --- a/packages/devai/tests/Unit/Commands/UpdateCommandTest.php +++ b/packages/devai/tests/Unit/Commands/UpdateCommandTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Marko\CodeIndexer\Module\ModuleWalker; +use Marko\CodeIndexer\ValueObject\ModuleInfo; use Marko\Core\Attributes\Command; use Marko\Core\Command\CommandInterface; use Marko\Core\Command\Input; @@ -19,6 +20,7 @@ class UpdateCommandTestStubOrchestrator extends InstallationOrchestrator { public ?InstallationContext $capturedContext = null; + public bool $installCalled = false; /** @var array{status: string, log?: list} */ @@ -27,6 +29,7 @@ class UpdateCommandTestStubOrchestrator extends InstallationOrchestrator public function install( InstallationContext $ctx, string $projectRoot, + ?callable $onProgress = null, ): array { $this->capturedContext = $ctx; $this->installCalled = true; @@ -50,8 +53,7 @@ class UpdateCommandTestNullRunner implements CommandRunnerInterface public function run( string $command, array $args = [], - ): array - { + ): array { return ['exitCode' => 0, 'stdout' => '', 'stderr' => '']; } @@ -190,10 +192,10 @@ public function __construct(private string $authPath) {} public function walk(): array { - return [new \Marko\CodeIndexer\ValueObject\ModuleInfo( + return [new ModuleInfo( name: 'marko/authentication', path: $this->authPath, - namespace: '' + namespace: '', )]; } }; diff --git a/packages/devai/tests/Unit/Installation/InstallationOrchestratorTest.php b/packages/devai/tests/Unit/Installation/InstallationOrchestratorTest.php index 4a42ecad..cada5323 100644 --- a/packages/devai/tests/Unit/Installation/InstallationOrchestratorTest.php +++ b/packages/devai/tests/Unit/Installation/InstallationOrchestratorTest.php @@ -256,7 +256,35 @@ function makeInstallOrchestrator( expect($result['status'])->toBe('installed') ->and($result['log'])->toBeArray() ->and($result['log'])->not->toBeEmpty() - ->and(implode("\n", $result['log']))->toContain('[test-agent] installed'); + ->and(implode("\n", $result['log']))->toContain('[test-agent] configured'); +}); + +it('streams live progress messages to the supplied callback before each slow step runs', function (): void { + $agent = makeInstallSpyAgent(installed: true); + $orchestrator = makeInstallOrchestrator(makeInstallRegistry(['test-agent' => $agent])); + + $messages = []; + $orchestrator->install( + new InstallationContext(selectedAgents: ['test-agent']), + $this->tempRoot, + function (string $message) use (&$messages): void { + $messages[] = $message; + }, + ); + + $joined = implode("\n", $messages); + expect($joined)->toContain('Configuring agent: test-agent') + ->and($joined)->toContain('Running marko discovery:cache') + ->and($joined)->toContain('Running marko indexer:rebuild'); +}); + +it('treats the progress callback as optional and installs without one', function (): void { + $agent = makeInstallSpyAgent(installed: true); + $orchestrator = makeInstallOrchestrator(makeInstallRegistry(['test-agent' => $agent])); + + $result = $orchestrator->install(new InstallationContext(selectedAgents: ['test-agent']), $this->tempRoot); + + expect($result['status'])->toBe('installed'); }); it('invokes install() once per selected agent', function (): void { diff --git a/packages/devai/tests/Unit/Process/ConfirmationPrompterTest.php b/packages/devai/tests/Unit/Process/ConfirmationPrompterTest.php index cabe035b..1693a62c 100644 --- a/packages/devai/tests/Unit/Process/ConfirmationPrompterTest.php +++ b/packages/devai/tests/Unit/Process/ConfirmationPrompterTest.php @@ -28,8 +28,10 @@ public function isInteractive(): bool return $this->interactive; } - public function confirm(string $question, bool $default): bool - { + public function confirm( + string $question, + bool $default, + ): bool { return $this->answer; } }; @@ -82,6 +84,26 @@ public function confirm(string $question, bool $default): bool expect($prompter->isInteractive())->toBeFalse(); }); + + it('writes the question and a Y/n hint to output before reading the answer', function (): void { + $output = fopen('php://memory', 'r+'); + $prompter = new StdinPrompter(makeMemoryStream("y\n"), output: $output); + + $prompter->confirm('Install the thing?', default: true); + + rewind($output); + expect(stream_get_contents($output))->toBe('Install the thing? [Y/n] '); + }); + + it('reflects the default in the hint when the default is false', function (): void { + $output = fopen('php://memory', 'r+'); + $prompter = new StdinPrompter(makeMemoryStream("\n"), output: $output); + + $prompter->confirm('Proceed?', default: false); + + rewind($output); + expect(stream_get_contents($output))->toBe('Proceed? [y/N] '); + }); }); describe('FakePrompter', function (): void {