From fd86e3f0ed6b6b4aeda484b9c43989bd30b46a19 Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 18:41:46 +0100 Subject: [PATCH 1/4] fix: handle version input identically in get() and activate() Deck::activate() only accepted an int, so Deck::activate('order-summary', 'v2') raised a TypeError. The 0.4.2 notes described both get() and activate() as accepting mixed version types, but only get() was widened at the time. The CLI masked it because ActivatePromptCommand parses to an int before calling. Both methods now resolve through the ResolvesVersion trait, so 2, '2', and 'v2' are equivalent. Passing an int continues to work. An unparseable version previously produced a message with the version missing entirely, such as "Version for prompt [order-summary] does not exist." Both methods now throw InvalidVersionException naming the offending value. --- src/Exceptions/InvalidVersionException.php | 13 ++++ src/Facades/Deck.php | 2 +- src/PromptManager.php | 22 +++++-- tests/Unit/PromptManagerTest.php | 72 +++++++++++++++++++++- 4 files changed, 101 insertions(+), 8 deletions(-) diff --git a/src/Exceptions/InvalidVersionException.php b/src/Exceptions/InvalidVersionException.php index a4b5014..2fe5ee8 100644 --- a/src/Exceptions/InvalidVersionException.php +++ b/src/Exceptions/InvalidVersionException.php @@ -24,4 +24,17 @@ public static function noVersions(string $name): self { return new self("No versions found for prompt [{$name}]."); } + + /** + * Create an exception for a version that could not be parsed. + * + * @param string $name The name of the prompt. + * @param string|int $version The version input that could not be parsed. + */ + public static function unparseable(string $name, string|int $version): self + { + return new self( + "Invalid version [{$version}] for prompt [{$name}]. Use a positive number like [1] or [v1]." + ); + } } diff --git a/src/Facades/Deck.php b/src/Facades/Deck.php index 019f576..0192320 100644 --- a/src/Facades/Deck.php +++ b/src/Facades/Deck.php @@ -11,7 +11,7 @@ * @method static \PromptPHP\Deck\PromptTemplate get(string $name, string|int|null $version = null) * @method static \PromptPHP\Deck\PromptTemplate active(string $name) * @method static array versions(string $name) - * @method static bool activate(string $name, int $version) + * @method static bool activate(string $name, string|int $version) * @method static void track(string $promptName, int $version, array $data) * * @see PromptManager diff --git a/src/PromptManager.php b/src/PromptManager.php index 13bf2df..3a30524 100644 --- a/src/PromptManager.php +++ b/src/PromptManager.php @@ -53,8 +53,8 @@ public function get(string $name, string|int|null $version = null): PromptTempla if ($version === null) { $version = $this->getActiveVersion($name); } else { - $versionInput = (string) $version; - $version = $this->parseVersion($versionInput); + $version = $this->parseVersion((string) $version) + ?? throw InvalidVersionException::unparseable($name, $version); } $cacheKey = $this->config->get('deck.cache.prefix', 'deck:')."{$name}.v{$version}"; @@ -132,11 +132,23 @@ public function versions(string $name): array /** * Activate a specific version. * - * For simplicity, we'll store in a JSON file or use the database if tracking is enabled. - * We'll assume we have a "prompt_versions" table with an "is_active" column. + * Accepts the same version formats as get(), so both of these work: + * + * Deck::activate('order-summary', 'v2') + * Deck::activate('order-summary', 2) + * + * The active version is recorded in the database when tracking is enabled, + * and in the prompt's root metadata.json either way. + * + * @param string|int $version The version to activate, e.g. 2 or 'v2'. + * + * @throws InvalidVersionException if the version cannot be parsed. */ - public function activate(string $name, int $version): bool + public function activate(string $name, string|int $version): bool { + $version = $this->parseVersion((string) $version) + ?? throw InvalidVersionException::unparseable($name, $version); + $this->ensureVersionExists($name, $version); if ($this->trackingConfig['enabled'] ?? false) { diff --git a/tests/Unit/PromptManagerTest.php b/tests/Unit/PromptManagerTest.php index 87abf3e..139e330 100644 --- a/tests/Unit/PromptManagerTest.php +++ b/tests/Unit/PromptManagerTest.php @@ -274,8 +274,6 @@ function freshManager(?array $configOverrides = []): PromptManager expect($meta['active_version'])->toBe(1); }); -use InvalidArgumentException; - test('activate() preserves existing metadata keys when updating active_version', function () { $this->createPromptFixture('preserve-meta', 1, 'sys', 'usr', null, [ 'name' => 'preserve-meta', @@ -573,3 +571,73 @@ function freshManager(?array $configOverrides = []): PromptManager ->and($versions[0]['metadata']['author'])->toBe('Alice') ->and($versions[1]['metadata']['description'])->toBe('Shared'); }); + +// ===================================================================== +// Version input formats — get() and activate() must agree +// ===================================================================== + +test('activate() accepts a v-prefixed version string', function () { + $this->createPromptFixture('str-activate', 1, 'sys'); + $this->createPromptFixture('str-activate', 2, 'sys'); + + expect(freshManager()->activate('str-activate', 'v2'))->toBeTrue(); + + $meta = json_decode(file_get_contents("{$this->tempDir}/str-activate/metadata.json"), true); + expect($meta['active_version'])->toBe(2); +}); + +test('activate() accepts a numeric version string', function () { + $this->createPromptFixture('num-str-activate', 1, 'sys'); + $this->createPromptFixture('num-str-activate', 2, 'sys'); + + expect(freshManager()->activate('num-str-activate', '2'))->toBeTrue(); + + $meta = json_decode(file_get_contents("{$this->tempDir}/num-str-activate/metadata.json"), true); + expect($meta['active_version'])->toBe(2); +}); + +test('activate() still accepts an integer version', function () { + $this->createPromptFixture('int-activate', 1, 'sys'); + $this->createPromptFixture('int-activate', 2, 'sys'); + + expect(freshManager()->activate('int-activate', 2))->toBeTrue(); + + $meta = json_decode(file_get_contents("{$this->tempDir}/int-activate/metadata.json"), true); + expect($meta['active_version'])->toBe(2); +}); + +test('activate() reports the offending value for an unparseable version', function () { + $this->createPromptFixture('bad-activate', 1, 'sys'); + + freshManager()->activate('bad-activate', 'banana'); +})->throws( + InvalidVersionException::class, + 'Invalid version [banana] for prompt [bad-activate]. Use a positive number like [1] or [v1].' +); + +test('activate() rejects a zero version', function () { + $this->createPromptFixture('zero-activate', 1, 'sys'); + + freshManager()->activate('zero-activate', 'v0'); +})->throws(InvalidVersionException::class, 'Invalid version [v0]'); + +test('get() reports the offending value for an unparseable version', function () { + $this->createPromptFixture('bad-get', 1, 'sys'); + + freshManager()->get('bad-get', 'banana'); +})->throws( + InvalidVersionException::class, + 'Invalid version [banana] for prompt [bad-get]. Use a positive number like [1] or [v1].' +); + +test('get() and activate() accept the same version formats', function () { + $this->createPromptFixture('parity', 1, 'sys v1'); + $this->createPromptFixture('parity', 2, 'sys v2'); + + $manager = freshManager(); + + foreach (['v2', '2', 2] as $input) { + expect($manager->get('parity', $input)->version())->toBe(2) + ->and($manager->activate('parity', $input))->toBeTrue(); + } +}); From 09e38a9b55666dd0d6b7bae06aca2cb55b85e66c Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 18:42:03 +0100 Subject: [PATCH 2/4] docs: correct prompt structure diagrams and API signatures Every structure diagram omitted the version-level metadata.json that make:prompt has written since 0.4.4. The README additionally showed a user.md that make:prompt does not create without --user, and a second version that a single run does not create. Multi-version diagrams now mark which version is live, and the README shows prompt:list output alongside the tree. Creating a version has not changed the active version since 0.4.4, but the README still described new versions as activating automatically. Documents activate() accepting string|int, and corrects the get() signature, which read ?int rather than string|int|null. Diagrams were taken from real scaffolds rather than written by hand. --- README.md | 70 ++++++++++++++++++++++---- docs/advanced/api-reference.mdx | 9 ++-- docs/core/make-prompt.mdx | 9 ++-- docs/core/prompts.mdx | 19 ++++--- docs/getting-started/configuration.mdx | 12 +++-- docs/getting-started/introduction.mdx | 7 +-- 6 files changed, 96 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index ce64a32..2310077 100644 --- a/README.md +++ b/README.md @@ -48,15 +48,28 @@ php artisan make:prompt order-summary This creates the following structure +```txt +resources/prompts/order-summary/ +├── metadata.json # Prompt-level: name, description, roles, active version +└── v1/ + ├── metadata.json # This version's own metadata + └── system.md +``` + +Pass `--user` to add a user prompt, or `--role=` for any other role + +```bash +php artisan make:prompt order-summary --user --role=assistant +``` + ```txt resources/prompts/order-summary/ ├── metadata.json -├── v1/ -│ ├── system.md -│ └── user.md -└── v2/ +└── v1/ + ├── metadata.json ├── system.md - └── user.md + ├── user.md + └── assistant.md ``` Edit `resources/prompts/order-summary/v1/system.md` with your prompt content. Use `{{ $variable }}` syntax for dynamic values: @@ -87,14 +100,46 @@ $messages = $prompt->toMessages(['tone' => 'friendly', 'order' => $orderDetails] ### Versioning -Create a new version of an existing prompt +Run the command again on an existing prompt and Deck offers to create the next version or overwrite the current one ```bash php artisan make:prompt order-summary -# Automatically creates v2, v3, etc. + +# Prompt [order-summary] already exists at version 1. +# What would you like to do? +# [version ] Create a new version (v2) +# [overwrite] Overwrite version 1 ``` -Activate a specific version +Creating a version never changes which one your application serves, so you can draft freely in production + +```txt +resources/prompts/order-summary/ +├── metadata.json # "active_version": 1 +├── v1/ # live: what Deck::get('order-summary') returns +│ ├── metadata.json +│ └── system.md +└── v2/ # drafted, not serving traffic yet + ├── metadata.json + └── system.md +``` + +Check which version is live at any time + +```bash +php artisan prompt:list --all +``` + +```txt ++---------------+----------------+--------+-------------+ +| Prompt | Active Version | Active | Description | ++---------------+----------------+--------+-------------+ +| order-summary | v1 | ✅ | | +| order-summary | v2 | | | ++---------------+----------------+--------+-------------+ +``` + +Promote the new version when you are ready ```bash php artisan prompt:activate order-summary v2 @@ -104,10 +149,17 @@ php artisan prompt:activate order-summary v2 php artisan prompt:activate order-summary 2 ``` -Or load a specific version programmatically +The `active_version` key in the prompt's root `metadata.json` flips to `2`, and every `Deck::get('order-summary')` call starts returning v2. Roll back by activating v1 again. + +Both formats work programmatically too ```php +// Load a specific version. $prompt = Deck::get('order-summary', 'v2'); + +// Promote a version. +Deck::activate('order-summary', 'v2'); +Deck::activate('order-summary', 2); ``` ### Laravel AI SDK Integration diff --git a/docs/advanced/api-reference.mdx b/docs/advanced/api-reference.mdx index 9ffa845..82dc74f 100644 --- a/docs/advanced/api-reference.mdx +++ b/docs/advanced/api-reference.mdx @@ -64,15 +64,18 @@ $versions = $manager->versions('order-summary'); // ] ``` -#### `activate(string $name, int $version): bool` +#### `activate(string $name, string|int $version): bool` Activate a specific version of a prompt. Returns `true` on success. +Accepts the same version formats as `get()`, so `2`, `'2'`, and `'v2'` are equivalent. A version that cannot be parsed throws `InvalidVersionException`. + - **With tracking enabled:** Updates the `prompt_versions` database table. - **Without tracking:** Writes to `metadata.json` in the prompt directory. ```php $manager->activate('order-summary', 2); +$manager->activate('order-summary', 'v2'); ``` #### `track(string $promptName, int $version, array $data): void` @@ -222,10 +225,10 @@ Static proxy to the `PromptManager` singleton. | Method | Returns | Description | | ------------------------------------------------------------ | ---------------- | ------------------------------------------- | -| `Deck::get(string $name, ?int $version = null)` | `PromptTemplate` | Load a prompt by name and optional version. | +| `Deck::get(string $name, string\|int\|null $version = null)` | `PromptTemplate` | Load a prompt by name and optional version. | | `Deck::active(string $name)` | `PromptTemplate` | Load the active version of a prompt. | | `Deck::versions(string $name)` | `array` | List all versions for a prompt. | -| `Deck::activate(string $name, int $version)` | `bool` | Activate a specific version. | +| `Deck::activate(string $name, string\|int $version)` | `bool` | Activate a specific version. | | `Deck::track(string $name, int $version, array $data)` | `void` | Record a prompt execution. | --- diff --git a/docs/core/make-prompt.mdx b/docs/core/make-prompt.mdx index f5bc78c..57c9e20 100644 --- a/docs/core/make-prompt.mdx +++ b/docs/core/make-prompt.mdx @@ -22,12 +22,13 @@ This generates the following structure inside your configured prompts directory ``` resources/prompts/ └── order-summary/ - ├── v1/ - │ └── system.md - └── metadata.json + ├── metadata.json # Prompt-level: name, description, roles, active version + └── v1/ + ├── metadata.json # This version's own metadata + └── system.md ``` -A **system prompt** file is always created. A `metadata.json` file is placed at the prompt root to record the prompt's name, description, roles, and creation timestamp. +A **system prompt** file is always created. Two `metadata.json` files are written: one at the prompt root recording the prompt's name, description, roles, and creation timestamp, and one inside the version directory recording that version alone. ### Interactive mode diff --git a/docs/core/prompts.mdx b/docs/core/prompts.mdx index 50daf51..c0971fa 100644 --- a/docs/core/prompts.mdx +++ b/docs/core/prompts.mdx @@ -290,16 +290,20 @@ Prompts are versioned using directory-based versioning. Each version lives in it ``` resources/prompts/ └── order-summary/ - ├── v1/ + ├── metadata.json # "active_version": 1 + ├── v1/ # live + │ ├── metadata.json │ ├── system.md │ └── user.md - ├── v2/ - │ ├── system.md - │ ├── user.md - │ └── assistant.md - └── metadata.json + └── v2/ # drafted, not serving traffic yet + ├── metadata.json + ├── system.md + ├── user.md + └── assistant.md ``` +Only the active version is served. Creating a new version leaves `active_version` untouched, so you can draft in production and promote with [`prompt:activate`](/core/commands#promptactivate) when ready. + Each version directory can contain: - Any number of role files (e.g. `system.md`, `user.md`, `assistant.md`) @@ -334,8 +338,11 @@ Set a specific version as the active version: ```php Deck::activate('order-summary', 2); +Deck::activate('order-summary', 'v2'); ``` +The version accepts the same formats as `Deck::get()` — `2`, `'2'`, and `'v2'` are equivalent. Anything that cannot be parsed throws `InvalidVersionException`. + **When database tracking is enabled**, this updates the `prompt_versions` table — setting `is_active = false` on all versions of that prompt, then `is_active = true` on the specified version. **When tracking is disabled**, it writes the `active_version` key to the prompt's root `metadata.json` file. diff --git a/docs/getting-started/configuration.mdx b/docs/getting-started/configuration.mdx index ea217b6..65118ac 100644 --- a/docs/getting-started/configuration.mdx +++ b/docs/getting-started/configuration.mdx @@ -67,12 +67,14 @@ Currently, only the `directory` strategy is supported. Each version is stored in ``` resources/prompts/order-summary/ -├── v1/ +├── metadata.json # "active_version": 1 +├── v1/ # live +│ ├── metadata.json │ └── system.md -├── v2/ -│ ├── system.md -│ └── user.md -└── metadata.json +└── v2/ # drafted, not serving traffic yet + ├── metadata.json + ├── system.md + └── user.md ``` ## Cache diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index cc6eb0a..b79cb38 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -33,9 +33,10 @@ This creates the following structure: ``` resources/prompts/ └── order-summary/ - ├── v1/ - │ └── system.md - └── metadata.json + ├── metadata.json # Prompt-level: name, description, roles, active version + └── v1/ + ├── metadata.json # This version's own metadata + └── system.md ``` Edit `resources/prompts/order-summary/v1/system.md` with your prompt content. Use `{{ $variable }}` syntax for dynamic values: From ebc61de366429776032acf3f144d42f0fd91c314 Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 18:42:03 +0100 Subject: [PATCH 3/4] ci: check code style and changelog parity Formatting was never verified in CI, which let the whole codebase drift out of Pint compliance before 0.4.4. The style check runs on a single PHP version since formatting does not vary across the matrix. CHANGELOG.md and the documentation site's changelog are written separately and drift silently, which happened while preparing this release. The parity job asserts every released version appears in both, in the same order. The test workflow now runs `composer test` rather than calling Pest directly, so CI and the documented contributor command cannot diverge. --- .github/workflows/quality.yml | 82 +++++++++++++++++++++++++++++++++++ .github/workflows/tests.yml | 2 +- 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/quality.yml diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..cb1725c --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,82 @@ +name: quality + +on: + push: + branches: + - main + - "*.x" + pull_request: + +jobs: + pint: + name: Code style + runs-on: ubuntu-24.04 + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Check formatting + run: composer test:lint + + changelog: + name: Changelog parity + runs-on: ubuntu-24.04 + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + # CHANGELOG.md and the documentation site's changelog page are written + # separately and drift silently. Every released version must appear in + # both, in the same order. + - name: Compare CHANGELOG.md against the documentation changelog + run: | + python3 - <<'PY' + import re, sys + from pathlib import Path + + repo = Path("CHANGELOG.md").read_text() + docs = Path("docs/changelog.mdx").read_text() + + # "## [0.4.5] - 2026-08-03" -> 0.4.5, ignoring [Unreleased]. + in_repo = re.findall(r"^## \[(\d[^\]]*)\]", repo, re.M) + + # '' -> 0.4.5 + in_docs = [v.lstrip("v") for v in re.findall(r' in docs/changelog.mdx") + + for missing in [v for v in in_docs if v not in in_repo]: + problems.append(f"{missing} has an in docs/changelog.mdx but no CHANGELOG.md entry") + + if not problems and in_repo != in_docs: + problems.append( + "both files list the same versions in a different order\n" + f" CHANGELOG.md : {' '.join(in_repo)}\n" + f" docs/changelog.mdx : {' '.join(in_docs)}" + ) + + if problems: + print("Changelog parity check failed:\n") + for p in problems: + print(f" - {p}") + print("\nUpdate both files so each release appears in each, newest first.") + sys.exit(1) + + print(f"OK - {len(in_repo)} releases match across both changelogs.") + print(f" {' '.join(in_repo)}") + PY diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1f123aa..821ceff 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -44,4 +44,4 @@ jobs: --with="orchestra/testbench=${{ matrix.testbench }}" - name: Execute tests - run: vendor/bin/pest + run: composer test From 8e1ad1842e6615e15f817fe19d6778c9a76a9285 Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Mon, 3 Aug 2026 18:42:03 +0100 Subject: [PATCH 4/4] docs: add the 0.4.5 changelog entry --- CHANGELOG.md | 21 +++++++++++++++++++++ docs/changelog.mdx | 25 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e802988..9a1182c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +## [0.4.5] - 2026-08-03 + +### Added + +- Added a `quality` workflow running the Pint formatting check on every push and pull request, and asserting that every release in `CHANGELOG.md` has a matching entry in the documentation site's changelog. Formatting was previously never checked in CI, and the two changelogs could drift silently. + +### Changed + +- `Deck::activate()` now accepts `string|int` versions, so `'v2'`, `'2'`, and `2` are all valid. Only `Deck::get()` was widened in `0.4.2`, despite the changelog describing both. +- The `tests` workflow now runs `composer test` rather than calling `vendor/bin/pest` directly, so CI and the documented contributor command cannot diverge. + +### Fixed + +- Fixed an unparseable version producing a message with an empty version number, such as `Version for prompt [order-summary] does not exist.` Both `Deck::get()` and `Deck::activate()` now throw `InvalidVersionException` naming the offending value. +- Fixed the README prompt structure diagram, which showed a `user.md` that `make:prompt` does not create without `--user`, a second version that a single run does not create, and omitted the version-level `metadata.json` added in `0.4.4`. +- Fixed the prompt structure diagrams on the introduction, configuration, prompts, and make:prompt documentation pages, which all omitted the version-level `metadata.json`. +- Fixed the README describing new versions as activating automatically. Creating a version has not changed the active version since `0.4.4`. +- Fixed the documented `Deck::get()` signature, which described `?int` rather than `string|int|null`. + +### Removed + ## [0.4.4] - 2026-08-03 ### Fixed diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 3212da8..acae31a 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -6,6 +6,31 @@ 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). + + +**Added** + +- A `quality` CI workflow now checks formatting with Pint on every push and pull request, and asserts that every release in the repository `CHANGELOG.md` has a matching entry on this page. Formatting was never checked in CI before, and the two changelogs could drift apart unnoticed. +- The test workflow now runs `composer test` rather than calling Pest directly, so CI and the documented contributor command cannot diverge. + +**Changed** + +- **`Deck::activate()` now accepts string versions.** `'v2'`, `'2'`, and `2` are all valid, matching what `Deck::get()` has accepted since `v0.4.2`. Only `get()` was widened at the time, so `Deck::activate('order-summary', 'v2')` raised a `TypeError` despite the release notes describing both. Passing an integer continues to work unchanged. + + ```php + Deck::activate('order-summary', 'v2'); + Deck::activate('order-summary', 2); + ``` + +**Fixed** + +- **Unparseable versions now name the offending value.** Both `Deck::get()` and `Deck::activate()` threw a message with the version missing entirely — `Version for prompt [order-summary] does not exist.` They now throw `InvalidVersionException` reading `Invalid version [banana] for prompt [order-summary]. Use a positive number like [1] or [v1].` +- Corrected the prompt structure diagrams throughout the documentation and README. They omitted the version-level `metadata.json` introduced in `v0.4.4`, and the README showed a `user.md` that `make:prompt` does not create without `--user` alongside a second version that a single run does not create. +- Corrected the README describing new versions as becoming active automatically. Creating a version has not changed the active version since `v0.4.4`. +- Corrected the documented `Deck::get()` signature, which read `?int` rather than `string|int|null`. + + + **Fixed**