diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 04f9dec..43faaea 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,11 +9,13 @@ "plugins": [ { "name": "firstdraft", - "source": "./skills/create-full-stack-app", - "strict": false, - "skills": [ - "./" - ], + "version": "0.1.0-alpha.3", + "source": { + "source": "npm", + "package": "@firstdraft.com/claude-code", + "version": "0.1.0-alpha.3", + "registry": "https://registry.npmjs.org/" + }, "displayName": "First Draft", "description": "Experimental Foundation Plan authoring and bounded Rails application creation with First Draft", "author": { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02b2b31..6d6d892 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -34,6 +35,7 @@ jobs: fetch-depth: 0 path: tmp/firstdraft-cli persist-credentials: false - - run: git -C tmp/firstdraft-cli merge-base --is-ancestor f55edffc9e88924f9a4c95f41c4d0bc9b72422f8 HEAD - - run: git -C tmp/firstdraft-cli checkout --detach f55edffc9e88924f9a4c95f41c4d0bc9b72422f8 + - run: git -C tmp/firstdraft-cli merge-base --is-ancestor e53eb38d7e8254e6ba1e660b38c5d32d0314be17 HEAD + - run: git -C tmp/firstdraft-cli checkout --detach e53eb38d7e8254e6ba1e660b38c5d32d0314be17 - run: node script/check-cli-contract.mjs tmp/firstdraft-cli + - run: node script/check-claude-plugin-package.mjs --cli-root tmp/firstdraft-cli diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..5ef40b2 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,127 @@ +name: Publish Claude plugin + +on: + push: + tags: ["claude-v*"] + +permissions: {} + +concurrency: + group: claude-plugin-npm-publish + cancel-in-progress: false + +jobs: + verify: + name: Verify release + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.0 + package-manager-cache: false + - name: Verify tag and source commit + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = "firstdraft/skills" + test "$GITHUB_EVENT_NAME" = "push" + test "$GITHUB_REF_TYPE" = "tag" + test "$GITHUB_REF_PROTECTED" = "true" + release_sha="$(git rev-parse 'HEAD^{commit}')" + event_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" + test "$release_sha" = "$event_sha" + git fetch --force --no-tags origin \ + "+refs/heads/main:refs/remotes/origin/main" \ + "+refs/tags/${GITHUB_REF_NAME}:refs/release-check/tag" + test "$release_sha" = "$(git rev-parse 'refs/release-check/tag^{commit}')" + package_version="$(node --print 'JSON.parse(require("node:fs").readFileSync("packages/claude-plugin/package.template.json", "utf8")).version')" + test "$GITHUB_REF_NAME" = "claude-v$package_version" + git rev-list --first-parent refs/remotes/origin/main > "$RUNNER_TEMP/main-first-parent" + grep -Fqx "$release_sha" "$RUNNER_TEMP/main-first-parent" + - run: npm ci --ignore-scripts + - run: npm audit + - run: npm run check + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: firstdraft/cli + ref: main + fetch-depth: 0 + path: tmp/firstdraft-cli + persist-credentials: false + - run: git -C tmp/firstdraft-cli merge-base --is-ancestor e53eb38d7e8254e6ba1e660b38c5d32d0314be17 HEAD + - run: git -C tmp/firstdraft-cli checkout --detach e53eb38d7e8254e6ba1e660b38c5d32d0314be17 + - run: node script/claude-plugin-package.mjs pack "$RUNNER_TEMP/plugin" --cli-root tmp/firstdraft-cli + + publish: + name: Publish to npm + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: npm + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.0 + package-manager-cache: false + registry-url: https://registry.npmjs.org/ + - name: Verify approved release + env: + NPM_RELEASE_ENABLED: ${{ vars.NPM_RELEASE_ENABLED }} + run: | + set -euo pipefail + test "$NPM_RELEASE_ENABLED" = "true" + test "$GITHUB_REPOSITORY" = "firstdraft/skills" + test "$GITHUB_EVENT_NAME" = "push" + test "$GITHUB_REF_TYPE" = "tag" + test "$GITHUB_REF_PROTECTED" = "true" + release_sha="$(git rev-parse 'HEAD^{commit}')" + event_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" + test "$release_sha" = "$event_sha" + git fetch --force --no-tags origin \ + "+refs/heads/main:refs/remotes/origin/main" \ + "+refs/tags/${GITHUB_REF_NAME}:refs/release-check/tag" + test "$release_sha" = "$(git rev-parse 'refs/release-check/tag^{commit}')" + package_version="$(node --print 'JSON.parse(require("node:fs").readFileSync("packages/claude-plugin/package.template.json", "utf8")).version')" + test "$GITHUB_REF_NAME" = "claude-v$package_version" + git rev-list --first-parent refs/remotes/origin/main > "$RUNNER_TEMP/main-first-parent" + grep -Fqx "$release_sha" "$RUNNER_TEMP/main-first-parent" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: firstdraft/cli + ref: main + fetch-depth: 0 + path: tmp/firstdraft-cli + persist-credentials: false + - run: git -C tmp/firstdraft-cli merge-base --is-ancestor e53eb38d7e8254e6ba1e660b38c5d32d0314be17 HEAD + - run: git -C tmp/firstdraft-cli checkout --detach e53eb38d7e8254e6ba1e660b38c5d32d0314be17 + - name: Verify CLI release is published + run: | + set -euo pipefail + cli_version="$(node --print 'JSON.parse(require("node:fs").readFileSync("tmp/firstdraft-cli/package.json", "utf8")).version')" + test "$(npm view "@firstdraft.com/cli@$cli_version" version)" = "$cli_version" + - run: node script/claude-plugin-package.mjs pack "$RUNNER_TEMP/plugin" --cli-root tmp/firstdraft-cli + - name: Verify publication bytes + run: | + set -euo pipefail + package_version="${GITHUB_REF_NAME#claude-v}" + tarball="$RUNNER_TEMP/plugin/firstdraft.com-claude-code-$package_version.tgz" + expected="$(node --print 'JSON.parse(require("node:fs").readFileSync("release/compatibility.json", "utf8")).plugin_source.tarball_sha256')" + actual="$(shasum -a 256 "$tarball" | awk '{print $1}')" + test "$actual" = "$expected" + - name: Publish verified package + run: npm publish "$RUNNER_TEMP/plugin/firstdraft.com-claude-code-${GITHUB_REF_NAME#claude-v}.tgz" --access public --tag next --provenance --ignore-scripts + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..030dad1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# Agent Instructions — First Draft Skills + +## Release coordination + +- Treat a merge to `main` as integration, not release authorization. After merging, report the exact merged SHA and + ask whether to coordinate a candidate across `firstdraft`, `cli`, and `skills` and promote it. +- SemVer compatibility establishes candidate eligibility only. Record the exact SHA of every repository and the + packed Claude plugin SHA-256, then follow [`RELEASING.md`](RELEASING.md). +- Do not publish npm packages, deploy First Draft, or release the plugin without explicit user approval. If the user + declines promotion, identify the merged SHA as unpromoted. +- Never reuse a published npm version or marketplace SemVer with different package bytes. Revisions and corrections + use a new version. +- Keep deployment, package publication, marketplace promotion, and replay mutations serialized through one + operator. Reconcile an ambiguous publication or push outcome read-only before retrying. +- Before pushing a `claude-v*` publication tag, verify its protection ruleset, the `npm` environment's required + reviewers, and the deliberately enabled `NPM_RELEASE_ENABLED` gate. +- The installable Claude package is assembled from the canonical Skill during packing. Do not commit a second + editable copy under `packages/`. diff --git a/README.md b/README.md index f79a322..c3b9f8a 100644 --- a/README.md +++ b/README.md @@ -98,181 +98,75 @@ its full prepared capability boundary. ### Claude Code plugin preview -The repository also packages a source-only Claude Code plugin named `firstdraft`. Its marketplace entry points only -at the canonical `skills/create-full-stack-app` directory used by the portable Skill. The observed isolated installed -plugin cache contained no second copy of the Skill instructions, repository test harness, or runtime dependency -tree. A marketplace tree is a separate footprint: the smoke did not inventory an isolated marketplace tree, and no -Git-hosted marketplace tree has been observed. The root manifest exists only for the one-session local preview -below. From a checkout, validate the marketplace manifest and root preview manifest without installing either one: +The repository assembles an installable Claude Code plugin named `firstdraft` as the public npm package +`@firstdraft.com/claude-code`. Packing copies the canonical `skills/create-full-stack-app` directory into a temporary +staging tree; the generated copy is never edited or committed. The package also includes a small `firstdraft` +adapter and the exact packed bytes of `@firstdraft.com/cli@0.1.0-alpha.2`, so installing the plugin supplies both the +Skill and its compatible CLI without requiring Claude Code to install transitive npm dependencies. + +The marketplace catalog uses Claude Code's documented `npm` plugin source and pins +`@firstdraft.com/claude-code@0.1.0-alpha.3`. The installable manifest asks Claude Code for the staging API URL and a +sensitive API token. Claude stores sensitive configuration in secure storage and exports plugin options only to +plugin subprocesses. The adapter maps those options to the CLI's environment without printing them. Users should +create the token in First Draft's browser UI and enter it in Claude's configuration prompt, never paste it into an +agent conversation or command line. Installed-plugin configuration is authoritative: when it supplies the API URL, +the adapter deliberately ignores any ambient `FIRSTDRAFT_API_TOKEN` so a credential cannot cross API origins. The +environment variable remains a standalone-CLI configuration path. + +The root `firstdraft-preview` manifest remains a checkout-local development path and has a separate preview-only +version. From a checkout, validate the marketplace and preview manifests without installing either one: ```sh claude plugin validate --strict . claude plugin validate --strict .claude-plugin/plugin.json ``` -The root preview manifest uses the distinct name `firstdraft-preview` and carries a preview-only `0.1.0` version -because direct strict manifest validation requires a semantic version. The separate name cannot collide with an -installed `firstdraft@firstdraft-skills` plugin in the same session. That manifest is excluded from the marketplace -plugin source and does not version an ordinary installation. The source-only marketplace plugin deliberately omits -a semantic version while it is experimental. -The marketplace entry's `"strict": false` selects the marketplace entry as the entire plugin definition. The -[official marketplace strict-mode documentation](https://code.claude.com/docs/en/plugin-marketplaces#strict-mode) -says this mode permits a raw source directory without its own `plugin.json`; a source manifest that also declares -components would conflict with the marketplace definition. It does not relax validation. Separately, the -[official plugin validation reference](https://code.claude.com/docs/en/plugins-reference#unrecognized-fields) says -`claude plugin validate --strict` treats validation warnings, including unrecognized fields, as errors for CI. Both -strict validation commands above remain release gates. -The [official marketplace documentation](https://code.claude.com/docs/en/plugin-marketplaces#version-resolution-and-release-channels) -says a Git-hosted marketplace falls back to the commit SHA when a plugin version is omitted. That is the documented -expectation here, not observed Git-hosted installation evidence. Add a marketplace semantic version only when plugin -changes follow an explicit release-and-version-bump cadence. - -The documented local-development path starts one Claude Code session from the checkout without registering a -marketplace or installing the plugin: +The official [marketplace documentation](https://code.claude.com/docs/en/plugin-marketplaces#plugin-sources) +documents npm plugin sources and notes that installed plugins are copied into Claude Code's cache. The official +[plugin reference](https://code.claude.com/docs/en/plugins-reference#file-locations-reference) documents that +executables in a plugin-root `bin/` directory are added to the Bash tool's `PATH`. Its +[user-configuration section](https://code.claude.com/docs/en/plugins-reference#user-configuration) documents +sensitive `userConfig` values and their `CLAUDE_PLUGIN_OPTION_*` subprocess environment variables. These claims +were rechecked on 2026-08-05; public installation remains unobserved until publication. -```sh -claude --plugin-dir . -``` - -The 2026-08-04 Home Inventory evaluation observed a headless Claude Code 2.1.221 session load the -`Skill(firstdraft-preview:create-full-stack-app)` identifier from candidate revision -`b5c3897b240bfa3a9117d1a564d8e6b7d783e993` through an explicit `--plugin-dir ` without marketplace -registration or installation. It did not exercise the interactive command above. The Movie Catalog process also -received the candidate through `--plugin-dir`, but its retained evidence does not record Skill discovery or -invocation. Neither run establishes a model-backed session using the separately marketplace-installed plugin. -The isolated 2026-08-04 installation established the Claude Code 2.1.221 runtime observation. As a separate renewal -step, the current official plugin reference was rechecked and the allowlist below was pinned to its documented -locations. - -Under that documented preview model, the whole checkout is the preview plugin root. Repository checks therefore -forbid every other default component location documented for Claude Code 2.1.221: root `SKILL.md`, `agents/`, `bin/`, -`commands/`, `hooks/`, `monitors/`, `output-styles/`, `themes/`, `workflows/`, `settings.json`, `.mcp.json`, and -`.lsp.json`. They also require exactly one canonical subtree beneath `skills/`. Checks examine Git-index entries at -those enumerated checkout-root component locations, verify those locations on disk, and recursively inventory the -complete on-disk `skills/` subtree, including untracked entries there. That coverage prevents the preview from -silently growing a component outside the narrow marketplace source without claiming a scan of every working-tree -path. - -The [official plugin documentation](https://code.claude.com/docs/en/plugins) says `--plugin-dir` loads a plugin for -local development and plugin Skills use the `plugin-name:skill-name` namespace. The Home run observed the headless -tool identifier above. Interactive `/firstdraft-preview:create-full-stack-app` invocation and invocation through an -installed `firstdraft@firstdraft-skills` marketplace plugin remain unobserved. - -Before release, when Claude Code is available, manually exercise the real add-and-install path with child state -redirected away from the user's Claude configuration and plugin cache and with the scoped monitoring described -below: - -Close every other Claude Code session before running the smoke. Claude Code sessions share plugin registries and -caches; a concurrent legitimate update will change a monitored real-state path and correctly make this check fail. - -The dated evidence's `~/.claude.json` exclusion and default real-state targets require `CLAUDE_CONFIG_DIR` and -`CLAUDE_CODE_PLUGIN_CACHE_DIR` to be unset in the parent shell. Both smoke commands fail before resolving Claude, -inspecting real Claude state, or creating temporary state if either override is present. The diagnostic names only -the override variables, never their values. +For checkout-local development, start one Claude Code session without registering a marketplace: ```sh -npm run check:claude-plugin-install -``` - -That check does not write repository evidence. To renew the machine-readable observation after intentionally -reviewing a Claude Code upgrade or packaging change, run: - -```sh -npm run record:claude-plugin-install +claude --plugin-dir . ``` -The recording command regenerates only the machine-readable JSON. Its UTC date and observed Claude Code version are -deliberate review pins. If either changes, rename the dated Markdown evidence file to the new observation date, -refresh its title, CLI version, transcript, inventory, digest table, state-presence bullets, and real-state monitor -summary line, dated default-component-location recheck, and model-session conclusion against any newer evaluation -evidence; update the evidence path and the expected date/version pins in `test/repository.test.mjs`; then rerun the -repository checks and both strict validations. Recheck the checkout-root default-component allowlist against the new -version's documented discovery locations as part of that review. A changed pin is a request to re-review current -component discovery and isolation behavior, not a mechanical update. -The smoke's expected live component inventory is deliberately hardcoded. If a reviewed Claude Code discovery change -alters it, update the assertion in `script/check-claude-plugin-install.mjs`, its repository-test pins, the generated -observation, and the dated prose together. Rerunning the recording command alone cannot renew that expectation. - -The smoke runs both strict manifest validations through the native CLI before any mutation and derives each recorded -validation result from that exact invocation's captured successful validator output. It then constructs a -minimal child environment containing only isolated Claude, home, temporary, and XDG state; -traffic and updater controls; and a guard-only PATH. It does not inherit credentials, tokens, API keys, SSH agent, -proxy, Git, Node, dynamic-loader, or unrelated variables. The PATH guards block and record common Node -package-manager invocations. Every child command runs from a newly created isolated working directory rather than -the checkout. Because that guard-only PATH cannot safely support arbitrary interpreter lookup, the smoke requires -`CLAUDE_BIN` or the parent PATH to resolve to a regular, executable native Claude Code binary and rejects shebang -wrappers. Before and after, it recursively fingerprints content and metadata beneath the real target plugin's cache, -data, and marketplace paths while monitoring the real Claude registries, settings, and credential metadata. It -byte-compares the installed cache with the eight canonical Skill files, checks the live Claude component inventory -is one combined Skills/Commands entry and zero Agents, Hooks, MCP servers, and LSP servers, and removes all temporary -state before returning. Claude Code reports Skills and Commands in one combined Skills count. The smoke therefore -derives Commands absence separately from the marketplace's lack of a Commands declaration and the exact installed -file set; it does not present Commands as a live count. The PATH guards detect ordinary package-manager lookup; they -are not a claim that an executable invoked by an absolute path is impossible. - -The portable `agents/openai.yaml` file is Skill metadata, not a Claude Code Agent definition. The -[official plugin reference](https://code.claude.com/docs/en/plugins-reference) currently discovers plugin Agents -from Markdown definitions under `agents/` or explicit manifest paths. The observed `Agents=0` result therefore -depends on current Claude Code discovery behavior and must be rechecked whenever the CLI version changes. - -Real-state monitoring is deliberately scoped rather than recursive over all Claude state. It covers content and -metadata for the plugin registries and catalog, the `firstdraft-skills` cache, data, and marketplace trees, and -metadata for settings and credentials. It excludes the high-churn `~/.claude.json`, plugin maintenance markers, -session history, and unrelated Claude configuration; the smoke makes no whole-configuration monitoring claim. -Claude Code 2.1.221 did not create `plugins/marketplaces/firstdraft-skills` for the isolated local-directory -registration or `plugins/data/firstdraft-firstdraft-skills` during its isolated install. `targetMarketplace` and -`targetData` are conservative candidate-path monitors for the unobserved Git-hosted installation, not confirmed -current CLI storage layouts; their absence is not load-bearing isolation evidence. -It refuses to make an unchanged-state claim if any monitored target or nested entry is a symbolic link. -The smoke reports which named targets were present and absent and refuses to make an unchanged-state claim unless at -least one core registry target is present. It checks the real targets immediately after marketplace registration -and again after plugin installation, aborting before the second mutation if the first escaped isolation. A detected -escape reports the exact uninstall and marketplace-removal commands for the operator to inspect and run. -Its diagnostic names every changed monitor together with its resolved absolute filesystem path. - -The ordinary repository test owns the exact eight-file allowlist and fails if this documented count, the canonical -source shape, the checkout-root preview component paths on disk, or the smoke's isolation assignments drift. The -non-recording isolated smoke also compares its live CLI version, captured strict-validation results, component -inventory, and installed bytes with the committed observation. Real-state presence is run-local information rather -than a cross-machine release-gate value: every run independently requires at least one core registry target and -proves the monitored targets unchanged. Packaging drift fails closed and directs the operator through the explicit -evidence-renewal review. The dated -[Claude Code plugin install smoke evidence](evidence/2026-08-04-claude-code-plugin-install-smoke.md) and its -[generated machine-readable observation](evidence/claude-code-plugin-install-observation.json) record the current -CLI version, per-file sizes and SHA-256 digests, installed tree digest, component inventory, cleanup, and exact -real-state target presence. Source drift fails with an instruction to rerun the isolated recording command instead -of treating hand-edited prose as installation evidence. - -This isolated install smoke is a manual release check. Ordinary `sh script/check` and hosted CI retain structural -and syntax assertions but do not assume Claude Code is installed. - -Once this packaging is merged and the release gates below are satisfied, the intended ordinary installation from -GitHub is: +Once both npm packages and the catalog are explicitly released, the intended colleague installation is: ```sh -CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 claude plugin marketplace add firstdraft/skills +claude plugin marketplace add firstdraft/skills claude plugin install firstdraft@firstdraft-skills ``` -The [official environment-variable reference](https://code.claude.com/docs/en/env-vars) documents -`CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1` for cloning GitHub `owner/repo` shorthand over HTTPS rather than SSH. The -assignment above makes the intended path independent of SSH setup; users with working GitHub SSH configuration may -omit it. No live GitHub clone has been observed for this package. - -Do not run those installation commands for ordinary use yet. The required -`@firstdraft.com/cli@0.1.0-alpha.2` package remains unpublished, and no compatible First Draft staging or live -endpoint has completed the agent-to-private-GitHub journey. The plugin metadata is source packaging, not release or -execution evidence. Keep using `gh skill preview` when evaluating the portable Skill path. +Those public commands are not yet expected to work: neither npm package has been published and this catalog change +has not been released. Local packed-install and isolated-Claude checks establish only prepublication behavior. The +historical 2026-08-04 source-only install report remains evidence for its recorded revision, not this composite +package. A dated [vendored-CLI smoke](evidence/2026-08-05-claude-plugin-vendored-cli-smoke.md) records the current +local npm-source install, the rejected transitive-dependency design, and successful bare-command discovery. See +[`RELEASING.md`](RELEASING.md) for the approval-gated qualification and publication sequence. ## Development -The installed Skills contain no executable code or runtime packages. Repository checks use Node.js 22 or newer -and one locked development dependency for exact JSON Schema validation: +Cross-repository compatibility metadata and the approval-gated release process are documented in +[`RELEASING.md`](RELEASING.md). + +The portable Skill directories contain no executable code or runtime packages. The assembled Claude plugin adds +only its CLI adapter and exact vendored CLI package. Repository checks use Node.js 22 or newer and one locked development +dependency for exact JSON Schema validation: Repository checks require `git` on `PATH` and a real Git checkout with its index and working tree available. A source archive, exported tree, or installed plugin cache is insufficient because the preview-boundary checks use the Git index for the enumerated checkout-root component locations and inspect those paths plus the complete `skills/` -subtree on disk. +subtree on disk. Evidence and compatibility checks also read pinned historical commits and require full, +unshallowed history containing them. The version-to-source check also reads compatibility documents reachable from +every local ref, including fetched immutable candidate tags. Hosted CI uses `fetch-depth: 0`. In a local clone, +fetch tags with `git fetch origin --tags`, then inspect `git rev-parse --is-shallow-repository`; when it returns +`true`, run `git fetch --unshallow origin` or fetch the required full commits through an approved equivalent before +running checks. ```sh npm ci --ignore-scripts @@ -280,13 +174,13 @@ sh script/check ``` The CLI contract check requires a checkout at the exact reviewed revision -`f55edffc9e88924f9a4c95f41c4d0bc9b72422f8`, whose independently reproduced JavaScript-source runtime digest is -`9e5a4bd0f16f49ab2e17c04f7defc59366f8fa073f772b310d8f684177890eab`: +`e53eb38d7e8254e6ba1e660b38c5d32d0314be17`, whose independently reproduced JavaScript-source runtime digest is +`0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad`: ```sh git -C fetch origin main -git -C merge-base --is-ancestor f55edffc9e88924f9a4c95f41c4d0bc9b72422f8 origin/main -git -C checkout --detach f55edffc9e88924f9a4c95f41c4d0bc9b72422f8 +git -C merge-base --is-ancestor e53eb38d7e8254e6ba1e660b38c5d32d0314be17 origin/main +git -C checkout --detach e53eb38d7e8254e6ba1e660b38c5d32d0314be17 node script/check-cli-contract.mjs ``` diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..38bff69 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,85 @@ +# Releasing First Draft Skills + +This repository participates in a coordinated release with +[`firstdraft/firstdraft`](https://github.com/firstdraft/firstdraft) and +[`firstdraft/cli`](https://github.com/firstdraft/cli). A merge to `main` integrates source; it does not authorize a +plugin release, npm publication, or First Draft deployment. + +## Release identity + +The installable `firstdraft@firstdraft-skills` plugin is the public npm package +`@firstdraft.com/claude-code`. Its current candidate version is `0.1.0-alpha.3`. The marketplace catalog points to +that exact package and version. The checkout-local `firstdraft-preview` manifest and private root +`@firstdraft/skills@0.0.0` package are test tooling, not release identities. + +Packing deterministically assembles the plugin from the canonical `skills/create-full-stack-app` directory, the +installable manifest and CLI adapter under `packages/claude-plugin`, the exact packed files from +`@firstdraft.com/cli@0.1.0-alpha.2`, and the repository license. Colleagues therefore install the Skill and compatible +CLI together through Claude Code rather than managing a separate global CLI or relying on transitive installation. + +[`release/compatibility.json`](release/compatibility.json) records the plugin package name, exact packed tarball +SHA-256, compatible service API range, exact CLI version, and Foundation Plan format. One marketplace SemVer maps +forever to exactly one package tarball. Never reuse a published npm version or catalog version for different bytes. +A compatible result establishes candidate eligibility only; it never authorizes deployment or publication. + +## Candidate flow + +1. Resolve clean, full-history checkouts at exact SHAs for all three repositories. Run each repository's checks and + the cross-repository compatibility gates. +2. Pack the exact CLI and Claude plugin candidates. Verify the plugin tarball SHA-256 against + `release/compatibility.json`, install both tarballs into an isolated temporary npm project, and confirm the + plugin-local `firstdraft` adapter runs the exact CLI version. +3. Validate the staged plugin with the real Claude Code CLI. Exercise a local marketplace in isolated Claude state + and confirm Skill discovery, sensitive configuration handling, and CLI invocation. This is prepublication + evidence, not proof of a public install. +4. One operator deploys the exact service candidate to staging and runs the approved Movie Catalog qualification + and singleton replay with the exact CLI and Skill candidates. Preserve the three repository SHAs, package + hashes, service revision, retained identifiers, and evidence. +5. A human decides whether the same candidate should be promoted. Publishing and catalog promotion require new, + explicit authorization. + +## Publication order + +After approval, one operator performs these mutations serially: + +Before pushing a release tag, verify that a GitHub ruleset protects `claude-v*` tags from deletion and unauthorized +updates, the `npm` environment requires the intended human reviewer, and its `NPM_RELEASE_ENABLED` variable is +deliberately set to `true`. The workflow fails closed unless the tag is protected and its commit is on `main`. + +1. Publish the exact compatible CLI package and reconcile its registry identity read-only. +2. Publish the already-qualified `@firstdraft.com/claude-code` tarball with npm provenance under the prerelease + dist-tag, then verify the registry returns the expected version and integrity. +3. Merge or fast-forward the marketplace catalog on `main` so + `claude plugin marketplace add firstdraft/skills` resolves to the published package version. +4. In fresh isolated Claude state, run the exact public installation: + + ```sh + claude plugin marketplace add firstdraft/skills + claude plugin install firstdraft@firstdraft-skills + ``` + +5. Start a fresh model session, confirm the Skill is discoverable, complete staging token onboarding without + exposing the token in chat or logs, and repeat the bounded qualification if required by the release decision. + +If any external mutation has an ambiguous result, stop and inspect the registry, Git ref, or deployment read-only. +Do not retry until its identity is known. Release corrections are forward-only and use a new SemVer. + +The plugin package is published by pushing protected tag `claude-v$package_version`. That tag triggers +`.github/workflows/publish.yml`, which rechecks the source commit, verifies the exact CLI release already exists in +npm, vendors that exact CLI checkout, reproduces the recorded plugin tarball digest, and publishes those bytes. Pushing the tag is therefore the +publication mutation and requires the explicit approval above. + +## Checks + +From a full-history checkout, run: + +```sh +npm ci --ignore-scripts +npm run check +node script/check-claude-plugin-package.mjs --cli-root /path/to/exact/cli +``` + +Stage the package and validate it with the current supported Claude Code CLI. Use isolated Claude configuration for +install tests; do not alter a colleague's real Claude state during qualification. A local validation or packed +install does not prove the two public commands until both npm packages and the GitHub marketplace catalog are +reachable externally. diff --git a/evidence/2026-08-05-claude-plugin-vendored-cli-smoke.md b/evidence/2026-08-05-claude-plugin-vendored-cli-smoke.md new file mode 100644 index 0000000..96968ca --- /dev/null +++ b/evidence/2026-08-05-claude-plugin-vendored-cli-smoke.md @@ -0,0 +1,23 @@ +# Claude plugin vendored-CLI smoke — 2026-08-05 + +## Outcome + +Claude Code 2.1.222 successfully installed the `firstdraft` plugin through an npm marketplace source backed by an +isolated loopback Verdaccio registry. The installed plugin's root `bin/firstdraft` executable was present and ran the +vendored CLI as a bare Bash command inside a fresh Claude session, printing exactly `0.1.0-alpha.2` on stdout. + +## Design finding + +An earlier package candidate declared `@firstdraft.com/cli@0.1.0-alpha.2` as an npm dependency. Claude Code installed +that npm-sourced plugin but did not materialize its dependency in the plugin cache; the adapter failed with +`ERR_MODULE_NOT_FOUND`. The release candidate therefore vendors the exact packed CLI files beneath `vendor/cli` and +does not depend on transitive npm installation. + +## Boundaries + +- The marketplace, registry, packages, Claude configuration, and plugin cache were isolated local test resources. +- The test used no First Draft API token and made no request to staging, GitHub, or the public npm registry. +- Direct cache execution and one fresh Claude model session both observed CLI version `0.1.0-alpha.2`. +- The test proves local npm-source installation, CLI materialization, and Bash PATH discovery. It does not prove the + public GitHub marketplace, public npm publication, colleague authentication, or the Movie Catalog journey. +- Local test resources were retained; cleanup was not performed. diff --git a/package-lock.json b/package-lock.json index 9bb23aa..88e2c3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,9 +40,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 8cf34bc..960d9cf 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,12 @@ "devDependencies": { "ajv": "8.20.0" }, + "overrides": { + "fast-uri": "3.1.5" + }, "scripts": { - "check": "node --test", - "check:claude-plugin-install": "node script/check-claude-plugin-install.mjs", - "record:claude-plugin-install": "node script/check-claude-plugin-install.mjs --observation-output evidence/claude-code-plugin-install-observation.json" + "check": "sh script/check", + "check:claude-plugin-package": "node script/check-claude-plugin-package.mjs", + "check:release-compatibility": "node script/check-release-compatibility.mjs" } } diff --git a/packages/claude-plugin/.claude-plugin/plugin.json b/packages/claude-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..18593da --- /dev/null +++ b/packages/claude-plugin/.claude-plugin/plugin.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "firstdraft", + "displayName": "First Draft", + "version": "0.1.0-alpha.3", + "description": "Author a Foundation Plan with your user and compile a bounded Rails and iPhone application", + "author": { + "name": "First Draft", + "url": "https://github.com/firstdraft" + }, + "homepage": "https://github.com/firstdraft/skills", + "repository": "https://github.com/firstdraft/skills", + "license": "MIT", + "keywords": [ + "foundation-plan", + "rails", + "application-generation" + ], + "skills": [ + "./skills/create-full-stack-app" + ], + "userConfig": { + "api_url": { + "type": "string", + "title": "First Draft API URL", + "description": "The First Draft service used for Plan analysis and Compilation", + "required": true, + "default": "https://staging.firstdraft.com" + }, + "api_token": { + "type": "string", + "title": "First Draft API token", + "description": "A token created in First Draft after signing in", + "sensitive": true, + "required": true + } + } +} diff --git a/packages/claude-plugin/bin/firstdraft b/packages/claude-plugin/bin/firstdraft new file mode 100755 index 0000000..26fe579 --- /dev/null +++ b/packages/claude-plugin/bin/firstdraft @@ -0,0 +1,12 @@ +#!/bin/sh + +target=$0 +while [ -L "$target" ]; do + link=$(readlink "$target") + case "$link" in + /*) target=$link ;; + *) target=$(dirname "$target")/$link ;; + esac +done + +exec node "$(dirname "$target")/firstdraft.js" "$@" diff --git a/packages/claude-plugin/bin/firstdraft.js b/packages/claude-plugin/bin/firstdraft.js new file mode 100644 index 0000000..319a20d --- /dev/null +++ b/packages/claude-plugin/bin/firstdraft.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; + +const apiUrlOption = "CLAUDE_PLUGIN_OPTION_api_url"; +const apiTokenOption = "CLAUDE_PLUGIN_OPTION_api_token"; +const cli = fileURLToPath( + new URL("../vendor/cli/bin/firstdraft.js", import.meta.url), +); +const environment = {...process.env}; +const apiUrlConfigured = Boolean(environment[apiUrlOption]); +const apiTokenConfigured = Boolean(environment[apiTokenOption]); + +if (!apiUrlConfigured && apiTokenConfigured) { + process.stderr.write( + `${JSON.stringify({ + error: "plugin_configuration_incomplete", + detail: "Configure the First Draft API URL in Claude Code.", + })}\n`, + ); + process.exit(2); +} + +if (apiUrlConfigured) { + environment.FIRSTDRAFT_API_URL = environment[apiUrlOption]; + delete environment.FIRSTDRAFT_API_TOKEN; +} +if (apiTokenConfigured) { + environment.FIRSTDRAFT_API_TOKEN = environment[apiTokenOption]; +} +delete environment[apiUrlOption]; +delete environment[apiTokenOption]; + +const child = spawn(process.execPath, [cli, ...process.argv.slice(2)], { + env: environment, + stdio: "inherit", +}); +const [status, signal] = await once(child, "exit"); + +if (signal) { + process.kill(process.pid, signal); +} else { + process.exitCode = status ?? 1; +} diff --git a/packages/claude-plugin/package.template.json b/packages/claude-plugin/package.template.json new file mode 100644 index 0000000..fe5569f --- /dev/null +++ b/packages/claude-plugin/package.template.json @@ -0,0 +1,27 @@ +{ + "name": "@firstdraft.com/claude-code", + "version": "0.1.0-alpha.3", + "description": "First Draft Foundation Plan authoring for Claude Code", + "license": "MIT", + "type": "module", + "bin": { + "firstdraft": "bin/firstdraft" + }, + "engines": { + "node": ">=22.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/firstdraft/skills.git" + }, + "homepage": "https://github.com/firstdraft/skills#readme", + "bugs": { + "url": "https://github.com/firstdraft/skills/issues" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "registry": "https://registry.npmjs.org/", + "tag": "next" + } +} diff --git a/release/compatibility.json b/release/compatibility.json new file mode 100644 index 0000000..ce7f006 --- /dev/null +++ b/release/compatibility.json @@ -0,0 +1,21 @@ +{ + "format": "firstdraft.release-compatibility/1", + "component": "skills", + "version": "0.1.0-alpha.3", + "plugin_source": { + "package": "@firstdraft.com/claude-code", + "tarball_sha256": "1167fcdf43fba9040fc4068371fe263e779f26c6c88eb3fe3207369e12d32ba0" + }, + "requires": { + "api_contract": [ + ">= 0.1.0", + "< 0.2.0" + ], + "cli": [ + "= 0.1.0-alpha.2" + ], + "foundation_plan_formats": [ + "firstdraft.foundation-plan.sketch/0.19" + ] + } +} diff --git a/script/check b/script/check index 196a20b..453f952 100755 --- a/script/check +++ b/script/check @@ -4,4 +4,6 @@ set -eu repository=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repository" +node script/check-release-compatibility.mjs +node script/check-claude-plugin-package.mjs node --test 'test/**/*.test.mjs' diff --git a/script/check-claude-plugin-install.mjs b/script/check-claude-plugin-install.mjs index c6c66e5..db86ab9 100644 --- a/script/check-claude-plugin-install.mjs +++ b/script/check-claude-plugin-install.mjs @@ -45,9 +45,16 @@ import { stateTargetPresence, } from "./plugin-isolation.mjs"; +const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const installSmokeMarketplace = JSON.parse( + readFileSync( + path.join(repository, ".claude-plugin", "marketplace.json"), + "utf8", + ), +); +assertLocalInstallSmokeSupported(installSmokeMarketplace); assertDefaultClaudeStateLocations(process.env); -const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const portableSkill = path.join( repository, "skills", @@ -524,9 +531,8 @@ function assertMatchesCommittedObservation(current) { reviewedPackagingObservation(current), reviewedPackagingObservation(committed), "live Claude Code plugin observation differs from committed evidence; " + - "review current discovery and isolation behavior, run " + - "`npm run record:claude-plugin-install`, rename and refresh the dated " + - "evidence, then update the reviewed test pins", + "the historical recording path is retired, so use the vendored-package " + + "qualification in RELEASING.md for new evidence", ); } @@ -554,3 +560,17 @@ function requestedObservationPath(arguments_) { ); return path.resolve(arguments_[1]); } + +function assertLocalInstallSmokeSupported(marketplace) { + const marketplacePlugin = marketplace.plugins?.find( + ({ name }) => name === "firstdraft", + ); + assert(marketplacePlugin, "marketplace plugin entry is missing"); + assert.equal( + typeof marketplacePlugin.source, + "string", + "the historical local Claude Code install smoke does not support the npm " + + "plugin source; use the staged-package preflight and then qualify the " + + "published package through the public marketplace", + ); +} diff --git a/script/check-claude-plugin-package.mjs b/script/check-claude-plugin-package.mjs new file mode 100644 index 0000000..1d1c7f5 --- /dev/null +++ b/script/check-claude-plugin-package.mjs @@ -0,0 +1,301 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { canonicalClaudePluginSkillFiles } from "./claude-plugin-boundaries.mjs"; +import { packClaudePlugin } from "./claude-plugin-package.mjs"; +import { cliPackageVersion } from "./cli-contract/config.mjs"; + +const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const packageTemplate = readJson( + path.join(repository, "packages", "claude-plugin", "package.template.json"), +); +const pluginManifest = readJson( + path.join( + repository, + "packages", + "claude-plugin", + ".claude-plugin", + "plugin.json", + ), +); +const compatibility = readJson( + path.join(repository, "release", "compatibility.json"), +); +const cliRootIndex = process.argv.indexOf("--cli-root"); +const cliRoot = + cliRootIndex === -1 + ? undefined + : path.resolve(process.argv[cliRootIndex + 1] || ""); + +assert.equal(packageTemplate.name, "@firstdraft.com/claude-code"); +assert.equal(packageTemplate.version, compatibility.version); +assert.equal(pluginManifest.name, "firstdraft"); +assert.equal(pluginManifest.version, compatibility.version); +assert.equal(packageTemplate.dependencies, undefined); +assert.equal(pluginManifest.userConfig.api_url.sensitive, undefined); +assert.equal( + pluginManifest.userConfig.api_url.default, + "https://staging.firstdraft.com", +); +assert.equal(pluginManifest.userConfig.api_token.sensitive, true); + +const temporaryDirectory = mkdtempSync( + path.join(tmpdir(), "firstdraft-claude-plugin-check-"), +); +const cleanEnvironment = {...process.env}; +for (const name of [ + "FIRSTDRAFT_API_URL", + "FIRSTDRAFT_API_TOKEN", + "CLAUDE_PLUGIN_OPTION_api_url", + "CLAUDE_PLUGIN_OPTION_api_token", +]) { + delete cleanEnvironment[name]; +} + +try { + const fakeCliRoot = createFakeCli(temporaryDirectory); + const packageCliRoot = cliRoot || fakeCliRoot; + const first = await packClaudePlugin( + path.join(temporaryDirectory, "first"), + packageCliRoot, + ); + const second = await packClaudePlugin( + path.join(temporaryDirectory, "second"), + packageCliRoot, + ); + assert.equal(first.sha256, second.sha256, "plugin tarballs must be deterministic"); + if (cliRoot) { + assert.equal( + first.sha256, + compatibility.plugin_source.tarball_sha256, + "plugin tarball must match its release compatibility digest", + ); + } + + const expectedFiles = [ + ".claude-plugin/plugin.json", + "LICENSE", + "bin/firstdraft", + "bin/firstdraft.js", + "package.json", + ...canonicalClaudePluginSkillFiles.map( + (file) => `skills/create-full-stack-app/${file}`, + ), + ].sort(); + const packagedFiles = first.manifest.files.map(({path: file}) => file).sort(); + assert.deepEqual( + packagedFiles.filter((file) => !file.startsWith("vendor/cli/")), + expectedFiles, + ); + assert(packagedFiles.includes("vendor/cli/package.json")); + assert(packagedFiles.includes("vendor/cli/bin/firstdraft.js")); + for (const file of first.manifest.files) { + const executable = + file.path === "bin/firstdraft" || + file.path === "vendor/cli/bin/firstdraft.js"; + assert.equal( + file.mode, + executable ? 0o755 : 0o644, + `${file.path} has an unexpected package mode`, + ); + } + + const fakePlugin = await packClaudePlugin( + path.join(temporaryDirectory, "fake-plugin"), + fakeCliRoot, + ); + const fakeInstallation = installPackages({ + directory: path.join(temporaryDirectory, "fake-installation"), + packages: [fakePlugin.tarball], + }); + const canaryToken = `fd_${"a".repeat(43)}`; + const execution = run( + pluginExecutable(fakeInstallation), + ["probe"], + fakeInstallation, + { + ...process.env, + CLAUDE_PLUGIN_OPTION_api_url: "https://staging.firstdraft.com", + CLAUDE_PLUGIN_OPTION_api_token: canaryToken, + FIRSTDRAFT_API_URL: "https://wrong.example.com", + FIRSTDRAFT_API_TOKEN: `fd_${"b".repeat(43)}`, + }, + ); + assert.deepEqual(JSON.parse(execution.stdout), { + apiToken: canaryToken, + apiUrl: "https://staging.firstdraft.com", + arguments: ["probe"], + pluginApiTokenPresent: false, + pluginApiUrlPresent: false, + }); + assert.equal(execution.stderr, ""); + + for (const pluginEnvironment of [ + {CLAUDE_PLUGIN_OPTION_api_token: canaryToken}, + ]) { + const incomplete = spawnSync( + pluginExecutable(fakeInstallation), + ["probe"], + { + cwd: fakeInstallation, + encoding: "utf8", + env: { + ...process.env, + ...pluginEnvironment, + FIRSTDRAFT_API_URL: "https://ambient.example.com", + FIRSTDRAFT_API_TOKEN: `fd_${"b".repeat(43)}`, + }, + }, + ); + assert.equal(incomplete.status, 2); + assert.equal(incomplete.stdout, ""); + assert.equal( + incomplete.stderr, + '{"error":"plugin_configuration_incomplete","detail":"Configure the First Draft API URL in Claude Code."}\n', + ); + assert.doesNotMatch(incomplete.stderr, /fd_[A-Za-z0-9_-]+/); + } + + const urlOnly = run( + pluginExecutable(fakeInstallation), + ["probe"], + fakeInstallation, + { + ...process.env, + CLAUDE_PLUGIN_OPTION_api_url: "https://staging.firstdraft.com", + FIRSTDRAFT_API_TOKEN: `fd_${"b".repeat(43)}`, + }, + ); + assert.deepEqual(JSON.parse(urlOnly.stdout), { + apiUrl: "https://staging.firstdraft.com", + arguments: ["probe"], + pluginApiTokenPresent: false, + pluginApiUrlPresent: false, + }); + + const linkedExecution = run( + path.join(fakeInstallation, "node_modules", ".bin", "firstdraft"), + ["probe"], + fakeInstallation, + cleanEnvironment, + ); + assert.deepEqual(JSON.parse(linkedExecution.stdout), { + arguments: ["probe"], + pluginApiTokenPresent: false, + pluginApiUrlPresent: false, + }); + + if (cliRoot) { + const cliPackage = readJson(path.join(cliRoot, "package.json")); + assert.equal(cliPackage.name, "@firstdraft.com/cli"); + assert.equal(cliPackage.version, cliPackageVersion); + const actualInstallation = installPackages({ + directory: path.join(temporaryDirectory, "actual-installation"), + packages: [first.tarball], + }); + const version = run( + pluginExecutable(actualInstallation), + ["--version"], + actualInstallation, + ); + assert.equal(version.stdout, `${cliPackageVersion}\n`); + assert.equal(version.stderr, ""); + } +} finally { + rmSync(temporaryDirectory, {recursive: true, force: true}); +} + +process.stdout.write("Claude plugin package is deterministic and valid.\n"); + +function createFakeCli(directory) { + const source = path.join(directory, "fake-cli"); + mkdirSync(path.join(source, "bin"), {recursive: true}); + writeFileSync( + path.join(source, "package.json"), + `${JSON.stringify({ + name: "@firstdraft.com/cli", + version: cliPackageVersion, + type: "module", + bin: {firstdraft: "bin/firstdraft.js"}, + })}\n`, + ); + const executable = path.join(source, "bin", "firstdraft.js"); + writeFileSync( + executable, + `#!/usr/bin/env node +process.stdout.write(JSON.stringify({ + apiToken: process.env.FIRSTDRAFT_API_TOKEN, + apiUrl: process.env.FIRSTDRAFT_API_URL, + arguments: process.argv.slice(2), + pluginApiTokenPresent: Object.hasOwn(process.env, "CLAUDE_PLUGIN_OPTION_api_token"), + pluginApiUrlPresent: Object.hasOwn(process.env, "CLAUDE_PLUGIN_OPTION_api_url"), +}) + "\\n"); +`, + ); + chmodSync(executable, 0o755); + return source; +} + +function installPackages({directory, packages}) { + mkdirSync(directory, {recursive: true}); + writeFileSync( + path.join(directory, "package.json"), + '{"name":"firstdraft-claude-plugin-smoke","private":true}\n', + ); + run( + process.env.npm_execpath || "npm", + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--offline", + "--no-save", + ...packages, + ], + directory, + ); + return directory; +} + +function pluginExecutable(installation) { + return path.join( + installation, + "node_modules", + "@firstdraft.com", + "claude-code", + "bin", + "firstdraft", + ); +} + +function run(command, arguments_, directory, environment = process.env) { + const result = spawnSync(command, arguments_, { + cwd: directory, + encoding: "utf8", + env: environment, + }); + assert.equal( + result.status, + 0, + [result.error?.message, `command exited ${result.status}; output suppressed`] + .filter(Boolean) + .join("; "), + ); + return result; +} + +function readJson(file) { + return JSON.parse(readFileSync(file, "utf8")); +} diff --git a/script/check-release-compatibility.mjs b/script/check-release-compatibility.mjs new file mode 100644 index 0000000..8f6dc15 --- /dev/null +++ b/script/check-release-compatibility.mjs @@ -0,0 +1,302 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + cliPackageVersion, + foundationPlanFormat, +} from "./cli-contract/config.mjs"; + +const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const compatibilityPath = "release/compatibility.json"; +const semanticVersionPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; + +export function isSemanticVersion(value) { + return typeof value === "string" && semanticVersionPattern.test(value); +} + +export function assertSkillsReleaseCompatibility({ + compatibility, + installableManifest, + marketplace, + packageDocument, + packageTemplate, + previewManifest, +}) { + assertExactKeys( + compatibility, + ["component", "format", "plugin_source", "requires", "version"], + "release compatibility", + ); + assert.equal( + compatibility.format, + "firstdraft.release-compatibility/1", + ); + assert.equal(compatibility.component, "skills"); + assertSemanticVersion(compatibility.version, "Skills compatibility version"); + assertExactKeys( + compatibility.plugin_source, + ["package", "tarball_sha256"], + "plugin source identity", + ); + assert.equal( + compatibility.plugin_source.package, + "@firstdraft.com/claude-code", + ); + assert.match( + compatibility.plugin_source.tarball_sha256, + /^[0-9a-f]{64}$/, + "plugin source identity must use a full lowercase SHA-256", + ); + + assertExactKeys( + compatibility.requires, + ["api_contract", "cli", "foundation_plan_formats"], + "release compatibility requirements", + ); + for (const [name, requirements] of Object.entries(compatibility.requires)) { + assertStringArray(requirements, `${name} requirements`); + } + for (const requirement of [ + ...compatibility.requires.api_contract, + ...compatibility.requires.cli, + ]) { + assertComparator(requirement); + } + assert.deepEqual(compatibility.requires, { + api_contract: [">= 0.1.0", "< 0.2.0"], + cli: [`= ${cliPackageVersion}`], + foundation_plan_formats: [foundationPlanFormat], + }); + + assert.equal(marketplace.name, "firstdraft-skills"); + assert(Array.isArray(marketplace.plugins), "marketplace plugins must be an array"); + const installablePlugins = marketplace.plugins.filter( + ({ name }) => name === "firstdraft", + ); + assert.equal( + installablePlugins.length, + 1, + "marketplace must contain exactly one installable firstdraft plugin", + ); + const installablePlugin = installablePlugins[0]; + assertSemanticVersion( + installablePlugin.version, + "installable marketplace plugin version", + ); + assert.equal( + compatibility.version, + installablePlugin.version, + "compatibility version must match the installable marketplace plugin", + ); + assertExactKeys( + installablePlugin.source, + ["package", "registry", "source", "version"], + "installable marketplace plugin source", + ); + assert.equal(installablePlugin.source.source, "npm"); + assert.equal( + installablePlugin.source.registry, + "https://registry.npmjs.org/", + ); + assert.equal( + compatibility.plugin_source.package, + installablePlugin.source.package, + "compatibility package must match the marketplace plugin source", + ); + assert.equal( + compatibility.version, + installablePlugin.source.version, + "compatibility version must match the marketplace package source", + ); + + assert.equal(installableManifest.name, installablePlugin.name); + assert.equal(installableManifest.version, compatibility.version); + assert.deepEqual(installableManifest.skills, [ + "./skills/create-full-stack-app", + ]); + + assert.equal(packageTemplate.name, compatibility.plugin_source.package); + assert.equal(packageTemplate.version, compatibility.version); + assert.equal(packageTemplate.dependencies, undefined); + + assert.equal(previewManifest.name, "firstdraft-preview"); + assert.notEqual( + previewManifest.name, + installablePlugin.name, + "checkout preview manifest must remain distinct from the installable plugin", + ); + assert.equal(packageDocument.name, "@firstdraft/skills"); + assert.equal(packageDocument.version, "0.0.0"); + assert.equal( + packageDocument.private, + true, + "the root npm package is private tooling, not the plugin release identity", + ); + + return compatibility; +} + +export async function checkSkillsReleaseCompatibility(root = repository) { + const [ + compatibility, + installableManifest, + marketplace, + packageDocument, + packageTemplate, + previewManifest, + ] = await Promise.all([ + readJson(path.join(root, "release", "compatibility.json")), + readJson( + path.join( + root, + "packages", + "claude-plugin", + ".claude-plugin", + "plugin.json", + ), + ), + readJson(path.join(root, ".claude-plugin", "marketplace.json")), + readJson(path.join(root, "package.json")), + readJson( + path.join( + root, + "packages", + "claude-plugin", + "package.template.json", + ), + ), + readJson(path.join(root, ".claude-plugin", "plugin.json")), + ]); + + const checked = assertSkillsReleaseCompatibility({ + compatibility, + installableManifest, + marketplace, + packageDocument, + packageTemplate, + previewManifest, + }); + assertVersionSourceHistory({ + compatibility: checked, + historicalCompatibilities: readHistoricalCompatibilities({ root }), + }); + + return checked; +} + +export function assertVersionSourceHistory({ + compatibility, + historicalCompatibilities, +}) { + for (const historical of historicalCompatibilities) { + if ( + historical.compatibility.component !== compatibility.component || + historical.compatibility.version !== compatibility.version + ) { + continue; + } + assert.deepEqual( + historical.compatibility.plugin_source, + compatibility.plugin_source, + `Skills version ${compatibility.version} was already mapped to a ` + + `different plugin source at ${historical.revision}; assign a new ` + + "never-reused SemVer", + ); + } +} + +export function readHistoricalCompatibilities({ + root, + spawn = spawnSync, +}) { + const history = spawn( + "git", + [ + "log", + "--all", + "--diff-merges=first-parent", + "--no-patch", + "--diff-filter=AM", + "--format=%H", + "--", + compatibilityPath, + ], + { cwd: root, encoding: "utf8" }, + ); + assertGitSucceeded(history, "could not inspect release compatibility history"); + + return history.stdout + .trim() + .split("\n") + .filter(Boolean) + .map((revision) => { + assert.match(revision, /^[0-9a-f]{40,64}$/); + const document = spawn( + "git", + ["show", `${revision}:${compatibilityPath}`], + { cwd: root, encoding: "utf8" }, + ); + assertGitSucceeded( + document, + `could not read release compatibility at ${revision}`, + ); + return { + revision, + compatibility: JSON.parse(document.stdout), + }; + }); +} + +function assertGitSucceeded(result, message) { + assert.equal( + result.status, + 0, + `${message}: ` + + [result.error?.message, result.stderr].filter(Boolean).join("; "), + ); +} + +function assertExactKeys(value, expected, label) { + assert( + value && typeof value === "object" && !Array.isArray(value), + `${label} must be an object`, + ); + assert.deepEqual( + Object.keys(value).sort(), + [...expected].sort(), + `${label} must contain exactly the supported keys`, + ); +} + +function assertStringArray(value, label) { + assert(Array.isArray(value) && value.length > 0, `${label} must be nonempty`); + for (const item of value) { + assert.equal(typeof item, "string", `${label} must contain only strings`); + } +} + +function assertComparator(value) { + const match = /^(>=|<=|>|<|=) (.+)$/.exec(value); + assert(match, `invalid SemVer comparator: ${value}`); + assertSemanticVersion(match[2], `SemVer comparator ${value}`); +} + +function assertSemanticVersion(value, label) { + assert(isSemanticVersion(value), `${label} must be a valid semantic version`); +} + +async function readJson(file) { + return JSON.parse(await readFile(file, "utf8")); +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + await checkSkillsReleaseCompatibility(); + process.stdout.write("Skills release compatibility metadata is valid.\n"); +} diff --git a/script/claude-plugin-boundaries.mjs b/script/claude-plugin-boundaries.mjs index 15df38e..cec0108 100644 --- a/script/claude-plugin-boundaries.mjs +++ b/script/claude-plugin-boundaries.mjs @@ -26,6 +26,10 @@ export const forbiddenCheckoutRootClaudePluginComponentPaths = Object.freeze([ "workflows", ]); +// The checkout preview deliberately exposes only the portable Skill. The assembled +// installable plugin separately admits bin/ as Claude Code's documented executable +// component so the exact CLI adapter is available to the Bash tool. + export const forbiddenClaudePluginPathSegments = Object.freeze([ "evals", "node_modules", diff --git a/script/claude-plugin-package.mjs b/script/claude-plugin-package.mjs new file mode 100644 index 0000000..74c9eab --- /dev/null +++ b/script/claude-plugin-package.mjs @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmod, + cp, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const packageSource = path.join(repository, "packages", "claude-plugin"); +const skillSource = path.join( + repository, + "skills", + "create-full-stack-app", +); + +export async function stageClaudePlugin(destination, cliRoot) { + assert(cliRoot, "the exact CLI checkout is required"); + const target = path.resolve(destination); + await mkdir(target, {recursive: true}); + await Promise.all([ + cp( + path.join(packageSource, ".claude-plugin"), + path.join(target, ".claude-plugin"), + {recursive: true}, + ), + cp(path.join(packageSource, "bin"), path.join(target, "bin"), { + recursive: true, + }), + cp(skillSource, path.join(target, "skills", "create-full-stack-app"), { + recursive: true, + }), + cp(path.join(repository, "LICENSE"), path.join(target, "LICENSE")), + cp( + path.join(packageSource, "package.template.json"), + path.join(target, "package.json"), + ), + ]); + await stageCli(path.resolve(cliRoot), path.join(target, "vendor", "cli")); + await chmod(path.join(target, "bin", "firstdraft.js"), 0o644); + await chmod(path.join(target, "bin", "firstdraft"), 0o755); + return target; +} + +export async function packClaudePlugin(destination, cliRoot) { + const staging = await mkdtemp(path.join(tmpdir(), "firstdraft-claude-plugin-")); + try { + await stageClaudePlugin(staging, cliRoot); + await mkdir(destination, {recursive: true}); + const result = spawnSync( + process.env.npm_execpath || "npm", + [ + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + path.resolve(destination), + ], + {cwd: staging, encoding: "utf8"}, + ); + assert.equal( + result.status, + 0, + [result.error?.message, result.stderr].filter(Boolean).join("; "), + ); + const [manifest] = JSON.parse(result.stdout); + assert(manifest, "npm pack did not return a package manifest"); + const tarball = path.join(path.resolve(destination), manifest.filename); + return { + manifest, + sha256: createHash("sha256") + .update(await readFile(tarball)) + .digest("hex"), + tarball, + }; + } finally { + await rm(staging, {recursive: true, force: true}); + } +} + +async function main() { + const [command, argument, option, cliRoot] = process.argv.slice(2); + assert( + command === "stage" || command === "pack", + "usage: claude-plugin-package.mjs ", + ); + assert(argument, "a destination directory is required"); + assert.equal(option, "--cli-root", "--cli-root is required"); + assert(cliRoot, "--cli-root is required"); + + if (command === "stage") { + await stageClaudePlugin(argument, cliRoot); + process.stdout.write(`${path.resolve(argument)}\n`); + return; + } + + const packed = await packClaudePlugin(argument, cliRoot); + await writeFile( + `${packed.tarball}.sha256`, + `${packed.sha256} ${path.basename(packed.tarball)}\n`, + ); + process.stdout.write( + `${JSON.stringify({sha256: packed.sha256, tarball: packed.tarball})}\n`, + ); +} + +async function stageCli(cliRoot, destination) { + const packedDirectory = await mkdtemp( + path.join(tmpdir(), "firstdraft-cli-package-"), + ); + try { + const packed = spawnSync( + process.env.npm_execpath || "npm", + [ + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + packedDirectory, + ], + {cwd: cliRoot, encoding: "utf8"}, + ); + assert.equal( + packed.status, + 0, + [packed.error?.message, packed.stderr].filter(Boolean).join("; "), + ); + const [manifest] = JSON.parse(packed.stdout); + assert(manifest, "npm pack did not return a CLI package manifest"); + await mkdir(destination, {recursive: true}); + const extracted = spawnSync( + "tar", + [ + "-xzf", + path.join(packedDirectory, manifest.filename), + "--strip-components=1", + "-C", + destination, + ], + {encoding: "utf8"}, + ); + assert.equal( + extracted.status, + 0, + [extracted.error?.message, extracted.stderr].filter(Boolean).join("; "), + ); + await normalizeCliModes(destination, destination); + } finally { + await rm(packedDirectory, {recursive: true, force: true}); + } +} + +async function normalizeCliModes(root, current) { + for (const entry of await readdir(current, {withFileTypes: true})) { + const item = path.join(current, entry.name); + assert.equal(entry.isSymbolicLink(), false, `unexpected CLI symlink: ${item}`); + if (entry.isDirectory()) { + await chmod(item, 0o755); + await normalizeCliModes(root, item); + continue; + } + assert(entry.isFile(), `unexpected CLI package entry: ${item}`); + const relative = path.relative(root, item); + await chmod(item, relative === path.join("bin", "firstdraft.js") ? 0o755 : 0o644); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + await main(); +} diff --git a/script/cli-contract/config.mjs b/script/cli-contract/config.mjs index 007e0ea..899d2a9 100644 --- a/script/cli-contract/config.mjs +++ b/script/cli-contract/config.mjs @@ -1,6 +1,6 @@ -export const cliRevision = "f55edffc9e88924f9a4c95f41c4d0bc9b72422f8"; +export const cliRevision = "e53eb38d7e8254e6ba1e660b38c5d32d0314be17"; export const cliRuntimeSha256 = - "9e5a4bd0f16f49ab2e17c04f7defc59366f8fa073f772b310d8f684177890eab"; + "0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad"; export const cliPackageName = "@firstdraft.com/cli"; export const cliPackageVersion = "0.1.0-alpha.2"; diff --git a/skills/create-full-stack-app/SKILL.md b/skills/create-full-stack-app/SKILL.md index e94a383..565ad08 100644 --- a/skills/create-full-stack-app/SKILL.md +++ b/skills/create-full-stack-app/SKILL.md @@ -59,13 +59,16 @@ Require these public commands: - `plan init`, `plan push`, `plan status`, and zero-flag `plan compile`; and - `compilation status` and `compilation download`. -There is no public `plan publish`, `plan subject-id`, or `plan compile --output` contract. Do not install, -download, or upgrade the unreleased CLI automatically. If a required command is missing, report that capability -gap rather than approximating it with direct HTTP. +There is no public `plan publish`, `plan subject-id`, or `plan compile --output` contract. A marketplace installation +of this Skill supplies its exact compatible CLI dependency. Do not install, download, or upgrade another CLI +automatically. If a required command is missing or reports a different version, report a plugin installation defect +and ask the user to reinstall or update the plugin rather than approximating it with direct HTTP. `.firstdraft/state.json` is private CLI-owned concurrency state. Do not print, paste, commit, or treat it as -agent-authored Plan content. Let the user configure `FIRSTDRAFT_API_TOKEN` outside the conversation; never ask them -to paste it or place it on a command line. +agent-authored Plan content. For an installed plugin, let the user configure the API token through the plugin's +sensitive configuration prompt; it is authoritative and an ambient `FIRSTDRAFT_API_TOKEN` is deliberately ignored +when plugin configuration supplies the API URL. `FIRSTDRAFT_API_TOKEN` remains available to a standalone CLI. Never +ask the user to paste a token or place it on a command line. ## Initialize or resume the local Plan diff --git a/skills/create-full-stack-app/references/diagnostics-and-recovery.md b/skills/create-full-stack-app/references/diagnostics-and-recovery.md index e143745..935b8aa 100644 --- a/skills/create-full-stack-app/references/diagnostics-and-recovery.md +++ b/skills/create-full-stack-app/references/diagnostics-and-recovery.md @@ -5,8 +5,8 @@ Handled leaf-command failures write exactly one JSON object to standard error. B structured fields rather than the human-readable `detail` or the broad process exit status. The reviewed CLI contract in this stack is revision -`f55edffc9e88924f9a4c95f41c4d0bc9b72422f8`, with JavaScript-source runtime digest -`9e5a4bd0f16f49ab2e17c04f7defc59366f8fa073f772b310d8f684177890eab`. Its source package identifies itself as +`e53eb38d7e8254e6ba1e660b38c5d32d0314be17`, with JavaScript-source runtime digest +`0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad`. Its source package identifies itself as `@firstdraft.com/cli@0.1.0-alpha.2`, but remains unpublished. Check the command surface rather than assuming the version alone establishes compatibility. @@ -123,6 +123,7 @@ tree. | Commands | `error` | Recovery meaning | | --- | --- | --- | | Any leaf command | `invalid_arguments` | Syntax failed before the requested action. Read that command's help. | +| Plugin adapter | `plugin_configuration_incomplete` | Configure the missing API URL through Claude Code's plugin configuration prompt. | | `plan init` | `local_initialization_failed` | Preserve possibly partial local state. | | `plan push`, `plan status`, `plan compile`, `compilation status`, `compilation download` | `authentication_required` | Configure the token outside the conversation. | | `plan push`, `plan status`, `plan compile`, `compilation status`, `compilation download` | `local_input_unreadable` | Preserve unreadable local files; do not reconstruct private state. | diff --git a/skills/create-full-stack-app/references/foundation-plan-019.md b/skills/create-full-stack-app/references/foundation-plan-019.md index 4893179..523b7c4 100644 --- a/skills/create-full-stack-app/references/foundation-plan-019.md +++ b/skills/create-full-stack-app/references/foundation-plan-019.md @@ -64,8 +64,8 @@ not release or execution evidence. The bounded local Compilation evidence used reviewed CLI revision `121272cd592055354d09a4fe90e55c3ca002770c`, with JavaScript-source runtime digest `205e664df0ed9c7e63651a1c2c01e749a04d8879fe7f62cc4c1e13b66dce738d`. The current contract fixtures and check use -reviewed successor revision `f55edffc9e88924f9a4c95f41c4d0bc9b72422f8`, with independently reproduced -JavaScript-source runtime digest `9e5a4bd0f16f49ab2e17c04f7defc59366f8fa073f772b310d8f684177890eab`, as +reviewed successor revision `e53eb38d7e8254e6ba1e660b38c5d32d0314be17`, with independently reproduced +JavaScript-source runtime digest `0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad`, as contract provenance rather than release or execution evidence. That successor exposes `generate uuid`, `generate application-key`, `plan init`, `plan push`, `plan status`, zero-flag `plan compile`, `compilation status`, and `compilation download`. It deliberately has no public `plan subject-id`, `plan publish`, or local-start diff --git a/test/release-compatibility.test.mjs b/test/release-compatibility.test.mjs new file mode 100644 index 0000000..7a29b56 --- /dev/null +++ b/test/release-compatibility.test.mjs @@ -0,0 +1,266 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + assertSkillsReleaseCompatibility, + assertVersionSourceHistory, + checkSkillsReleaseCompatibility, + isSemanticVersion, + readHistoricalCompatibilities, +} from "../script/check-release-compatibility.mjs"; +import { + cliPackageVersion, + foundationPlanFormat, +} from "../script/cli-contract/config.mjs"; + +const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +test("release compatibility matches the installable plugin manifest", async () => { + const compatibility = await checkSkillsReleaseCompatibility(repository); + + assert.deepEqual(compatibility, { + format: "firstdraft.release-compatibility/1", + component: "skills", + version: "0.1.0-alpha.3", + plugin_source: { + package: "@firstdraft.com/claude-code", + tarball_sha256: + "1167fcdf43fba9040fc4068371fe263e779f26c6c88eb3fe3207369e12d32ba0", + }, + requires: { + api_contract: [">= 0.1.0", "< 0.2.0"], + cli: [`= ${cliPackageVersion}`], + foundation_plan_formats: [foundationPlanFormat], + }, + }); +}); + +test("release compatibility rejects shape and manifest drift", async () => { + const documents = await releaseDocuments(); + const withExtraKey = structuredClone(documents); + withExtraKey.compatibility.release = true; + assert.throws( + () => assertSkillsReleaseCompatibility(withExtraKey), + /exactly the supported keys/, + ); + + const withInvalidRequirement = structuredClone(documents); + withInvalidRequirement.compatibility.requires.cli = ["0.1.0-alpha.2"]; + assert.throws( + () => assertSkillsReleaseCompatibility(withInvalidRequirement), + /invalid SemVer comparator/, + ); + + const withMarketplaceDrift = structuredClone(documents); + withMarketplaceDrift.marketplace.plugins[0].version = "0.1.1"; + assert.throws( + () => assertSkillsReleaseCompatibility(withMarketplaceDrift), + /must match the installable marketplace plugin/, + ); + + const withSourceDrift = structuredClone(documents); + withSourceDrift.marketplace.plugins[0].source.package = "@other/plugin"; + assert.throws( + () => assertSkillsReleaseCompatibility(withSourceDrift), + /package must match/, + ); + + const withShortDigest = structuredClone(documents); + withShortDigest.compatibility.plugin_source.tarball_sha256 = "abc123"; + assert.throws( + () => assertSkillsReleaseCompatibility(withShortDigest), + /full lowercase SHA-256/, + ); +}); + +test("release compatibility uses strict semantic versions", () => { + for (const version of ["0.1.0", "0.1.0-alpha.1", "1.0.0+build.7"]) { + assert.equal(isSemanticVersion(version), true, version); + } + for (const version of ["v0.1.0", "01.0.0", "0.1", "0.1.0-"]) { + assert.equal(isSemanticVersion(version), false, version); + } +}); + +test("one Skills version maps permanently to one plugin source", () => { + const current = { + component: "skills", + version: "0.1.0-alpha.1", + plugin_source: { + package: "@firstdraft.com/claude-code", + tarball_sha256: "1".repeat(64), + }, + }; + assert.doesNotThrow(() => + assertVersionSourceHistory({ + compatibility: current, + historicalCompatibilities: [ + { + revision: "2".repeat(40), + compatibility: structuredClone(current), + }, + { + revision: "3".repeat(40), + compatibility: { + ...structuredClone(current), + version: "0.1.0-alpha.2", + plugin_source: { + ...current.plugin_source, + tarball_sha256: "4".repeat(64), + }, + }, + }, + ], + }), + ); + + assert.throws( + () => + assertVersionSourceHistory({ + compatibility: current, + historicalCompatibilities: [ + { + revision: "5".repeat(40), + compatibility: { + ...structuredClone(current), + plugin_source: { + ...current.plugin_source, + tarball_sha256: "6".repeat(64), + }, + }, + }, + ], + }), + /version 0\.1\.0-alpha\.1 was already mapped.*assign a new never-reused SemVer/, + ); +}); + +test("release history is read from every committed ref", () => { + const invocations = []; + const historical = readHistoricalCompatibilities({ + root: "/catalog", + spawn(command, arguments_, options) { + invocations.push([command, arguments_, options]); + if (arguments_[0] === "log") { + return { status: 0, stderr: "", stdout: `${"7".repeat(40)}\n` }; + } + return { + status: 0, + stderr: "", + stdout: JSON.stringify({ + component: "skills", + version: "0.1.0-alpha.1", + plugin_source: { + package: "@firstdraft.com/claude-code", + tarball_sha256: "8".repeat(64), + }, + }), + }; + }, + }); + + assert.deepEqual(invocations, [ + [ + "git", + [ + "log", + "--all", + "--diff-merges=first-parent", + "--no-patch", + "--diff-filter=AM", + "--format=%H", + "--", + "release/compatibility.json", + ], + { cwd: "/catalog", encoding: "utf8" }, + ], + [ + "git", + ["show", `${"7".repeat(40)}:release/compatibility.json`], + { cwd: "/catalog", encoding: "utf8" }, + ], + ]); + assert.equal(historical.length, 1); + assert.equal(historical[0].revision, "7".repeat(40)); + assert.equal( + historical[0].compatibility.plugin_source.tarball_sha256, + "8".repeat(64), + ); +}); + +test("release operator and agent instructions track the candidate", async () => { + const [marketplace, releasing, agents] = await Promise.all([ + readJson(".claude-plugin/marketplace.json"), + readText("RELEASING.md"), + readText("AGENTS.md"), + ]); + const candidate = marketplace.plugins.find(({ name }) => name === "firstdraft"); + + assert(releasing.includes(`version is \`${candidate.version}\``)); + assert(releasing.includes(candidate.source.package)); + assert(releasing.includes("claude-v$package_version")); + assert.match( + releasing, + /One marketplace SemVer maps\s+forever to exactly one package tarball/, + ); + assert.match( + releasing, + /Publishing and catalog promotion require new,[\s\S]*?explicit authorization/, + ); + assert.match( + releasing, + /reconcile its registry identity read-only/, + ); + assert.match( + releasing, + /Release corrections are forward-only and use a new SemVer/, + ); + + assert.match( + agents, + /Never reuse a published npm version or marketplace SemVer with different package bytes/, + ); + assert.match( + agents, + /Reconcile an ambiguous publication or push outcome read-only before retrying/, + ); + assert.match(agents, /serialized through one[\s\S]*?operator/); +}); + +async function releaseDocuments() { + const [ + compatibility, + installableManifest, + marketplace, + packageDocument, + packageTemplate, + previewManifest, + ] = await Promise.all([ + readJson("release/compatibility.json"), + readJson("packages/claude-plugin/.claude-plugin/plugin.json"), + readJson(".claude-plugin/marketplace.json"), + readJson("package.json"), + readJson("packages/claude-plugin/package.template.json"), + readJson(".claude-plugin/plugin.json"), + ]); + + return { + compatibility, + installableManifest, + marketplace, + packageDocument, + packageTemplate, + previewManifest, + }; +} + +async function readJson(relativePath) { + return JSON.parse(await readText(relativePath)); +} + +async function readText(relativePath) { + return readFile(path.join(repository, relativePath), "utf8"); +} diff --git a/test/repository.test.mjs b/test/repository.test.mjs index c68d687..f6b644a 100644 --- a/test/repository.test.mjs +++ b/test/repository.test.mjs @@ -17,7 +17,6 @@ import { import { assertNoObservationAbsolutePathLeaks, observedFileBytes, - observedFileInventory, observedFileTreeSha256, renderManifestValidationEvidence, renderStatePresenceNames, @@ -73,8 +72,12 @@ const compilationEvidenceCliBaseline = const compilationEvidenceCliRuntimeDigest = "205e664df0ed9c7e63651a1c2c01e749a04d8879fe7f62cc4c1e13b66dce738d"; const cliContractBaseline = - "f55edffc9e88924f9a4c95f41c4d0bc9b72422f8"; + "e53eb38d7e8254e6ba1e660b38c5d32d0314be17"; const cliContractRuntimeDigest = + "0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad"; +const historicalCliContractBaseline = + "f55edffc9e88924f9a4c95f41c4d0bc9b72422f8"; +const historicalCliContractRuntimeDigest = "9e5a4bd0f16f49ab2e17c04f7defc59366f8fa073f772b310d8f684177890eab"; const compilationProvenanceServiceBaseline = "5811bb3013cf25072db74355597f60d85be3c05b"; @@ -86,6 +89,8 @@ const freshModelServiceTree = "076415a4b1e34cc458a85186e1e335503eb30612"; const freshModelPluginBaseline = "b5c3897b240bfa3a9117d1a564d8e6b7d783e993"; +const marketplacePluginSourceBaseline = + "8ffbd9688f39118ddeeb48a3da7e5bc309b7be5e"; const freshModelPluginRuntimeDigest = "a5c3bfe0dd8d5396a692c4204c670e10cbc4b996883f76025d9e8a6586becc7b"; const freshModelClaudeExecutableDigest = @@ -238,11 +243,13 @@ test("revision pins remain exhaustive across coordination surfaces", async () => foundationPlanServerBaseline, compilationEvidenceCliBaseline, cliContractBaseline, + historicalCliContractBaseline, compilationProvenanceServiceBaseline, productJourneySmokeBaseline, freshModelServiceBaseline, freshModelServiceTree, freshModelPluginBaseline, + marketplacePluginSourceBaseline, freshModelPublicationTree, freshModelPublicationCommit, foundationIosCoreRevision, @@ -279,13 +286,13 @@ test("fresh Claude Code evidence is exact and bounded", async () => { const homeResponse = await readFile(homeInventoryOpeningResponse, "utf8"); const observation = JSON.parse(observationSource); assertRevisionTokens(evidence, [ - cliContractBaseline, + historicalCliContractBaseline, freshModelServiceBaseline, freshModelPluginBaseline, ]); assertRevisionTokens(homeResponse, []); assertRevisionTokens(observationSource, [ - cliContractBaseline, + historicalCliContractBaseline, freshModelServiceBaseline, freshModelServiceTree, freshModelPluginBaseline, @@ -321,8 +328,8 @@ test("fresh Claude Code evidence is exact and bounded", async () => { freshModelPluginBaseline, freshModelPluginRuntimeDigest, freshModelClaudeExecutableDigest, - cliContractBaseline, - cliContractRuntimeDigest, + historicalCliContractBaseline, + historicalCliContractRuntimeDigest, ]) { assert(evidence.includes(value)); } @@ -412,15 +419,18 @@ test("fresh Claude Code evidence is exact and bounded", async () => { tree_sha: freshModelServiceTree, }); assert.deepEqual(observation.cli, { - revision: cliContractBaseline, - runtime_sha256: cliContractRuntimeDigest, + revision: historicalCliContractBaseline, + runtime_sha256: historicalCliContractRuntimeDigest, version: "0.1.0-alpha.2", }); assert.deepEqual(observation.plugin, { revision: freshModelPluginBaseline, runtime_sha256: freshModelPluginRuntimeDigest, }); - assert.equal(await pluginRuntimeDigest(), freshModelPluginRuntimeDigest); + assert.equal( + pluginRuntimeDigestAtRevision(freshModelPluginBaseline), + freshModelPluginRuntimeDigest, + ); assert.deepEqual(observation.command_ledger, { "compilation.help": 1, "generate.help": 1, @@ -519,138 +529,64 @@ test("installable Skills follow the portable repository profile", async () => { }); test("Claude Code packaging reuses the portable Skill exactly once", async () => { - const plugin = JSON.parse( + const previewManifest = JSON.parse( await readFile(path.join(claudePluginDirectory, "plugin.json"), "utf8"), ); const marketplace = JSON.parse( await readFile(path.join(claudePluginDirectory, "marketplace.json"), "utf8"), ); - const portableSkillPath = `./skills/${portableSkillName}`; - - assert.deepEqual(plugin, { - $schema: "https://json.schemastore.org/claude-code-plugin-manifest.json", - name: claudePreviewPluginName, - displayName: "First Draft Preview", - version: "0.1.0", - description: - "Experimental Foundation Plan authoring and bounded Rails application creation with First Draft", - author: { - name: "First Draft", - url: "https://github.com/firstdraft", - }, - homepage: "https://github.com/firstdraft/skills", - repository: "https://github.com/firstdraft/skills", - license: "MIT", - keywords: ["foundation-plan", "rails", "application-generation"], - skills: [portableSkillPath], - }); - assert.deepEqual(marketplace, { - $schema: "https://json.schemastore.org/claude-code-marketplace.json", - name: claudeMarketplaceName, - description: "Experimental First Draft application-authoring skills for Claude Code", - owner: { - name: "First Draft", - url: "https://github.com/firstdraft", - }, - plugins: [ - { - name: claudePluginName, - source: "./skills/create-full-stack-app", - strict: false, - skills: ["./"], - displayName: "First Draft", - description: - "Experimental Foundation Plan authoring and bounded Rails application creation with First Draft", - author: { - name: "First Draft", - url: "https://github.com/firstdraft", - }, - homepage: "https://github.com/firstdraft/skills", - repository: "https://github.com/firstdraft/skills", - license: "MIT", - keywords: ["foundation-plan", "rails", "application-generation"], - category: "development", - tags: ["foundation-plan", "rails"], - }, - ], - }); - assert(!("version" in marketplace.plugins[0])); - - const pluginSkillDirectory = path.resolve(repository, portableSkillPath); - assert.equal( - pluginSkillDirectory, - path.join(skillsDirectory, portableSkillName), - ); - assert.equal( - path.resolve(repository, marketplace.plugins[0].source), - pluginSkillDirectory, + const packageTemplate = JSON.parse( + await readFile( + path.join(repository, "packages", "claude-plugin", "package.template.json"), + "utf8", + ), ); - assert.equal( - path.resolve(pluginSkillDirectory, marketplace.plugins[0].skills[0]), - pluginSkillDirectory, + const installableManifest = JSON.parse( + await readFile( + path.join(repository, "packages", "claude-plugin", ".claude-plugin", "plugin.json"), + "utf8", + ), ); - assert((await stat(path.join(pluginSkillDirectory, "SKILL.md"))).isFile()); + + assert.equal(previewManifest.name, claudePreviewPluginName); + assert.deepEqual(previewManifest.skills, [`./skills/${portableSkillName}`]); + assert.equal(marketplace.name, claudeMarketplaceName); + assert.equal(marketplace.plugins.length, 1); + assert.equal(marketplace.plugins[0].name, claudePluginName); + assert.equal(marketplace.plugins[0].version, "0.1.0-alpha.3"); + assert.deepEqual(marketplace.plugins[0].source, { + source: "npm", + package: "@firstdraft.com/claude-code", + version: "0.1.0-alpha.3", + registry: "https://registry.npmjs.org/", + }); + assert.equal(packageTemplate.dependencies, undefined); + assert.deepEqual(installableManifest.skills, [ + "./skills/create-full-stack-app", + ]); + assert.equal(installableManifest.userConfig.api_token.sensitive, true); const repositoryFiles = trackedFiles(); const previewComponents = repositoryFiles .map((file) => path.relative(repository, file)) - .filter((relativePath) => { - const segments = relativePath.split(path.sep); - return forbiddenCheckoutRootClaudePluginComponentPaths.includes( - segments[0], - ); - }); - assert.deepEqual( - previewComponents, - [], - "the checkout-root plugin preview must not auto-discover components " + - "outside the marketplace source", - ); - assert.deepEqual(forbiddenCheckoutRootClaudePluginComponentPaths, [ - ".lsp.json", - ".mcp.json", - "SKILL.md", - "agents", - "bin", - "commands", - "hooks", - "monitors", - "output-styles", - "settings.json", - "themes", - "workflows", - ]); + .filter((relativePath) => + forbiddenCheckoutRootClaudePluginComponentPaths.includes( + relativePath.split(path.sep)[0], + ), + ); + assert.deepEqual(previewComponents, []); for (const relativePath of forbiddenCheckoutRootClaudePluginComponentPaths) { await assert.rejects( lstat(path.join(repository, relativePath)), (error) => error.code === "ENOENT", - `the checkout-root preview discovers ${relativePath} from the working tree`, ); } - const checkoutSkillsDirectoryDetails = await lstat(skillsDirectory); - assert.equal( - checkoutSkillsDirectoryDetails.isDirectory(), - true, - "the checkout-root skills path must be a directory, not a link", - ); - const checkoutSkillEntries = await readdir(skillsDirectory, { - withFileTypes: true, - }); - assert.deepEqual( - checkoutSkillEntries.map((entry) => entry.name).sort(), - [portableSkillName], - "the checkout-root preview must expose only the canonical portable Skill", - ); - assert.equal( - checkoutSkillEntries[0].isDirectory(), - true, - "the canonical checkout-root Skill must be a directory, not a link", - ); + + const pluginSkillDirectory = path.join(skillsDirectory, portableSkillName); const skillFiles = repositoryFiles.filter( (file) => path.basename(file) === "SKILL.md", ); assert.deepEqual(skillFiles, [path.join(pluginSkillDirectory, "SKILL.md")]); - const canonicalBody = await readFile(skillFiles[0]); const exactCopies = []; for (const file of repositoryFiles) { @@ -662,544 +598,48 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => const installedSourceFiles = (await filesUnder(pluginSkillDirectory)).map( (file) => path.relative(pluginSkillDirectory, file), ); - assert.deepEqual(canonicalClaudePluginSkillFiles, [ - "LICENSE.txt", - "SKILL.md", - "agents/openai.yaml", - "references/diagnostics-and-recovery.md", - "references/examples.md", - "references/foundation-plan-0.19.schema.json", - "references/foundation-plan-019.md", - "references/modeling-guide.md", - ]); assert.deepEqual(installedSourceFiles, canonicalClaudePluginSkillFiles); - assert.deepEqual(forbiddenClaudePluginPathSegments, [ - "evals", - "node_modules", - "package-lock.json", - "package.json", - "script", - "scripts", - "test", - ]); const forbiddenSegments = new Set(forbiddenClaudePluginPathSegments); for (const relativePath of installedSourceFiles) { - const segments = relativePath.split(path.sep); - assert.equal( - segments.some((segment) => forbiddenSegments.has(segment)), - false, - `unexpected installed source path: ${relativePath}`, - ); assert.equal( - segments.includes("commands"), + relativePath + .split(path.sep) + .some((segment) => forbiddenSegments.has(segment)), false, - `installed source contains a Commands component: ${relativePath}`, - ); - assert.equal( - segments.includes("hooks"), - false, - `installed source contains a Hooks component: ${relativePath}`, - ); - assert.notEqual( - path.basename(relativePath), - ".mcp.json", - `installed source contains an MCP component: ${relativePath}`, + `unexpected portable Skill path: ${relativePath}`, ); - if (segments.includes("agents")) { - assert.notEqual( - path.extname(relativePath), - ".md", - `installed source contains an Agent component: ${relativePath}`, - ); - } - } - for (const component of [ - "agents", - "commands", - "hooks", - "lspServers", - "mcpServers", - ]) { - assert(!Object.hasOwn(marketplace.plugins[0], component)); } - const installedSourceBytes = ( - await Promise.all( - installedSourceFiles.map(async (relativePath) => - (await stat(path.join(pluginSkillDirectory, relativePath))).size, - ), - ) - ).reduce((total, size) => total + size, 0); - assert.equal(installedSourceFiles.length, 8); - assert.equal(installedSourceBytes, 207_433); - const readme = await readFile(path.join(repository, "README.md"), "utf8"); - assert.match(readme, /eight canonical Skill files/); - assert.match( - readme, - /product-specific plugin packaging now points to and reuses this\s+canonical Skill; it does not fork the instructions/, - ); - assert.match( - readme, - /observed isolated installed\s+plugin cache contained no second copy of the Skill instructions, repository test harness, or runtime dependency\s+tree[\s\S]*?marketplace tree is a separate footprint[\s\S]*?did not inventory an isolated marketplace tree[\s\S]*?no\s+Git-hosted marketplace tree has been observed/, - ); - assert.match( - readme, - /validate the marketplace manifest and root preview manifest without installing either one/, - ); - assert(readme.includes("evidence/2026-08-04-claude-code-plugin-install-smoke.md")); - assert( - readme.includes( - "evidence/claude-code-plugin-install-observation.json", - ), - ); - assert.match( - readme, - /preview-only `0\.1\.0` version[\s\S]*?direct strict manifest validation[\s\S]*?excluded from the marketplace\s+plugin source/, + const packageSources = trackedFiles().filter((file) => + file.startsWith(path.join(repository, "packages", "claude-plugin")), ); - assert( - readme.includes( - "https://code.claude.com/docs/en/plugin-marketplaces#version-resolution-and-release-channels", - ), - ); - assert.match( - readme, - /Git-hosted marketplace falls back to the commit SHA[\s\S]*?documented\s+expectation here, not observed Git-hosted installation evidence/, - ); - assert( - readme.includes( - "https://code.claude.com/docs/en/plugin-marketplaces#strict-mode", - ), - ); - assert( - readme.includes( - "https://code.claude.com/docs/en/plugins-reference#unrecognized-fields", - ), - ); - assert.match( - readme, - /marketplace entry's `"strict": false` selects the marketplace entry as the entire plugin definition[\s\S]*?raw source directory without its own `plugin\.json`[\s\S]*?source manifest that also declares\s+components would conflict[\s\S]*?does not relax validation[\s\S]*?`claude plugin validate --strict` treats validation warnings, including unrecognized fields, as errors for CI[\s\S]*?Both\s+strict validation commands above remain release gates/, - ); - assert(readme.includes("https://code.claude.com/docs/en/plugins")); - assert(readme.includes("https://code.claude.com/docs/en/plugins-reference")); - assert(readme.includes("https://code.claude.com/docs/en/env-vars")); - assert( - readme.includes( - "CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 claude plugin marketplace add firstdraft/skills", - ), - ); - assert.match( - readme, - /`CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1` for cloning GitHub `owner\/repo` shorthand over HTTPS rather than SSH[\s\S]*?users with working GitHub SSH configuration may\s+omit it[\s\S]*?No live GitHub clone has been observed for this package/, - ); - assert.match( - readme, - /root preview manifest uses the distinct name `firstdraft-preview`[\s\S]*?cannot collide with an\s+installed `firstdraft@firstdraft-skills`[\s\S]*?documented local-development path[\s\S]*?without registering a\s+marketplace or installing the plugin[\s\S]*?claude --plugin-dir \.[\s\S]*?Home Inventory evaluation observed a headless Claude Code 2\.1\.221 session load the\s+`Skill\(firstdraft-preview:create-full-stack-app\)` identifier[\s\S]*?explicit `--plugin-dir ` without marketplace\s+registration or installation[\s\S]*?did not exercise the interactive command[\s\S]*?Movie Catalog process[\s\S]*?retained evidence does not record Skill discovery or\s+invocation[\s\S]*?Home run observed the headless\s+tool identifier[\s\S]*?Interactive `\/firstdraft-preview:create-full-stack-app` invocation[\s\S]*?remain unobserved/, - ); - assert.match( - readme, - /whole checkout is the preview plugin root[\s\S]*?default component location documented for Claude Code 2\.1\.221[\s\S]*?exactly one canonical subtree beneath `skills\/`[\s\S]*?enumerated checkout-root component locations[\s\S]*?complete on-disk `skills\/` subtree[\s\S]*?including untracked entries there[\s\S]*?without claiming a scan of every working-tree/, - ); - assert.match( - readme, - /recording command regenerates only the machine-readable JSON[\s\S]*?UTC date and observed Claude Code version[\s\S]*?rename the dated Markdown evidence file[\s\S]*?state-presence bullets, and real-state monitor\s+summary line[\s\S]*?dated default-component-location recheck[\s\S]*?model-session conclusion against any newer evaluation\s+evidence[\s\S]*?update the evidence path and the expected date\/version pins[\s\S]*?rerun the\s+repository checks and both strict validations[\s\S]*?Recheck the checkout-root default-component allowlist/, - ); - assert.match( - readme, - /`~\/\.claude\.json` exclusion and default real-state targets require `CLAUDE_CONFIG_DIR` and\s+`CLAUDE_CODE_PLUGIN_CACHE_DIR` to be unset[\s\S]*?fail before resolving Claude,\s+inspecting real Claude state, or creating temporary state[\s\S]*?names only\s+the override variables, never their values/, - ); - assert.match( - readme, - /expected live component inventory is deliberately hardcoded[\s\S]*?assertion in `script\/check-claude-plugin-install\.mjs`[\s\S]*?repository-test pins[\s\S]*?generated\s+observation[\s\S]*?dated prose together[\s\S]*?Rerunning the recording command alone cannot renew that expectation/, - ); - assert.match( - readme, - /`agents\/openai\.yaml` file is Skill metadata, not a Claude Code Agent definition[\s\S]*?observed `Agents=0` result[\s\S]*?rechecked whenever the CLI version changes/, - ); - assert.match( - readme, - /Every child command runs from a newly created isolated working directory[\s\S]*?requires\s+`CLAUDE_BIN` or the parent PATH to resolve to a regular, executable native Claude Code binary and rejects shebang\s+wrappers/, - ); - assert.match( - readme, - /excludes the high-churn `~\/\.claude\.json`[\s\S]*?no whole-configuration monitoring claim/, - ); - assert.match( - readme, - /refuses to make an unchanged-state claim if any monitored target or nested entry is a symbolic link/, - ); - assert.match( - readme, - /Close every other Claude Code session before running the smoke[\s\S]*?share plugin registries and\s+caches[\s\S]*?concurrent legitimate update[\s\S]*?make this check fail/, - ); - assert.match( - readme, - /diagnostic names every changed monitor together with its resolved absolute filesystem path/, - ); - assert.match( - readme, - /Claude Code 2\.1\.221 did not create `plugins\/marketplaces\/firstdraft-skills`[\s\S]*?`plugins\/data\/firstdraft-firstdraft-skills`[\s\S]*?`targetMarketplace` and\s+`targetData` are conservative candidate-path monitors[\s\S]*?not confirmed\s+current CLI storage layouts[\s\S]*?absence is not load-bearing isolation evidence/, - ); - assert.match( - readme, - /compares its live CLI version, captured strict-validation results, component\s+inventory, and installed bytes with the committed observation[\s\S]*?Real-state presence is run-local information rather\s+than a cross-machine release-gate value[\s\S]*?independently requires at least one core registry target and\s+proves the monitored targets unchanged/, - ); - assert.match( - readme, - /Repository checks require `git` on `PATH` and a real Git checkout with its index and working tree available[\s\S]*?source\s+archive, exported tree, or installed plugin cache is insufficient[\s\S]*?Git\s+index for the enumerated checkout-root component locations[\s\S]*?complete `skills\/`\s+subtree on disk/, - ); - const observationSource = await readFile(claudePluginObservation, "utf8"); - const observation = JSON.parse(observationSource); - assert.equal(observation.schemaVersion, 3); assert.equal( - observation.observedOn, - "2026-08-04", - "observation date changed; rerun the isolated recording, rename and " + - "refresh the dated evidence, then update this reviewed pin", - ); - assert.equal( - observation.claudeCode.version, - "2.1.221", - "Claude Code version changed; rerun the isolated recording, review " + - "component discovery and isolation, refresh the dated evidence, then " + - "update this pin", - ); - assert.deepEqual(observation.claudeCode.componentInventory, { - agents: 0, - hooks: 0, - lspServers: 0, - mcpServers: 0, - skillsAndCommands: 1, - }); - assert.deepEqual( - observation.manifestValidation.marketplace.normalizedArgv, - ["", "plugin", "validate", "--strict", ""], - ); - assert.deepEqual( - observation.manifestValidation.previewPlugin.normalizedArgv, - [ - "", - "plugin", - "validate", - "--strict", - "/.claude-plugin/plugin.json", - ], - ); - assert.deepEqual( - Object.fromEntries( - Object.entries(observation.manifestValidation).map(([name, value]) => [ - name, - { passed: value.passed, path: value.path, strict: value.strict }, - ]), - ), - { - marketplace: { passed: true, path: ".", strict: true }, - previewPlugin: { - passed: true, - path: ".claude-plugin/plugin.json", - strict: true, - }, - }, - ); - for (const validation of Object.values(observation.manifestValidation)) { - assert.match(validation.capturedOutput, /Validation passed$/); - assert(!validation.capturedOutput.includes(repository)); - } - assert.equal(observation.installedPlugin.marketplace, claudeMarketplaceName); - assert.equal(observation.installedPlugin.name, claudePluginName); - assert.equal(observation.installedPlugin.commandsDeclared, false); - assert.equal(observation.installedPlugin.fileCount, 8); - assert.equal(observation.installedPlugin.totalBytes, 207_433); - assert.deepEqual( - observation.installedPlugin.files.map((file) => file.path), - canonicalClaudePluginSkillFiles, - ); - const currentFileInventory = observedFileInventory( - pluginSkillDirectory, - canonicalClaudePluginSkillFiles, - ); - assert.deepEqual( - currentFileInventory, - observation.installedPlugin.files, - "canonical Skill bytes differ from the observed isolated install; " + - "rerun `npm run record:claude-plugin-install` to regenerate evidence", - ); - assert.equal( - observedFileBytes(observation.installedPlugin.files), - observation.installedPlugin.totalBytes, - ); - assert.equal( - observedFileTreeSha256(observation.installedPlugin.files), - observation.installedPlugin.treeSha256, - ); - assert.match(observation.installedPlugin.treeSha256, /^[0-9a-f]{64}$/); - for (const file of observation.installedPlugin.files) { - assert.match(file.sha256, /^[0-9a-f]{64}$/, file.path); - } - assert.deepEqual(observation.checks, { - childWorkingDirectory: "isolated", - packageManagerInvocation: "absent", - realStateUnchanged: true, - temporaryStateRemoved: true, - }); - assert.deepEqual(observation.realStateMonitor.requiredRegistryAnyOf, [ - "installedPlugins", - "knownMarketplaces", - "pluginCatalog", - ]); - assert.deepEqual(observation.realStateMonitor.excluded, ["~/.claude.json"]); - assert( - observation.realStateMonitor.requiredRegistryAnyOf.some((name) => - observation.realStateMonitor.present.includes(name), - ), - "observed real-state monitoring is vacuous", - ); - assert.deepEqual( - [ - ...observation.realStateMonitor.present, - ...observation.realStateMonitor.absent, - ].sort(), - [ - "credentials", - "installedPlugins", - "knownMarketplaces", - "pluginCatalog", - "settings", - "settingsLocal", - "targetCache", - "targetData", - "targetMarketplace", - ], - ); - assertNoObservationAbsolutePathLeaks(observation); - - const evidence = await readFile(claudePluginEvidence, "utf8"); - assertNoObservationAbsolutePathLeaks({ evidenceMarkdown: evidence }); - assert(evidence.includes(`Claude Code ${observation.claudeCode.version}`)); - assert(evidence.includes(`# Claude Code plugin install smoke — ${observation.observedOn}`)); - assert( - evidence.includes( - `${observation.installedPlugin.fileCount} canonical Skill files, ${observation.installedPlugin.totalBytes} bytes`, - ), - ); - assert( - evidence.includes( - "live inventory Skills=1, Agents=0, Hooks=0, MCP servers=0, LSP servers=0", - ), - ); - assert(evidence.includes("derived Commands=absent")); - assert(evidence.includes("CLI combines Skills/Commands")); - assert(!evidence.includes("Commands=0")); - assert(evidence.includes("no PATH-level package manager invocation")); - const documentedPresenceSummary = - `real-state monitor present=${renderStatePresenceNames(observation.realStateMonitor.present)}, ` + - `absent=${renderStatePresenceNames(observation.realStateMonitor.absent)}, ` + - `excluded=${renderStatePresenceNames(observation.realStateMonitor.excluded)}`; - assert( - evidence.includes(documentedPresenceSummary), - `packaging evidence must contain the canonical real-state summary: ${documentedPresenceSummary}`, - ); - const documentedStatePresenceBlock = [ - `- Present: ${renderEvidenceStateNames(observation.realStateMonitor.present)}`, - `- Absent: ${renderEvidenceStateNames(observation.realStateMonitor.absent)}`, - `- Excluded: ${renderEvidenceStateNames(observation.realStateMonitor.excluded)}`, - ].join("\n"); - assertEvidenceStatePresenceBlock( - evidence, - documentedStatePresenceBlock, - ); - const evidenceWithAppendedStateBullet = evidence.replace( - `${documentedStatePresenceBlock}\n\nAt least one`, - `${documentedStatePresenceBlock}\n- Unexpected: \`notObserved\`\n\nAt least one`, - ); - assert.notEqual(evidenceWithAppendedStateBullet, evidence); - assert.throws( - () => - assertEvidenceStatePresenceBlock( - evidenceWithAppendedStateBullet, - documentedStatePresenceBlock, - ), - /state-presence bullets differ from the observation/, - ); - for (const [label, validation] of [ - ["marketplace", observation.manifestValidation.marketplace], - ["preview plugin", observation.manifestValidation.previewPlugin], - ]) { - const renderedValidation = renderManifestValidationEvidence( - label, - validation, - ); - assert( - evidence.includes(renderedValidation), - `packaging evidence must render the observed ${label} validation:\n${renderedValidation}`, - ); - } - assert.match( - evidence, - /rendered from\s+the machine-readable observation's repository-relative target,\s+normalized child argv, and captured normalized\s+output[\s\S]*?normalized evidence fields, not verbatim shell commands\s+or npm-wrapper output/, - ); - assert.match( - evidence, - /marketplace manifest and root preview manifest both passed strict validation/, - ); - assert.equal( - [...evidence.matchAll(/^✔ Validation passed$/gm)].length, - 2, - "packaging evidence must contain one captured success line per strict validation", - ); - assert.match( - evidence, - /Real-state presence remains run-local information and is not compared across machines[\s\S]*?every\s+run still requires a core registry target and proves monitored state unchanged/, - ); - assert(evidence.includes("The exact recording command was:")); - assert(evidence.includes("$ npm run record:claude-plugin-install")); - assert(evidence.includes("local source-only packaging check")); - assert(evidence.includes("explicit isolated values only")); - assert(evidence.includes("without traversing symlinks")); - assert.match(evidence, /all\s+monitored real-state entries were unchanged/); - assert.match( - evidence, - /excludes the high-churn `~\/\.claude\.json`[\s\S]*?real-state claim is limited/, - ); - assert.match( - evidence, - /`agents\/openai\.yaml` file is Skill metadata[\s\S]*?`Agents=0`[\s\S]*?rerun whenever Claude Code\s+is upgraded/, - ); - assert.match( - evidence, - /During this 2\.1\.221 evidence renewal[\s\S]*?current official plugin reference[\s\S]*?canonical `skills\/` discovery plus the same twelve[\s\S]*?root\s+`SKILL\.md`[\s\S]*?`commands\/`[\s\S]*?`agents\/`[\s\S]*?`workflows\/`[\s\S]*?`output-styles\/`[\s\S]*?`themes\/`[\s\S]*?`hooks\/`[\s\S]*?`\.mcp\.json`[\s\S]*?`\.lsp\.json`[\s\S]*?`monitors\/`[\s\S]*?`bin\/`[\s\S]*?`settings\.json`[\s\S]*?installation observation itself does not establish that documentation-wide\s+completeness claim/, - ); - assert.match(evidence, /temporary directory\s+was removed/); - assert.match( - evidence, - /None of the monitored real Claude registry, catalog, target-cache, settings, or\s+credential targets changed[\s\S]*?two conservative candidate paths also remained\s+absent/, - ); - assert.match( - evidence, - /Claude Code 2\.1\.221 did not create\s+`\/plugins\/marketplaces\/firstdraft-skills`[\s\S]*?`\/plugins\/data\/firstdraft-firstdraft-skills`[\s\S]*?`targetMarketplace` and `targetData` are conservative\s+candidate-path monitors[\s\S]*?not\s+confirmed current CLI storage layouts[\s\S]*?absence is not load-bearing\s+isolation evidence/, - ); - assert.doesNotMatch( - evidence, - /No marketplace or plugin was added to the operator's real Claude configuration/, - ); - assert.match(evidence, /did not\s+publish or release the plugin/); - assert.doesNotMatch(evidence, /\b[0-9a-f]{40}\b/); - const documentedFileRows = [ - ...evidence.matchAll(/^\| `([^`]+)` \| ([\d,]+) \|$/gm), - ].map((match) => [ - match[1], - Number.parseInt(match[2].replaceAll(",", ""), 10), - ]); - assert.deepEqual( - documentedFileRows, - observation.installedPlugin.files.map((file) => [file.path, file.bytes]), - ); - const documentedTotal = evidence.match( - /^\| \*\*Total\*\* \| \*\*([\d,]+)\*\* \|$/m, - ); - assert(documentedTotal, "packaging evidence omits the byte total"); - assert.equal( - Number.parseInt(documentedTotal[1].replaceAll(",", ""), 10), - observation.installedPlugin.totalBytes, + packageSources.some((file) => path.basename(file) === "SKILL.md"), + false, + "the installable package must not commit a second editable Skill copy", ); - const smokeScript = path.join( - repository, - "script", - "check-claude-plugin-install.mjs", - ); - assert((await stat(smokeScript)).isFile()); - const syntaxCheck = spawnSync(process.execPath, ["--check", smokeScript], { - cwd: repository, - encoding: "utf8", - }); - assert.equal(syntaxCheck.status, 0, syntaxCheck.stderr); - const smokeSource = await readFile(smokeScript, "utf8"); - const defaultStateLocationPrecondition = smokeSource.indexOf( - "assertDefaultClaudeStateLocations(process.env)", - ); - const claudeResolution = smokeSource.indexOf( - 'const claude = resolveNativeExecutable(process.env.CLAUDE_BIN ?? "claude")', - ); - const realStateSnapshot = smokeSource.indexOf( - "const realStateBefore = realClaudeStateSnapshot()", - ); - assert( - defaultStateLocationPrecondition >= 0 && - defaultStateLocationPrecondition < claudeResolution && - claudeResolution < realStateSnapshot, - "default real-state location precondition must run before Claude resolution and state inspection", - ); - assert.doesNotMatch( - smokeSource, - /process\.env\.(?:CLAUDE_CONFIG_DIR|CLAUDE_CODE_PLUGIN_CACHE_DIR)\s*\?\?/, - ); - const versionRead = smokeSource.indexOf( - 'runPluginCommand(claude, ["--version"], commandOptions)', - ); - const marketplaceValidation = smokeSource.indexOf( - "const marketplaceValidationArguments = [", - ); - const marketplaceValidationEvidence = smokeSource.indexOf( - "const marketplaceValidation = observedManifestValidation({", - ); - const previewValidationEvidence = smokeSource.indexOf( - "const previewValidation = observedManifestValidation({", - ); - const marketplaceAdd = smokeSource.indexOf( - '["plugin", "marketplace", "add", repository, "--scope", "user"]', - ); - const marketplaceGuard = smokeSource.indexOf( - 'assertRealStateUnchanged("after isolated marketplace add")', - ); - const isolatedMarketplaceTreeCheck = smokeSource.indexOf( - '"isolated directory marketplace unexpectedly created a persistent tree"', - ); + const packageReadme = await readFile(path.join(repository, "README.md"), "utf8"); assert.match( - smokeSource, - /pathEntryExists\(isolatedMarketplaceTree\)[\s\S]*?pathEntryExists\(isolatedPluginData\)/, - ); - const pluginInstall = smokeSource.indexOf( - '["plugin", "install", `${pluginName}@${marketplaceName}`, "--scope", "user"]', - ); - const pluginGuard = smokeSource.indexOf( - 'assertRealStateUnchanged("after isolated plugin install")', - ); - assert( - versionRead >= 0 && - versionRead < marketplaceValidation && - marketplaceValidation < marketplaceValidationEvidence && - marketplaceValidationEvidence < previewValidationEvidence && - previewValidationEvidence < marketplaceAdd && - marketplaceAdd < isolatedMarketplaceTreeCheck && - isolatedMarketplaceTreeCheck < marketplaceGuard && - marketplaceGuard < pluginInstall && - pluginInstall < pluginGuard, - "the real-state guard must run between isolated mutations and after install", + packageReadme, + /Packing copies the canonical `skills\/create-full-stack-app` directory/, ); assert.match( - smokeSource, - /claude plugin uninstall firstdraft@firstdraft-skills --scope user[\s\S]*?claude plugin marketplace remove firstdraft-skills --scope user/, + packageReadme, + /plugin-root `bin\/` directory are added to the Bash tool's `PATH`/, ); assert.match( - smokeSource, - /resolvedStateTargetDiagnostics\(changedRealState, realStateTargets\)\.join\(", "\)/, + packageReadme, + /claude plugin marketplace add firstdraft\/skills[\s\S]*?claude plugin install firstdraft@firstdraft-skills/, ); - assert.match( - smokeSource, - /assertMatchesCommittedObservation\(observation\)[\s\S]*?reviewedPackagingObservation\(current\)[\s\S]*?reviewedPackagingObservation\(committed\)[\s\S]*?review current discovery and isolation behavior/, - ); - const packageDocument = JSON.parse( - await readFile(path.join(repository, "package.json"), "utf8"), - ); - assert.equal( - packageDocument.scripts["check:claude-plugin-install"], - "node script/check-claude-plugin-install.mjs", - ); - assert.equal( - packageDocument.scripts["record:claude-plugin-install"], - "node script/check-claude-plugin-install.mjs --observation-output " + - "evidence/claude-code-plugin-install-observation.json", + const vendoredSmoke = await readFile( + path.join(repository, "evidence", "2026-08-05-claude-plugin-vendored-cli-smoke.md"), + "utf8", ); + assert.match(vendoredSmoke, /Claude Code 2\.1\.222/); + assert.match(vendoredSmoke, /printing exactly `0\.1\.0-alpha\.2`/); + assert.match(vendoredSmoke, /did not materialize its dependency/); + assert.doesNotMatch(vendoredSmoke, /(?:\/Users\/|\/home\/|[A-Za-z]:\\)/); }); test("repository inventory traverses .git directories and rejects unsafe .git entries", async () => { @@ -1265,6 +705,10 @@ test("CI checks the exact modular CLI contract", async () => { path.join(repository, ".github", "workflows", "ci.yml"), "utf8", ); + const publishWorkflow = await readFile( + path.join(repository, ".github", "workflows", "publish.yml"), + "utf8", + ); const contractCheck = await readFile( path.join(repository, "script", "check-cli-contract.mjs"), "utf8", @@ -1279,6 +723,18 @@ test("CI checks the exact modular CLI contract", async () => { `repository: firstdraft/cli\\s+ref: main\\s+fetch-depth: 0`, ), ); + assert.equal( + [...publishWorkflow.matchAll(/[0-9a-f]{40}/g)].filter( + ([revision]) => revision === cliContractBaseline, + ).length, + 4, + ); + assert.doesNotMatch( + publishWorkflow + .replace(/^.*uses:\s+\S+@[0-9a-f]{40}.*$/gm, "") + .replaceAll(cliContractBaseline, ""), + /\b[0-9a-f]{40}\b/, + ); assert.match( workflow, new RegExp( @@ -1293,6 +749,10 @@ test("CI checks the exact modular CLI contract", async () => { workflow, /node script\/check-cli-contract\.mjs tmp\/firstdraft-cli/, ); + assert.match( + workflow, + /node script\/check-claude-plugin-package\.mjs --cli-root tmp\/firstdraft-cli/, + ); assert(contractConfig.includes(cliContractBaseline)); assert(contractConfig.includes(cliContractRuntimeDigest)); assert.match(contractConfig, /src\/commands\/compilation\.js/); @@ -3812,18 +3272,19 @@ function assertRevisionTokens(source, expected) { assert.deepEqual(revisionTokens(source), [...expected].sort()); } -async function pluginRuntimeDigest() { - const files = trackedFiles().filter((file) => { - const relativePath = path.relative(repository, file); - return ( +function pluginRuntimeDigestAtRevision(revision) { + const relativePaths = gitTreePathsAtRevision( + revision, + ".claude-plugin", + "skills/create-full-stack-app", + ).filter( + (relativePath) => /^\.claude-plugin\/[^/]+\.json$/.test(relativePath) || - relativePath.startsWith("skills/create-full-stack-app/") - ); - }); + relativePath.startsWith("skills/create-full-stack-app/"), + ); const digest = createHash("sha256"); - for (const file of files) { - const relativePath = path.relative(repository, file); - const source = await readFile(file); + for (const relativePath of relativePaths) { + const source = gitBlobAtRevision(revision, relativePath); const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(Buffer.byteLength(relativePath)); const sourceLength = Buffer.alloc(8); @@ -3836,6 +3297,48 @@ async function pluginRuntimeDigest() { return digest.digest("hex"); } +function gitBlobAtRevision(revision, relativePath) { + const blob = spawnSync("git", ["show", `${revision}:${relativePath}`], { + cwd: repository, + encoding: "buffer", + maxBuffer: 10 * 1024 * 1024, + }); + assert.equal( + blob.status, + 0, + `git show failed for ${revision}:${relativePath}: ` + + spawnBufferText(blob.stderr), + ); + return blob.stdout; +} + +function gitTreePathsAtRevision(revision, ...roots) { + const tree = spawnSync( + "git", + [ + "ls-tree", + "-r", + "--name-only", + "-z", + revision, + "--", + ...roots, + ], + { cwd: repository, encoding: "buffer" }, + ); + assert.equal( + tree.status, + 0, + `git ls-tree failed for ${revision}: ${spawnBufferText(tree.stderr)}`, + ); + const relativePaths = spawnBufferText(tree.stdout) + .split("\0") + .filter(Boolean) + .sort(); + assert(relativePaths.length > 0, `no Git tree paths found for ${revision}`); + return relativePaths; +} + function trackedFiles() { const result = spawnSync("git", ["ls-files", "-z"], { cwd: repository,