diff --git a/CHANGELOG.md b/CHANGELOG.md index 119f8c5..889f604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ schemas, and the set of gate and audit rule names — renaming a rule or making an advisory into an error is a breaking change, because someone's CI depends on the exit code. +## 0.2.0 — 2026-08-31 + +Pi support. Nothing changes for the harnesses already supported: the skill +payload the installer copies is byte-identical to 0.1.0. + +### Added + +- **Native Pi package.** `pi install npm:seodraft` registers the skill from a + `pi` manifest in `package.json`. Pi loads it in place, so nothing is copied + into your repo and `pi update` keeps it current. Install without a version: + a pinned spec is deliberately skipped by `pi update --extensions`. +- **`/seodraft ` prompt template** (`prompts/seodraft.md`), so Pi + keeps the same invocation as every other harness instead of only + `/skill:seodraft`. It uses `$1` and `${@:2}` and nothing else — released Pi + builds do not substitute bash-style defaults like `${1:-status}` and leak the + raw token into the prompt. +- **`pi` installer provider** (`.pi/skills/`), for parity with + `--providers=claude,codex,cursor,gemini,opencode`. Use the package **or** + this, not both: Pi reports a name collision and loads whichever it finds + first. A `.agents/skills/` install from Codex is already visible to Pi. + +### Guarantees + +- A test asserts every provider receives an identical payload, and that + `prompts/` — a package-root resource — never leaks into the copied skill. +- The tarball smoke test now fails if any `pi` manifest root is missing from + the published `files`, which would make Pi load an empty package. +- 81 tests across 10 suites. + ## 0.1.0 — 2026-08-25 First release. Everything below is new. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a200cd1..e3614a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,10 +9,12 @@ two or three constraints that are easy to break by accident. git clone https://github.com/ch0rch/seodraft.git cd seodraft pnpm install # vitest is the only dependency, and it is dev-only -pnpm test # 75 tests, 9 suites, runs in under a second +pnpm test # 81 tests, 10 suites, runs in under a second ``` There is no build step. `skill/` is the product; the installer copies it. +`prompts/` holds Pi prompt templates, declared through the `pi` manifest in +`package.json` and loaded in place — never copied into a harness. ## The one hard rule diff --git a/README.md b/README.md index c11b200..123a87b 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ article, forever. Everything that needs taste stays with you and your agent. - Node.js ≥ 18 - A Markdown/MDX content repo — Astro, Next.js, Hugo and Jekyll are detected automatically, and Markdoc/Keystatic (`.mdoc`) works too -- One of: Claude Code, Codex, Cursor, Gemini CLI, OpenCode +- One of: Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Pi ## Install @@ -44,10 +44,10 @@ npx seodraft@latest install ``` `install` detects which harness folders exist (`.claude`, `.agents`, `.cursor`, -`.gemini`, `.opencode`), asks you to confirm, and copies the skill in. Pass -`--providers=claude,cursor` to skip the prompt, or `--scope=global` to install -into `~` instead of the project. `update` re-copies over an existing install — -run it after bumping the package. +`.gemini`, `.opencode`, `.pi`), asks you to confirm, and copies the skill in. +Pass `--providers=claude,cursor` to skip the prompt, or `--scope=global` to +install into `~` instead of the project. `update` re-copies over an existing +install — run it after bumping the package. Installing globally works too, and gives you the CLI on `PATH` for CI use: @@ -65,6 +65,25 @@ node /path/to/seodraft/bin/seodraft.mjs install # from your content repo Then open your agent in your content repo and run `/seodraft init`. +### Pi + +Pi loads seodraft as a native package, in place — nothing is copied into your +repo and `pi update` keeps it current: + +```bash +pi install npm:seodraft +``` + +Install it **without** a version: a pinned spec (`npm:seodraft@0.2.0`) is +deliberately skipped by `pi update --extensions`. The package ships the skill +plus a `/seodraft ` prompt template; `/skill:seodraft` works too. + +Pick one route per machine — the package **or** +`seodraft install --providers=pi`. Both at once makes pi report a `seodraft` +name collision and load only the first one it finds. And if you already +installed for Codex, pi picks the skill up for free: it reads `.agents/skills/` +as well. + ## Commands Inside your agent: @@ -193,12 +212,15 @@ it mechanically. ```bash pnpm install -pnpm test # 75 tests, 9 suites +pnpm test # 81 tests, 10 suites pnpm check:deps # the dependency guard pnpm smoke # packs the tarball, installs it, runs it ``` There is no build step: `skill/` is the product and the installer copies it. +`prompts/` is Pi-only: it is loaded straight out of the package and is never +part of the copied skill. + `fixtures/astro-blog/` is a content repo seeded with a known set of defects that the gate and audit tests assert against exactly — it is a test fixture, not sample data. diff --git a/package.json b/package.json index b398595..a6dbf43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seodraft", - "version": "0.1.0", + "version": "0.2.0", "description": "Local-first SEO/AEO content engine: installs an agent skill + deterministic scripts into your AI coding harness.", "keywords": [ "seo", @@ -10,7 +10,8 @@ "mdx", "agent-skill", "claude-code", - "cli" + "cli", + "pi-package" ], "homepage": "https://github.com/ch0rch/seodraft#readme", "bugs": "https://github.com/ch0rch/seodraft/issues", @@ -27,10 +28,19 @@ "bin": { "seodraft": "bin/seodraft.mjs" }, + "pi": { + "skills": [ + "./skill" + ], + "prompts": [ + "./prompts" + ] + }, "files": [ "bin", "src", "skill", + "prompts", "!**/*.test.mjs" ], "engines": { diff --git a/prompts/seodraft.md b/prompts/seodraft.md new file mode 100644 index 0000000..c9ed6b1 --- /dev/null +++ b/prompts/seodraft.md @@ -0,0 +1,19 @@ +--- +description: Run a seodraft command — init, keywords, plan, write, audit or status +argument-hint: " [notes]" +--- + +Run the seodraft `$1` command. If that command name came through empty, run +`status` instead. + +1. Read the `seodraft` skill's `SKILL.md` — its location is listed in + ``. +2. Read the reference doc its command router maps this command to, before + executing anything. +3. Follow that procedure exactly. Run the deterministic scripts from this + repo's root, resolving their paths against the skill directory. + +The skill's invariants are absolute: validate state after every write, never +publish past the gate, never fabricate sources. + +Extra instructions for this run: ${@:2} diff --git a/scripts/smoke-tarball.mjs b/scripts/smoke-tarball.mjs index 746e96b..598ad97 100755 --- a/scripts/smoke-tarball.mjs +++ b/scripts/smoke-tarball.mjs @@ -55,6 +55,20 @@ try { [...shipped].every((entry) => !entry.endsWith(".test.mjs")), ); + // Pi loads a package's resources straight out of the install directory, so + // a manifest root missing from `files` means `pi install npm:seodraft` + // succeeds and registers nothing at all. + const piManifest = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).pi; + for (const [type, entries] of Object.entries(piManifest)) { + for (const entry of entries) { + const root = entry.replace(/^\.\//, ""); + check( + `tarball ships the pi ${type} root ${root}`, + [...shipped].some((packedPath) => packedPath === root || packedPath.startsWith(`${root}/`)), + ); + } + } + // 2. Install the tarball into its own prefix, like npx resolving a version. const prefix = path.join(work, "prefix"); fs.mkdirSync(prefix); diff --git a/src/installer/install.test.mjs b/src/installer/install.test.mjs index 1f6a349..eca7c25 100644 --- a/src/installer/install.test.mjs +++ b/src/installer/install.test.mjs @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { detectProviders, installSkill } from "./install.mjs"; +import { PROVIDER_IDS } from "./providers.mjs"; import { copyFixture, repoRoot } from "../../tests/helpers.mjs"; let tempRoot = null; @@ -44,6 +45,28 @@ describe("installer", () => { expect(fs.readFileSync(marker, "utf8")).not.toBe("locally modified\n"); }); + it("ships an identical payload to every provider, pi included", () => { + // The skill content is the product; only the harness wrapper folder + // differs. A provider that receives a different tree is a bug. + tempRoot = copyFixture(); + const installed = installSkill({ providerIds: PROVIDER_IDS, base: tempRoot, packageRoot: repoRoot }); + expect(installed).toHaveLength(PROVIDER_IDS.length); + + const tree = (dir) => + fs + .readdirSync(dir, { recursive: true }) + .map((p) => p.split(path.sep).join("/")) + .sort(); + const reference = tree(path.join(tempRoot, ".claude", "skills", "seodraft")); + expect(reference).toContain("SKILL.md"); + // Package-root resources (pi prompt templates) are never part of the skill. + expect(reference).not.toContain("prompts"); + + for (const { provider, dest } of installed) { + expect(tree(dest), provider).toEqual(reference); + } + }); + it("detects providers by harness folder presence", () => { tempRoot = copyFixture(); fs.mkdirSync(path.join(tempRoot, ".claude"), { recursive: true }); diff --git a/src/installer/providers.mjs b/src/installer/providers.mjs index 02aa6c5..575b4b0 100644 --- a/src/installer/providers.mjs +++ b/src/installer/providers.mjs @@ -12,6 +12,7 @@ export const PROVIDERS = { cursor: { harnessDir: ".cursor", label: "Cursor" }, gemini: { harnessDir: ".gemini", label: "Gemini CLI" }, opencode: { harnessDir: ".opencode", label: "OpenCode" }, + pi: { harnessDir: ".pi", label: "Pi" }, }; export const PROVIDER_IDS = Object.keys(PROVIDERS); diff --git a/tests/pi-package.test.mjs b/tests/pi-package.test.mjs new file mode 100644 index 0000000..071beb7 --- /dev/null +++ b/tests/pi-package.test.mjs @@ -0,0 +1,86 @@ +/** + * Distribution contract for the Pi package route (`pi install npm:seodraft`). + * + * Pi loads resources declared under the `pi` key in package.json straight out + * of the installed package — nothing is copied. So the manifest, the tarball + * `files` list and the prompt-template syntax are part of the shipped product + * and are asserted here. + */ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseDocument } from "../skill/scripts/lib/frontmatter.mjs"; +import { repoRoot } from "./helpers.mjs"; +import { PRODUCT_NAME } from "../src/installer/providers.mjs"; + +const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")); + +/** + * Argument syntax pi actually substitutes: `$N`, `$@`, `$ARGUMENTS`, + * `${@:N}`, `${@:N:L}`. Bash-style defaults (`${1:-status}`) are documented + * on pi's main branch but are left verbatim in the expanded prompt by + * released builds (verified against pi 0.76.0), which leaks template syntax + * into the model's context. + */ +const SUPPORTED_TOKEN = /^\$\{@:\d+(?::\d+)?\}$/; +const TOKEN = /\$\{[^}]*\}/g; + +describe("pi manifest", () => { + it("declares the gallery keyword", () => { + expect(pkg.keywords).toContain("pi-package"); + }); + + it("declares only resource roots that exist and ship in the tarball", () => { + const roots = Object.entries(pkg.pi).filter(([, value]) => Array.isArray(value)); + expect(roots.length).toBeGreaterThan(0); + + for (const [type, entries] of roots) { + for (const entry of entries) { + const abs = path.join(repoRoot, entry); + expect(fs.existsSync(abs), `${type}: ${entry}`).toBe(true); + const top = entry.replace(/^\.\//, "").split("/")[0]; + expect(pkg.files, `${type}: ${entry}`).toContain(top); + } + } + }); + + it("points each skills entry at a directory pi resolves to exactly one skill", () => { + // Pi's rule: a directory holding SKILL.md is a skill root and is not + // recursed into. Nesting one level deeper would load nothing. + for (const entry of pkg.pi.skills) { + const skillFile = path.join(repoRoot, entry, "SKILL.md"); + expect(fs.existsSync(skillFile), entry).toBe(true); + + const { frontmatter } = parseDocument(fs.readFileSync(skillFile, "utf8")); + // Pi takes the name from frontmatter, not the parent directory, and + // registers it as `/skill:`. + expect(frontmatter.name).toBe(PRODUCT_NAME); + expect(String(frontmatter.description ?? "").length).toBeGreaterThan(0); + } + }); +}); + +describe("pi prompt templates", () => { + const dirs = pkg.pi.prompts.map((entry) => path.join(repoRoot, entry)); + const files = dirs.flatMap((dir) => + fs + .readdirSync(dir) + .filter((name) => name.endsWith(".md")) + .map((name) => path.join(dir, name)), + ); + + it("ships at least one template", () => { + expect(files.length).toBeGreaterThan(0); + }); + + it.each(files.map((file) => [path.basename(file), file]))("%s is loadable and fully substitutable", (_name, file) => { + const source = fs.readFileSync(file, "utf8"); + const { frontmatter, error } = parseDocument(source); + expect(error).toBeUndefined(); + expect(String(frontmatter.description ?? "").length).toBeGreaterThan(0); + + for (const token of source.match(TOKEN) ?? []) { + expect(SUPPORTED_TOKEN.test(token), `unsupported template token ${token}`).toBe(true); + } + }); +});