From e1fd7b18f2701f6f0cbd1251f1aac4443b26dd60 Mon Sep 17 00:00:00 2001 From: Stefan Preu Date: Wed, 24 Jun 2026 20:47:20 +0200 Subject: [PATCH 1/2] feat(sdk): `pm package init --declarative` scaffolds a composeExtension blueprint starter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scaffold matrix only demonstrated imperative `defineExtension({ activate })` authoring + the runtime test helpers. The SDK's flagship declarative loop (`defineExtensionBlueprint` → `composeExtension`) and the author-time preflight capstone (`assertExtensionPreflight`) — which SDK.md calls "the capstone of the author → register → test loop" — were demonstrated by no generated starter, so a new author running `pm package init` never discovered the easiest, best-practice path. Discovered by dogfooding the full 9-capability matrix in a temp workspace (every imperative starter typechecks and passes its generated tests). Add `pm package init --declarative`: - `index.ts`: `defineCommand` → `defineExtensionBlueprint({ commands, deactivate })` → `export default composeExtension(blueprint)`. - `index.test.ts`: author-time `assertExtensionPreflight(blueprint, { identity, target })` (asserts derived + synthesized capabilities) + runtime `createExtensionTestHarness` (`assertCommandContract`/`runCommand`/`deactivate`). - README documents the declarative loop; manifest/package.json/tsconfig/.gitignore stay byte-identical to the imperative commands starter. Scoped to package-mode (composeExtension is a runtime SDK *value* import; the import-free extension-only starters must load without it) and the commands capability (the canonical composeExtension example). Extension-mode, non-commands `--capability`, and non-init use each return a clear USAGE error. Full capability-matrix generalization is tracked in pm-lfdv. Wired the flag through register-setup (commander + options reader), extension.ts (validation, passthrough, `style` in result), completion (bash/zsh/fish), and cli-contracts (+regenerated contracts snapshot); documented in SDK.md. pm-8mxg (epic pm-ugqx). 5049 tests, 100/100/100/100 coverage; typecheck/static-quality/docs-skills/ contracts gates green. Manually dogfooded end-to-end: scaffold → `tsc --noEmit` → `node --test` → `pm install --project` → command dispatches the composed module. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HEUoAbSunPoG3Xt83VYd4k --- .agents/pm/features/pm-8mxg.toon | 37 +++ .agents/pm/history/pm-8mxg.jsonl | 8 + .agents/pm/history/pm-lfdv.jsonl | 1 + .agents/pm/tasks/pm-lfdv.toon | 12 + CHANGELOG.md | 1 + docs/SDK.md | 13 + src/cli/commands/completion.ts | 4 +- src/cli/commands/extension.ts | 12 +- src/cli/commands/extension/scaffold.ts | 277 +++++++++++++++++- src/cli/register-setup.ts | 3 + src/sdk/cli-contracts.ts | 2 + tests/fixtures/contracts/full.json | 18 ++ .../unit/commands/completion-command.spec.ts | 2 +- .../unit/extensions/extension-command.spec.ts | 94 ++++++ ...extension-scaffold-define-guidance.spec.ts | 88 ++++++ 15 files changed, 565 insertions(+), 7 deletions(-) create mode 100644 .agents/pm/features/pm-8mxg.toon create mode 100644 .agents/pm/history/pm-8mxg.jsonl create mode 100644 .agents/pm/history/pm-lfdv.jsonl create mode 100644 .agents/pm/tasks/pm-lfdv.toon diff --git a/.agents/pm/features/pm-8mxg.toon b/.agents/pm/features/pm-8mxg.toon new file mode 100644 index 000000000..b392ad5b4 --- /dev/null +++ b/.agents/pm/features/pm-8mxg.toon @@ -0,0 +1,37 @@ +id: pm-8mxg +title: "pm package init --declarative: scaffold a composeExtension blueprint starter + author-time preflight test" +description: "Add a `--declarative` flag to `pm package init` that scaffolds the SDK's flagship declarative authoring loop. Today every scaffolded starter (all 9 --capability variants) authors via imperative `defineExtension({ activate })` and tests via the runtime helpers (activateExtensionForTest/assertRegistered*/runRegistered*/deactivateExtensionForTest). NONE demonstrates `composeExtension`/`defineExtensionBlueprint` (the declarative loop SDK.md calls 'the capstone of the author -> register -> test loop') or the author-time preflight capstone (`assertExtensionPreflight`/`preflightExtension`) or the ergonomic runtime capstone (`createExtensionTestHarness`). A new author who runs `pm package init` never discovers the easiest, best-practice authoring path. The `--declarative` starter generates index.ts as `defineExtensionBlueprint` + `composeExtension(blueprint)` and index.test.ts as `assertExtensionPreflight(blueprint, { identity, target })` (author-time) + `createExtensionTestHarness(module)` (runtime) + teardown. Scoped to package-mode (composeExtension is a runtime SDK value import; bundled extension-only installs stay import-free per SDK.md) and the commands capability (the canonical composeExtension example); other capabilities and extension-mode error clearly. Discovered by dogfooding the full scaffold matrix in a temp workspace (all 9 imperative starters typecheck + pass their generated tests). Goal: project management = context management — make creating new packages as easy and customizable as possible." +type: Feature +status: closed +priority: 2 +tags[5]: "area:extensions","area:packages","area:sdk",dx,pm-cli +created_at: "2026-06-24T18:26:57.582Z" +updated_at: "2026-06-24T18:46:03.585Z" +author: claude-code-agent +goal: project management = context management +value: "Surfaces the declarative composeExtension authoring loop + author-time preflight capstone in the scaffold so package authors discover and adopt the easiest, best-practice path on first `pm package init`." +parent: pm-ugqx +resolution: "Added `pm package init --declarative` — a package-mode, commands-capability blueprint starter that scaffolds `defineExtensionBlueprint` → `composeExtension(blueprint)` (index.ts) plus an index.test.ts exercising the author-time `assertExtensionPreflight` capstone and the runtime `createExtensionTestHarness`. Wired the flag through register-setup/extension/completion/cli-contracts; documented in SDK.md." +expected_result: "`pm package init --declarative` generates a typecheck-clean, test-passing composeExtension starter; extension-mode, non-commands `--capability`, and non-init use each return a clear USAGE error; 100% coverage and all quality gates stay green." +actual_result: "Delivered as specified: scaffold generates the declarative starter (style=declarative), all three error paths return exit 2, 5049 tests pass at 100/100/100/100 coverage, typecheck/static-quality/docs-skills/contracts gates green, and the composed module installs + dispatches end-to-end in a dogfooded temp workspace." +dependencies[6]{id,kind,created_at,author,source_kind}: + pm-iqq0,related,"2026-06-24T18:27:35.960Z",null,null + pm-jcyn,related,"2026-06-24T18:27:35.960Z",null,null + pm-lfdv,related,"2026-06-24T18:27:35.960Z",null,null + pm-nhby,related,"2026-06-24T18:27:35.960Z",null,null + pm-nvgy,related,"2026-06-24T18:27:35.960Z",null,null + pm-ozaf,related,"2026-06-24T18:27:35.960Z",null,null +learnings[1]{created_at,author,text}: + "2026-06-24T18:45:22.620Z",claude-code-agent,"A declarative scaffolded package's index.ts has a runtime VALUE import of composeExtension from @unbrained/pm-cli/sdk (unlike the imperative starter, whose only SDK import is the erased `ExtensionApi` type). So an end-to-end spawnSync dispatch test must link the host SDK into the temp workspace (node_modules/@unbrained/pm-cli -> repo root) or the composed module fails to load — this is exactly why --declarative is package-mode-only (the SDK is a linked dependency) and why the import-free extension-only starters must NOT use composeExtension. The imperative spawn test needs no such link because its type-only import is stripped on load." +files[6]{path,scope,note}: + docs/SDK.md,project,null + src/cli/commands/completion.ts,project,null + src/cli/commands/extension.ts,project,null + src/cli/commands/extension/scaffold.ts,project,null + src/cli/register-setup.ts,project,null + src/sdk/cli-contracts.ts,project,null +tests[2]{command,path,scope,timeout_seconds,pm_context_mode,env_set,env_clear,shared_host_safe,assert_stdout_contains,assert_stdout_regex,assert_stderr_contains,assert_stderr_regex,assert_stdout_min_lines,assert_json_field_equals,assert_json_field_gte,note}: + tests/unit/extensions/extension-command.spec.ts,null,project,null,null,null,null,null,null,null,null,null,null,null,null,null + tests/unit/extensions/extension-scaffold-define-guidance.spec.ts,null,project,null,null,null,null,null,null,null,null,null,null,null,null,null +close_reason: "Shipped `pm package init --declarative`: scaffolds a composeExtension blueprint starter (defineExtensionBlueprint → composeExtension) with an author-time assertExtensionPreflight + createExtensionTestHarness test. Package-mode + commands only (composeExtension is a runtime SDK value import), with clear USAGE errors for extension-mode, non-commands capabilities, and non-init use. Wired through register-setup/extension/completion/cli-contracts (+regenerated contracts snapshot) and documented in SDK.md. 5049 tests, 100/100/100/100 coverage, all quality gates green; manually dogfooded end-to-end (scaffold → typecheck → node --test → install → dispatch). Full-capability-matrix generalization tracked in pm-lfdv." +body: "## Implementation (2026-06-24)\n\n`pm package init --declarative` scaffolds the SDK's declarative authoring loop.\n\n**Generated files (declarative, package-mode + commands):**\n- `index.ts`: `defineCommand` → `defineExtensionBlueprint({ commands, deactivate })` → `export default composeExtension(blueprint)` (exports `pingCommand`/`blueprint`/default module).\n- `index.test.ts`: author-time `assertExtensionPreflight(blueprint, { identity, target })` (asserts derived + synthesized `capabilities: [\"commands\"]`) + runtime `createExtensionTestHarness(extension)` (`assertCommandContract`/`runCommand`/`deactivate`).\n- `README.md`: \"## Declarative Authoring\" section; package.json/manifest.json/tsconfig.json/.gitignore byte-identical to the imperative commands starter.\n\n**Constraints (enforced in `scaffoldExtensionProject`, USAGE exit):**\n- package-mode only — `composeExtension` is a runtime SDK value import (extension-only installs stay import-free per SDK.md).\n- commands capability only (canonical example); follow-up [[pm-lfdv]] generalizes to all 9 capabilities.\n- `--declarative` only valid with `--init/--scaffold`.\n\n**Wiring:** `register-setup.ts` (commander option on both lifecycle + init sub-command, options reader), `extension.ts` (option type, validation, passthrough, `style` in result), `completion.ts` (bash/zsh/fish), `cli-contracts.ts` (`EXTENSION_FLAG_CONTRACTS` + `EXTENSION_INIT_FLAG_CONTRACTS`) + regenerated `tests/fixtures/contracts/full.json`.\n\n**Verification:** 5049 tests, 100/100/100/100 coverage; typecheck (src+packages+examples), static-quality (100% docstrings), docs-skills gates green. Manual dogfood in a temp workspace: scaffold → `tsc --noEmit` clean → `node --test` 2/2 pass → `pm install --project` → `pm ping` dispatches the composed module (SDK linked). All three error paths return exit 2 with clear messages." diff --git a/.agents/pm/history/pm-8mxg.jsonl b/.agents/pm/history/pm-8mxg.jsonl new file mode 100644 index 000000000..877a25834 --- /dev/null +++ b/.agents/pm/history/pm-8mxg.jsonl @@ -0,0 +1,8 @@ +{"ts":"2026-06-24T18:26:57.582Z","author":"claude-code-agent","op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-8mxg"},{"op":"add","path":"/metadata/title","value":"pm package init --declarative: scaffold a composeExtension blueprint starter + author-time preflight test"},{"op":"add","path":"/metadata/description","value":"Add a `--declarative` flag to `pm package init` that scaffolds the SDK's flagship declarative authoring loop. Today every scaffolded starter (all 9 --capability variants) authors via imperative `defineExtension({ activate })` and tests via the runtime helpers (activateExtensionForTest/assertRegistered*/runRegistered*/deactivateExtensionForTest). NONE demonstrates `composeExtension`/`defineExtensionBlueprint` (the declarative loop SDK.md calls 'the capstone of the author -> register -> test loop') or the author-time preflight capstone (`assertExtensionPreflight`/`preflightExtension`) or the ergonomic runtime capstone (`createExtensionTestHarness`). A new author who runs `pm package init` never discovers the easiest, best-practice authoring path. The `--declarative` starter generates index.ts as `defineExtensionBlueprint` + `composeExtension(blueprint)` and index.test.ts as `assertExtensionPreflight(blueprint, { identity, target })` (author-time) + `createExtensionTestHarness(module)` (runtime) + teardown. Scoped to package-mode (composeExtension is a runtime SDK value import; bundled extension-only installs stay import-free per SDK.md) and the commands capability (the canonical composeExtension example); other capabilities and extension-mode error clearly. Discovered by dogfooding the full scaffold matrix in a temp workspace (all 9 imperative starters typecheck + pass their generated tests). Goal: project management = context management — make creating new packages as easy and customizable as possible."},{"op":"add","path":"/metadata/type","value":"Feature"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":["area:extensions","area:packages","area:sdk","dx","pm-cli"]},{"op":"add","path":"/metadata/created_at","value":"2026-06-24T18:26:57.582Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-06-24T18:26:57.582Z"},{"op":"add","path":"/metadata/author","value":"claude-code-agent"},{"op":"add","path":"/metadata/goal","value":"project management = context management"},{"op":"add","path":"/metadata/value","value":"Surfaces the declarative composeExtension authoring loop + author-time preflight capstone in the scaffold so package authors discover and adopt the easiest, best-practice path on first `pm package init`."},{"op":"add","path":"/metadata/parent","value":"pm-ugqx"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"302a0aeafa49aefceb7fd6cdb1c84bfdc7cabc7bcd0f2db7dd4a8bc48dd1b532","message":""} +{"ts":"2026-06-24T18:27:35.968Z","author":"claude-code-agent","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-24T18:27:35.968Z"},{"op":"add","path":"/metadata/dependencies","value":[{"id":"pm-iqq0","kind":"related","created_at":"2026-06-24T18:27:35.960Z"},{"id":"pm-jcyn","kind":"related","created_at":"2026-06-24T18:27:35.960Z"},{"id":"pm-lfdv","kind":"related","created_at":"2026-06-24T18:27:35.960Z"},{"id":"pm-nhby","kind":"related","created_at":"2026-06-24T18:27:35.960Z"},{"id":"pm-nvgy","kind":"related","created_at":"2026-06-24T18:27:35.960Z"},{"id":"pm-ozaf","kind":"related","created_at":"2026-06-24T18:27:35.960Z"}]}],"before_hash":"302a0aeafa49aefceb7fd6cdb1c84bfdc7cabc7bcd0f2db7dd4a8bc48dd1b532","after_hash":"caa27435f3a7859ce7b5fc30c37ecb1e78409dd545d491ee871287a557c7eb47"} +{"ts":"2026-06-24T18:45:18.168Z","author":"claude-code-agent","op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-24T18:45:18.168Z"},{"op":"add","path":"/metadata/files","value":[{"path":"docs/SDK.md","scope":"project"},{"path":"src/cli/commands/completion.ts","scope":"project"},{"path":"src/cli/commands/extension.ts","scope":"project"},{"path":"src/cli/commands/extension/scaffold.ts","scope":"project"},{"path":"src/cli/register-setup.ts","scope":"project"},{"path":"src/sdk/cli-contracts.ts","scope":"project"}]}],"before_hash":"caa27435f3a7859ce7b5fc30c37ecb1e78409dd545d491ee871287a557c7eb47","after_hash":"9ca6c00cdc56aadf517fce52ccca10b1975c63ef39a0bb37cda66ef2bb8892d0"} +{"ts":"2026-06-24T18:45:19.578Z","author":"claude-code-agent","op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-24T18:45:19.578Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"tests/unit/extensions/extension-command.spec.ts","scope":"project"},{"command":"tests/unit/extensions/extension-scaffold-define-guidance.spec.ts","scope":"project"}]}],"before_hash":"9ca6c00cdc56aadf517fce52ccca10b1975c63ef39a0bb37cda66ef2bb8892d0","after_hash":"bed2996e2a3637cdf43002bd64bc088f72f3b47f8b0df7503f145e1401062bc3"} +{"ts":"2026-06-24T18:45:22.620Z","author":"claude-code-agent","op":"learning_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-24T18:45:22.620Z"},{"op":"add","path":"/metadata/learnings","value":[{"created_at":"2026-06-24T18:45:22.620Z","author":"claude-code-agent","text":"A declarative scaffolded package's index.ts has a runtime VALUE import of composeExtension from @unbrained/pm-cli/sdk (unlike the imperative starter, whose only SDK import is the erased `ExtensionApi` type). So an end-to-end spawnSync dispatch test must link the host SDK into the temp workspace (node_modules/@unbrained/pm-cli -> repo root) or the composed module fails to load — this is exactly why --declarative is package-mode-only (the SDK is a linked dependency) and why the import-free extension-only starters must NOT use composeExtension. The imperative spawn test needs no such link because its type-only import is stripped on load."}]}],"before_hash":"bed2996e2a3637cdf43002bd64bc088f72f3b47f8b0df7503f145e1401062bc3","after_hash":"d70f9a5f7c49f2aceff9a1c84109e626435d9e67a0e891f18b79f4f86ec6ce2b"} +{"ts":"2026-06-24T18:45:37.579Z","author":"claude-code-agent","op":"append","patch":[{"op":"replace","path":"/body","value":"## Implementation (2026-06-24)\n\n`pm package init --declarative` scaffolds the SDK's declarative authoring loop.\n\n**Generated files (declarative, package-mode + commands):**\n- `index.ts`: `defineCommand` → `defineExtensionBlueprint({ commands, deactivate })` → `export default composeExtension(blueprint)` (exports `pingCommand`/`blueprint`/default module).\n- `index.test.ts`: author-time `assertExtensionPreflight(blueprint, { identity, target })` (asserts derived + synthesized `capabilities: [\"commands\"]`) + runtime `createExtensionTestHarness(extension)` (`assertCommandContract`/`runCommand`/`deactivate`).\n- `README.md`: \"## Declarative Authoring\" section; package.json/manifest.json/tsconfig.json/.gitignore byte-identical to the imperative commands starter.\n\n**Constraints (enforced in `scaffoldExtensionProject`, USAGE exit):**\n- package-mode only — `composeExtension` is a runtime SDK value import (extension-only installs stay import-free per SDK.md).\n- commands capability only (canonical example); follow-up [[pm-lfdv]] generalizes to all 9 capabilities.\n- `--declarative` only valid with `--init/--scaffold`.\n\n**Wiring:** `register-setup.ts` (commander option on both lifecycle + init sub-command, options reader), `extension.ts` (option type, validation, passthrough, `style` in result), `completion.ts` (bash/zsh/fish), `cli-contracts.ts` (`EXTENSION_FLAG_CONTRACTS` + `EXTENSION_INIT_FLAG_CONTRACTS`) + regenerated `tests/fixtures/contracts/full.json`.\n\n**Verification:** 5049 tests, 100/100/100/100 coverage; typecheck (src+packages+examples), static-quality (100% docstrings), docs-skills gates green. Manual dogfood in a temp workspace: scaffold → `tsc --noEmit` clean → `node --test` 2/2 pass → `pm install --project` → `pm ping` dispatches the composed module (SDK linked). All three error paths return exit 2 with clear messages."},{"op":"replace","path":"/metadata/updated_at","value":"2026-06-24T18:45:37.579Z"}],"before_hash":"d70f9a5f7c49f2aceff9a1c84109e626435d9e67a0e891f18b79f4f86ec6ce2b","after_hash":"4d40e68044a44568ff6e2809a28a78cee78b35aa6d220adc4bbe139c42201aaf"} +{"ts":"2026-06-24T18:45:52.691Z","author":"claude-code-agent","op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-24T18:45:52.691Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/close_reason","value":"Shipped `pm package init --declarative`: scaffolds a composeExtension blueprint starter (defineExtensionBlueprint → composeExtension) with an author-time assertExtensionPreflight + createExtensionTestHarness test. Package-mode + commands only (composeExtension is a runtime SDK value import), with clear USAGE errors for extension-mode, non-commands capabilities, and non-init use. Wired through register-setup/extension/completion/cli-contracts (+regenerated contracts snapshot) and documented in SDK.md. 5049 tests, 100/100/100/100 coverage, all quality gates green; manually dogfooded end-to-end (scaffold → typecheck → node --test → install → dispatch). Full-capability-matrix generalization tracked in pm-lfdv."}],"before_hash":"4d40e68044a44568ff6e2809a28a78cee78b35aa6d220adc4bbe139c42201aaf","after_hash":"75c7313a280af5f864a7404396613e720b3f9c9f9eb9ce6dfec2dc3df71808d5"} +{"ts":"2026-06-24T18:46:03.585Z","author":"claude-code-agent","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-24T18:46:03.585Z"},{"op":"add","path":"/metadata/resolution","value":"Added `pm package init --declarative` — a package-mode, commands-capability blueprint starter that scaffolds `defineExtensionBlueprint` → `composeExtension(blueprint)` (index.ts) plus an index.test.ts exercising the author-time `assertExtensionPreflight` capstone and the runtime `createExtensionTestHarness`. Wired the flag through register-setup/extension/completion/cli-contracts; documented in SDK.md."},{"op":"add","path":"/metadata/expected_result","value":"`pm package init --declarative` generates a typecheck-clean, test-passing composeExtension starter; extension-mode, non-commands `--capability`, and non-init use each return a clear USAGE error; 100% coverage and all quality gates stay green."},{"op":"add","path":"/metadata/actual_result","value":"Delivered as specified: scaffold generates the declarative starter (style=declarative), all three error paths return exit 2, 5049 tests pass at 100/100/100/100 coverage, typecheck/static-quality/docs-skills/contracts gates green, and the composed module installs + dispatches end-to-end in a dogfooded temp workspace."}],"before_hash":"75c7313a280af5f864a7404396613e720b3f9c9f9eb9ce6dfec2dc3df71808d5","after_hash":"52d10f7e7fa9c641a96c6d825c4755d1b221c7120c2769d31a2c77c3a72388b5"} diff --git a/.agents/pm/history/pm-lfdv.jsonl b/.agents/pm/history/pm-lfdv.jsonl new file mode 100644 index 000000000..2951a469a --- /dev/null +++ b/.agents/pm/history/pm-lfdv.jsonl @@ -0,0 +1 @@ +{"ts":"2026-06-24T18:27:15.801Z","author":"claude-code-agent","op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-lfdv"},{"op":"add","path":"/metadata/title","value":"Extend pm package init --declarative to the full capability matrix (hooks/search/importers/schema/renderers/parser/preflight/services)"},{"op":"add","path":"/metadata/description","value":"Follow-up to the commands-only --declarative blueprint starter (pm-8mxg). Generalize the declarative scaffold so every --capability variant can emit its composeExtension(blueprint) form (mapping each surface to its ExtensionBlueprint field: hooks->hooks.afterCommand, search->searchProviders/vectorStoreAdapters, importers->importers/exporters, schema->itemTypes/itemFields/migrations, renderers->renderers record, parser->parsers record, preflight->preflights, services->services record) plus the matching author-time assertExtensionPreflight test. Requires a shared per-capability registration-definition builder so the imperative activate body and the declarative blueprint stay in lockstep (the current README define* snippet uses simplified run bodies that do not match the real registrations, so it cannot be reused verbatim). Scoped out of pm-8mxg to keep that PR focused, reviewable, and 100%-coverable."},{"op":"add","path":"/metadata/type","value":"Task"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":3},{"op":"add","path":"/metadata/tags","value":["area:extensions","area:packages","area:sdk","dx","follow-up","pm-cli"]},{"op":"add","path":"/metadata/created_at","value":"2026-06-24T18:27:15.801Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-06-24T18:27:15.801Z"},{"op":"add","path":"/metadata/author","value":"claude-code-agent"},{"op":"add","path":"/metadata/parent","value":"pm-ugqx"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"f6ccdf2f3f48173ae9679dc22b042ebc9e635a77d8f6dca2a85da5a7dca9c42a","message":""} diff --git a/.agents/pm/tasks/pm-lfdv.toon b/.agents/pm/tasks/pm-lfdv.toon new file mode 100644 index 000000000..e306c8de4 --- /dev/null +++ b/.agents/pm/tasks/pm-lfdv.toon @@ -0,0 +1,12 @@ +id: pm-lfdv +title: Extend pm package init --declarative to the full capability matrix (hooks/search/importers/schema/renderers/parser/preflight/services) +description: "Follow-up to the commands-only --declarative blueprint starter (pm-8mxg). Generalize the declarative scaffold so every --capability variant can emit its composeExtension(blueprint) form (mapping each surface to its ExtensionBlueprint field: hooks->hooks.afterCommand, search->searchProviders/vectorStoreAdapters, importers->importers/exporters, schema->itemTypes/itemFields/migrations, renderers->renderers record, parser->parsers record, preflight->preflights, services->services record) plus the matching author-time assertExtensionPreflight test. Requires a shared per-capability registration-definition builder so the imperative activate body and the declarative blueprint stay in lockstep (the current README define* snippet uses simplified run bodies that do not match the real registrations, so it cannot be reused verbatim). Scoped out of pm-8mxg to keep that PR focused, reviewable, and 100%-coverable." +type: Task +status: open +priority: 3 +tags[6]: "area:extensions","area:packages","area:sdk",dx,follow-up,pm-cli +created_at: "2026-06-24T18:27:15.801Z" +updated_at: "2026-06-24T18:27:15.801Z" +author: claude-code-agent +parent: pm-ugqx +body: "" diff --git a/CHANGELOG.md b/CHANGELOG.md index f3750c1c3..96e4f160b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- pm package init --declarative: scaffold a composeExtension blueprint starter + author-time preflight test ([pm-8mxg](https://github.com/unbraind/pm-cli/blob/main/.agents/pm/features/pm-8mxg.toon)) - pm next: recommend the next actionable (unblocked, ready) work item with rationale + blocked companion ([pm-nj90](https://github.com/unbraind/pm-cli/blob/main/.agents/pm/features/pm-nj90.toon)) - Complete scaffold capability matrix: --capability renderers/parser/preflight/services starters ([pm-i5p5](https://github.com/unbraind/pm-cli/blob/main/.agents/pm/features/pm-i5p5.toon)) - pm package/extension init --capability schema: scaffold custom item type/field/migration starter ([pm-d1ig](https://github.com/unbraind/pm-cli/blob/main/.agents/pm/features/pm-d1ig.toon)) diff --git a/docs/SDK.md b/docs/SDK.md index 9d84e723c..8e23327c4 100644 --- a/docs/SDK.md +++ b/docs/SDK.md @@ -564,6 +564,10 @@ activate regardless). The `schema` starter is the deliberate exception: it omits `activation.commands` so its custom item type — a global contribution that built-in commands like `pm create ` must see — activates conservatively for every command rather than gating on the package's own commands. + +Each `--capability` starter authors an imperative `activate` body. To scaffold the +declarative `composeExtension` form instead, pass `--declarative` (package-mode, +commands capability) — see [Declarative Authoring](#declarative-authoring). See [EXTENSIONS.md](EXTENSIONS.md) for the manifest-field reference. ## Self-Identity and Lifecycle @@ -787,6 +791,15 @@ malformed definition surfaces the same activation diagnostic as a hand-written hand-written `activate` bodies so they load in extension-only installs; reach for `composeExtension` in npm package-mode authoring where the SDK is a dependency. +For a generated starting point, `pm package init --declarative` scaffolds +this loop end to end: an `index.ts` that authors a `defineExtensionBlueprint` +blueprint and exports `composeExtension(blueprint)`, plus an `index.test.ts` that +guards it with the author-time `assertExtensionPreflight` capstone and exercises +the composed module through `createExtensionTestHarness`. It is package-mode and +commands-only (`composeExtension` is a runtime SDK value import, so it belongs in +package-mode authoring where the SDK is a linked dependency, not the import-free +extension-only starters); other capabilities scaffold the imperative starter. + ### Modular blueprints Tracked: [pm-high](../.agents/pm/tasks/pm-high.toon), diff --git a/src/cli/commands/completion.ts b/src/cli/commands/completion.ts index 9cab75511..4cdfbee64 100644 --- a/src/cli/commands/completion.ts +++ b/src/cli/commands/completion.ts @@ -388,7 +388,7 @@ export function generateBashScript( ` COMPREPLY=(${compgen("--criterion --clear-criteria --format --policy --json --quiet --no-changed-fields --pm-path --path --no-extensions --no-pager --profile --help")})`, " ;;", " extension)", - ` COMPREPLY=(${compgen("init scaffold install uninstall explore manage describe reload doctor adopt adopt-all activate deactivate --init --scaffold --capability --install --uninstall --explore --manage --describe --reload --watch --doctor --adopt --adopt-all --activate --deactivate --project --local --global --gh --github --ref --detail --trace --runtime-probe --fix-managed-state --strict-exit --fail-on-warn --json --quiet --no-changed-fields --pm-path --path --no-extensions --no-pager --profile --help")})`, + ` COMPREPLY=(${compgen("init scaffold install uninstall explore manage describe reload doctor adopt adopt-all activate deactivate --init --scaffold --capability --declarative --install --uninstall --explore --manage --describe --reload --watch --doctor --adopt --adopt-all --activate --deactivate --project --local --global --gh --github --ref --detail --trace --runtime-probe --fix-managed-state --strict-exit --fail-on-warn --json --quiet --no-changed-fields --pm-path --path --no-extensions --no-pager --profile --help")})`, " ;;", " comments)", ` COMPREPLY=(${compgen("--add --stdin --file --edit --delete --limit --author --message --allow-audit-comment --force --json --quiet --no-changed-fields --pm-path --path --no-extensions --no-pager --profile --help")})`, @@ -1622,6 +1622,7 @@ ${zshSearchRuntimeFieldFlags} '--json[Output JSON]' \\ '--init[Generate a starter extension scaffold at target path]' \\ '--scaffold[Alias for --init]' \\ '--capability[Capability the init scaffold targets]:capability:(${SCAFFOLD_CAPABILITIES.join(" ")})' \\ + '--declarative[Scaffold the composeExtension blueprint starter (package-mode, commands)]' \\ '--install[Install extension from local path or GitHub source]' \\ '--uninstall[Uninstall extension by name]' \\ '--explore[List discovered extensions for selected scope]' \\ @@ -2581,6 +2582,7 @@ complete -c pm -n '__fish_seen_subcommand_from extension' -a 'init scaffold inst complete -c pm -n '__fish_seen_subcommand_from extension' -l init -d 'Generate starter extension scaffold' complete -c pm -n '__fish_seen_subcommand_from extension' -l scaffold -d 'Alias for --init' complete -c pm -n '__fish_seen_subcommand_from extension' -l capability -d 'Capability the init scaffold targets' -r -a '${SCAFFOLD_CAPABILITIES.join(" ")}' +complete -c pm -n '__fish_seen_subcommand_from extension' -l declarative -d 'Scaffold the composeExtension blueprint starter (package-mode, commands)' complete -c pm -n '__fish_seen_subcommand_from extension' -l install -d 'Install extension from local path or GitHub source' complete -c pm -n '__fish_seen_subcommand_from extension' -l uninstall -d 'Uninstall extension by name' complete -c pm -n '__fish_seen_subcommand_from extension' -l explore -d 'List discovered extensions for selected scope' diff --git a/src/cli/commands/extension.ts b/src/cli/commands/extension.ts index db526a54a..754c35efe 100644 --- a/src/cli/commands/extension.ts +++ b/src/cli/commands/extension.ts @@ -151,6 +151,7 @@ export interface ExtensionCommandOptions { fixManagedState?: boolean; fields?: string; capability?: string; + declarative?: boolean; vocabulary?: "extension" | "package"; } @@ -1192,6 +1193,9 @@ export async function runExtension( if (options.capability !== undefined && action !== "init") { throw new PmCliError("--capability is only valid with --init/--scaffold.", EXIT_CODE.USAGE); } + if (options.declarative === true && action !== "init") { + throw new PmCliError("--declarative is only valid with --init/--scaffold.", EXIT_CODE.USAGE); + } /* c8 ignore start -- alias-normalization matrix is covered by resolveAction tests; this IIFE only rewrites positional aliases */ const normalizedTarget = (() => { const normalizedInput = target?.trim().toLowerCase(); @@ -1256,7 +1260,12 @@ export async function runExtension( throw new PmCliError('Action "init" does not accept --gh/--github/--ref options.', EXIT_CODE.USAGE); } const scaffoldTarget = requireTarget(normalizedTarget, action); - const scaffold = await scaffoldExtensionProject(scaffoldTarget, options.vocabulary ?? "extension", options.capability); + const scaffold = await scaffoldExtensionProject( + scaffoldTarget, + options.vocabulary ?? "extension", + options.capability, + options.declarative === true, + ); const quotedTargetPath = JSON.stringify(scaffold.target_path); // Forward-slash the path for the copy-pasteable `cd` hint: Windows cmd.exe / // PowerShell reject the doubled backslashes JSON.stringify emits, while both @@ -1269,6 +1278,7 @@ export async function runExtension( command: scaffold.command_name, }, capability: scaffold.capability, + style: scaffold.style, target_path: scaffold.target_path, created_directory: scaffold.created_directory, files: scaffold.files, diff --git a/src/cli/commands/extension/scaffold.ts b/src/cli/commands/extension/scaffold.ts index 45d8e30ec..00536528f 100644 --- a/src/cli/commands/extension/scaffold.ts +++ b/src/cli/commands/extension/scaffold.ts @@ -396,11 +396,25 @@ interface ExtensionScaffoldResult { extension_name: string; command_name: string; capability: ExtensionScaffoldCapability; + /** + * Authoring style of the generated entrypoint: `"imperative"` (a hand-written + * `activate` body, the default) or `"declarative"` (a `composeExtension` + * blueprint). The declarative style is package-mode + commands only. + */ + style: ExtensionScaffoldStyle; target_path: string; created_directory: boolean; files: ExtensionScaffoldFileResult[]; } +/** + * Authoring style the scaffolder targets for the generated entrypoint. The + * default `"imperative"` emits a hand-written `activate` body; `"declarative"` + * emits the SDK's `composeExtension` blueprint loop (package-mode + commands + * only — see {@link buildDeclarativeEntrypoint}). + */ +export type ExtensionScaffoldStyle = "imperative" | "declarative"; + /** * Build the `activate` body lines for the starter entrypoint. The base body * always registers the starter command; capability-specific variants append @@ -1161,6 +1175,235 @@ function buildScaffoldActivationCommands( return [commandName]; } +/** + * Build the declarative (`composeExtension`) variant of the starter `index.ts`. + * + * Where {@link buildActivateBodyLines} emits an imperative `activate` that calls + * `api.register*` directly, this emits the SDK's flagship declarative loop: the + * starter command is authored with `defineCommand`, collected into an + * `ExtensionBlueprint` via `defineExtensionBlueprint`, and turned into the runtime + * module by `composeExtension` (which generates the `activate` that registers + * every blueprint surface in order). The command, blueprint, and module are all + * exported so the colocated test can preflight the blueprint and exercise the + * module — see {@link buildDeclarativeSampleTestSource}. + * + * It is package-mode and commands-only by construction (the canonical + * `composeExtension` example): `composeExtension` is a runtime SDK *value* import, + * so it belongs in package-mode authoring where the SDK is a linked dependency, + * not in the import-free extension-only starters. {@link scaffoldExtensionProject} + * enforces both constraints before this is reached. + */ +function buildDeclarativeEntrypoint(extensionName: string, commandName: string): string { + return [ + 'import { composeExtension, defineCommand, defineExtensionBlueprint } from "@unbrained/pm-cli/sdk";', + "", + "// The starter command, authored with the `defineCommand` builder so its literal", + "// name is preserved and the handler `context` is inferred. Exporting it lets you", + "// unit-test the definition in isolation, apart from the blueprint.", + "export const pingCommand = defineCommand({", + ` name: ${JSON.stringify(commandName)},`, + ' description: "Starter scaffold command. Replace with your own behavior.",', + " run: async (context) => ({", + " ok: true,", + ` source: ${JSON.stringify(extensionName)},`, + " command: context.command,", + ' message: "Starter extension scaffold is active.",', + " }),", + "});", + "", + "// Declarative authoring: describe WHAT the package registers as a blueprint and", + "// let `composeExtension` generate the `activate` that wires every surface in", + "// order, instead of hand-writing each `api.register*` call. Grow the package by", + "// adding blueprint fields (`parsers`, `hooks`, `itemTypes`, `searchProviders`,", + "// ...); `defineExtensionBlueprint` contract-checks the blueprint where it is", + "// authored. Keep `manifest.json`'s `capabilities` equal to the set this blueprint", + "// derives (here: [\"commands\"]).", + "export const blueprint = defineExtensionBlueprint({", + " commands: [pingCommand],", + " // `deactivate` is the teardown counterpart to the generated `activate`: pm runs", + " // it on host shutdown/reload to release anything the package opened (timers,", + " // connections, caches). This starter holds none, so it stays a no-op.", + " deactivate: () => {},", + "});", + "", + "// `composeExtension(blueprint)` is the package's default export — the runtime", + "// `ExtensionModule` pm loads. Guard the blueprint with `assertExtensionPreflight`", + "// in index.test.ts before publishing.", + "export default composeExtension(blueprint);", + "", + ].join("\n"); +} + +/** + * Build the declarative variant of the colocated `node:test` suite + * (`index.test.ts`) for the `composeExtension` commands starter. + * + * Unlike {@link buildSampleTestSource}, which threads the standalone + * `activateExtensionForTest`/`assertRegistered*`/`runRegistered*` helpers, this + * exercises the two capstones the declarative loop unlocks: the author-time + * `assertExtensionPreflight(blueprint, { identity, target })` (lint + manifest + * synthesis + version-compat in one call) over the exported `blueprint`, and the + * ergonomic runtime `createExtensionTestHarness(module)` whose `assert*`/`run*`/ + * `deactivate` methods are pre-bound to the right activation sub-registry. The + * `identity`/`target` versions are pinned to the scaffolded `pm_min_version` so + * the synthesized manifest is trivially compatible and the suite stays + * deterministic; an author edits them as the package matures. + */ +function buildDeclarativeSampleTestSource(extensionName: string, commandName: string): string { + return [ + 'import assert from "node:assert/strict";', + 'import { test } from "node:test";', + 'import { assertExtensionPreflight, createExtensionTestHarness } from "@unbrained/pm-cli/sdk/testing";', + 'import extension, { blueprint } from "./index.ts";', + "", + `test(${JSON.stringify(`${extensionName} passes author-time preflight`)}, () => {`, + " // assertExtensionPreflight is the author-time capstone: it lints the blueprint,", + " // synthesizes the least-privilege manifest from `identity`, and checks the version", + " // bounds against `target` in one call, throwing on any blocking finding. It returns", + " // the full report so you can inspect the derived data.", + " const report = assertExtensionPreflight(blueprint, {", + " identity: {", + ` name: ${JSON.stringify(extensionName)},`, + ' version: "0.1.0",', + ' entry: "./index.ts",', + " priority: 0,", + ` pm_min_version: ${JSON.stringify(SCAFFOLD_PM_MIN_VERSION)},`, + " },", + ` target: { pmVersion: ${JSON.stringify(SCAFFOLD_PM_MIN_VERSION)} },`, + " });", + " // The capability set is DERIVED from the blueprint, never hand-synced. Keep", + " // manifest.json's `capabilities` equal to this list.", + ' assert.deepEqual(report.capabilities, ["commands"]);', + ' assert.deepEqual(report.manifest?.capabilities, ["commands"]);', + "});", + "", + `test(${JSON.stringify(`${extensionName} registers and runs its starter command`)}, async () => {`, + " // createExtensionTestHarness activates the composed module once and binds the", + " // assert*/run*/deactivate helpers to the right activation sub-registry, so you", + " // never thread activation.registrations vs activation.commands by hand.", + " // `capabilities` mirrors manifest.json so the in-memory activation grants what the", + " // blueprint needs.", + " const ext = await createExtensionTestHarness(extension, {", + ` name: ${JSON.stringify(extensionName)},`, + ' capabilities: ["commands"],', + " });", + " // assertCommandContract throws unless the command is registered, so reaching the", + " // next line already proves the wiring; assert on the returned definition to", + " // demonstrate inspecting registered metadata.", + ` const registered = ext.assertCommandContract({ command: ${JSON.stringify(commandName)} });`, + ' assert.equal(typeof registered.command.description, "string");', + "", + " // runCommand invokes the handler through pm's real dispatch engine, so this asserts", + " // behavior - not just that the command is wired. Replace these assertions as you", + " // flesh out your command.", + ` const invocation = await ext.runCommand({ command: ${JSON.stringify(commandName)} });`, + " assert.equal(invocation.handled, true);", + " assert.deepEqual(invocation.result, {", + " ok: true,", + ` source: ${JSON.stringify(extensionName)},`, + ` command: ${JSON.stringify(commandName)},`, + ' message: "Starter extension scaffold is active.",', + " });", + "", + " // deactivate runs pm's real teardown engine over the module; exactly one extension", + " // tears down with no failures.", + " const teardown = await ext.deactivate();", + " assert.equal(teardown.deactivated, 1);", + "});", + "", + ].join("\n"); +} + +/** + * Build the README for the declarative (`composeExtension`) package starter. + * + * It documents the same metadata/validation sections as the imperative package + * README but frames the entrypoint around the declarative loop and the author-time + * preflight test, so the generated docs match the generated `index.ts`/ + * `index.test.ts`. + */ +function buildDeclarativePackageReadme(packageName: string, commandName: string): string { + return [ + `# ${packageName}`, + "", + "Generated by `pm package init --declarative`.", + "", + "## Included Files", + "- `package.json`: package metadata, `typecheck`/`test` scripts, and `pm` resource manifest.", + "- `manifest.json`: package metadata, capabilities, and `activation.commands` (the command paths that lazily activate this package).", + "- `index.ts`: the TypeScript manifest entry — a `defineExtensionBlueprint` blueprint composed into the runtime module by `composeExtension`, plus a `deactivate` teardown stub.", + "- `index.test.ts`: sample `node:test` suite covering author-time preflight (`assertExtensionPreflight`) and runtime command invocation/teardown via `createExtensionTestHarness`.", + TSCONFIG_BULLET, + "- `.gitignore`: ignores `node_modules/`, logs, and the TypeScript build cache.", + "", + "## Quick Start", + "This package is authored AND loaded as TypeScript: the manifest `entry` is", + "`./index.ts` and pm imports it directly via Node's native type stripping", + "(Node >=22.18), so there is no build step — install and run:", + "```bash", + "npm install", + "pm install --project ", + `pm ${commandName}`, + "pm package doctor --project --detail summary", + "```", + "", + "## Declarative Authoring", + "`index.ts` uses the SDK's declarative authoring loop instead of an imperative", + "`activate` body. You describe WHAT the package registers as an", + "`ExtensionBlueprint` and `composeExtension` generates the `activate` that wires", + "every surface in order:", + "- `defineCommand` authors the starter command with literal-type preservation and", + " contextual handler inference.", + "- `defineExtensionBlueprint({ commands: [pingCommand], deactivate })` declares the", + " registration surface; add more fields (`parsers`, `hooks`, `itemTypes`,", + " `searchProviders`, ...) as the package grows.", + "- `composeExtension(blueprint)` is the default export — the runtime module pm", + " loads. Its generated `activate` requires exactly the capabilities the blueprint", + " derives, so `manifest.json`'s `capabilities` never drift.", + "", + "## Validate the Package", + "`npm install` pulls the peer SDK and TypeScript; `npm run typecheck` checks the", + "source against the SDK contracts and `npm test` runs the colocated sample:", + "```bash", + "npm install", + "npm run typecheck", + "npm test", + "```", + "`index.test.ts` exercises both authoring capstones: `assertExtensionPreflight`", + "(the author-time lint + manifest synthesis + version-compatibility check over the", + "exported `blueprint`) and `createExtensionTestHarness` (the runtime fixture whose", + "`assert*`/`run*`/`deactivate` methods bind to the right activation sub-registry).", + "`npm test` runs `node --test`, which strips types on load and executes", + "`index.test.ts` directly against the `@unbrained/pm-cli/sdk/testing` helpers - no", + "compile step and no extra test runner required.", + "", + "## Lazy Activation", + "`manifest.json` declares `activation.commands`: the exact command paths this", + "package's composed `activate` registers. pm imports and activates the package", + "lazily — only when an invoked command path matches one of these entries — so", + "unrelated commands (`pm list`, `pm search`, ...) never pay to load it. Keep this", + "list in sync with the blueprint's `commands`: add an entry when you add a command", + "and remove one you drop. Globally-scoped surfaces (hooks, parser/preflight/renderer", + "overrides, and search providers for built-in search commands) still activate", + "regardless of this list.", + "", + "## Compatibility Bounds", + "`manifest.json` cannot hold comments, so the version-compatibility fields are documented here:", + `- \`manifest_version\` (integer): manifest schema generation. Leave at \`${SCAFFOLD_MANIFEST_VERSION}\` unless you adopt a newer manifest schema.`, + `- \`pm_min_version\` (string): lowest pm CLI version that may load this package. Scaffolded as \`${SCAFFOLD_PM_MIN_VERSION}\`. The loader blocks the package on older CLIs.`, + "- `pm_max_version` (string, optional): highest pm CLI version that may load this package. Add it to block CLIs that are newer than the version you have validated against. The loader blocks the package when the CLI exceeds this bound.", + "", + "## Policy Metadata", + "The starter command is pure compute, so `manifest.json` declares `trusted: true`, `sandbox_profile: \"strict\"`, and all six permission keys as `false`. Keep that least-privilege shape for pure packages; relax only the specific permission your package actually needs and verify with `pm package doctor --project --detail deep --trace`.", + "", + "## Notes", + "- Author in `index.ts`; pm loads it directly (no build), so edits take effect on the next install/reload — there is no `.js` to regenerate.", + "- Move larger runtimes into sibling or subdirectory `*.ts` modules and import them with their real `.ts` extension; `tsconfig.json` type-checks every `*.ts` in the package (recursively).", + "- Use `@unbrained/pm-cli/sdk` as the public SDK import for richer package runtimes.", + "", + ].join("\n"); +} + /** * Implements build starter extension scaffold files for the public runtime surface of this module. */ @@ -1169,6 +1412,7 @@ export function buildStarterExtensionScaffoldFiles( commandName: string, vocabulary: "extension" | "package", capability: ExtensionScaffoldCapability = "commands", + declarative: boolean = false, ): Record { const packageName = `pm-${extensionName}`; const capabilities = SCAFFOLD_MANIFEST_CAPABILITIES[capability]; @@ -1563,14 +1807,18 @@ export function buildStarterExtensionScaffoldFiles( "- Use `@unbrained/pm-cli/sdk` as the public SDK import for richer package runtimes.", "", ].join("\n"); + // The declarative starter swaps the imperative entrypoint/test/README for the + // `composeExtension` blueprint form (package-mode + commands only, enforced by + // scaffoldExtensionProject). package.json, manifest.json, tsconfig.json, and + // .gitignore are identical to the imperative commands starter. return { "package.json": packageJson, "manifest.json": manifest, - "index.ts": entrypoint, - "index.test.ts": sampleTest, + "index.ts": declarative ? buildDeclarativeEntrypoint(extensionName, commandName) : entrypoint, + "index.test.ts": declarative ? buildDeclarativeSampleTestSource(extensionName, commandName) : sampleTest, "tsconfig.json": tsconfig, ".gitignore": gitignore, - "README.md": packageReadme, + "README.md": declarative ? buildDeclarativePackageReadme(packageName, commandName) : packageReadme, }; } const readme = [ @@ -1627,6 +1875,7 @@ export async function scaffoldExtensionProject( target: string, vocabulary: "extension" | "package" = "extension", capability: string = "commands", + declarative: boolean = false, ): Promise { const normalizedCapability = capability.trim().toLowerCase(); if (!(SCAFFOLD_CAPABILITIES as readonly string[]).includes(normalizedCapability)) { @@ -1636,6 +1885,25 @@ export async function scaffoldExtensionProject( ); } const resolvedCapability = normalizedCapability as ExtensionScaffoldCapability; + // The declarative (`composeExtension` blueprint) starter is package-mode + + // commands only. `composeExtension` is a runtime SDK *value* import, so it + // belongs in package-mode authoring where the SDK is a linked dependency — not + // in the import-free extension-only starters that must load without it. The + // commands surface is the canonical declarative example; other capabilities use + // the imperative starter (tracked for a follow-up generalization). + if (declarative && vocabulary !== "package") { + throw new PmCliError( + "--declarative scaffolds a package-mode blueprint starter (composeExtension is a runtime SDK import). Use `pm package init`, not `pm extension init`.", + EXIT_CODE.USAGE, + ); + } + if (declarative && resolvedCapability !== "commands") { + throw new PmCliError( + `--declarative currently scaffolds the commands-capability blueprint starter (the canonical composeExtension example). Omit --capability or pass --capability commands; other capabilities use the imperative starter.`, + EXIT_CODE.USAGE, + ); + } + const style: ExtensionScaffoldStyle = declarative ? "declarative" : "imperative"; const normalizedTarget = target.trim(); const targetPath = path.resolve(process.cwd(), normalizedTarget); const extensionName = normalizeManagedDirectoryName(path.basename(targetPath)); @@ -1643,7 +1911,7 @@ export async function scaffoldExtensionProject( // Commander, so generated starters use space-separated command words while the // manifest and package identity keep their normalized directory names. const commandName = `${extensionName.replace(/-/g, " ")} ping`; - const scaffoldFiles = buildStarterExtensionScaffoldFiles(extensionName, commandName, vocabulary, resolvedCapability); + const scaffoldFiles = buildStarterExtensionScaffoldFiles(extensionName, commandName, vocabulary, resolvedCapability, declarative); let createdDirectory = false; if (await pathExists(targetPath)) { @@ -1695,6 +1963,7 @@ export async function scaffoldExtensionProject( extension_name: extensionName, command_name: commandName, capability: resolvedCapability, + style, target_path: targetPath, created_directory: createdDirectory, files, diff --git a/src/cli/register-setup.ts b/src/cli/register-setup.ts index 8ce8da97a..95c404e8e 100644 --- a/src/cli/register-setup.ts +++ b/src/cli/register-setup.ts @@ -72,6 +72,7 @@ function normalizeExtensionOptions( github: readString("github"), ref: readString("ref"), capability: readString("capability"), + declarative: readBoolean("declarative"), fields: readString("fields"), detail: readString("detail"), trace: readBoolean("trace"), @@ -176,6 +177,7 @@ function registerLifecycleCommand( .option("--init", `Generate a starter ${noun} scaffold at target path`) .option("--scaffold", "Alias for --init") .option("--capability ", `Capability the --init starter targets (${SCAFFOLD_CAPABILITIES.join("|")}; default commands)`) + .option("--declarative", "Scaffold the composeExtension blueprint starter (package-mode, commands capability)") .option("--install", `Install ${noun} from local path, bundled alias, npm: source, wildcard, or GitHub source`) .option("--uninstall", `Uninstall an installed ${noun}`) .option("--explore", `List discovered ${plural} in selected scope`) @@ -221,6 +223,7 @@ function registerLifecycleCommand( .alias("scaffold") .argument("", `Scaffold target directory path`) .option("--capability ", `Capability the starter targets (${SCAFFOLD_CAPABILITIES.join("|")}; default commands)`) + .option("--declarative", "Scaffold the composeExtension blueprint starter (package-mode, commands capability)") .description( vocabulary === "package" ? "Generate a starter package scaffold with package metadata, manifest, and entrypoint." diff --git a/src/sdk/cli-contracts.ts b/src/sdk/cli-contracts.ts index 6773c87d1..c32e80d46 100644 --- a/src/sdk/cli-contracts.ts +++ b/src/sdk/cli-contracts.ts @@ -551,6 +551,7 @@ export const EXTENSION_FLAG_CONTRACTS: CliFlagContract[] = [ { flag: "--init" }, { flag: "--scaffold" }, { flag: "--capability" }, + { flag: "--declarative" }, { flag: "--install" }, { flag: "--uninstall" }, { flag: "--explore" }, @@ -588,6 +589,7 @@ export const EXTENSION_SCOPE_FLAG_CONTRACTS: CliFlagContract[] = [ export const EXTENSION_INIT_FLAG_CONTRACTS: CliFlagContract[] = [ ...EXTENSION_SCOPE_FLAG_CONTRACTS, { flag: "--capability" }, + { flag: "--declarative" }, ]; export const EXTENSION_INSTALL_FLAG_CONTRACTS: CliFlagContract[] = [ diff --git a/tests/fixtures/contracts/full.json b/tests/fixtures/contracts/full.json index d0fdaefb3..eeae09a2b 100644 --- a/tests/fixtures/contracts/full.json +++ b/tests/fixtures/contracts/full.json @@ -2251,6 +2251,9 @@ { "flag": "--capability" }, + { + "flag": "--declarative" + }, { "flag": "--install" }, @@ -2494,6 +2497,9 @@ }, { "flag": "--capability" + }, + { + "flag": "--declarative" } ], "provider": "core" @@ -4576,6 +4582,9 @@ { "flag": "--capability" }, + { + "flag": "--declarative" + }, { "flag": "--install" }, @@ -4819,6 +4828,9 @@ }, { "flag": "--capability" + }, + { + "flag": "--declarative" } ], "provider": "core" @@ -4913,6 +4925,9 @@ { "flag": "--capability" }, + { + "flag": "--declarative" + }, { "flag": "--install" }, @@ -5156,6 +5171,9 @@ }, { "flag": "--capability" + }, + { + "flag": "--declarative" } ], "provider": "core" diff --git a/tests/unit/commands/completion-command.spec.ts b/tests/unit/commands/completion-command.spec.ts index 86b7fe882..20fddd45a 100644 --- a/tests/unit/commands/completion-command.spec.ts +++ b/tests/unit/commands/completion-command.spec.ts @@ -307,7 +307,7 @@ describe("generateBashScript", () => { it("includes extension doctor strict flags in bash completion", () => { const script = generateBashScript(); expect(script).toContain( - "--init --scaffold --capability --install --uninstall --explore --manage --describe --reload --watch --doctor --adopt --adopt-all --activate --deactivate --project --local --global --gh --github --ref --detail --trace --runtime-probe --fix-managed-state --strict-exit --fail-on-warn", + "--init --scaffold --capability --declarative --install --uninstall --explore --manage --describe --reload --watch --doctor --adopt --adopt-all --activate --deactivate --project --local --global --gh --github --ref --detail --trace --runtime-probe --fix-managed-state --strict-exit --fail-on-warn", ); }); diff --git a/tests/unit/extensions/extension-command.spec.ts b/tests/unit/extensions/extension-command.spec.ts index c11b983ff..0ad3c7f83 100644 --- a/tests/unit/extensions/extension-command.spec.ts +++ b/tests/unit/extensions/extension-command.spec.ts @@ -2132,6 +2132,100 @@ describe("extension command runtime", () => { }); }); + it("scaffolds a declarative composeExtension package that installs and dispatches end to end", async () => { + await withTempPmPath(async (context) => { + const scaffoldPath = path.join(context.tempRoot, "starter-declarative"); + const scaffold = await runExtension(scaffoldPath, { + init: true, + project: true, + vocabulary: "package", + declarative: true, + }, { path: context.pmPath }); + expect(scaffold.action).toBe("init"); + expect(scaffold.details).toMatchObject({ + capability: "commands", + style: "declarative", + extension: { + name: "starter-declarative", + command: "starter declarative ping", + }, + created_directory: true, + }); + + // The declarative entrypoint is the composeExtension blueprint loop. + const entry = await readFile(path.join(scaffoldPath, "index.ts"), "utf8"); + expect(entry).toContain('import { composeExtension, defineCommand, defineExtensionBlueprint } from "@unbrained/pm-cli/sdk";'); + expect(entry).toContain("export const blueprint = defineExtensionBlueprint({"); + expect(entry).toContain("export default composeExtension(blueprint);"); + expect(entry).not.toContain("export function activate(api: ExtensionApi)"); + + // The colocated test exercises the author-time preflight + runtime harness. + const sampleTest = await readFile(path.join(scaffoldPath, "index.test.ts"), "utf8"); + expect(sampleTest).toContain("assertExtensionPreflight(blueprint, {"); + expect(sampleTest).toContain("createExtensionTestHarness(extension, {"); + + // The manifest is the same least-privilege commands manifest, so the + // composed module installs and dispatches exactly like the imperative one. + const manifest = JSON.parse(await readFile(path.join(scaffoldPath, "manifest.json"), "utf8")) as Record; + expect(manifest.capabilities).toEqual(["commands"]); + + const install = await runExtension(scaffoldPath, { install: true, project: true }, { path: context.pmPath }); + expect(install.details).toMatchObject({ + extension: { name: "starter-declarative" }, + activated: true, + command_paths: ["starter declarative ping"], + }); + // composeExtension is a runtime SDK *value* import (unlike the imperative + // starter, whose only SDK import is the erased `ExtensionApi` type), so the + // composed module needs `@unbrained/pm-cli/sdk` resolvable when pm loads it. + // A real package install links the host SDK; mirror that here by linking the + // running repo into the workspace's node_modules so the spawned CLI can + // resolve the subpath, then prove the module loads and dispatches for real. + const sdkLinkDir = path.join(context.pmPath, "node_modules", "@unbrained"); + await mkdir(sdkLinkDir, { recursive: true }); + await symlink(process.cwd(), path.join(sdkLinkDir, "pm-cli"), "dir"); + const invoked = spawnSync( + process.execPath, + [path.join(process.cwd(), "dist/cli.js"), "--path", context.pmPath, "starter", "declarative", "ping", "--json"], + { + cwd: context.pmPath, + encoding: "utf8", + env: { ...process.env, PM_TELEMETRY_DISABLED: "1", PM_SENTRY_DISABLED: "1" }, + }, + ); + expect(invoked.status).toBe(0); + expect(JSON.parse(invoked.stdout) as Record).toMatchObject({ + ok: true, + command: "starter declarative ping", + message: "Starter extension scaffold is active.", + }); + }); + }); + + it("rejects --declarative for extension-mode and non-commands capabilities, and outside init", async () => { + await withTempPmPath(async (context) => { + // composeExtension is a runtime SDK import, so the declarative starter is + // package-mode only — extension-mode is rejected before any file is written. + await expect( + runExtension(path.join(context.tempRoot, "decl-ext"), { init: true, project: true, declarative: true }, { path: context.pmPath }), + ).rejects.toThrow(/--declarative scaffolds a package-mode blueprint starter/); + + // The declarative starter targets the commands surface only. + await expect( + runExtension( + path.join(context.tempRoot, "decl-search"), + { init: true, project: true, vocabulary: "package", capability: "search", declarative: true }, + { path: context.pmPath }, + ), + ).rejects.toThrow(/--declarative currently scaffolds the commands-capability blueprint starter/); + + // --declarative is a scaffold-only flag. + await expect( + runExtension(undefined, { explore: true, project: true, declarative: true }, { path: context.pmPath }), + ).rejects.toThrow(/--declarative is only valid with --init\/--scaffold/); + }); + }); + it("scaffolds hook-capability packages with runnable SDK hook tests", async () => { await withTempPmPath(async (context) => { const scaffoldPath = path.join(context.tempRoot, "starter-hooks"); diff --git a/tests/unit/extensions/extension-scaffold-define-guidance.spec.ts b/tests/unit/extensions/extension-scaffold-define-guidance.spec.ts index e49297484..31344b359 100644 --- a/tests/unit/extensions/extension-scaffold-define-guidance.spec.ts +++ b/tests/unit/extensions/extension-scaffold-define-guidance.spec.ts @@ -315,3 +315,91 @@ describe("extension scaffold define builder guidance", () => { expect(readme).toContain("npx tsc"); }); }); + +describe("declarative composeExtension package scaffold", () => { + it("authors index.ts through the composeExtension blueprint loop instead of an imperative activate", () => { + const scaffold = buildStarterExtensionScaffoldFiles("kit", "kit ping", "package", "commands", true); + const entry = scaffold["index.ts"] ?? ""; + + // The flagship declarative loop: defineCommand authors the command, it is + // collected into a defineExtensionBlueprint blueprint, and composeExtension + // generates the runtime module. No hand-written `activate(api)` body. + expect(entry).toContain('import { composeExtension, defineCommand, defineExtensionBlueprint } from "@unbrained/pm-cli/sdk";'); + expect(entry).toContain("export const pingCommand = defineCommand({"); + expect(entry).toContain('name: "kit ping",'); + expect(entry).toContain("export const blueprint = defineExtensionBlueprint({"); + expect(entry).toContain("commands: [pingCommand],"); + // The blueprint's deactivate hatch is composed onto the module verbatim. + expect(entry).toContain("deactivate: () => {},"); + expect(entry).toContain("export default composeExtension(blueprint);"); + // It is genuinely declarative: no imperative activate(api) body and no + // `defineExtension` wrapper. + expect(entry).not.toContain("export function activate(api: ExtensionApi)"); + expect(entry).not.toContain("import { defineExtension }"); + }); + + it("tests the declarative starter via the author-time preflight + runtime harness capstones", () => { + const scaffold = buildStarterExtensionScaffoldFiles("kit", "kit ping", "package", "commands", true); + const sampleTest = scaffold["index.test.ts"] ?? ""; + + // The two capstones the declarative loop unlocks: author-time preflight over + // the exported blueprint, and the ergonomic runtime harness over the module. + expect(sampleTest).toContain( + 'import { assertExtensionPreflight, createExtensionTestHarness } from "@unbrained/pm-cli/sdk/testing";', + ); + expect(sampleTest).toContain('import extension, { blueprint } from "./index.ts";'); + expect(sampleTest).toContain("const report = assertExtensionPreflight(blueprint, {"); + // The synthesized manifest + derived capabilities are asserted, proving the + // blueprint never drifts from manifest.json's capabilities. + expect(sampleTest).toContain('assert.deepEqual(report.capabilities, ["commands"]);'); + expect(sampleTest).toContain('assert.deepEqual(report.manifest?.capabilities, ["commands"]);'); + expect(sampleTest).toContain("const ext = await createExtensionTestHarness(extension, {"); + expect(sampleTest).toContain('const registered = ext.assertCommandContract({ command: "kit ping" });'); + expect(sampleTest).toContain('const invocation = await ext.runCommand({ command: "kit ping" });'); + expect(sampleTest).toContain("const teardown = await ext.deactivate();"); + expect(sampleTest).toContain("assert.equal(teardown.deactivated, 1);"); + // The declarative test does not reach for the standalone activate/assert + // helpers the imperative starter uses — the harness binds them for it. + expect(sampleTest).not.toContain("activateExtensionForTest"); + expect(sampleTest).not.toContain("assertRegisteredCommandContract"); + }); + + it("documents the declarative authoring loop in the README", () => { + const readme = buildStarterExtensionScaffoldFiles("kit", "kit ping", "package", "commands", true)["README.md"] ?? ""; + expect(readme).toContain("Generated by `pm package init --declarative`."); + expect(readme).toContain("## Declarative Authoring"); + expect(readme).toContain("`composeExtension`"); + expect(readme).toContain("`assertExtensionPreflight`"); + expect(readme).toContain("`createExtensionTestHarness`"); + // The declarative entrypoint bullet replaces the imperative one. + expect(readme).toContain("composed into the runtime module by `composeExtension`"); + }); + + it("keeps the package metadata identical to the imperative commands starter", () => { + const declarative = buildStarterExtensionScaffoldFiles("kit", "kit ping", "package", "commands", true); + const imperative = buildStarterExtensionScaffoldFiles("kit", "kit ping", "package", "commands", false); + + // Only the entrypoint, test, and README differ; the manifest, package.json, + // tsconfig, and .gitignore are byte-identical so the declarative starter + // installs and activates exactly like the imperative commands starter. + expect(declarative["manifest.json"]).toBe(imperative["manifest.json"]); + expect(declarative["package.json"]).toBe(imperative["package.json"]); + expect(declarative["tsconfig.json"]).toBe(imperative["tsconfig.json"]); + expect(declarative[".gitignore"]).toBe(imperative[".gitignore"]); + expect(declarative["index.ts"]).not.toBe(imperative["index.ts"]); + expect(declarative["index.test.ts"]).not.toBe(imperative["index.test.ts"]); + + const manifest = JSON.parse(declarative["manifest.json"] ?? "{}") as Record; + expect(manifest.capabilities).toEqual(["commands"]); + expect(manifest.activation).toEqual({ commands: ["kit ping"] }); + }); + + it("ignores the declarative flag for extension-mode scaffolds (handled by scaffoldExtensionProject)", () => { + // buildStarterExtensionScaffoldFiles only consumes `declarative` in the + // package branch; extension-mode passes through to the imperative starter. + // scaffoldExtensionProject rejects extension-mode + declarative before this. + const entry = buildStarterExtensionScaffoldFiles("local-ext", "local ext ping", "extension", "commands", true)["index.ts"] ?? ""; + expect(entry).toContain("export function activate(api: ExtensionApi): void {"); + expect(entry).not.toContain("composeExtension(blueprint)"); + }); +}); From 21f98dd78d8afa07632ab9f99faf6f17eb70420e Mon Sep 17 00:00:00 2001 From: Stefan Preu Date: Wed, 24 Jun 2026 20:56:33 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(test,docs):=20address=20PR=20#358=20bot?= =?UTF-8?q?=20review=20=E2=80=94=20Windows=20junction=20symlink=20+=20SDK.?= =?UTF-8?q?md=20flag=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use a Windows junction (over the absolute cwd) instead of a "dir" symlink in the declarative end-to-end test so it does not need admin / Developer Mode on Windows; Node ignores the type argument on POSIX, so Linux/macOS are unchanged. (Gemini + CodeRabbit, tests/unit/extensions/extension-command.spec.ts) - Clarify in SDK.md that `--declarative` is a `pm package init`/`pm package scaffold` flag (package-mode, commands capability), not a general `--capability` toggle. (CodeRabbit, docs/SDK.md) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HEUoAbSunPoG3Xt83VYd4k --- docs/SDK.md | 5 +++-- tests/unit/extensions/extension-command.spec.ts | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/SDK.md b/docs/SDK.md index 8e23327c4..da9c1ac42 100644 --- a/docs/SDK.md +++ b/docs/SDK.md @@ -566,8 +566,9 @@ built-in commands like `pm create ` must see — activates conservatively every command rather than gating on the package's own commands. Each `--capability` starter authors an imperative `activate` body. To scaffold the -declarative `composeExtension` form instead, pass `--declarative` (package-mode, -commands capability) — see [Declarative Authoring](#declarative-authoring). +declarative `composeExtension` form instead, pass `--declarative` to +`pm package init` / `pm package scaffold` (it is an init/scaffold flag, package-mode +and commands capability only) — see [Declarative Authoring](#declarative-authoring). See [EXTENSIONS.md](EXTENSIONS.md) for the manifest-field reference. ## Self-Identity and Lifecycle diff --git a/tests/unit/extensions/extension-command.spec.ts b/tests/unit/extensions/extension-command.spec.ts index 0ad3c7f83..15d6c3e95 100644 --- a/tests/unit/extensions/extension-command.spec.ts +++ b/tests/unit/extensions/extension-command.spec.ts @@ -2183,7 +2183,10 @@ describe("extension command runtime", () => { // resolve the subpath, then prove the module loads and dispatches for real. const sdkLinkDir = path.join(context.pmPath, "node_modules", "@unbrained"); await mkdir(sdkLinkDir, { recursive: true }); - await symlink(process.cwd(), path.join(sdkLinkDir, "pm-cli"), "dir"); + // "junction" rather than "dir": a Windows directory symlink needs admin / + // Developer Mode, while a junction (over the absolute cwd) does not. Node + // ignores the type argument on POSIX, so Linux/macOS get an ordinary symlink. + await symlink(process.cwd(), path.join(sdkLinkDir, "pm-cli"), "junction"); const invoked = spawnSync( process.execPath, [path.join(process.cwd(), "dist/cli.js"), "--path", context.pmPath, "starter", "declarative", "ping", "--json"],