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 @@
-
+
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 @@
-
+
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 @@
-