From 8397d95c74f0e1f2f9a462993e271409bdd53cf6 Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 17:04:33 +0100 Subject: [PATCH 01/11] style: apply Pint formatting Import ordering, fully-qualified name extraction, and a missing final newline across files untouched by the 0.4.4 fixes. No behaviour change, separated so the fix commits that follow read cleanly. --- src/Concerns/HasPromptTemplate.php | 7 ++-- src/Concerns/ResolvesVersion.php | 2 +- src/Console/Commands/PromptDiffCommand.php | 2 +- src/Facades/Deck.php | 3 +- src/Listeners/AfterMakeAgent.php | 3 +- tests/Architecture/ProvidersTest.php | 4 ++- tests/Feature/AfterMakeAgentTest.php | 32 ++++++++++--------- .../Commands/ActivatePromptCommandTest.php | 7 ++-- tests/Feature/MigrationTest.php | 3 +- tests/Unit/ExceptionsTest.php | 6 ++-- tests/Unit/PromptTest.php | 3 +- 11 files changed, 41 insertions(+), 31 deletions(-) diff --git a/src/Concerns/HasPromptTemplate.php b/src/Concerns/HasPromptTemplate.php index 9347100..7928b94 100644 --- a/src/Concerns/HasPromptTemplate.php +++ b/src/Concerns/HasPromptTemplate.php @@ -4,9 +4,10 @@ namespace PromptPHP\Deck\Concerns; -use Stringable; +use Laravel\Ai\Messages\Message; use PromptPHP\Deck\PromptManager; use PromptPHP\Deck\PromptTemplate; +use Stringable; /** * Trait for integrating Deck templates with Laravel AI SDK agents. @@ -135,9 +136,9 @@ public function promptMessages(?array $only = null): array ); // Convert to Laravel AI SDK Message objects if available. - if (class_exists(\Laravel\Ai\Messages\Message::class)) { + if (class_exists(Message::class)) { return array_map( - fn (array $msg) => new \Laravel\Ai\Messages\Message($msg['role'], $msg['content']), + fn (array $msg) => new Message($msg['role'], $msg['content']), $rawMessages ); } diff --git a/src/Concerns/ResolvesVersion.php b/src/Concerns/ResolvesVersion.php index 9cc1baa..49ebab3 100644 --- a/src/Concerns/ResolvesVersion.php +++ b/src/Concerns/ResolvesVersion.php @@ -26,4 +26,4 @@ public function parseVersion(string $value): ?int return (int) $matches[1]; } -} \ No newline at end of file +} diff --git a/src/Console/Commands/PromptDiffCommand.php b/src/Console/Commands/PromptDiffCommand.php index 86f76c3..0cfd62d 100644 --- a/src/Console/Commands/PromptDiffCommand.php +++ b/src/Console/Commands/PromptDiffCommand.php @@ -6,9 +6,9 @@ use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; +use PromptPHP\Deck\Exceptions\PromptNotFoundException; use SebastianBergmann\Diff\Differ; use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder; -use PromptPHP\Deck\Exceptions\PromptNotFoundException; class PromptDiffCommand extends Command { diff --git a/src/Facades/Deck.php b/src/Facades/Deck.php index cff01a3..019f576 100644 --- a/src/Facades/Deck.php +++ b/src/Facades/Deck.php @@ -5,6 +5,7 @@ namespace PromptPHP\Deck\Facades; use Illuminate\Support\Facades\Facade; +use PromptPHP\Deck\PromptManager; /** * @method static \PromptPHP\Deck\PromptTemplate get(string $name, string|int|null $version = null) @@ -13,7 +14,7 @@ * @method static bool activate(string $name, int $version) * @method static void track(string $promptName, int $version, array $data) * - * @see \PromptPHP\Deck\PromptManager + * @see PromptManager */ class Deck extends Facade { diff --git a/src/Listeners/AfterMakeAgent.php b/src/Listeners/AfterMakeAgent.php index c96e2d8..bea9a85 100644 --- a/src/Listeners/AfterMakeAgent.php +++ b/src/Listeners/AfterMakeAgent.php @@ -5,6 +5,7 @@ namespace PromptPHP\Deck\Listeners; use Illuminate\Console\Events\CommandFinished; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Str; /** @@ -56,7 +57,7 @@ public function handle(CommandFinished $event): void return; } - $exitCode = \Illuminate\Support\Facades\Artisan::call('make:prompt', [ + $exitCode = Artisan::call('make:prompt', [ 'name' => $promptName, ]); diff --git a/tests/Architecture/ProvidersTest.php b/tests/Architecture/ProvidersTest.php index 8f8d52b..37ce2bd 100644 --- a/tests/Architecture/ProvidersTest.php +++ b/tests/Architecture/ProvidersTest.php @@ -1,6 +1,8 @@ expect('PromptPHP\Deck\Providers') ->classes() - ->toExtend(\Illuminate\Support\ServiceProvider::class); + ->toExtend(ServiceProvider::class); diff --git a/tests/Feature/AfterMakeAgentTest.php b/tests/Feature/AfterMakeAgentTest.php index 56a6f45..418abaf 100644 --- a/tests/Feature/AfterMakeAgentTest.php +++ b/tests/Feature/AfterMakeAgentTest.php @@ -3,10 +3,12 @@ declare(strict_types=1); use Illuminate\Console\Events\CommandFinished; +use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Artisan; +use PromptPHP\Deck\Listeners\AfterMakeAgent; use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\BufferedOutput; -use PromptPHP\Deck\Listeners\AfterMakeAgent; // ===================================================================== // Listener instantiation @@ -56,14 +58,14 @@ $promptDir = "{$basePath}/sales-coach"; if (is_dir($promptDir)) { - (new \Illuminate\Filesystem\Filesystem)->deleteDirectory($promptDir); + (new Filesystem)->deleteDirectory($promptDir); } $input = new ArrayInput(['name' => 'SalesCoach']); $output = new BufferedOutput; // Bind the 'name' argument explicitly since ArrayInput needs definition. - $input = Mockery::mock(\Symfony\Component\Console\Input\InputInterface::class); + $input = Mockery::mock(InputInterface::class); $input->shouldReceive('getArgument')->with('name')->andReturn('SalesCoach'); $output = new BufferedOutput; @@ -83,13 +85,13 @@ expect($text)->toContain('SalesCoach'); // Cleanup. - (new \Illuminate\Filesystem\Filesystem)->deleteDirectory("{$basePath}/sales-coach"); + (new Filesystem)->deleteDirectory("{$basePath}/sales-coach"); }); test('handle() converts PascalCase agent names to kebab-case prompts', function () { $basePath = config('deck.path'); - $input = Mockery::mock(\Symfony\Component\Console\Input\InputInterface::class); + $input = Mockery::mock(InputInterface::class); $input->shouldReceive('getArgument')->with('name')->andReturn('DocumentAnalyzer'); $output = new BufferedOutput; @@ -105,7 +107,7 @@ expect($text)->toContain('document-analyzer'); // Cleanup. - (new \Illuminate\Filesystem\Filesystem)->deleteDirectory("{$basePath}/document-analyzer"); + (new Filesystem)->deleteDirectory("{$basePath}/document-analyzer"); }); // ===================================================================== @@ -115,7 +117,7 @@ test('handle() skips when scaffold_on_make_agent config is false', function () { config()->set('deck.scaffold_on_make_agent', false); - $input = Mockery::mock(\Symfony\Component\Console\Input\InputInterface::class); + $input = Mockery::mock(InputInterface::class); $input->shouldNotReceive('getArgument'); $output = new BufferedOutput; @@ -129,7 +131,7 @@ }); test('handle() skips when input returns null for name argument', function () { - $input = Mockery::mock(\Symfony\Component\Console\Input\InputInterface::class); + $input = Mockery::mock(InputInterface::class); $input->shouldReceive('getArgument')->with('name')->andReturn(null); $output = new BufferedOutput; @@ -143,7 +145,7 @@ }); test('handle() skips when agent name argument is empty', function () { - $input = Mockery::mock(\Symfony\Component\Console\Input\InputInterface::class); + $input = Mockery::mock(InputInterface::class); $input->shouldReceive('getArgument')->with('name')->andReturn(''); $output = new BufferedOutput; @@ -157,8 +159,8 @@ }); test('handle() skips when getArgument throws', function () { - $input = Mockery::mock(\Symfony\Component\Console\Input\InputInterface::class); - $input->shouldReceive('getArgument')->with('name')->andThrow(new \RuntimeException('No such argument')); + $input = Mockery::mock(InputInterface::class); + $input->shouldReceive('getArgument')->with('name')->andThrow(new RuntimeException('No such argument')); $output = new BufferedOutput; @@ -176,7 +178,7 @@ // Pre-create the prompt via make:prompt. Artisan::call('make:prompt', ['name' => 'existing-agent']); - $input = Mockery::mock(\Symfony\Component\Console\Input\InputInterface::class); + $input = Mockery::mock(InputInterface::class); $input->shouldReceive('getArgument')->with('name')->andReturn('ExistingAgent'); $output = new BufferedOutput; @@ -191,13 +193,13 @@ expect($output->fetch())->not->toContain('Deck'); // Cleanup. - (new \Illuminate\Filesystem\Filesystem)->deleteDirectory("{$basePath}/existing-agent"); + (new Filesystem)->deleteDirectory("{$basePath}/existing-agent"); }); test('handle() strips namespace prefix from agent name', function () { $basePath = config('deck.path'); - $input = Mockery::mock(\Symfony\Component\Console\Input\InputInterface::class); + $input = Mockery::mock(InputInterface::class); $input->shouldReceive('getArgument')->with('name')->andReturn('App\\Ai\\Agents\\SupportBot'); $output = new BufferedOutput; @@ -214,5 +216,5 @@ expect($text)->toContain('support-bot'); // Cleanup. - (new \Illuminate\Filesystem\Filesystem)->deleteDirectory("{$basePath}/support-bot"); + (new Filesystem)->deleteDirectory("{$basePath}/support-bot"); }); diff --git a/tests/Feature/Commands/ActivatePromptCommandTest.php b/tests/Feature/Commands/ActivatePromptCommandTest.php index c45345d..572a7b5 100644 --- a/tests/Feature/Commands/ActivatePromptCommandTest.php +++ b/tests/Feature/Commands/ActivatePromptCommandTest.php @@ -1,6 +1,7 @@ createPromptFixture('act-prompt', 1, 'sys v1', 'usr v1'); @@ -16,12 +17,12 @@ test('prompt:activate returns failure when exception is thrown', function () { // We mock the PromptManager to throw an exception. - $mock = \Mockery::mock(\PromptPHP\Deck\PromptManager::class); + $mock = Mockery::mock(PromptManager::class); $mock->shouldReceive('activate') ->with('bad-prompt', 1) - ->andThrow(new \Exception('Something went wrong')); + ->andThrow(new Exception('Something went wrong')); - $this->app->instance(\PromptPHP\Deck\PromptManager::class, $mock); + $this->app->instance(PromptManager::class, $mock); $this->artisan('prompt:activate', ['name' => 'bad-prompt', 'version' => 1]) ->expectsOutput('Something went wrong') diff --git a/tests/Feature/MigrationTest.php b/tests/Feature/MigrationTest.php index 5a89546..1d0bfe9 100644 --- a/tests/Feature/MigrationTest.php +++ b/tests/Feature/MigrationTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use PromptPHP\Deck\Models\PromptExecution; @@ -132,7 +133,7 @@ function rollbackMigrations(): void 'created_at' => now(), 'updated_at' => now(), ]); -})->throws(\Illuminate\Database\QueryException::class); +})->throws(QueryException::class); test('prompt_versions allows same name with different versions', function () { runMigrations(); diff --git a/tests/Unit/ExceptionsTest.php b/tests/Unit/ExceptionsTest.php index 18561da..8210793 100644 --- a/tests/Unit/ExceptionsTest.php +++ b/tests/Unit/ExceptionsTest.php @@ -3,8 +3,8 @@ declare(strict_types=1); use PromptPHP\Deck\Exceptions\ConfigurationException; -use PromptPHP\Deck\Exceptions\InvalidVersionException; use PromptPHP\Deck\Exceptions\DeckException; +use PromptPHP\Deck\Exceptions\InvalidVersionException; use PromptPHP\Deck\Exceptions\PromptNotFoundException; use PromptPHP\Deck\Exceptions\PromptRenderingException; @@ -12,14 +12,14 @@ test('DeckException extends base Exception', function () { expect(DeckException::class) - ->toExtend(\Exception::class); + ->toExtend(Exception::class); }); test('ConfigurationException extends DeckException', function () { $e = ConfigurationException::invalidPath('/some/path'); expect($e)->toBeInstanceOf(DeckException::class) - ->and($e)->toBeInstanceOf(\Exception::class); + ->and($e)->toBeInstanceOf(Exception::class); }); test('InvalidVersionException extends DeckException', function () { diff --git a/tests/Unit/PromptTest.php b/tests/Unit/PromptTest.php index ba68e0c..f38984e 100644 --- a/tests/Unit/PromptTest.php +++ b/tests/Unit/PromptTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Contracts\Support\Arrayable; use PromptPHP\Deck\PromptTemplate; // ===================================================================== @@ -308,5 +309,5 @@ test('Prompt implements Arrayable', function () { $prompt = new PromptTemplate('test', 1, ['system' => 'sys']); - expect($prompt)->toBeInstanceOf(\Illuminate\Contracts\Support\Arrayable::class); + expect($prompt)->toBeInstanceOf(Arrayable::class); }); From ae9b34b737321c806e08fda56753713fdf5e8b65 Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 17:05:39 +0100 Subject: [PATCH 02/11] chore: refine Composer package keywords --- composer.json | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index e55e63b..438f219 100644 --- a/composer.json +++ b/composer.json @@ -15,9 +15,7 @@ } ], "keywords": [ - "promptphp", - "deck", - "prompt-deck", + "laravel", "ai", "prompts", "prompt-management", @@ -26,9 +24,7 @@ "variable-interpolation", "performance-tracking", "ab-testing", - "laravel", - "laravel-ai", - "laravel-package" + "promptphp" ], "require": { "php": "^8.2", From 10fd1b5e6e1a8331176f7af35577756bbd7c2d50 Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 17:06:07 +0100 Subject: [PATCH 03/11] chore: split linting out of the Composer test script --- composer.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 438f219..0f1a18c 100644 --- a/composer.json +++ b/composer.json @@ -67,9 +67,11 @@ } }, "scripts": { - "test": [ - "vendor/bin/pint --test", - "vendor/bin/pest" + "test": "vendor/bin/pest", + "test:architecture": "vendor/bin/pest tests/Architecture", + "format": "vendor/bin/pint", + "test:lint": [ + "vendor/bin/pint --parallel --test" ] }, "minimum-stability": "dev", From 01dac7e01a200343697533af8d92918b6a3a4ad4 Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 17:08:35 +0100 Subject: [PATCH 04/11] build: correct the Database factories PSR-4 mapping The mapping pointed at `src/database/factories/`, which does not exist. It resolved only by falling back to the broader `src/` mapping. Also drops the Seeders mapping, which points at no directory in any casing. --- composer.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 0f1a18c..cd4dc80 100644 --- a/composer.json +++ b/composer.json @@ -47,8 +47,7 @@ "autoload": { "psr-4": { "PromptPHP\\Deck\\": "src/", - "PromptPHP\\Deck\\Database\\Factories\\": "src/database/factories/", - "PromptPHP\\Deck\\Database\\Seeders\\": "src/database/seeders/" + "PromptPHP\\Deck\\Database\\Factories\\": "src/Database/Factories/" } }, "autoload-dev": { From 511342c824824ff1617bd596408a8018eb1cf0fc Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 17:12:27 +0100 Subject: [PATCH 05/11] fix: publish migrations from the correct directory `registerPublishing()` pointed at `src/database/migrations`, but the directory is `src/Database/migrations`. On case-sensitive filesystems `vendor:publish --tag=deck-migrations` reported success without copying anything, leaving `database/migrations` empty on Linux and case-sensitive macOS volumes. Adds a test asserting every publishable source path the provider registers actually exists on disk. --- src/Providers/DeckServiceProvider.php | 11 ++++-- tests/Feature/DeckServiceProviderTest.php | 46 ++++++++++++++++++++--- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/Providers/DeckServiceProvider.php b/src/Providers/DeckServiceProvider.php index 30ffe7b..848d324 100644 --- a/src/Providers/DeckServiceProvider.php +++ b/src/Providers/DeckServiceProvider.php @@ -7,11 +7,14 @@ use Illuminate\Console\Events\CommandFinished; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use Laravel\Ai\AiServiceProvider; +use PromptPHP\Deck\Ai\TrackPromptMiddleware; use PromptPHP\Deck\Console\Commands\ActivatePromptCommand; use PromptPHP\Deck\Console\Commands\ListPromptsCommand; use PromptPHP\Deck\Console\Commands\MakePromptCommand; use PromptPHP\Deck\Console\Commands\PromptDiffCommand; use PromptPHP\Deck\Console\Commands\TestPromptCommand; +use PromptPHP\Deck\Listeners\AfterMakeAgent; use PromptPHP\Deck\PromptManager; class DeckServiceProvider extends ServiceProvider @@ -66,7 +69,7 @@ protected function registerPublishing(): void // Publish migrations. if ($this->app->runningInConsole()) { $this->publishes([ - __DIR__.'/../database/migrations/' => database_path('migrations'), + __DIR__.'/../Database/migrations/' => database_path('migrations'), ], 'deck-migrations'); // Publish config. @@ -97,14 +100,14 @@ protected function registerArtisanCommands(): void */ protected function registerAiSdkIntegration(): void { - if (class_exists(\Laravel\Ai\AiServiceProvider::class)) { - $this->app->singleton(\PromptPHP\Deck\Ai\TrackPromptMiddleware::class); + if (class_exists(AiServiceProvider::class)) { + $this->app->singleton(TrackPromptMiddleware::class); // Auto-scaffold a prompt when `make:agent` finishes successfully. if (config('deck.scaffold_on_make_agent', true)) { Event::listen( CommandFinished::class, - \PromptPHP\Deck\Listeners\AfterMakeAgent::class + AfterMakeAgent::class ); } } diff --git a/tests/Feature/DeckServiceProviderTest.php b/tests/Feature/DeckServiceProviderTest.php index 7509a64..c99f5d3 100644 --- a/tests/Feature/DeckServiceProviderTest.php +++ b/tests/Feature/DeckServiceProviderTest.php @@ -2,7 +2,10 @@ declare(strict_types=1); +use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\ServiceProvider; use PromptPHP\Deck\PromptManager; +use PromptPHP\Deck\Providers\DeckServiceProvider; test('PromptManager is registered as a singleton', function () { $instance1 = $this->app->make(PromptManager::class); @@ -32,7 +35,7 @@ }); test('Artisan commands are registered', function () { - $commands = \Illuminate\Support\Facades\Artisan::all(); + $commands = Artisan::all(); expect($commands)->toHaveKey('make:prompt') ->and($commands)->toHaveKey('prompt:list') @@ -43,18 +46,51 @@ test('publishable config is registered', function () { // Verify the provider has registered publishable resources. - $publishes = \Illuminate\Support\ServiceProvider::pathsToPublish( - \PromptPHP\Deck\Providers\DeckServiceProvider::class, + $publishes = ServiceProvider::pathsToPublish( + DeckServiceProvider::class, 'deck-config' ); expect($publishes)->not->toBeEmpty(); }); +test('every publishable source path exists on disk', function () { + // A registered path that does not exist publishes nothing while still + // reporting success — the failure mode is completely silent, so assert + // that every source we advertise is really there. Directory casing + // mistakes only surface on case-sensitive filesystems. + $publishes = ServiceProvider::pathsToPublish( + DeckServiceProvider::class + ); + + expect($publishes)->not->toBeEmpty(); + + $missing = array_values(array_filter( + array_keys($publishes), + fn (string $source) => ! file_exists($source), + )); + + expect($missing)->toBe([]); +}); + +test('migrations are publishable and the source directory holds the migrations', function () { + $publishes = ServiceProvider::pathsToPublish( + DeckServiceProvider::class, + 'deck-migrations' + ); + + expect($publishes)->not->toBeEmpty(); + + $source = array_key_first($publishes); + + expect(is_dir($source))->toBeTrue() + ->and(glob(rtrim($source, '/').'/*.php'))->toHaveCount(2); +}); + test('stubs are not included in default provider publishing', function () { // When publishing via --provider, stubs should not be included. - $allPublishes = \Illuminate\Support\ServiceProvider::pathsToPublish( - \PromptPHP\Deck\Providers\DeckServiceProvider::class + $allPublishes = ServiceProvider::pathsToPublish( + DeckServiceProvider::class ); $stubPaths = array_filter($allPublishes, fn ($path) => str_contains($path, '.stub')); From 2f116470f8d8d68d083c858b35e049342f93d42c Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 17:18:12 +0100 Subject: [PATCH 06/11] fix: preserve and surface prompt metadata across versions Two halves of the two-level metadata scheme were never wired up. `make:prompt` rewrote the prompt's root `metadata.json` from scratch, discarding `active_version`, so scaffolding a draft silently promoted it to active. The file is now merged, preserving `active_version`, the existing description, the original `created_at`, and any hand-added keys. `make:prompt` recorded name, description, and roles at the prompt root, but `PromptManager` only ever read the version-level `v{n}/metadata.json`, so `metadata()` was always empty and the `prompt:list` description column always blank. `make:prompt` now writes version-level metadata, and reads merge the root file under the version file. `active_version` is excluded from `metadata()`, being routing state rather than template metadata. Since a new version no longer becomes active, `make:prompt` now prints how to promote it. --- docs/advanced/api-reference.mdx | 2 +- docs/core/commands.mdx | 2 +- docs/core/make-prompt.mdx | 42 ++++- docs/core/prompts.mdx | 13 +- src/Concerns/ReadsJsonFiles.php | 36 ++++ src/Console/Commands/MakePromptCommand.php | 52 +++++- src/PromptManager.php | 54 +++--- .../Commands/ListPromptsCommandTest.php | 41 +++++ .../Commands/MakePromptCommandTest.php | 159 +++++++++++++++++- tests/Unit/PromptManagerTest.php | 74 ++++++++ 10 files changed, 431 insertions(+), 44 deletions(-) create mode 100644 src/Concerns/ReadsJsonFiles.php diff --git a/docs/advanced/api-reference.mdx b/docs/advanced/api-reference.mdx index da57f2b..84dc0cb 100644 --- a/docs/advanced/api-reference.mdx +++ b/docs/advanced/api-reference.mdx @@ -185,7 +185,7 @@ $prompt->name(); // 'order-summary' #### `metadata(): array` -Get the prompt metadata. Returns an empty array if no metadata is defined. +Get the prompt metadata: the prompt's root `metadata.json` merged with the version's own `metadata.json`, version-level keys winning. The `active_version` key is excluded. Returns an empty array if no metadata is defined. ```php $prompt->metadata(); // ['description' => '...', 'variables' => [...]] diff --git a/docs/core/commands.mdx b/docs/core/commands.mdx index c1e09a6..69a2550 100644 --- a/docs/core/commands.mdx +++ b/docs/core/commands.mdx @@ -142,7 +142,7 @@ Output: - If the prompts directory does not exist, a warning is displayed. - If no prompts are found, an informational message is shown. -- Descriptions come from each version's `metadata.json`. +- Descriptions come from the prompt's root `metadata.json`, and a version may override the shared description in its own `metadata.json`. ## prompt:activate diff --git a/docs/core/make-prompt.mdx b/docs/core/make-prompt.mdx index d5d77db..bc8cdd8 100644 --- a/docs/core/make-prompt.mdx +++ b/docs/core/make-prompt.mdx @@ -86,17 +86,22 @@ resources/prompts/ │ ├── system.md # Always created │ ├── user.md # Created with --user or -u │ ├── assistant.md # Created with --role=assistant - │ └── developer.md # Created with --role=developer + │ ├── developer.md # Created with --role=developer + │ └── metadata.json # This version's metadata ├── v2/ │ └── ... - └── metadata.json + └── metadata.json # Prompt-level metadata ``` The file extension is controlled by the `deck.extension` configuration value (default: `md`). For example, setting it to `txt` produces `system.txt`, `user.txt`, etc. ### Metadata -A `metadata.json` file is written to the prompt root directory each time the command runs. It captures: +The command writes two metadata files: one at the prompt root describing the prompt as a whole, and one inside the version directory describing that version. + +#### Prompt metadata + +`/metadata.json` is **merged**, never replaced, each time the command runs: ```json { @@ -111,10 +116,33 @@ A `metadata.json` file is written to the prompt root directory each time the com | Field | Description | | ------------- | ------------------------------------------------------------------------------- | | `name` | The kebab-case prompt name. | -| `description` | A human-readable summary. Populated via `--desc=` or the interactive flow. | -| `roles` | An ordered list of every role that was scaffolded. Always starts with `system`. | -| `variables` | Reserved for future use (template variable extraction). | -| `created_at` | ISO 8601 timestamp of creation. | +| `description` | A human-readable summary. Populated via `--desc=` or the interactive flow. Kept as-is when you create a new version without supplying a new description. | +| `roles` | An ordered list of every role scaffolded for the version just created. Always starts with `system`. | +| `variables` | Reserved for future use (template variable extraction). Never reset once you populate it. | +| `created_at` | ISO 8601 timestamp of when the **prompt** was first created. | + +Any other keys you add by hand are preserved — including `active_version`, so **scaffolding a new version never changes which version your application serves**. When another version is active, the command tells you how to promote the one you just created: + +``` +Version 2 of the [order-summary] prompt has been created successfully with the following roles: system. + +v1 is still the active version. +Run `php artisan prompt:activate order-summary v2` to make v2 live. +``` + +#### Version metadata + +`/v{n}/metadata.json` records that version alone: + +```json +{ + "version": 2, + "roles": ["system", "user"], + "created_at": "2025-01-20T09:12:00+00:00" +} +``` + +Add your own keys here to override prompt-level metadata for a single version — see [Metadata](/core/prompts#metadata) for how the two files merge. ## Roles diff --git a/docs/core/prompts.mdx b/docs/core/prompts.mdx index a4d9951..50daf51 100644 --- a/docs/core/prompts.mdx +++ b/docs/core/prompts.mdx @@ -161,14 +161,23 @@ if ($prompt->has('assistant')) { ### Metadata -Each prompt version can carry metadata (stored in `metadata.json` at the version level). Access it via the `metadata` method: +Metadata comes from two files, and `metadata` returns them merged: + +1. The prompt's root `metadata.json` — shared by every version (name, description, and anything else you record there). +2. The version's own `v{n}/metadata.json` — specific to that one version. + +Version-level keys win when both files define the same key, so a version can override the shared description without affecting its siblings. ```php $prompt->metadata(); // ['description' => 'Summarises customer orders', 'variables' => ['tone', 'input'], ...] ``` -Metadata is an associative array. If no `metadata.json` exists in the version directory, an empty array is returned. +Metadata is an associative array. If neither file exists, an empty array is returned. + + + The `active_version` key is never included. It records which version your application serves, which is routing state rather than metadata about the template you loaded. Read it with `Deck::active()` instead. + ### Name and version diff --git a/src/Concerns/ReadsJsonFiles.php b/src/Concerns/ReadsJsonFiles.php new file mode 100644 index 0000000..c29799b --- /dev/null +++ b/src/Concerns/ReadsJsonFiles.php @@ -0,0 +1,36 @@ + The decoded contents, or an empty array when the + * file is absent, unreadable, or is not a JSON object. + */ + protected function readJson(string $path): array + { + if (! $this->files->exists($path)) { + return []; + } + + $decoded = json_decode($this->files->get($path), true); + + return is_array($decoded) ? $decoded : []; + } +} diff --git a/src/Console/Commands/MakePromptCommand.php b/src/Console/Commands/MakePromptCommand.php index a2da658..3a55c06 100644 --- a/src/Console/Commands/MakePromptCommand.php +++ b/src/Console/Commands/MakePromptCommand.php @@ -6,9 +6,12 @@ use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; +use PromptPHP\Deck\Concerns\ReadsJsonFiles; class MakePromptCommand extends Command { + use ReadsJsonFiles; + protected $signature = 'make:prompt {name? : The name of the prompt} {--from= : Path to a stub file to use as template} {--desc= : A short description of what this prompt does} @@ -89,30 +92,63 @@ public function handle(): int $this->files->put($roleFile, $this->getRoleStubContent($roleName)); } - // Create metadata.json + // Build the role list for this version. $allRoles = ['system']; if ($createUser) { $allRoles[] = 'user'; } - $allRoles = array_merge($allRoles, array_map([$this, 'toKebabCase'], $roles)); + $allRoles = array_values(array_merge($allRoles, array_map([$this, 'toKebabCase'], $roles))); + + // Merge into the prompt-level metadata rather than replacing it, so keys + // this command does not own survive — above all `active_version`, which + // decides which version the application actually serves. + $existing = $this->readJson("{$promptPath}/metadata.json"); - $metadata = [ + $metadata = array_merge($existing, [ 'name' => $name, - 'description' => $description, - 'roles' => array_values($allRoles), - 'variables' => [], - 'created_at' => now()->toIso8601String(), - ]; + 'description' => $description !== '' ? $description : ($existing['description'] ?? ''), + 'roles' => $allRoles, + 'variables' => $existing['variables'] ?? [], + 'created_at' => $existing['created_at'] ?? now()->toIso8601String(), + ]); + $this->files->put("{$promptPath}/metadata.json", json_encode($metadata, JSON_PRETTY_PRINT)); + // Write this version's own metadata — the file PromptManager reads when + // resolving a template's metadata. + $this->files->put("{$versionPath}/metadata.json", json_encode([ + 'version' => $version, + 'roles' => $allRoles, + 'created_at' => now()->toIso8601String(), + ], JSON_PRETTY_PRINT)); + $roleList = implode(', ', $allRoles); $this->info("Version {$version} of the [{$name}] prompt has been created successfully with the following roles: {$roleList}."); + $this->hintActivation($name, $version, $existing['active_version'] ?? null); + return Command::SUCCESS; } + /** + * Tell the user how to promote the version just created. + * + * Scaffolding a version deliberately leaves the active version alone, so + * without this hint a newly created version can look like it did nothing. + */ + protected function hintActivation(string $name, int $version, mixed $activeVersion): void + { + if ($activeVersion === null || (int) $activeVersion === $version) { + return; + } + + $this->newLine(); + $this->comment("v{$activeVersion} is still the active version."); + $this->comment("Run `php artisan prompt:activate {$name} v{$version}` to make v{$version} live."); + } + /** * Determine which version directory to create. * diff --git a/src/PromptManager.php b/src/PromptManager.php index 2650802..13bf2df 100644 --- a/src/PromptManager.php +++ b/src/PromptManager.php @@ -8,12 +8,14 @@ use Illuminate\Contracts\Config\Repository as Config; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\DB; +use PromptPHP\Deck\Concerns\ReadsJsonFiles; use PromptPHP\Deck\Concerns\ResolvesVersion; use PromptPHP\Deck\Exceptions\InvalidVersionException; use PromptPHP\Deck\Exceptions\PromptNotFoundException; class PromptManager { + use ReadsJsonFiles; use ResolvesVersion; protected Filesystem $files; @@ -160,9 +162,7 @@ public function activate(string $name, int $version): bool // Fallback: store in a JSON file in the prompt directory. $metadataFile = "{$this->basePath}/{$name}/metadata.json"; - $metadata = $this->files->exists($metadataFile) - ? json_decode($this->files->get($metadataFile), true) ?? [] - : []; + $metadata = $this->readJson($metadataFile); $metadata['active_version'] = $version; @@ -216,14 +216,10 @@ protected function getActiveVersion(string $name): int } // Fallback to metadata.json. - $metadataFile = "{$this->basePath}/{$name}/metadata.json"; - - if ($this->files->exists($metadataFile)) { - $metadata = json_decode($this->files->get($metadataFile), true); + $metadata = $this->readJson("{$this->basePath}/{$name}/metadata.json"); - if (isset($metadata['active_version'])) { - return (int) $metadata['active_version']; - } + if (isset($metadata['active_version'])) { + return (int) $metadata['active_version']; } // If no active version set, return the highest version number. @@ -261,31 +257,41 @@ protected function loadFromFiles(string $name, ?int $version): array } } - // Load metadata.json if present. - $metadata = []; - - if ($this->files->exists($metaFile = "{$versionPath}/metadata.json")) { - $metadata = json_decode($this->files->get($metaFile), true) ?? []; - } - return [ 'roles' => $roles, - 'metadata' => $metadata, + 'metadata' => $this->loadMetadata($name, $version), ]; } /** * Load metadata for a specific prompt version. + * + * Prompt-level metadata (name, description, and anything else recorded in + * the prompt's root metadata.json) forms the base, with the version's own + * metadata.json layered on top so version-specific keys win. */ - protected function loadMetadata(string $name, int $version): array + protected function loadMetadata(string $name, ?int $version): array { - $metaFile = "{$this->basePath}/{$name}/v{$version}/metadata.json"; + return array_merge( + $this->loadPromptMetadata($name), + $this->readJson("{$this->basePath}/{$name}/v{$version}/metadata.json") + ); + } - if ($this->files->exists($metaFile)) { - return json_decode($this->files->get($metaFile), true) ?? []; - } + /** + * Load the prompt-level metadata shared by every version. + * + * `active_version` is stripped: it records which version the application + * serves, which is prompt-level routing state rather than metadata about + * the template being rendered. + */ + protected function loadPromptMetadata(string $name): array + { + $metadata = $this->readJson("{$this->basePath}/{$name}/metadata.json"); + + unset($metadata['active_version']); - return []; + return $metadata; } /** diff --git a/tests/Feature/Commands/ListPromptsCommandTest.php b/tests/Feature/Commands/ListPromptsCommandTest.php index 6691ea3..ba8fe61 100644 --- a/tests/Feature/Commands/ListPromptsCommandTest.php +++ b/tests/Feature/Commands/ListPromptsCommandTest.php @@ -174,3 +174,44 @@ $this->artisan('prompt:list') ->assertExitCode(0); }); + +// ===================================================================== +// Descriptions scaffolded by make:prompt +// ===================================================================== + +test('prompt:list shows the description recorded by make:prompt', function () { + // End-to-end: make:prompt writes the description to the prompt-level + // metadata.json, and prompt:list must be able to read it back. + $this->artisan('make:prompt', ['name' => 'scaffolded', '--desc' => 'Summarises an order']) + ->assertSuccessful(); + + $this->artisan('prompt:list') + ->expectsTable( + ['Prompt', 'Active Version', 'Active', 'Description'], + [['scaffolded', 'v1', '✅', 'Summarises an order']] + ) + ->assertSuccessful(); +}); + +test('prompt:list --all shows the description for every version', function () { + $this->artisan('make:prompt', ['name' => 'all-versions', '--desc' => 'Shared description']) + ->assertSuccessful(); + + $this->artisan('make:prompt', ['name' => 'all-versions']) + ->expectsChoice('What would you like to do?', 'version', [ + 'version' => 'Create a new version (v2)', + 'overwrite' => 'Overwrite version 1', + 'cancel' => 'Cancel', + ]) + ->assertSuccessful(); + + $this->artisan('prompt:list', ['--all' => true]) + ->expectsTable( + ['Prompt', 'Active Version', 'Active', 'Description'], + [ + ['all-versions', 'v1', '', 'Shared description'], + ['all-versions', 'v2', '✅', 'Shared description'], + ] + ) + ->assertSuccessful(); +}); diff --git a/tests/Feature/Commands/MakePromptCommandTest.php b/tests/Feature/Commands/MakePromptCommandTest.php index 9dd4f8d..c713911 100644 --- a/tests/Feature/Commands/MakePromptCommandTest.php +++ b/tests/Feature/Commands/MakePromptCommandTest.php @@ -1,6 +1,7 @@ app['config']->set('deck.path', $newPath); // Re-register the singleton with the new path. - $this->app->forgetInstance(\PromptPHP\Deck\PromptManager::class); + $this->app->forgetInstance(PromptManager::class); $this->artisan('make:prompt', ['name' => 'nested-prompt']) ->assertSuccessful(); @@ -597,3 +598,159 @@ ->expectsOutputToContain('Version 2 of the [msg-test] prompt') ->assertSuccessful(); }); + +// ===================================================================== +// Metadata preservation across versions +// ===================================================================== + +test('make:prompt preserves active_version when creating a new version', function () { + $this->artisan('make:prompt', ['name' => 'preserve-active'])->assertSuccessful(); + + app(PromptManager::class)->activate('preserve-active', 1); + + $this->artisan('make:prompt', ['name' => 'preserve-active']) + ->expectsChoice('What would you like to do?', 'version', [ + 'version' => 'Create a new version (v2)', + 'overwrite' => 'Overwrite version 1', + 'cancel' => 'Cancel', + ]) + ->assertSuccessful(); + + $meta = json_decode(file_get_contents("{$this->tempDir}/preserve-active/metadata.json"), true); + + // Scaffolding a new version must never change what the application serves. + expect($meta['active_version'])->toBe(1) + ->and(app(PromptManager::class)->active('preserve-active')->version())->toBe(1); +}); + +test('make:prompt preserves the existing description when no new one is given', function () { + $this->artisan('make:prompt', ['name' => 'keep-desc', '--desc' => 'Summarises an order']) + ->assertSuccessful(); + + $this->artisan('make:prompt', ['name' => 'keep-desc']) + ->expectsChoice('What would you like to do?', 'version', [ + 'version' => 'Create a new version (v2)', + 'overwrite' => 'Overwrite version 1', + 'cancel' => 'Cancel', + ]) + ->assertSuccessful(); + + $meta = json_decode(file_get_contents("{$this->tempDir}/keep-desc/metadata.json"), true); + + expect($meta['description'])->toBe('Summarises an order'); +}); + +test('make:prompt overrides the description when --desc is given', function () { + $this->artisan('make:prompt', ['name' => 'new-desc', '--desc' => 'Original'])->assertSuccessful(); + + $this->artisan('make:prompt', ['name' => 'new-desc', '--desc' => 'Updated']) + ->expectsChoice('What would you like to do?', 'version', [ + 'version' => 'Create a new version (v2)', + 'overwrite' => 'Overwrite version 1', + 'cancel' => 'Cancel', + ]) + ->assertSuccessful(); + + $meta = json_decode(file_get_contents("{$this->tempDir}/new-desc/metadata.json"), true); + + expect($meta['description'])->toBe('Updated'); +}); + +test('make:prompt preserves hand-edited metadata keys', function () { + $this->artisan('make:prompt', ['name' => 'hand-edited'])->assertSuccessful(); + + // Simulate a user editing the file by hand. + $path = "{$this->tempDir}/hand-edited/metadata.json"; + $meta = json_decode(file_get_contents($path), true); + $meta['variables'] = ['tone', 'order']; + $meta['owner'] = 'platform-team'; + $originalCreatedAt = $meta['created_at']; + file_put_contents($path, json_encode($meta, JSON_PRETTY_PRINT)); + + $this->artisan('make:prompt', ['name' => 'hand-edited']) + ->expectsChoice('What would you like to do?', 'version', [ + 'version' => 'Create a new version (v2)', + 'overwrite' => 'Overwrite version 1', + 'cancel' => 'Cancel', + ]) + ->assertSuccessful(); + + $meta = json_decode(file_get_contents($path), true); + + expect($meta['variables'])->toBe(['tone', 'order']) + ->and($meta['owner'])->toBe('platform-team') + ->and($meta['created_at'])->toBe($originalCreatedAt); +}); + +// ===================================================================== +// Version-level metadata +// ===================================================================== + +test('make:prompt writes version-level metadata for the created version', function () { + $this->artisan('make:prompt', [ + 'name' => 'ver-meta', + '--user' => true, + '--role' => ['assistant'], + ])->assertSuccessful(); + + $meta = json_decode(file_get_contents("{$this->tempDir}/ver-meta/v1/metadata.json"), true); + + expect($meta['version'])->toBe(1) + ->and($meta['roles'])->toBe(['system', 'user', 'assistant']) + ->and($meta['created_at'])->not->toBeEmpty(); +}); + +test('make:prompt records each version roles separately', function () { + $this->artisan('make:prompt', ['name' => 'per-version'])->assertSuccessful(); + + $this->artisan('make:prompt', ['name' => 'per-version', '--user' => true]) + ->expectsChoice('What would you like to do?', 'version', [ + 'version' => 'Create a new version (v2)', + 'overwrite' => 'Overwrite version 1', + 'cancel' => 'Cancel', + ]) + ->assertSuccessful(); + + $v1 = json_decode(file_get_contents("{$this->tempDir}/per-version/v1/metadata.json"), true); + $v2 = json_decode(file_get_contents("{$this->tempDir}/per-version/v2/metadata.json"), true); + + expect($v1['roles'])->toBe(['system']) + ->and($v2['roles'])->toBe(['system', 'user']); +}); + +// ===================================================================== +// Activation hint +// ===================================================================== + +test('make:prompt hints how to activate when another version is live', function () { + $this->artisan('make:prompt', ['name' => 'hint-me'])->assertSuccessful(); + + app(PromptManager::class)->activate('hint-me', 1); + + $this->artisan('make:prompt', ['name' => 'hint-me']) + ->expectsChoice('What would you like to do?', 'version', [ + 'version' => 'Create a new version (v2)', + 'overwrite' => 'Overwrite version 1', + 'cancel' => 'Cancel', + ]) + ->expectsOutputToContain('v1 is still the active version.') + ->expectsOutputToContain('php artisan prompt:activate hint-me v2') + ->assertSuccessful(); +}); + +test('make:prompt does not hint for a brand new prompt', function () { + $this->artisan('make:prompt', ['name' => 'no-hint']) + ->doesntExpectOutputToContain('is still the active version') + ->assertSuccessful(); +}); + +test('make:prompt does not hint when the created version is already active', function () { + $this->artisan('make:prompt', ['name' => 'same-version'])->assertSuccessful(); + + app(PromptManager::class)->activate('same-version', 1); + + // --force overwrites v1, which is the version already active. + $this->artisan('make:prompt', ['name' => 'same-version', '--force' => true]) + ->doesntExpectOutputToContain('is still the active version') + ->assertSuccessful(); +}); diff --git a/tests/Unit/PromptManagerTest.php b/tests/Unit/PromptManagerTest.php index 2ac4b2e..87abf3e 100644 --- a/tests/Unit/PromptManagerTest.php +++ b/tests/Unit/PromptManagerTest.php @@ -499,3 +499,77 @@ function freshManager(?array $configOverrides = []): PromptManager expect($prompt->system())->toBe('dotted'); }); + +// ===================================================================== +// Metadata merging — prompt-level + version-level +// ===================================================================== + +test('get() merges prompt-level metadata into the version metadata', function () { + $this->createPromptFixture( + 'merged-meta', + 1, + 'sys', + 'usr', + ['author' => 'Alice'], + ['description' => 'Summarises an order', 'active_version' => 1], + ); + + $metadata = freshManager()->get('merged-meta', 1)->metadata(); + + expect($metadata['description'])->toBe('Summarises an order') + ->and($metadata['author'])->toBe('Alice'); +}); + +test('get() lets version metadata win over prompt-level metadata', function () { + $this->createPromptFixture( + 'meta-precedence', + 1, + 'sys', + 'usr', + ['description' => 'Version specific'], + ['description' => 'Prompt wide'], + ); + + expect(freshManager()->get('meta-precedence', 1)->metadata()['description']) + ->toBe('Version specific'); +}); + +test('get() excludes active_version from metadata', function () { + $this->createPromptFixture( + 'no-active-key', + 1, + 'sys', + 'usr', + null, + ['description' => 'Has an active version', 'active_version' => 1], + ); + + $metadata = freshManager()->get('no-active-key', 1)->metadata(); + + expect($metadata)->not->toHaveKey('active_version') + ->and($metadata['description'])->toBe('Has an active version'); +}); + +test('get() still returns empty metadata when neither metadata file exists', function () { + $this->createPromptFixture('truly-no-meta', 1, 'sys', 'usr'); + + expect(freshManager()->get('truly-no-meta', 1)->metadata())->toBe([]); +}); + +test('get() ignores malformed metadata JSON instead of failing', function () { + $this->createPromptFixture('bad-json', 1, 'sys', 'usr'); + file_put_contents("{$this->tempDir}/bad-json/metadata.json", '{not valid json'); + + expect(freshManager()->get('bad-json', 1)->metadata())->toBe([]); +}); + +test('versions() merges prompt-level metadata into every version', function () { + $this->createPromptFixture('shared-desc', 1, 'sys', 'usr', ['author' => 'Alice']); + $this->createPromptFixture('shared-desc', 2, 'sys', 'usr', null, ['description' => 'Shared']); + + $versions = freshManager()->versions('shared-desc'); + + expect($versions[0]['metadata']['description'])->toBe('Shared') + ->and($versions[0]['metadata']['author'])->toBe('Alice') + ->and($versions[1]['metadata']['description'])->toBe('Shared'); +}); From 824e871a2a21fed8a750d35477affc9bc06fdb65 Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 17:19:29 +0100 Subject: [PATCH 07/11] fix: honour v-prefixed versions in prompt:test (int) 'v2' is a falsy 0, so --ver=v2 fell through to the active version while reporting the wrong number in its header. Now resolves through the ResolvesVersion trait, accepting both 2 and v2, and fails with a clear message on unparseable input. --- src/Console/Commands/TestPromptCommand.php | 22 ++++++++-- .../Commands/TestPromptCommandTest.php | 43 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/Console/Commands/TestPromptCommand.php b/src/Console/Commands/TestPromptCommand.php index 23476fa..ea23125 100644 --- a/src/Console/Commands/TestPromptCommand.php +++ b/src/Console/Commands/TestPromptCommand.php @@ -5,12 +5,15 @@ namespace PromptPHP\Deck\Console\Commands; use Illuminate\Console\Command; +use PromptPHP\Deck\Concerns\ResolvesVersion; use PromptPHP\Deck\PromptManager; class TestPromptCommand extends Command { + use ResolvesVersion; + protected $signature = 'prompt:test {name : The prompt name} - {--ver= : Specific version (defaults to active)} + {--ver= : Specific version, e.g. 2 or v2 (defaults to active)} {--input= : The input to test} {--variables= : JSON string of variables}'; @@ -27,7 +30,7 @@ public function __construct(PromptManager $manager) public function handle(): int { $name = $this->argument('name'); - $version = $this->option('ver') ? (int) $this->option('ver') : null; + $versionInput = $this->option('ver'); $input = $this->option('input') ?? 'Sample user input'; $variablesJson = $this->option('variables') ?? '{}'; @@ -38,8 +41,21 @@ public function handle(): int return Command::FAILURE; } + // A null version means "use whichever version is active". + $version = null; + + if ($versionInput !== null && $versionInput !== '') { + $version = $this->parseVersion((string) $versionInput); + + if ($version === null) { + $this->error("Invalid version [{$versionInput}] provided. Use a positive number like [1] or [v1]."); + + return Command::FAILURE; + } + } + try { - $prompt = $version + $prompt = $version !== null ? $this->manager->get($name, $version) : $this->manager->active($name); diff --git a/tests/Feature/Commands/TestPromptCommandTest.php b/tests/Feature/Commands/TestPromptCommandTest.php index cafc847..2836072 100644 --- a/tests/Feature/Commands/TestPromptCommandTest.php +++ b/tests/Feature/Commands/TestPromptCommandTest.php @@ -128,3 +128,46 @@ $this->artisan('prompt:test', ['name' => 'nonexistent']) ->assertFailed(); }); + +// ===================================================================== +// prompt:test — version resolution +// ===================================================================== + +test('prompt:test renders a v-prefixed version rather than the active one', function () { + $this->createPromptFixture('v-prefixed', 1, 'SYSTEM FROM V1', 'usr v1', null, ['active_version' => 1]); + $this->createPromptFixture('v-prefixed', 2, 'SYSTEM FROM V2', 'usr v2'); + + $this->artisan('prompt:test', ['name' => 'v-prefixed', '--ver' => 'v2']) + ->expectsOutputToContain('Testing prompt [v-prefixed] version 2') + ->expectsOutputToContain('SYSTEM FROM V2') + ->doesntExpectOutputToContain('SYSTEM FROM V1') + ->assertSuccessful(); +}); + +test('prompt:test renders a numeric version rather than the active one', function () { + $this->createPromptFixture('numeric-ver', 1, 'SYSTEM FROM V1', 'usr v1', null, ['active_version' => 1]); + $this->createPromptFixture('numeric-ver', 2, 'SYSTEM FROM V2', 'usr v2'); + + $this->artisan('prompt:test', ['name' => 'numeric-ver', '--ver' => '2']) + ->expectsOutputToContain('Testing prompt [numeric-ver] version 2') + ->expectsOutputToContain('SYSTEM FROM V2') + ->assertSuccessful(); +}); + +test('prompt:test fails with a clear message for an unparseable version', function () { + $this->createPromptFixture('bad-ver', 1, 'sys', 'usr', null, ['active_version' => 1]); + + $this->artisan('prompt:test', ['name' => 'bad-ver', '--ver' => 'banana']) + ->expectsOutput('Invalid version [banana] provided. Use a positive number like [1] or [v1].') + ->assertFailed(); +}); + +test('prompt:test falls back to the active version when --ver is omitted', function () { + $this->createPromptFixture('no-ver', 1, 'SYSTEM FROM V1', 'usr v1', null, ['active_version' => 1]); + $this->createPromptFixture('no-ver', 2, 'SYSTEM FROM V2', 'usr v2'); + + $this->artisan('prompt:test', ['name' => 'no-ver']) + ->expectsOutputToContain('Testing prompt [no-ver] version 1') + ->expectsOutputToContain('SYSTEM FROM V1') + ->assertSuccessful(); +}); From 019a4ee89af18dd7dae526b904d985ce87df6269 Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 17:20:28 +0100 Subject: [PATCH 08/11] feat: publish a combined Packagist download count Deck ships under two Packagist names, and the deprecated `veeqtoh/prompt-deck` still takes a large share of installs. Shields cannot sum packages, so a scheduled workflow publishes the combined total to a shields endpoint on an orphan badges branch. --- .github/workflows/downloads-badge.yml | 114 ++++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/downloads-badge.yml diff --git a/.github/workflows/downloads-badge.yml b/.github/workflows/downloads-badge.yml new file mode 100644 index 0000000..815bda9 --- /dev/null +++ b/.github/workflows/downloads-badge.yml @@ -0,0 +1,114 @@ +name: downloads badge + +# Deck ships under two Packagist names: the current `promptphp/deck` and the +# deprecated `veeqtoh/prompt-deck`, which still receives a large share of +# installs. Shields cannot sum packages, so this job publishes a combined +# total to a shields endpoint JSON on the orphan `badges` branch. +# +# Scheduled runs only fire from the default branch. + +on: + schedule: + - cron: "17 4 * * *" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: downloads-badge + cancel-in-progress: false + +jobs: + badge: + name: Publish combined download count + runs-on: ubuntu-24.04 + + steps: + - name: Sum Packagist downloads + id: totals + run: | + set -euo pipefail + + fetch_total() { + curl -fsSL --retry 3 --retry-delay 5 \ + "https://packagist.org/packages/$1.json" \ + | jq -e '.package.downloads.total' + } + + current=$(fetch_total "promptphp/deck") + legacy=$(fetch_total "veeqtoh/prompt-deck") + total=$((current + legacy)) + + echo "promptphp/deck: ${current}" + echo "veeqtoh/prompt-deck: ${legacy}" + echo "combined: ${total}" + + # Format the way shields does: 1234 -> 1.2k, 1234567 -> 1.2M, + # dropping a trailing .0 so we render "15k" rather than "15.0k". + # The unit is promoted after rounding, so 999999 reads "1M" not "1000k". + message=$(awk -v n="${total}" 'BEGIN { + if (n < 1000) { printf "%d", n; exit } + + v = n / 1000; u = "k" + + if (v >= 999.95) { v = v / 1000; u = "M" } + + s = sprintf("%.1f", v) + sub(/\.0$/, "", s) + printf "%s%s", s, u + }') + + echo "message=${message}" >> "$GITHUB_OUTPUT" + + - name: Skip when the badge is unchanged + id: check + run: | + set -euo pipefail + + existing=$(curl -fsSL \ + "https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/badges/downloads.json" \ + | jq -r '.message' 2>/dev/null || echo "") + + if [ "${existing}" = "${{ steps.totals.outputs.message }}" ]; then + echo "Badge already reads [${existing}] — nothing to publish." + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Publish to the badges branch + if: steps.check.outputs.changed == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MESSAGE: ${{ steps.totals.outputs.message }} + run: | + set -euo pipefail + + mkdir badges && cd badges + + jq -n \ + --arg message "${MESSAGE}" \ + '{ + schemaVersion: 1, + label: "total downloads", + message: $message, + color: "blue" + }' > downloads.json + + cat downloads.json + + # A single-commit orphan branch: force-pushing keeps the badge + # history from growing without bound and never touches 0.x. + git init -q + git checkout -q -b badges + git remote add origin \ + "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + + git add downloads.json + git \ + -c user.name="github-actions[bot]" \ + -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit -q -m "Update combined download count to ${MESSAGE}" + + git push -q --force origin badges diff --git a/README.md b/README.md index 6b040ee..868b88a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Latest Version on Packagist PHP from Packagist GitHub license - Total Downloads on Packagist + Total Downloads on Packagist across promptphp/deck and veeqtoh/prompt-deck Featured in Laravel News

From 7015d7f8583b0889b1c4bb624f2329f1201ed320 Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 17:21:29 +0100 Subject: [PATCH 09/11] fix: correct stale Prompt Deck branding and links The `v0.4.0` rename left the docs site name, the logo wordmark, the docs landing page link, and the README licence link pointing at the old identity. --- README.md | 2 +- docs/docs.json | 2 +- docs/index.mdx | 4 ++-- docs/logo/dark.svg | 4 ++-- docs/logo/light.svg | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 868b88a..ce64a32 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

Latest Version on Packagist PHP from Packagist - GitHub license + GitHub license Total Downloads on Packagist across promptphp/deck and veeqtoh/prompt-deck Featured in Laravel News

diff --git a/docs/docs.json b/docs/docs.json index 88b475a..c79353b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1,7 +1,7 @@ { "$schema": "https://mintlify.com/docs.json", "theme": "mint", - "name": "Prompt Deck by PromptPHP", + "name": "Deck by PromptPHP", "colors": { "primary": "#6366F1", "light": "#818CF8", diff --git a/docs/index.mdx b/docs/index.mdx index b3cadaa..670a4a0 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -14,7 +14,7 @@ mode: "custom" ✨ Formerly Prompt Deck - Now Deck by PromptPHP -

+

Deck by PromptPHP

@@ -30,7 +30,7 @@ mode: "custom" Sponsor - + GitHub diff --git a/docs/logo/dark.svg b/docs/logo/dark.svg index d7839dd..885ea8d 100644 --- a/docs/logo/dark.svg +++ b/docs/logo/dark.svg @@ -1,8 +1,8 @@ - + - Prompt Deck + Deck diff --git a/docs/logo/light.svg b/docs/logo/light.svg index 1917692..fc8ac0d 100644 --- a/docs/logo/light.svg +++ b/docs/logo/light.svg @@ -1,8 +1,8 @@ - + - Prompt Deck + Deck From 70c92cb5af96802c3883c2fe26d5fc12b6ea49ec Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 17:22:21 +0100 Subject: [PATCH 10/11] docs: add a changelog page to the documentation site --- docs/changelog.mdx | 134 +++++++++++++++++++++++++++++++++++++++++++++ docs/docs.json | 6 ++ 2 files changed, 140 insertions(+) create mode 100644 docs/changelog.mdx diff --git a/docs/changelog.mdx b/docs/changelog.mdx new file mode 100644 index 0000000..3212da8 --- /dev/null +++ b/docs/changelog.mdx @@ -0,0 +1,134 @@ +--- +title: "Changelog" +description: "New features, fixes, and breaking changes in Deck by PromptPHP." +rss: true +--- + +Deck follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Subscribe to the [RSS feed](https://deck.promptphp.com/changelog/rss.xml) to be notified of new releases, or browse the [full history on GitHub](https://github.com/promptphp/deck/blob/0.x/CHANGELOG.md). + + + +**Fixed** + +- **Migrations now publish on case-sensitive filesystems.** `vendor:publish --tag=deck-migrations` reported success without copying anything on Linux and case-sensitive macOS volumes. If your `database/migrations` directory came up empty after installing Deck, publish again after upgrading. +- **Creating a version no longer changes which version is live.** `make:prompt` rewrote the prompt's root `metadata.json` from scratch, discarding the `active_version` key, so scaffolding a draft silently promoted it to active. The file is now merged: `active_version`, your description, the original `created_at`, a populated `variables` list, and any keys you added by hand all survive. +- **Prompt metadata is now readable.** `make:prompt` recorded the name, description, and roles in the prompt's root `metadata.json`, but Deck only ever read the version-level `v{n}/metadata.json`. `PromptTemplate::metadata()` was always empty and the `prompt:list` description column was always blank. `make:prompt` now writes version-level metadata too, and reads merge the two files with version keys winning. See [Metadata](/core/prompts#metadata). +- **`prompt:test --ver=v2` renders the version you asked for.** The `v` prefix was discarded during parsing, so the command quietly rendered the active version while reporting the wrong number in its header. Both `2` and `v2` now work, and an unparseable value fails with a clear message. +- Corrected the `PromptPHP\Deck\Database\Factories\` PSR-4 mapping, which pointed at a directory that does not exist. +- Corrected the README downloads badge, which reported the deprecated `veeqtoh/prompt-deck` package, and the stale repository and licence links. + +**Added** + +- `make:prompt` now tells you how to promote a new version when a different one is active: + + ``` + Version 2 of the [order-summary] prompt has been created successfully with the following roles: system. + + v1 is still the active version. + Run `php artisan prompt:activate order-summary v2` to make v2 live. + ``` + +**Removed** + +- Removed the `PromptPHP\Deck\Database\Seeders\` autoload mapping, which pointed at a directory that does not exist. + + + + + +**Fixed** + +- Widened the `sebastian/diff` constraint to allow `v8` and `v9`, so Deck can be installed alongside Pest 5. [#12](https://github.com/promptphp/deck/pull/12) + + + + + +**Fixed** + +- `Deck::get()` and `Deck::activate()` now accept mixed version types, resolving versions through the shared `ResolvesVersion` trait. + + + + + +**Fixed** + +- `prompt:activate` now accepts both `1` and `v1` version formats. + + + + + + + This release renamed the package and its namespace. See the [upgrade guide](https://github.com/promptphp/deck/blob/0.x/UPGRADE.md) before upgrading from `v0.3.x`. + + +**Changed** + +- Renamed the package from `veeqtoh/prompt-deck` to `promptphp/deck`. +- Renamed the PHP namespace from `Veeqtoh\PromptDeck` to `PromptPHP\Deck`. +- Renamed the public package identity from Prompt Deck to Deck by PromptPHP. +- Updated installation, usage, README, badges, documentation links, and package metadata for the new PromptPHP organisation. [#7](https://github.com/promptphp/deck/pull/7) + +**Removed** + +- Removed the old `Veeqtoh\PromptDeck` public namespace. +- Removed old Prompt Deck naming from the main public API. + + + + + +**Added** + +- Published this documentation site, built on Mintlify. [#5](https://github.com/promptphp/deck/pull/5) +- Added dedicated pages for installation, configuration, commands, prompt management, Laravel AI SDK integration, tracking, testing, and the API reference. + +**Changed** + +- Reworked the documentation from flat markdown files into organised MDX pages. [#6](https://github.com/promptphp/deck/pull/6) + +**Fixed** + +- Fixed stale documentation links and navigation paths. + + + + + +**Added** + +- Added a `CHANGELOG` to the repository. +- Added a GitHub Actions workflow for automated testing. +- Added a `.gitattributes` file to manage text handling and export-ignore rules. + +**Changed** + +- Refined the Composer package keywords for discoverability. + + + + + +**Added** + +- Added support for Laravel 13. [#3](https://github.com/promptphp/deck/pull/3) +- Added the `scaffold_on_make_agent` option to toggle auto-scaffolding of prompts when you run `make:agent`. See [Configuration](/getting-started/configuration). + + + + + +The first release. [#1](https://github.com/promptphp/deck/pull/1) + +**Added** + +- Versioned prompt management with file-based storage in structured prompt directories. +- Variable interpolation for prompt templates. +- Artisan commands for creating, listing, testing, diffing, and activating prompts. See [Commands](/core/commands). +- Prompt execution tracking. See [Tracking](/advanced/tracking). +- A/B testing through versioned prompt activation and tracking. +- Optional Laravel AI SDK integration. See [Laravel AI SDK](/integrations/ai-sdk). + + diff --git a/docs/docs.json b/docs/docs.json index c79353b..b5c8c9e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -39,6 +39,12 @@ "advanced/testing", "advanced/api-reference" ] + }, + { + "group": "Releases", + "pages": [ + "changelog" + ] } ], "global": { From d86b55762cf1079cb81781b90faf813d58327392 Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 17:23:11 +0100 Subject: [PATCH 11/11] docs: add the 0.4.4 CHANGELOG entry --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b6e3c..e802988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +## [0.4.4] - 2026-08-03 + +### Fixed + +- Fixed migration publishing, which silently failed on case-sensitive filesystems. `DeckServiceProvider` pointed at `src/database/migrations` while the directory is `src/Database/migrations`, so `vendor:publish --tag=deck-migrations` reported success whilst failing on Linux and macOS case-sensitive volumes. +- Fixed `make:prompt` overwriting the `active_version` key by rewriting the prompt's root `metadata.json`. Creating a new version silently promoted it to active. The file is now merged, preserving `active_version`, the existing description, the original `created_at`, a populated `variables` list, and any keys added by hand. +- Fixed prompt metadata being unreachable. `make:prompt` wrote the name, description, and roles to the prompt's root `metadata.json`, but `PromptManager` only ever read the version-level `v{n}/metadata.json`, so `PromptTemplate::metadata()` was always empty and the `prompt:list` description column was always blank. `make:prompt` now writes version-level metadata, and metadata reads merge the prompt-level file with the version-level file, version keys winning. The `active_version` key is excluded from `metadata()`. +- Fixed `prompt:test --ver=v2` silently rendering the active version instead of the one requested. `(int) 'v2'` evaluated to a falsy `0`, so the command fell through to `active()` while reporting the wrong version number in its header. The command now uses the `ResolvesVersion` trait, accepts both `2` and `v2`, and fails with a clear message on unparseable input. +- Fixed the `PromptPHP\Deck\Database\Factories\` PSR-4 mapping pointing at the non-existent lowercase `src/database/factories/`. + +- Fixed the README downloads badge reporting the deprecated `veeqtoh/prompt-deck` package instead of a combined figure. +- Fixed the docs landing page linking to the pre-rename `promptphp/prompt-deck` repository, and the README licence link pointing at a `master` branch that does not exist. + +### Added + +- `make:prompt` now prints how to activate the version it just created when a different version is live. +- Added a scheduled `downloads badge` workflow that publishes the combined Packagist download count for `promptphp/deck` and the deprecated `veeqtoh/prompt-deck` to a shields endpoint on the orphan `badges` branch. +- Added a changelog page to the documentation site, under a new Releases group, with an RSS feed at `/changelog/rss.xml`. + +### Removed + +- Removed the `PromptPHP\Deck\Database\Seeders\` autoload mapping, which pointed at a directory that does not exist. + ## [0.4.3] - 2026-07-29 ### Fixed