Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>` 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.
Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 28 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:

Expand All @@ -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 <command>` 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:
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand All @@ -27,10 +28,19 @@
"bin": {
"seodraft": "bin/seodraft.mjs"
},
"pi": {
"skills": [
"./skill"
],
"prompts": [
"./prompts"
]
},
"files": [
"bin",
"src",
"skill",
"prompts",
"!**/*.test.mjs"
],
"engines": {
Expand Down
19 changes: 19 additions & 0 deletions prompts/seodraft.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
description: Run a seodraft command — init, keywords, plan, write, audit or status
argument-hint: "<init|keywords|plan|write|audit|status> [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
`<available_skills>`.
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}
14 changes: 14 additions & 0 deletions scripts/smoke-tarball.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions src/installer/install.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand Down
1 change: 1 addition & 0 deletions src/installer/providers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
86 changes: 86 additions & 0 deletions tests/pi-package.test.mjs
Original file line number Diff line number Diff line change
@@ -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:<name>`.
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);
}
});
});