diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 4440912..6bbe1dc 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -1,14 +1,14 @@
{
- "name": "derrickko-clify",
+ "name": "codeyogi911-clify",
"owner": {
- "name": "derrickko",
- "url": "https://github.com/derrickko"
+ "name": "codeyogi911",
+ "url": "https://github.com/codeyogi911"
},
"plugins": [
{
"name": "clify",
- "description": "Generate self-updating CLI repos from API documentation URLs.",
- "version": "0.1.0",
+ "description": "Generate A+ Node.js CLIs from API documentation. Copy a hand-crafted exemplar, mechanically substitute API-specific content, then verify with a deterministic validation gate.",
+ "version": "0.2.0",
"source": "./"
}
]
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 804e09b..7232650 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,13 +1,19 @@
{
"name": "clify",
- "version": "0.1.0",
- "description": "Generate self-updating CLI repos from API documentation URLs. Produces Node.js CLIs with agent framework wrappers, smoke tests, and a self-learning knowledge system.",
+ "version": "0.2.0",
+ "description": "Generate A+ Node.js CLIs from API documentation. Copy a hand-crafted exemplar, mechanically substitute API-specific content, then verify with a deterministic validation gate.",
"author": {
- "name": "Derrick",
- "url": "https://github.com/derrickko"
+ "name": "codeyogi911",
+ "url": "https://github.com/codeyogi911"
},
- "homepage": "https://github.com/derrickko/clify",
- "repository": "https://github.com/derrickko/clify",
+ "homepage": "https://github.com/codeyogi911/clify",
+ "repository": "https://github.com/codeyogi911/clify",
"license": "MIT",
- "keywords": ["cli-generator", "api", "openapi", "codegen", "agent"]
+ "keywords": ["cli-generator", "api", "openapi", "codegen", "agent"],
+ "skills": [
+ { "name": "clify-scaffold", "source": "skills/clify-scaffold/SKILL.md" },
+ { "name": "clify-validate", "source": "skills/clify-validate/SKILL.md" },
+ { "name": "clify-sync", "source": "skills/clify-sync/SKILL.md" }
+ ],
+ "capabilities": ["network", "codegen", "file-write"]
}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..11bd767
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,22 @@
+name: test
+on:
+ push:
+ pull_request:
+jobs:
+ clify:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ node: ["20", "22"]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ - name: clify unit tests
+ run: npm test
+ - name: validate exemplar
+ run: node bin/clify.mjs validate examples/jsonplaceholder-cli
+ - name: exemplar tests
+ working-directory: examples/jsonplaceholder-cli
+ run: npm test
diff --git a/README.md b/README.md
index e21a16f..3b9e8f0 100644
--- a/README.md
+++ b/README.md
@@ -1,258 +1,125 @@
clify
- Paste a URL. Get a CLI.
+ Paste a URL. Get a tested, A+ grade CLI.
- A Claude Code plugin that turns any API documentation into a self-updating CLI repo.
+ A Claude Code plugin that generates Node.js CLIs from API documentation by copying a hand-crafted exemplar, mechanically substituting API-specific content, and verifying with a deterministic validation gate. Inspired by google/agents-cli.
-
+
---
-```
-You: /clify https://developer.calendly.com/api-docs
-
-Claude: Fetching docs... detected REST API.
- Crawling 8 doc pages...
-
- Detected API: Calendly
- Base URL: https://api.calendly.com
- Auth: Bearer token via CALENDLY_API_KEY
+## Why
- Resources (15 endpoints):
- events: list, get, cancel
- scheduling-links: list, create
- users: get, list
- invitees: list, get, no-show
- webhooks: list, get, create, delete
- organizations: get, invite
+MCPs are heavy; CLIs are the natural fit for agents. clify produces CLIs that are:
- CLI name: calendly-cli
+- **Tested by default.** Every generated repo ships with smoke + integration tests and CI on Node 20 & 22.
+- **Verifiable.** A deterministic validation gate (`clify validate`) checks 8 categories — manifest consistency, coverage bookkeeping, structural reachability, declared nuances, secrets, CI, and tests.
+- **Reproducible.** The deterministic phases (copy + rename + validate) are pure JS — same pass/fail for any agent.
+- **Honest.** Endpoints that get dropped require an explicit reason in `coverage.json`. No silent omissions.
- Recommendations:
- 1. Include all 6 resources — small enough to ship as one CLI.
- 2. Map DELETE /scheduled_events/{uuid} → events cancel (not
- events delete) — Calendly treats this as a cancellation.
+## Architecture
- Before generating, one question:
- Docs show both OAuth and Personal Access Token auth.
- Recommendation: Personal Access Token only — simpler for
- CLI/agent use. OK?
+clify is **a binary + skills**, on the same model as `google/agents-cli`:
-You: yes, ship it
+| Layer | What it does | What runs it |
+|---|---|---|
+| **Skills** | Fetch docs, parse free-form HTML/Markdown, consult the user on tradeoffs, rewrite per-API content. | LLM (Claude/Codex) |
+| **`bin/clify.mjs`** | `clify validate ` — full validation gate. `clify scaffold-init ` — copy exemplar + rename. `clify sync-check ` — re-fetch docs, hash diff. | Pure JS, no LLM |
-Claude: Generated ./calendly-cli/ with:
- - 6 resources, 15 actions
- - CLI: bin/calendly-cli.mjs
- - Skills: skills/calendly/SKILL.md, skills/sync/SKILL.md
- - Tests: 15 passing
-
- Install as Claude Code plugin:
- claude plugin add ./calendly-cli
-```
+Anything verifiable by code goes in the binary. Anything requiring judgment stays in skills. The binary is what makes verification reproducible across agent vendors.
## Install
+In a Claude Code session:
+
```
-/plugin marketplace add derrickko/clify
-/plugin install clify@derrickko-clify
+/plugin install codeyogi911/clify
```
-Or from a local clone:
+Or clone and link:
-```bash
-git clone https://github.com/derrickko/clify.git
-```
```
-/plugin add ./clify
+git clone https://github.com/codeyogi911/clify
+cd clify
+npm install
+npm link # makes `clify` available as a CLI
```
-## What You Get
+## Use
-One command produces a complete, installable repo:
+Inside Claude Code:
```
-calendly-cli/
- ├── bin/calendly-cli.mjs ← zero-dep CLI (Node.js built-ins only)
- ├── skills/calendly/SKILL.md ← Claude Code skill with guided setup
- ├── skills/sync/SKILL.md ← detects doc changes, regenerates
- ├── knowledge/ ← patterns learned from usage
- ├── test/smoke.test.mjs ← structural tests (pass without API key)
- ├── .claude-plugin/ ← plugin registration
- ├── AGENTS.md ← Codex / OpenAI agent instructions
- ├── .clify.json ← metadata + content hash for sync
- ├── .env.example ← annotated credential template
- ├── package.json
- └── LICENSE (MIT)
+/clify-scaffold https://docs.example.com/api
```
-The generated CLI works three ways:
-
-| Mode | How | Example |
-|------|-----|---------|
-| **Standalone** | Run directly from terminal | `calendly-cli events list --status active` |
-| **Claude Code plugin** | Install and use conversationally | `"cancel all my Calendly events for next Friday"` |
-| **Codex / agent** | Reads AGENTS.md for autonomous use | Agent runs CLI commands with structured JSON output |
-
-## Features
-
-| | Feature | Details |
-|-|---------|---------|
-| **Zero deps** | Generated CLIs use only Node.js built-ins | No `node_modules`, no supply chain risk |
-| **Any format** | OpenAPI specs parsed directly; HTML/Markdown crawled and extracted | Structured specs preferred for accuracy |
-| **Self-update** | `/sync` re-crawls docs, diffs content hashes, regenerates on change | Knowledge files preserved across syncs |
-| **Guided setup** | Generated skill walks through auth + defaults on first use | Auto-detects pervasive parameters (e.g., `workspace_id`) |
-| **Knowledge system** | Learns gotchas, patterns, and shortcuts as you use the CLI | Agents consult knowledge before every command |
-| **Smoke tests** | Every generated repo ships with structural tests | Tests pass with no API key — validates CLI shape, not API responses |
-| **Agent-native** | Structured JSON output, error taxonomy, three-level `--help` | Agents discover flags at runtime via `--help` |
-| **Consulted generation** | Shows parsed endpoints and recommendations before writing code | You approve, override, or refine before anything is generated |
+The skill walks the 13-step pipeline (fetch → parse → consult → init → substitute → validate → simplify → report) and produces `-cli/` next to the current directory.
-## Usage
+You can also call the binary verbs directly:
```
-/clify
+clify validate ./my-api-cli # check a generated repo
+clify scaffold-init demo-api --target . # copy exemplar + rename only
+clify sync-check ./my-api-cli # detect upstream doc drift
+clify --help
```
-That's it. clify fetches, parses, asks you to confirm, generates, and tests.
+## What "A+" means
-### Examples
+Every check in [`references/validation-gate.md`](references/validation-gate.md) passes:
-```bash
-# From OpenAPI specs (highest accuracy)
-/clify https://api.example.com/openapi.json
+- `package.json` ↔ `plugin.json` ↔ `marketplace.json` are consistent
+- Every endpoint has `included: true` or `dropped: true` + a reason
+- Every resource × action is reachable via `--help`
+- Hard-fail nuances (pagination, idempotency, multipart, deprecated) have artifacts
+- No real-shaped secrets in source
+- CI runs `npm test` on Node 20 + 22
+- `npm test` exits 0
-# From HTML documentation
-/clify https://developer.calendly.com/api-docs
+The exemplar at [`examples/jsonplaceholder-cli/`](examples/jsonplaceholder-cli/) is the canonical A+ implementation. Run the gate against it:
-# From Markdown docs
-/clify https://raw.githubusercontent.com/org/repo/main/docs/api.md
```
-
-### Generated CLI in Action
-
-Every generated CLI follows the same resource-action pattern:
-
-```bash
-# CRUD operations map naturally
-calendly-cli events list --status active --json
-calendly-cli webhooks create --url https://hook.example.com --events invitee.created
-calendly-cli scheduling-links create --owner-uri --max-event-count 1
-
-# Global flags work on every command
-calendly-cli invitees list --event --all # auto-paginate
-calendly-cli events get --uuid --verbose # show request/response details
-
-# Three-level help for runtime discovery
-calendly-cli --help # list all resources
-calendly-cli events --help # list actions for events
-calendly-cli events cancel --help # show flags with descriptions
-
-# Structured errors for agents
-calendly-cli events cancel --uuid bad
-# → { "type": "error", "code": "not_found", "message": "...", "retryable": false }
+node bin/clify.mjs validate examples/jsonplaceholder-cli
```
-### Self-Update
-
-When the API changes, the generated `/sync` skill keeps your CLI current:
+## Testing
```
-/sync
-→ "3 new endpoints, 1 removed, 2 modified"
-→ Regenerates CLI + skills
-→ Runs smoke tests
-→ Reviews knowledge/ for stale entries
+npm test # clify unit tests + deliberate-break tests
+npm run validate-exemplar # run the gate against the bundled exemplar
```
-Content hashes in `.clify.json` track exactly what changed. Knowledge files survive regeneration, so your learned patterns carry forward.
-
-### Knowledge System
+The clify CI workflow (`.github/workflows/test.yml`) runs both, plus the exemplar's own test suite, on Node 20 and 22.
-As agents use the generated CLI, they write down what they learn:
+## Repo layout
-```yaml
-# knowledge/upload-content-type.md
----
-type: gotcha
-command: "files upload"
-learned: 2026-04-06
-confidence: high
----
-Upload endpoint returns 422 if Content-Type header is missing.
-Always include --content-type application/octet-stream for binary uploads.
```
-
-Four knowledge types: **gotcha** (error → recovery), **pattern** (common flag combos), **shortcut** (multi-step workflows), **quirk** (API contradicts docs). The generated skill reads all knowledge files before every command, so accumulated patterns carry forward without touching code.
-
-## How It Works
-
-clify is a pure Claude Code skill with no runtime dependencies. The generation pipeline:
-
-1. **Fetch** — pulls the docs URL
-2. **Detect** — checks for OpenAPI/Swagger (parsed directly) vs HTML/Markdown (crawled up to depth 2)
-3. **Parse** — extracts endpoints, auth scheme, resource structure, pervasive parameters
-4. **Consult** — presents findings with opinionated recommendations; you confirm before generating
-5. **Generate** — writes all files following rigid conventions ([`conventions.md`](skills/clify/references/conventions.md))
-6. **Validate** — runs smoke tests, scans for leaked secrets, self-reviews generated skills
-7. **Report** — summary + install command
-
-OpenAPI specs skip crawling entirely — structured data means higher accuracy and fewer questions.
-
-
-Error taxonomy in generated CLIs
-
-Every generated CLI maps HTTP errors to a standard taxonomy that agents can act on programmatically:
-
-| Code | Retryable | When |
-|------|-----------|------|
-| `auth_missing` | No | No API key in `.env` |
-| `auth_invalid` | No | Key rejected (401) |
-| `validation_error` | No | Bad request (400, 422) |
-| `not_found` | No | Resource doesn't exist (404) |
-| `forbidden` | No | Insufficient permissions (403) |
-| `conflict` | No | State conflict (409) |
-| `rate_limited` | Yes | Too many requests (429) |
-| `server_error` | Yes | API server error (5xx) |
-| `network_error` | Yes | Connection failed |
-| `timeout` | Yes | Request exceeded timeout |
-
-Retry logic lives in the skill wrapper, not the CLI — the agent decides when and how to retry.
-
-
-
-
-Generated CLI conventions
-
-All generated CLIs follow the same contracts:
-
-- **Resource-action pattern:** ` [flags]`
-- **Standard CRUD mapping:** `list`, `get`, `create`, `update`, `delete`
-- **Non-CRUD verbs:** use the API's own terminology (`send`, `verify`, `cancel`)
-- **Nesting cap:** 2 levels max; deeper paths flatten to flags
-- **Global flags:** `--json`, `--dry-run`, `--help`, `--version`, `--verbose`, `--all`
-- **Per-action flags:** declared via `_flags` metadata — single source of truth for both parsing and help text
-- **`.env` loading:** reads from repo root only, never overrides shell env vars
-- **Pagination:** one page by default, `--all` auto-paginates
-- **`--body `:** escape hatch on every mutating endpoint
-
-See [`conventions.md`](skills/clify/references/conventions.md) for the full specification.
-
-
-
-## Contributing
-
-Contributions welcome.
-
-- [Report a bug](https://github.com/derrickko/clify/issues/new?labels=bug)
-- [Request a feature](https://github.com/derrickko/clify/issues/new?labels=enhancement)
+clify/
+├── bin/clify.mjs top-level binary (verbs)
+├── lib/
+│ ├── validate.mjs validation gate impl
+│ ├── scaffold-init.mjs file-copy + rename
+│ └── sync-check.mjs hash-diff
+├── skills/
+│ ├── clify-scaffold/SKILL.md the 13-step generation pipeline
+│ ├── clify-validate/SKILL.md wraps `clify validate`
+│ └── clify-sync/SKILL.md wraps `clify sync-check` + regeneration
+├── examples/jsonplaceholder-cli/ canonical A+ exemplar
+├── references/
+│ ├── conventions.md contracts every generated CLI honors
+│ └── validation-gate.md every check the gate enforces
+├── test/clify.test.mjs unit + deliberate-break tests
+└── .github/workflows/test.yml CI (Node 20, 22)
+```
## License
-[MIT](LICENSE)
+MIT — see [LICENSE](LICENSE).
diff --git a/bin/clify.mjs b/bin/clify.mjs
new file mode 100755
index 0000000..d5ea62d
--- /dev/null
+++ b/bin/clify.mjs
@@ -0,0 +1,146 @@
+#!/usr/bin/env node
+// clify — top-level CLI for the codegen plugin.
+// Verbs that do deterministic work: validate, scaffold-init, sync-check.
+// Verb that delegates to LLM: scaffold (informational, points to /clify-scaffold skill).
+import { readFileSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { validate } from "../lib/validate.mjs";
+import { scaffoldInit } from "../lib/scaffold-init.mjs";
+import { syncCheck } from "../lib/sync-check.mjs";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = resolve(__dirname, "..");
+const VERSION = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")).version;
+
+const HELP = `clify ${VERSION}
+Generate, validate, and sync agent-friendly CLIs from API documentation.
+
+Usage:
+ clify validate Run the validation gate (deterministic, no LLM)
+ clify scaffold-init Copy the exemplar to -cli/ and rename
+ (use --target to choose parent directory)
+ clify sync-check Re-fetch docs, hash, print diff summary
+ clify scaffold (LLM step) Use /clify-scaffold in Claude Code
+ clify --version
+ clify --help
+
+The validate, scaffold-init, and sync-check verbs are pure JS and reproducible across
+agent vendors. The scaffold verb is informational — full scaffold needs LLM judgment
+and runs as the /clify-scaffold skill in Claude Code.
+`;
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
+ process.stdout.write(HELP);
+ return;
+ }
+ if (args[0] === "--version" || args[0] === "-v") {
+ process.stdout.write(VERSION + "\n");
+ return;
+ }
+
+ const verb = args[0];
+ const rest = args.slice(1);
+
+ switch (verb) {
+ case "validate": return await runValidate(rest);
+ case "scaffold-init": return runScaffoldInit(rest);
+ case "sync-check": return await runSyncCheck(rest);
+ case "scaffold": return runScaffoldInfo(rest);
+ default:
+ process.stderr.write(`error: unknown verb '${verb}'\n\n${HELP}`);
+ process.exit(2);
+ }
+}
+
+async function runValidate(args) {
+ const dir = args.find((a) => !a.startsWith("-"));
+ const json = args.includes("--json");
+ const skipTests = args.includes("--skip-tests");
+ if (!dir) { process.stderr.write("Usage: clify validate [--json] [--skip-tests]\n"); process.exit(2); }
+ const report = await validate(dir, { skipTests });
+ if (json) {
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
+ } else {
+ process.stdout.write(formatReport(report));
+ }
+ process.exit(report.ok ? 0 : 1);
+}
+
+function formatReport(r) {
+ const lines = [];
+ lines.push(`clify validate ${r.dir}`);
+ lines.push(` ${r.summary.passed}/${r.summary.total} passed, ${r.summary.failed} failed, ${r.summary.warnings} warnings`);
+ const byCat = {};
+ for (const res of r.results) (byCat[res.category] ??= []).push(res);
+ const cats = Object.keys(byCat).sort();
+ for (const cat of cats) {
+ const items = byCat[cat];
+ const failed = items.filter((i) => !i.ok);
+ const status = failed.length === 0 ? "✓" : "✗";
+ lines.push(`\n[${status}] ${cat} (${items.length - failed.length}/${items.length})`);
+ for (const f of failed) {
+ lines.push(` FAIL ${f.name}` + (f.error ? ` — ${f.error}` : ""));
+ if (f.missing) lines.push(` missing: ${JSON.stringify(f.missing)}`);
+ if (f.failures) for (const x of f.failures) lines.push(` ${x}`);
+ if (f.issues) for (const x of f.issues) lines.push(` ${JSON.stringify(x)}`);
+ if (f.offenders) for (const x of f.offenders) lines.push(` secret '${x.pattern}' in ${x.file}`);
+ }
+ }
+ if (r.warnings.length) {
+ lines.push("\nwarnings:");
+ for (const w of r.warnings) lines.push(` ! ${w}`);
+ }
+ lines.push("");
+ return lines.join("\n");
+}
+
+function runScaffoldInit(args) {
+ const positional = args.filter((a) => !a.startsWith("-"));
+ const apiName = positional[0];
+ let target = process.cwd();
+ for (let i = 0; i < args.length; i++) if (args[i] === "--target" && args[i + 1]) target = args[++i];
+ const json = args.includes("--json");
+ if (!apiName) { process.stderr.write("Usage: clify scaffold-init [--target ] [--json]\n"); process.exit(2); }
+ try {
+ const result = scaffoldInit({ apiName, target });
+ if (json) process.stdout.write(JSON.stringify(result, null, 2) + "\n");
+ else process.stdout.write(`scaffolded ${result.apiName} → ${result.dir}\n`);
+ } catch (err) {
+ process.stderr.write(`error: ${err.message}\n`);
+ process.exit(1);
+ }
+}
+
+async function runSyncCheck(args) {
+ const dir = args.find((a) => !a.startsWith("-"));
+ const json = args.includes("--json");
+ if (!dir) { process.stderr.write("Usage: clify sync-check [--json]\n"); process.exit(2); }
+ try {
+ const result = await syncCheck(dir);
+ if (json) process.stdout.write(JSON.stringify(result, null, 2) + "\n");
+ else {
+ process.stdout.write(`api: ${result.apiName}\n`);
+ process.stdout.write(`docs: ${result.docsUrl}\n`);
+ process.stdout.write(`old: ${result.oldHash}\n`);
+ process.stdout.write(`new: ${result.newHash}\n`);
+ process.stdout.write(`changed: ${result.changed}\n`);
+ }
+ process.exit(result.changed ? 1 : 0);
+ } catch (err) {
+ process.stderr.write(`error: ${err.message}\n`);
+ process.exit(2);
+ }
+}
+
+function runScaffoldInfo(args) {
+ const url = args.find((a) => !a.startsWith("-")) || "";
+ process.stdout.write(`Full scaffolding needs LLM judgment (parsing free-form docs, deciding endpoint→action mapping).\nRun in Claude Code: /clify-scaffold ${url}\n\nThe scaffold skill calls 'clify scaffold-init' and 'clify validate' as deterministic\nsub-steps, but the parsing and substitution phases are LLM-driven.\n`);
+}
+
+main().catch((err) => {
+ process.stderr.write(`error: ${err.stack || err.message}\n`);
+ process.exit(2);
+});
diff --git a/examples/jsonplaceholder-cli/.claude-plugin/marketplace.json b/examples/jsonplaceholder-cli/.claude-plugin/marketplace.json
new file mode 100644
index 0000000..d51e4e4
--- /dev/null
+++ b/examples/jsonplaceholder-cli/.claude-plugin/marketplace.json
@@ -0,0 +1,7 @@
+{
+ "name": "jsonplaceholder-cli",
+ "description": "CLI for the JSONPlaceholder API. Generated by clify.",
+ "version": "0.1.0",
+ "author": { "name": "clify" },
+ "source": "./"
+}
diff --git a/examples/jsonplaceholder-cli/.claude-plugin/plugin.json b/examples/jsonplaceholder-cli/.claude-plugin/plugin.json
new file mode 100644
index 0000000..742596b
--- /dev/null
+++ b/examples/jsonplaceholder-cli/.claude-plugin/plugin.json
@@ -0,0 +1,12 @@
+{
+ "name": "jsonplaceholder-cli",
+ "version": "0.1.0",
+ "description": "CLI for the JSONPlaceholder API. Generated by clify.",
+ "author": { "name": "clify" },
+ "license": "MIT",
+ "skills": [
+ { "name": "jsonplaceholder", "source": "skills/jsonplaceholder/SKILL.md" },
+ { "name": "sync", "source": "skills/sync/SKILL.md" }
+ ],
+ "capabilities": ["network"]
+}
diff --git a/examples/jsonplaceholder-cli/.clify.json b/examples/jsonplaceholder-cli/.clify.json
new file mode 100644
index 0000000..8f502fa
--- /dev/null
+++ b/examples/jsonplaceholder-cli/.clify.json
@@ -0,0 +1,33 @@
+{
+ "apiName": "jsonplaceholder",
+ "docsUrl": "https://jsonplaceholder.typicode.com",
+ "crawledUrls": [
+ "https://jsonplaceholder.typicode.com",
+ "https://jsonplaceholder.typicode.com/guide/"
+ ],
+ "contentHash": "sha256:exemplar-static-not-from-live-fetch",
+ "generatedAt": "2026-04-26T00:00:00Z",
+ "clifyVersion": "0.2.0",
+ "nodeMinVersion": "20",
+ "auth": {
+ "envVar": "JSONPLACEHOLDER_API_KEY",
+ "scheme": "none",
+ "validationCommand": "posts list"
+ },
+ "defaults": [],
+ "nuances": {
+ "pagination": null,
+ "rateLimits": false,
+ "authScopes": false,
+ "deprecated": [],
+ "idempotency": [],
+ "multiPart": [],
+ "conditional": [],
+ "businessRules": 1
+ },
+ "coverage": {
+ "totalParsed": 6,
+ "totalIncluded": 6,
+ "totalDropped": 0
+ }
+}
diff --git a/examples/jsonplaceholder-cli/.env.example b/examples/jsonplaceholder-cli/.env.example
new file mode 100644
index 0000000..33ffa3a
--- /dev/null
+++ b/examples/jsonplaceholder-cli/.env.example
@@ -0,0 +1,11 @@
+# JSONPlaceholder is a public, auth-free API. No credentials required.
+# This file exists so generated CLIs all share the same .env shape.
+
+# @optional
+# JSONPLACEHOLDER_BASE_URL — override the API base URL. Used by integration tests.
+# JSONPLACEHOLDER_BASE_URL=http://127.0.0.1:3000
+
+# @optional
+# @how-to-get N/A — JSONPlaceholder is unauthenticated. Set to any non-empty value
+# to satisfy auth_missing checks if your harness expects one.
+# JSONPLACEHOLDER_API_KEY=
diff --git a/examples/jsonplaceholder-cli/.github/workflows/test.yml b/examples/jsonplaceholder-cli/.github/workflows/test.yml
new file mode 100644
index 0000000..9e61709
--- /dev/null
+++ b/examples/jsonplaceholder-cli/.github/workflows/test.yml
@@ -0,0 +1,16 @@
+name: test
+on:
+ push:
+ pull_request:
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ node: ["20", "22"]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ - run: npm test
diff --git a/examples/jsonplaceholder-cli/.gitignore b/examples/jsonplaceholder-cli/.gitignore
new file mode 100644
index 0000000..5be869c
--- /dev/null
+++ b/examples/jsonplaceholder-cli/.gitignore
@@ -0,0 +1,5 @@
+.env
+node_modules/
+.docs-snapshot/
+*.log
+.DS_Store
diff --git a/examples/jsonplaceholder-cli/AGENTS.md b/examples/jsonplaceholder-cli/AGENTS.md
new file mode 100644
index 0000000..c712812
--- /dev/null
+++ b/examples/jsonplaceholder-cli/AGENTS.md
@@ -0,0 +1,27 @@
+# Agent Instructions
+
+You are working with `jsonplaceholder-cli`, a thin CLI over the JSONPlaceholder REST API.
+
+## Before you act
+
+1. Read every file in `knowledge/` — they contain API quirks, business rules, and patterns.
+2. Use `jsonplaceholder-cli --help` and `jsonplaceholder-cli --help` to learn flags. The help output is generated from the resource registry, so it is always accurate.
+
+## Conventions
+
+- All commands follow ` [flags]`.
+- Errors are JSON-shaped on stderr with exit code 1. The `code` field tells you whether to retry (`retryable: true`) and how long to wait (`retryAfter` seconds).
+- The CLI does not retry on its own — that is your job.
+- Set `JSONPLACEHOLDER_BASE_URL` to point the CLI at a mock server during testing.
+
+## Writes do not persist
+
+JSONPlaceholder's `POST`, `PUT`, `PATCH`, and `DELETE` endpoints all return successful responses, but no data is actually written. The response includes a fabricated `id`. Do not chain `create` → `get` against JSONPlaceholder — the second call will 404. See `knowledge/writes-do-not-persist.md`.
+
+## Testing
+
+```
+npm test
+```
+
+runs both smoke tests (no network) and integration tests (against the bundled mock at `test/_mock-server.mjs`). CI runs the same on Node 20 and 22.
diff --git a/examples/jsonplaceholder-cli/LICENSE b/examples/jsonplaceholder-cli/LICENSE
new file mode 100644
index 0000000..a2e3f24
--- /dev/null
+++ b/examples/jsonplaceholder-cli/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 clify contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/examples/jsonplaceholder-cli/README.md b/examples/jsonplaceholder-cli/README.md
new file mode 100644
index 0000000..554a1a1
--- /dev/null
+++ b/examples/jsonplaceholder-cli/README.md
@@ -0,0 +1,65 @@
+# jsonplaceholder-cli
+
+A zero-dependency Node.js CLI for the [JSONPlaceholder](https://jsonplaceholder.typicode.com) REST API. Generated by [clify](https://github.com/codeyogi911/clify) — and used as the canonical exemplar that every other clify-generated CLI is copied from.
+
+## Install
+
+```
+npm install
+```
+
+(There are no dependencies — the install is a no-op.)
+
+## Use
+
+```
+node bin/jsonplaceholder-cli.mjs --help
+node bin/jsonplaceholder-cli.mjs posts list
+node bin/jsonplaceholder-cli.mjs posts get --id 1
+node bin/jsonplaceholder-cli.mjs posts create --title hi --body_text body --userId 1
+```
+
+Pipe to `jq`:
+
+```
+node bin/jsonplaceholder-cli.mjs posts list | jq '.[0:3]'
+```
+
+## Test
+
+```
+npm test
+```
+
+Runs both:
+- `test/smoke.test.mjs` — structural checks, no network.
+- `test/integration.test.mjs` — full request/response cycle against `test/_mock-server.mjs`.
+
+CI matrix: Node 20, 22 (`.github/workflows/test.yml`).
+
+## Structure
+
+| File | Role |
+|------|------|
+| `bin/jsonplaceholder-cli.mjs` | The CLI itself. Single file, all 6 resources × 5 actions. |
+| `test/_mock-server.mjs` | Zero-dep `node:http` mock used by integration tests. |
+| `test/smoke.test.mjs` | Structural smoke tests. |
+| `test/integration.test.mjs` | End-to-end against the mock. |
+| `skills/jsonplaceholder/SKILL.md` | Claude Code skill — agent-facing wrapper. |
+| `skills/sync/SKILL.md` | Skill that re-runs clify when upstream docs change. |
+| `knowledge/` | Captured API quirks and business rules. |
+| `.clify.json` | Generation metadata, auth scheme, detected nuances. |
+| `coverage.json` | Endpoint inclusion report (every `dropped: true` has a `reason`). |
+
+## What "A+" means
+
+Every check in [`references/validation-gate.md`](../../references/validation-gate.md) passes:
+manifest names match, smoke tests cover every resource × action, integration tests
+exercise CRUD plus the full error taxonomy (404/422/429/500/network), no hardcoded
+secrets, CI runs on Node 20 + 22.
+
+Run the gate yourself:
+
+```
+node ../../bin/clify.mjs validate .
+```
diff --git a/examples/jsonplaceholder-cli/bin/jsonplaceholder-cli.mjs b/examples/jsonplaceholder-cli/bin/jsonplaceholder-cli.mjs
new file mode 100755
index 0000000..8de20c2
--- /dev/null
+++ b/examples/jsonplaceholder-cli/bin/jsonplaceholder-cli.mjs
@@ -0,0 +1,332 @@
+#!/usr/bin/env node
+// jsonplaceholder-cli — Generated by clify. Edit at your own risk; `clify sync` overwrites.
+import { parseArgs } from "node:util";
+import { readFileSync, existsSync, statSync, createReadStream } from "node:fs";
+import { resolve, dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = resolve(__dirname, "..");
+
+const pkg = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8"));
+const VERSION = pkg.version;
+
+// ---------- env loader ----------
+function loadEnv() {
+ const path = join(REPO_ROOT, ".env");
+ if (!existsSync(path)) return;
+ for (const line of readFileSync(path, "utf8").split("\n")) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#")) continue;
+ const eq = trimmed.indexOf("=");
+ if (eq === -1) continue;
+ const key = trimmed.slice(0, eq).trim();
+ let val = trimmed.slice(eq + 1).trim();
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
+ val = val.slice(1, -1);
+ }
+ if (process.env[key] === undefined) process.env[key] = val;
+ }
+}
+loadEnv();
+
+const BASE_URL = (process.env.JSONPLACEHOLDER_BASE_URL || "https://jsonplaceholder.typicode.com").replace(/\/$/, "");
+
+// ---------- resource registry ----------
+function crudResource(name, idField = "id") {
+ return {
+ list: { method: "GET", path: `/${name}`, _flags: {} },
+ get: { method: "GET", path: `/${name}/:id`, _flags: { id: { type: "string", required: true, description: `${name.slice(0,-1)} id` } } },
+ create: { method: "POST", path: `/${name}`, _flags: bodyFlagsFor(name) },
+ update: { method: "PUT", path: `/${name}/:id`, _flags: { id: { type: "string", required: true, description: `${name.slice(0,-1)} id` }, ...bodyFlagsFor(name) } },
+ delete: { method: "DELETE", path: `/${name}/:id`, _flags: { id: { type: "string", required: true, description: `${name.slice(0,-1)} id` } } },
+ };
+}
+
+function bodyFlagsFor(resource) {
+ const common = { body: { type: "string", required: false, description: "Raw JSON body (overrides individual flags)" } };
+ switch (resource) {
+ case "posts": return { ...common, title: { type: "string", description: "Post title" }, body_text: { type: "string", description: "Post body (mapped to 'body')" }, userId: { type: "string", description: "Author user id" } };
+ case "comments": return { ...common, name: { type: "string", description: "Commenter name" }, email: { type: "string", description: "Commenter email" }, body_text: { type: "string", description: "Comment body (mapped to 'body')" }, postId: { type: "string", description: "Parent post id" } };
+ case "albums": return { ...common, title: { type: "string", description: "Album title" }, userId: { type: "string", description: "Owner user id" } };
+ case "photos": return { ...common, title: { type: "string", description: "Photo title" }, url: { type: "string", description: "Photo URL" }, thumbnailUrl: { type: "string", description: "Thumbnail URL" }, albumId: { type: "string", description: "Parent album id" } };
+ case "todos": return { ...common, title: { type: "string", description: "Todo title" }, completed: { type: "boolean", description: "Completed flag" }, userId: { type: "string", description: "Owner user id" } };
+ case "users": return { ...common, name: { type: "string", description: "User name" }, username: { type: "string", description: "Username" }, email: { type: "string", description: "Email" } };
+ default: return common;
+ }
+}
+
+// translate body_text → body in payloads (avoid clashing with --body raw escape hatch)
+function buildPayload(resource, values) {
+ const out = {};
+ for (const [k, v] of Object.entries(values)) {
+ if (v === undefined) continue;
+ if (k === "body") continue;
+ if (k === "id") continue;
+ if (k === "body_text") out.body = v;
+ else if (k === "userId" || k === "postId" || k === "albumId") out[k] = Number(v);
+ else out[k] = v;
+ }
+ return out;
+}
+
+const RESOURCES = {
+ posts: crudResource("posts"),
+ comments: crudResource("comments"),
+ albums: crudResource("albums"),
+ photos: crudResource("photos"),
+ todos: crudResource("todos"),
+ users: crudResource("users"),
+};
+
+// ---------- arg parsing ----------
+// --help/-h stay in `rest` so each layer can decide which help to show.
+const GLOBAL_FLAGS = new Set(["--json", "--dry-run", "--version", "-v", "--verbose", "--all"]);
+
+function splitGlobal(argv) {
+ const global = {};
+ const rest = [];
+ for (const arg of argv) {
+ if (GLOBAL_FLAGS.has(arg)) {
+ const key = arg.replace(/^--?/, "").replace(/-/g, "_");
+ global[key] = true;
+ if (arg === "-v") global.version = true;
+ if (arg === "--dry-run") global.dry_run = true;
+ } else {
+ rest.push(arg);
+ }
+ }
+ return { global, rest };
+}
+
+function hasHelp(args) {
+ return args.includes("--help") || args.includes("-h");
+}
+
+function toParseArgs(flagSpec) {
+ const options = {};
+ for (const [name, spec] of Object.entries(flagSpec)) {
+ options[name] = { type: spec.type === "boolean" ? "boolean" : "string" };
+ }
+ return options;
+}
+
+function checkRequired(values, flagSpec) {
+ const missing = [];
+ for (const [name, spec] of Object.entries(flagSpec)) {
+ if (spec.required && (values[name] === undefined || values[name] === "")) missing.push(name);
+ }
+ return missing;
+}
+
+// ---------- output ----------
+const isPiped = !process.stdout.isTTY;
+
+function output(data, jsonRequested) {
+ const wantJson = jsonRequested || isPiped;
+ if (wantJson) {
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
+ } else if (data === null || data === undefined) {
+ process.stdout.write("\n");
+ } else if (typeof data === "string") {
+ process.stdout.write(data + "\n");
+ } else if (Array.isArray(data)) {
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
+ } else {
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
+ }
+}
+
+function errorOut(code, message, opts = {}) {
+ const obj = { type: "error", code, message, retryable: !!opts.retryable };
+ if (opts.retryAfter !== undefined) obj.retryAfter = opts.retryAfter;
+ if (opts.details) obj.details = opts.details;
+ if (process.env.__JSONPLACEHOLDER_FORCE_JSON_ERR === "1" || isPiped) {
+ process.stderr.write(JSON.stringify(obj) + "\n");
+ } else {
+ process.stderr.write(`error[${code}]: ${message}\n`);
+ }
+ process.exit(1);
+}
+
+// ---------- HTTP ----------
+async function apiRequest({ method, path, query, body, headers = {}, dryRun, verbose, idempotencyKey, ifMatch, file }) {
+ const url = new URL(BASE_URL + path);
+ if (query) {
+ for (const [k, v] of Object.entries(query)) {
+ if (v !== undefined) url.searchParams.set(k, String(v));
+ }
+ }
+
+ const reqHeaders = { "user-agent": `jsonplaceholder-cli/${VERSION}`, ...headers };
+ let reqBody;
+
+ if (file) {
+ if (!existsSync(file)) errorOut("validation_error", `File not found: ${file}`);
+ const fd = new FormData();
+ const buf = readFileSync(file);
+ const blob = new Blob([buf]);
+ fd.append("file", blob, file.split("/").pop());
+ reqBody = fd;
+ } else if (body !== undefined && method !== "GET" && method !== "DELETE") {
+ reqHeaders["content-type"] = "application/json";
+ reqBody = JSON.stringify(body);
+ }
+
+ if (idempotencyKey) reqHeaders["idempotency-key"] = idempotencyKey;
+ if (ifMatch) reqHeaders["if-match"] = ifMatch;
+
+ if (dryRun) {
+ return { __dryRun: true, method, url: url.toString(), headers: reqHeaders, body: body ?? null };
+ }
+
+ let res;
+ try {
+ res = await fetch(url, { method, headers: reqHeaders, body: reqBody });
+ } catch (err) {
+ if (err.name === "AbortError" || /timeout/i.test(err.message || "")) {
+ errorOut("timeout", `Request timed out: ${err.message}`, { retryable: true });
+ }
+ errorOut("network_error", `Network error: ${err.message}`, { retryable: true });
+ }
+
+ const status = res.status;
+ const retryAfterHeader = res.headers.get("retry-after");
+ const retryAfter = retryAfterHeader ? Number(retryAfterHeader) : undefined;
+
+ let parsed;
+ const text = await res.text();
+ try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
+
+ if (verbose) {
+ const headerObj = {};
+ res.headers.forEach((v, k) => { headerObj[k] = v; });
+ process.stderr.write(JSON.stringify({ status, headers: headerObj }) + "\n");
+ }
+
+ if (status >= 200 && status < 300) return parsed;
+
+ // error mapping
+ const baseMsg = parsed && typeof parsed === "object" && parsed.message ? parsed.message : `HTTP ${status}`;
+ if (status === 401) errorOut("auth_invalid", baseMsg);
+ if (status === 403) errorOut("forbidden", baseMsg);
+ if (status === 404) errorOut("not_found", baseMsg);
+ if (status === 409) errorOut("conflict", baseMsg);
+ if (status === 400 || status === 422) errorOut("validation_error", baseMsg, { details: parsed });
+ if (status === 429) errorOut("rate_limited", `Rate limited.${retryAfter ? ` Retry after ${retryAfter}s.` : ""}`, { retryable: true, retryAfter });
+ if (status >= 500) errorOut("server_error", baseMsg, { retryable: true, retryAfter });
+ errorOut("network_error", baseMsg);
+}
+
+// ---------- help ----------
+function showRootHelp() {
+ let out = `jsonplaceholder-cli ${VERSION}\nCLI for the JSONPlaceholder API.\n\nUsage:\n jsonplaceholder-cli [flags]\n\nGlobal flags:\n --json Force JSON output\n --dry-run Print request without sending\n --verbose Print response status & headers to stderr\n --all Auto-paginate (no-op for JSONPlaceholder)\n --version, -v Print version\n --help, -h Show this help\n\nResources:\n`;
+ for (const r of Object.keys(RESOURCES).sort()) {
+ out += ` ${r.padEnd(10)} ${Object.keys(RESOURCES[r]).join(", ")}\n`;
+ }
+ out += `\nUse 'jsonplaceholder-cli --help' for actions, or ' --help' for flags.\n`;
+ return out;
+}
+
+function showResourceHelp(resource) {
+ const actions = RESOURCES[resource];
+ let out = `jsonplaceholder-cli ${resource}\n\nActions:\n`;
+ for (const [name, def] of Object.entries(actions)) {
+ out += ` ${name.padEnd(10)} ${def.method} ${def.path}\n`;
+ }
+ out += `\nUse 'jsonplaceholder-cli ${resource} --help' for flags.\n`;
+ return out;
+}
+
+function showActionHelp(resource, action) {
+ const def = RESOURCES[resource][action];
+ let out = `jsonplaceholder-cli ${resource} ${action}\n\n${def.method} ${def.path}\n\nFlags:\n`;
+ const entries = Object.entries(def._flags);
+ if (entries.length === 0) out += ` (none)\n`;
+ for (const [name, spec] of entries) {
+ const req = spec.required ? "required" : "optional";
+ const desc = spec.description || "";
+ out += ` --${name.padEnd(20)} ${spec.type.padEnd(8)} ${req.padEnd(8)} ${desc}\n`;
+ }
+ return out;
+}
+
+// ---------- main ----------
+async function main() {
+ const argv = process.argv.slice(2);
+ const { global, rest } = splitGlobal(argv);
+
+ if (global.version) { process.stdout.write(VERSION + "\n"); return; }
+
+ // No positional args, or only --help/-h → root help
+ const positional = rest.filter((a) => a !== "--help" && a !== "-h");
+ if (positional.length === 0) { process.stdout.write(showRootHelp()); return; }
+
+ const [resourceArg, actionArg, ...remaining] = positional;
+
+ if (!RESOURCES[resourceArg]) {
+ const available = Object.keys(RESOURCES).join(", ");
+ errorOut("validation_error", `Unknown resource: ${resourceArg}. Available: ${available}`);
+ }
+
+ // --help (no action provided)
+ if (!actionArg) {
+ process.stdout.write(showResourceHelp(resourceArg));
+ return;
+ }
+
+ const def = RESOURCES[resourceArg][actionArg];
+ if (!def) {
+ const available = Object.keys(RESOURCES[resourceArg]).join(", ");
+ errorOut("validation_error", `Unknown action: ${actionArg} on ${resourceArg}. Available: ${available}`);
+ }
+
+ // --help → action help
+ if (hasHelp(rest)) {
+ process.stdout.write(showActionHelp(resourceArg, actionArg));
+ return;
+ }
+
+ let parsed;
+ try {
+ parsed = parseArgs({ args: remaining, options: toParseArgs(def._flags), strict: true, allowPositionals: false });
+ } catch (err) {
+ errorOut("validation_error", err.message);
+ }
+
+ const missing = checkRequired(parsed.values, def._flags);
+ if (missing.length) errorOut("validation_error", `Missing required flag(s): ${missing.map((m) => `--${m}`).join(", ")}`);
+
+ // path interpolation
+ let path = def.path;
+ for (const m of path.matchAll(/:([a-zA-Z]+)/g)) {
+ const k = m[1];
+ const v = parsed.values[k];
+ path = path.replace(`:${k}`, encodeURIComponent(String(v)));
+ }
+
+ // body assembly
+ let body;
+ if (def.method !== "GET" && def.method !== "DELETE") {
+ if (parsed.values.body) {
+ try { body = JSON.parse(parsed.values.body); }
+ catch { errorOut("validation_error", "--body must be valid JSON"); }
+ } else {
+ body = buildPayload(resourceArg, parsed.values);
+ }
+ }
+
+ const result = await apiRequest({
+ method: def.method,
+ path,
+ body,
+ dryRun: !!global.dry_run,
+ verbose: !!global.verbose,
+ });
+
+ output(result, !!global.json);
+}
+
+main().catch((err) => {
+ errorOut("network_error", err.message || String(err));
+});
diff --git a/examples/jsonplaceholder-cli/coverage.json b/examples/jsonplaceholder-cli/coverage.json
new file mode 100644
index 0000000..f6a2921
--- /dev/null
+++ b/examples/jsonplaceholder-cli/coverage.json
@@ -0,0 +1,38 @@
+{
+ "parsedAt": "2026-04-26T00:00:00Z",
+ "totalParsed": 30,
+ "totalIncluded": 30,
+ "totalDropped": 0,
+ "endpoints": [
+ { "method": "GET", "path": "/posts", "resource": "posts", "action": "list", "included": true },
+ { "method": "GET", "path": "/posts/:id", "resource": "posts", "action": "get", "included": true },
+ { "method": "POST", "path": "/posts", "resource": "posts", "action": "create", "included": true },
+ { "method": "PUT", "path": "/posts/:id", "resource": "posts", "action": "update", "included": true },
+ { "method": "DELETE", "path": "/posts/:id", "resource": "posts", "action": "delete", "included": true },
+ { "method": "GET", "path": "/comments", "resource": "comments", "action": "list", "included": true },
+ { "method": "GET", "path": "/comments/:id", "resource": "comments", "action": "get", "included": true },
+ { "method": "POST", "path": "/comments", "resource": "comments", "action": "create", "included": true },
+ { "method": "PUT", "path": "/comments/:id", "resource": "comments", "action": "update", "included": true },
+ { "method": "DELETE", "path": "/comments/:id", "resource": "comments", "action": "delete", "included": true },
+ { "method": "GET", "path": "/albums", "resource": "albums", "action": "list", "included": true },
+ { "method": "GET", "path": "/albums/:id", "resource": "albums", "action": "get", "included": true },
+ { "method": "POST", "path": "/albums", "resource": "albums", "action": "create", "included": true },
+ { "method": "PUT", "path": "/albums/:id", "resource": "albums", "action": "update", "included": true },
+ { "method": "DELETE", "path": "/albums/:id", "resource": "albums", "action": "delete", "included": true },
+ { "method": "GET", "path": "/photos", "resource": "photos", "action": "list", "included": true },
+ { "method": "GET", "path": "/photos/:id", "resource": "photos", "action": "get", "included": true },
+ { "method": "POST", "path": "/photos", "resource": "photos", "action": "create", "included": true },
+ { "method": "PUT", "path": "/photos/:id", "resource": "photos", "action": "update", "included": true },
+ { "method": "DELETE", "path": "/photos/:id", "resource": "photos", "action": "delete", "included": true },
+ { "method": "GET", "path": "/todos", "resource": "todos", "action": "list", "included": true },
+ { "method": "GET", "path": "/todos/:id", "resource": "todos", "action": "get", "included": true },
+ { "method": "POST", "path": "/todos", "resource": "todos", "action": "create", "included": true },
+ { "method": "PUT", "path": "/todos/:id", "resource": "todos", "action": "update", "included": true },
+ { "method": "DELETE", "path": "/todos/:id", "resource": "todos", "action": "delete", "included": true },
+ { "method": "GET", "path": "/users", "resource": "users", "action": "list", "included": true },
+ { "method": "GET", "path": "/users/:id", "resource": "users", "action": "get", "included": true },
+ { "method": "POST", "path": "/users", "resource": "users", "action": "create", "included": true },
+ { "method": "PUT", "path": "/users/:id", "resource": "users", "action": "update", "included": true },
+ { "method": "DELETE", "path": "/users/:id", "resource": "users", "action": "delete", "included": true }
+ ]
+}
diff --git a/examples/jsonplaceholder-cli/knowledge/.gitkeep b/examples/jsonplaceholder-cli/knowledge/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/examples/jsonplaceholder-cli/knowledge/writes-do-not-persist.md b/examples/jsonplaceholder-cli/knowledge/writes-do-not-persist.md
new file mode 100644
index 0000000..1e2e068
--- /dev/null
+++ b/examples/jsonplaceholder-cli/knowledge/writes-do-not-persist.md
@@ -0,0 +1,18 @@
+---
+type: business-rule
+source: docs
+extracted: 2026-04-26
+confidence: high
+applies-to: ["posts.create", "posts.update", "posts.delete", "comments.create", "comments.update", "comments.delete", "albums.create", "albums.update", "albums.delete", "photos.create", "photos.update", "photos.delete", "todos.create", "todos.update", "todos.delete", "users.create", "users.update", "users.delete"]
+---
+
+JSONPlaceholder is a read-only sandbox. Mutating requests (`POST`, `PUT`, `PATCH`, `DELETE`) return successful responses but **do not persist any data on the server**.
+
+Practical consequences:
+
+- A `posts create` returns `id: 101` (always), but `posts get --id 101` will 404.
+- Concurrent `create` calls all "succeed" — there is no race condition because nothing is stored.
+- Useful for: agent integration tests, CLI demos, prototyping.
+- Not useful for: anything that requires read-after-write consistency.
+
+If you need a real persistent backend with the same shape, run JSONPlaceholder locally via `json-server` or use a different demo API.
diff --git a/examples/jsonplaceholder-cli/package.json b/examples/jsonplaceholder-cli/package.json
new file mode 100644
index 0000000..6f7dd6d
--- /dev/null
+++ b/examples/jsonplaceholder-cli/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "jsonplaceholder-cli",
+ "version": "0.1.0",
+ "description": "CLI for the JSONPlaceholder API. Generated by clify.",
+ "type": "module",
+ "bin": {
+ "jsonplaceholder-cli": "./bin/jsonplaceholder-cli.mjs"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "scripts": {
+ "test": "node --test test/*.test.mjs"
+ },
+ "license": "MIT"
+}
diff --git a/examples/jsonplaceholder-cli/skills/jsonplaceholder/SKILL.md b/examples/jsonplaceholder-cli/skills/jsonplaceholder/SKILL.md
new file mode 100644
index 0000000..62eaef0
--- /dev/null
+++ b/examples/jsonplaceholder-cli/skills/jsonplaceholder/SKILL.md
@@ -0,0 +1,101 @@
+---
+name: jsonplaceholder
+description: Wrap the JSONPlaceholder REST API. Use when the user says "/jsonplaceholder", asks to query posts/comments/albums/photos/todos/users on JSONPlaceholder, or wants to test agent CLI flows against a free, no-auth API.
+allowed-tools:
+ - Bash
+ - Read
+ - Write
+---
+
+# JSONPlaceholder CLI Skill
+
+Wrap the [JSONPlaceholder](https://jsonplaceholder.typicode.com) REST API via `jsonplaceholder-cli`. JSONPlaceholder is a public, auth-free API used for prototyping and demos — writes (`create`, `update`, `delete`) return successful responses but **do not actually persist**.
+
+**Before running any command, read every file in `knowledge/`.**
+
+## Triggers
+
+- User says `/jsonplaceholder`
+- User asks to list/get/create/update/delete posts, comments, albums, photos, todos, or users on JSONPlaceholder
+- User wants a quick CLI to demo agent ↔ API flows
+
+## Setup
+
+JSONPlaceholder requires no credentials. The CLI works out of the box. The only optional configuration is `JSONPLACEHOLDER_BASE_URL` (used by integration tests to point at a mock server).
+
+To verify the CLI is working:
+
+```
+jsonplaceholder-cli posts list
+```
+
+This should return an array of 100 posts.
+
+## Quick Reference
+
+| Resource | Actions | Notes |
+|---|---|---|
+| `posts` | list, get, create, update, delete | 100 posts, fields: title, body, userId |
+| `comments` | list, get, create, update, delete | tied to posts via `postId` |
+| `albums` | list, get, create, update, delete | tied to users via `userId` |
+| `photos` | list, get, create, update, delete | tied to albums via `albumId` |
+| `todos` | list, get, create, update, delete | tied to users via `userId`; has `completed` boolean |
+| `users` | list, get, create, update, delete | 10 users |
+
+All actions follow the resource-action pattern. Use `jsonplaceholder-cli --help` to see flags.
+
+## Global Flags
+
+- `--json` — force JSON output (auto when stdout is piped)
+- `--dry-run` — print request without sending
+- `--verbose` — print response status & headers to stderr
+- `--all` — auto-paginate (no-op here; JSONPlaceholder returns all results in one page)
+- `--version`, `-v`
+- `--help`, `-h`
+
+## Error Handling
+
+The CLI emits structured JSON errors to stderr with exit code 1.
+
+| Code | Meaning | Retryable |
+|---|---|---|
+| `validation_error` | Bad input (missing flag, unknown resource, 4xx body) | no |
+| `not_found` | Resource id doesn't exist | no |
+| `rate_limited` | 429 from upstream (rare for JSONPlaceholder) | yes — honor `retryAfter` |
+| `server_error` | 5xx from upstream | yes |
+| `network_error` | Connection failed | yes |
+| `timeout` | Request exceeded timeout | yes |
+
+The CLI never retries — retry policy lives here in the skill.
+
+## Knowledge System
+
+Read every file in `knowledge/` before issuing commands. Knowledge files capture API quirks, business rules, and patterns learned at runtime. After a non-trivial command sequence, append new findings as `knowledge/.md` with frontmatter `type:` set to `gotcha`, `pattern`, `shortcut`, `quirk`, or `business-rule`.
+
+## Common Workflows
+
+### 1. Get a post and its comments
+
+```
+jsonplaceholder-cli posts get --id 1
+jsonplaceholder-cli comments list --json | jq '[.[] | select(.postId == 1)]'
+```
+
+### 2. Create a post (echo-only — JSONPlaceholder does not persist)
+
+```
+jsonplaceholder-cli posts create \
+ --title "Hello" \
+ --body_text "World" \
+ --userId 1
+```
+
+The response includes `id: 101` but you cannot fetch it back.
+
+### 3. Smoke-test the CLI itself
+
+```
+cd jsonplaceholder-cli && npm test
+```
+
+Runs both smoke tests (no network) and integration tests (against the bundled mock server).
diff --git a/examples/jsonplaceholder-cli/skills/sync/SKILL.md b/examples/jsonplaceholder-cli/skills/sync/SKILL.md
new file mode 100644
index 0000000..175e8ba
--- /dev/null
+++ b/examples/jsonplaceholder-cli/skills/sync/SKILL.md
@@ -0,0 +1,34 @@
+---
+name: sync
+description: Re-fetch JSONPlaceholder docs, detect upstream drift, and regenerate the CLI's resources/actions if the docs hash has changed. Preserves knowledge/, .env, and .clify.json apiName/docsUrl.
+allowed-tools:
+ - Bash
+ - Read
+ - Write
+ - WebFetch
+---
+
+# JSONPlaceholder CLI Sync Skill
+
+Re-fetch the API docs and detect drift. If the upstream content hash has changed, regenerate the resource registry, integration tests, and SKILL.md while preserving `knowledge/`, `.env`, and the immutable parts of `.clify.json`.
+
+## Triggers
+
+- User says `/sync` or "sync this CLI"
+- User asks "is this CLI up to date with the docs?"
+
+## Workflow
+
+1. Read `.clify.json` to get `docsUrl` and `contentHash`.
+2. Run `clify sync-check ./` — recomputes the hash and prints diff summary.
+3. If hash matches, exit with "no changes".
+4. If hash differs:
+ - Re-fetch all `crawledUrls`.
+ - Re-parse endpoints and re-detect nuances.
+ - Regenerate `bin/-cli.mjs`, `test/integration.test.mjs`, `coverage.json`, and the resource sections of SKILL.md.
+ - **Do not** touch `knowledge/`, `.env`, or the user-edited parts of `.clify.json` (`apiName`, `docsUrl`).
+5. Run `clify validate ./`. If any check fails, surface the failures to the user.
+6. Review files in `knowledge/`: any that reference removed endpoints should be flagged for the user.
+7. Update `.clify.json` `contentHash` and `generatedAt`.
+
+For JSONPlaceholder specifically, sync is rarely needed — the API has been stable for years.
diff --git a/examples/jsonplaceholder-cli/test/_helpers.mjs b/examples/jsonplaceholder-cli/test/_helpers.mjs
new file mode 100644
index 0000000..2f37dfe
--- /dev/null
+++ b/examples/jsonplaceholder-cli/test/_helpers.mjs
@@ -0,0 +1,31 @@
+import { spawn } from "node:child_process";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+export const REPO_ROOT = resolve(__dirname, "..");
+export const CLI = resolve(REPO_ROOT, "bin/jsonplaceholder-cli.mjs");
+
+export function run(args = [], { env = {}, timeoutMs = 10000 } = {}) {
+ return new Promise((resolveRun) => {
+ const cleanEnv = { ...process.env, ...env };
+ delete cleanEnv.JSONPLACEHOLDER_API_KEY;
+ if (env.JSONPLACEHOLDER_API_KEY !== undefined) cleanEnv.JSONPLACEHOLDER_API_KEY = env.JSONPLACEHOLDER_API_KEY;
+ cleanEnv.__JSONPLACEHOLDER_FORCE_JSON_ERR = "1";
+ const child = spawn(process.execPath, [CLI, ...args], { env: cleanEnv });
+ let stdout = "", stderr = "";
+ child.stdout.on("data", (d) => { stdout += d.toString(); });
+ child.stderr.on("data", (d) => { stderr += d.toString(); });
+ const t = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
+ child.on("close", (code) => { clearTimeout(t); resolveRun({ stdout, stderr, exitCode: code ?? 0 }); });
+ });
+}
+
+export async function runJson(args = [], opts = {}) {
+ const r = await run([...args, "--json"], opts);
+ let parsed = null;
+ if (r.stdout.trim()) { try { parsed = JSON.parse(r.stdout); } catch {} }
+ let parsedErr = null;
+ if (r.stderr.trim()) { try { parsedErr = JSON.parse(r.stderr.trim().split("\n").pop()); } catch {} }
+ return { ...r, json: parsed, errJson: parsedErr };
+}
diff --git a/examples/jsonplaceholder-cli/test/_mock-server.mjs b/examples/jsonplaceholder-cli/test/_mock-server.mjs
new file mode 100644
index 0000000..7a0ff28
--- /dev/null
+++ b/examples/jsonplaceholder-cli/test/_mock-server.mjs
@@ -0,0 +1,98 @@
+// Zero-dep HTTP mock for integration tests.
+// Contract: see references/conventions.md "Mock Server Contract".
+import { createServer } from "node:http";
+
+export async function mockApi(routes) {
+ const requests = [];
+
+ const server = createServer(async (req, res) => {
+ const chunks = [];
+ for await (const c of req) chunks.push(c);
+ const raw = Buffer.concat(chunks).toString("utf8");
+ const ct = (req.headers["content-type"] || "").toLowerCase();
+ let body = raw;
+ if (ct.includes("application/json") && raw) {
+ try { body = JSON.parse(raw); } catch { /* leave as string */ }
+ }
+
+ const urlObj = new URL(req.url, "http://placeholder");
+ const path = urlObj.pathname;
+ const captured = {
+ method: req.method,
+ path,
+ query: Object.fromEntries(urlObj.searchParams.entries()),
+ headers: { ...req.headers },
+ body,
+ raw,
+ };
+ requests.push(captured);
+
+ const match = matchRoute(routes, req.method, path);
+ if (!match) {
+ res.writeHead(404, { "content-type": "application/json" });
+ res.end(JSON.stringify({ error: "no mock route", method: req.method, path }));
+ return;
+ }
+
+ let result = match.handler;
+ if (typeof result === "function") {
+ result = await result(captured, match.params);
+ }
+
+ const status = result.status ?? 200;
+ const headers = { "content-type": "application/json", ...(result.headers || {}) };
+ res.writeHead(status, headers);
+ if (result.body === undefined || result.body === null) {
+ res.end();
+ } else if (typeof result.body === "string") {
+ res.end(result.body);
+ } else {
+ res.end(JSON.stringify(result.body));
+ }
+ });
+
+ await new Promise((resolveListen, rejectListen) => {
+ server.once("error", rejectListen);
+ server.listen(0, "127.0.0.1", () => {
+ server.removeListener("error", rejectListen);
+ resolveListen();
+ });
+ });
+
+ const addr = server.address();
+ const url = `http://127.0.0.1:${addr.port}`;
+
+ return {
+ url,
+ requests,
+ async close() {
+ server.closeAllConnections?.();
+ await new Promise((r) => server.close(() => r()));
+ },
+ };
+}
+
+function matchRoute(routes, method, path) {
+ for (const [key, handler] of Object.entries(routes)) {
+ const [routeMethod, routePath] = key.split(/\s+/);
+ if (routeMethod !== method) continue;
+ const params = matchPath(routePath, path);
+ if (params) return { handler, params };
+ }
+ return null;
+}
+
+function matchPath(template, actual) {
+ const tParts = template.split("/").filter(Boolean);
+ const aParts = actual.split("/").filter(Boolean);
+ if (tParts.length !== aParts.length) return null;
+ const params = {};
+ for (let i = 0; i < tParts.length; i++) {
+ if (tParts[i].startsWith(":")) {
+ params[tParts[i].slice(1)] = decodeURIComponent(aParts[i]);
+ } else if (tParts[i] !== aParts[i]) {
+ return null;
+ }
+ }
+ return params;
+}
diff --git a/examples/jsonplaceholder-cli/test/integration.test.mjs b/examples/jsonplaceholder-cli/test/integration.test.mjs
new file mode 100644
index 0000000..a9b002b
--- /dev/null
+++ b/examples/jsonplaceholder-cli/test/integration.test.mjs
@@ -0,0 +1,139 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mockApi } from "./_mock-server.mjs";
+import { run, runJson } from "./_helpers.mjs";
+
+const RESOURCES = ["posts", "comments", "albums", "photos", "todos", "users"];
+
+async function withMock(routes, fn) {
+ const server = await mockApi(routes);
+ try { await fn(server); } finally { await server.close(); }
+}
+
+// ---------- per-resource CRUD coverage ----------
+
+for (const res of RESOURCES) {
+ test(`${res} list returns array`, async () => {
+ await withMock({ [`GET /${res}`]: { status: 200, body: [{ id: 1 }, { id: 2 }] } }, async (server) => {
+ const r = await runJson([res, "list"], { env: { JSONPLACEHOLDER_BASE_URL: server.url } });
+ assert.equal(r.exitCode, 0, r.stderr);
+ assert.ok(Array.isArray(r.json));
+ assert.equal(r.json.length, 2);
+ assert.equal(server.requests[0].method, "GET");
+ assert.equal(server.requests[0].path, `/${res}`);
+ });
+ });
+
+ test(`${res} get returns single`, async () => {
+ await withMock({
+ [`GET /${res}/:id`]: (req, params) => ({ status: 200, body: { id: Number(params.id), foo: "bar" } }),
+ }, async (server) => {
+ const r = await runJson([res, "get", "--id", "7"], { env: { JSONPLACEHOLDER_BASE_URL: server.url } });
+ assert.equal(r.exitCode, 0, r.stderr);
+ assert.equal(r.json.id, 7);
+ assert.equal(server.requests[0].path, `/${res}/7`);
+ });
+ });
+
+ test(`${res} create echoes payload`, async () => {
+ await withMock({
+ [`POST /${res}`]: (req) => ({ status: 201, body: { id: 101, ...req.body } }),
+ }, async (server) => {
+ const args = [res, "create", "--body", JSON.stringify({ probe: "value" })];
+ const r = await runJson(args, { env: { JSONPLACEHOLDER_BASE_URL: server.url } });
+ assert.equal(r.exitCode, 0, r.stderr);
+ assert.equal(r.json.id, 101);
+ assert.equal(r.json.probe, "value");
+ assert.equal(server.requests[0].method, "POST");
+ assert.deepEqual(server.requests[0].body, { probe: "value" });
+ });
+ });
+
+ test(`${res} update mutates`, async () => {
+ await withMock({
+ [`PUT /${res}/:id`]: (req, params) => ({ status: 200, body: { id: Number(params.id), ...req.body } }),
+ }, async (server) => {
+ const args = [res, "update", "--id", "3", "--body", JSON.stringify({ patch: 1 })];
+ const r = await runJson(args, { env: { JSONPLACEHOLDER_BASE_URL: server.url } });
+ assert.equal(r.exitCode, 0, r.stderr);
+ assert.equal(r.json.id, 3);
+ assert.equal(r.json.patch, 1);
+ });
+ });
+
+ test(`${res} delete returns null body`, async () => {
+ await withMock({
+ [`DELETE /${res}/:id`]: { status: 200, body: {} },
+ }, async (server) => {
+ const r = await runJson([res, "delete", "--id", "9"], { env: { JSONPLACEHOLDER_BASE_URL: server.url } });
+ assert.equal(r.exitCode, 0, r.stderr);
+ assert.equal(server.requests[0].method, "DELETE");
+ assert.equal(server.requests[0].path, `/${res}/9`);
+ });
+ });
+}
+
+// ---------- error paths ----------
+
+test("404 → not_found", async () => {
+ await withMock({ "GET /posts/:id": { status: 404, body: { message: "nope" } } }, async (server) => {
+ const r = await runJson(["posts", "get", "--id", "999"], { env: { JSONPLACEHOLDER_BASE_URL: server.url } });
+ assert.equal(r.exitCode, 1);
+ assert.equal(r.errJson.code, "not_found");
+ assert.equal(r.errJson.retryable, false);
+ });
+});
+
+test("422 → validation_error with details", async () => {
+ await withMock({ "POST /posts": { status: 422, body: { message: "bad", errors: ["title required"] } } }, async (server) => {
+ const r = await runJson(["posts", "create", "--body", "{}"], { env: { JSONPLACEHOLDER_BASE_URL: server.url } });
+ assert.equal(r.exitCode, 1);
+ assert.equal(r.errJson.code, "validation_error");
+ assert.ok(r.errJson.details);
+ });
+});
+
+test("429 with Retry-After → rate_limited + retryAfter", async () => {
+ await withMock({ "GET /posts": { status: 429, headers: { "retry-after": "30" }, body: { message: "slow down" } } }, async (server) => {
+ const r = await runJson(["posts", "list"], { env: { JSONPLACEHOLDER_BASE_URL: server.url } });
+ assert.equal(r.exitCode, 1);
+ assert.equal(r.errJson.code, "rate_limited");
+ assert.equal(r.errJson.retryable, true);
+ assert.equal(r.errJson.retryAfter, 30);
+ });
+});
+
+test("500 → server_error retryable", async () => {
+ await withMock({ "GET /posts": { status: 500, body: { message: "boom" } } }, async (server) => {
+ const r = await runJson(["posts", "list"], { env: { JSONPLACEHOLDER_BASE_URL: server.url } });
+ assert.equal(r.exitCode, 1);
+ assert.equal(r.errJson.code, "server_error");
+ assert.equal(r.errJson.retryable, true);
+ });
+});
+
+test("network down → network_error", async () => {
+ // Allocate a port then close immediately to guarantee nothing listens.
+ const server = await mockApi({});
+ const url = server.url;
+ await server.close();
+ const r = await runJson(["posts", "list"], { env: { JSONPLACEHOLDER_BASE_URL: url } });
+ assert.equal(r.exitCode, 1);
+ assert.equal(r.errJson.code, "network_error");
+ assert.equal(r.errJson.retryable, true);
+});
+
+test("BASE_URL override is honored", async () => {
+ await withMock({ "GET /posts": { status: 200, body: [] } }, async (server) => {
+ const r = await runJson(["posts", "list"], { env: { JSONPLACEHOLDER_BASE_URL: server.url } });
+ assert.equal(r.exitCode, 0);
+ assert.equal(server.requests.length, 1);
+ });
+});
+
+test("user-agent header is sent", async () => {
+ await withMock({ "GET /posts": { status: 200, body: [] } }, async (server) => {
+ await runJson(["posts", "list"], { env: { JSONPLACEHOLDER_BASE_URL: server.url } });
+ assert.match(server.requests[0].headers["user-agent"], /jsonplaceholder-cli\//);
+ });
+});
diff --git a/examples/jsonplaceholder-cli/test/smoke.test.mjs b/examples/jsonplaceholder-cli/test/smoke.test.mjs
new file mode 100644
index 0000000..f2efea7
--- /dev/null
+++ b/examples/jsonplaceholder-cli/test/smoke.test.mjs
@@ -0,0 +1,80 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { run, runJson, REPO_ROOT } from "./_helpers.mjs";
+
+const RESOURCES = ["posts", "comments", "albums", "photos", "todos", "users"];
+const ACTIONS = ["list", "get", "create", "update", "delete"];
+
+test("--version prints package.json version", async () => {
+ const pkg = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8"));
+ const r = await run(["--version"]);
+ assert.equal(r.exitCode, 0);
+ assert.equal(r.stdout.trim(), pkg.version);
+});
+
+test("--help lists all resources", async () => {
+ const r = await run(["--help"]);
+ assert.equal(r.exitCode, 0);
+ for (const res of RESOURCES) assert.match(r.stdout, new RegExp(`\\b${res}\\b`));
+});
+
+test(" --help lists all actions", async () => {
+ for (const res of RESOURCES) {
+ const r = await run([res, "--help"]);
+ assert.equal(r.exitCode, 0, `${res} --help failed: ${r.stderr}`);
+ for (const a of ACTIONS) assert.match(r.stdout, new RegExp(`\\b${a}\\b`));
+ }
+});
+
+test(" --help shows flag table", async () => {
+ const r = await run(["posts", "create", "--help"]);
+ assert.equal(r.exitCode, 0);
+ assert.match(r.stdout, /Flags:/);
+ assert.match(r.stdout, /--title/);
+});
+
+test("unknown resource → validation_error", async () => {
+ const r = await runJson(["nope", "list"]);
+ assert.equal(r.exitCode, 1);
+ assert.equal(r.errJson.code, "validation_error");
+});
+
+test("unknown action → validation_error listing available", async () => {
+ const r = await runJson(["posts", "frobnicate"]);
+ assert.equal(r.exitCode, 1);
+ assert.equal(r.errJson.code, "validation_error");
+ assert.match(r.errJson.message, /list|get|create|update|delete/);
+});
+
+test("required flag missing → validation_error (no crash)", async () => {
+ const r = await runJson(["posts", "get"]);
+ assert.equal(r.exitCode, 1);
+ assert.equal(r.errJson.code, "validation_error");
+ assert.match(r.errJson.message, /--id/);
+});
+
+test("--dry-run does not make network requests", async () => {
+ const r = await runJson(["posts", "list", "--dry-run"], {
+ env: { JSONPLACEHOLDER_BASE_URL: "http://127.0.0.1:1" },
+ });
+ assert.equal(r.exitCode, 0);
+ assert.equal(r.json.__dryRun, true);
+ assert.equal(r.json.method, "GET");
+});
+
+test("source has no hardcoded secrets", () => {
+ const src = readFileSync(join(REPO_ROOT, "bin/jsonplaceholder-cli.mjs"), "utf8");
+ const PATTERNS = [/sk_live_[A-Za-z0-9]{20,}/, /ghp_[A-Za-z0-9]{20,}/, /Bearer\s+[A-Za-z0-9_\-]{30,}/, /xoxb-[A-Za-z0-9-]{20,}/];
+ for (const p of PATTERNS) assert.ok(!p.test(src), `secret pattern ${p} matched`);
+});
+
+test("every resource × action is reachable via --help", async () => {
+ for (const res of RESOURCES) {
+ for (const a of ACTIONS) {
+ const r = await run([res, a, "--help"]);
+ assert.equal(r.exitCode, 0, `${res} ${a} --help failed`);
+ }
+ }
+});
diff --git a/lib/scaffold-init.mjs b/lib/scaffold-init.mjs
new file mode 100644
index 0000000..11438a2
--- /dev/null
+++ b/lib/scaffold-init.mjs
@@ -0,0 +1,92 @@
+// clify scaffold-init: deterministic exemplar copy + rename phase.
+// Used by the LLM-driven scaffold skill at step 8 of its pipeline.
+import { readdirSync, readFileSync, writeFileSync, mkdirSync, statSync, existsSync, copyFileSync } from "node:fs";
+import { join, resolve, dirname, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = resolve(__dirname, "..");
+const EXEMPLAR_DIR = join(REPO_ROOT, "examples/jsonplaceholder-cli");
+
+const FROM = "jsonplaceholder";
+const FROM_UPPER = "JSONPLACEHOLDER";
+const FROM_TITLE = "JSONPlaceholder";
+
+const TEXT_EXTENSIONS = new Set(["mjs", "js", "json", "md", "yml", "yaml", "txt", "example", "gitkeep", "gitignore"]);
+
+function isTextFile(name) {
+ if (name === ".env.example") return true;
+ if (name === ".gitignore") return true;
+ if (name === ".gitkeep") return true;
+ if (name === "LICENSE") return true;
+ const ext = name.split(".").pop();
+ return TEXT_EXTENSIONS.has(ext);
+}
+
+export function scaffoldInit({ apiName, target = process.cwd(), exemplarDir = EXEMPLAR_DIR }) {
+ if (!apiName) throw new Error("apiName required");
+ if (!/^[a-z][a-z0-9-]*$/.test(apiName)) throw new Error(`apiName must match /^[a-z][a-z0-9-]*$/, got: ${apiName}`);
+
+ const upper = apiName.toUpperCase().replace(/-/g, "_");
+ const title = apiName.split("-").map((p) => p[0].toUpperCase() + p.slice(1)).join(" ");
+ const destRoot = resolve(target, `${apiName}-cli`);
+
+ if (existsSync(destRoot)) throw new Error(`Destination already exists: ${destRoot}`);
+
+ copyTree(exemplarDir, destRoot, apiName, upper, title);
+
+ return { dir: destRoot, apiName, apiNameUpper: upper, apiNameTitle: title };
+}
+
+function copyTree(src, dst, apiName, upper, title) {
+ mkdirSync(dst, { recursive: true });
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
+ const srcPath = join(src, entry.name);
+ const renamedName = renameToken(entry.name, apiName);
+ const dstPath = join(dst, renamedName);
+ if (entry.isDirectory()) {
+ copyTree(srcPath, dstPath, apiName, upper, title);
+ } else if (entry.isFile()) {
+ if (isTextFile(entry.name)) {
+ const text = readFileSync(srcPath, "utf8");
+ writeFileSync(dstPath, substitute(text, apiName, upper, title));
+ } else {
+ copyFileSync(srcPath, dstPath);
+ }
+ }
+ }
+}
+
+function renameToken(name, apiName) {
+ if (name === FROM) return apiName;
+ return name.replaceAll(FROM, apiName);
+}
+
+export function substitute(text, apiName, upper, title) {
+ // Order matters: do the most specific (case-sensitive title) first, then upper, then lower.
+ return text
+ .replaceAll(FROM_UPPER, upper)
+ .replaceAll(FROM_TITLE, title)
+ .replaceAll(FROM, apiName);
+}
+
+// CLI entry when invoked directly
+if (import.meta.url === `file://${process.argv[1]}`) {
+ const args = process.argv.slice(2);
+ const apiName = args[0];
+ let target = process.cwd();
+ for (let i = 1; i < args.length; i++) {
+ if (args[i] === "--target" && args[i + 1]) { target = args[++i]; }
+ }
+ if (!apiName || apiName.startsWith("-")) {
+ process.stderr.write("Usage: clify scaffold-init [--target ]\n");
+ process.exit(1);
+ }
+ try {
+ const result = scaffoldInit({ apiName, target });
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
+ } catch (err) {
+ process.stderr.write(`error: ${err.message}\n`);
+ process.exit(1);
+ }
+}
diff --git a/lib/sync-check.mjs b/lib/sync-check.mjs
new file mode 100644
index 0000000..8d5622b
--- /dev/null
+++ b/lib/sync-check.mjs
@@ -0,0 +1,55 @@
+// clify sync-check: re-fetch docs, recompute content hash, print diff summary.
+// Pure advisory — does NOT regenerate. The sync skill consumes this output.
+import { readFileSync, existsSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { createHash } from "node:crypto";
+
+export async function syncCheck(repoDir, { fetcher = globalThis.fetch } = {}) {
+ const dir = resolve(repoDir);
+ const cfgPath = join(dir, ".clify.json");
+ if (!existsSync(cfgPath)) throw new Error(`.clify.json not found in ${dir}`);
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
+
+ const urls = cfg.crawledUrls?.length ? cfg.crawledUrls : (cfg.docsUrl ? [cfg.docsUrl] : []);
+ if (urls.length === 0) throw new Error(".clify.json has neither docsUrl nor crawledUrls");
+
+ const fetchedAt = new Date().toISOString();
+ const parts = [];
+ const fetched = [];
+ for (const url of urls) {
+ let body = "", status = 0, error;
+ try {
+ const res = await fetcher(url, { headers: { "user-agent": `clify-sync-check/${cfg.clifyVersion || "0"}` } });
+ status = res.status;
+ body = await res.text();
+ } catch (err) { error = err.message; }
+ fetched.push({ url, status, bytes: body.length, error });
+ if (body) parts.push(body);
+ }
+
+ const newHash = "sha256:" + createHash("sha256").update(parts.join("\n---\n")).digest("hex");
+ const oldHash = cfg.contentHash || null;
+ const changed = oldHash !== newHash;
+
+ return {
+ apiName: cfg.apiName,
+ docsUrl: cfg.docsUrl,
+ fetchedAt,
+ oldHash,
+ newHash,
+ changed,
+ fetched,
+ };
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+ const dir = process.argv[2];
+ if (!dir) { process.stderr.write("Usage: clify sync-check \n"); process.exit(1); }
+ syncCheck(dir).then((result) => {
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
+ process.exit(result.changed ? 1 : 0);
+ }).catch((err) => {
+ process.stderr.write(`error: ${err.message}\n`);
+ process.exit(2);
+ });
+}
diff --git a/lib/validate.mjs b/lib/validate.mjs
new file mode 100644
index 0000000..317a136
--- /dev/null
+++ b/lib/validate.mjs
@@ -0,0 +1,436 @@
+// clify validation gate — pure JS, no LLM.
+// Categories (8): manifest, smoke, integration, structural, coverage, nuances, secrets, ci
+// `nuances` is split: 4 hard-fail signals, the rest produce warnings.
+// Schema/business-knowledge concerns are folded into the relevant categories above.
+import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
+import { join, resolve, relative } from "node:path";
+import { spawnSync } from "node:child_process";
+
+const ALLOWED_DROP_REASONS = new Set([
+ "user-excluded-step-7",
+ "deprecated-in-docs",
+ "beta-flagged",
+ "internal-only",
+ "nesting-depth-cap",
+ "webhook-not-cli-shaped",
+ "streaming-not-cli-shaped",
+]);
+
+const ALLOWED_AUTH_SCHEMES = new Set(["bearer", "api-key-header", "basic", "none"]);
+
+const SECRET_PATTERNS = [
+ { name: "stripe-live", re: /sk_live_[A-Za-z0-9]{20,}/ },
+ { name: "github-token", re: /ghp_[A-Za-z0-9]{20,}/ },
+ { name: "github-fine-grained", re: /github_pat_[A-Za-z0-9_]{20,}/ },
+ { name: "slack-bot", re: /xoxb-[A-Za-z0-9-]{20,}/ },
+ { name: "aws-access-key", re: /AKIA[0-9A-Z]{16}/ },
+ { name: "openai-key", re: /sk-[A-Za-z0-9]{32,}/ },
+ { name: "bearer-literal", re: /Bearer\s+[A-Za-z0-9_\-]{30,}/ },
+];
+
+const HARD_NUANCES = ["pagination", "idempotency", "multipart", "deprecated"];
+
+export async function validate(repoDir, options = {}) {
+ const dir = resolve(repoDir);
+ const ctx = { dir, results: [], warnings: [] };
+
+ if (!existsSync(dir)) {
+ ctx.results.push(fail("manifest", "directory does not exist", { dir }));
+ return finish(ctx);
+ }
+
+ await checkManifests(ctx);
+ await checkSchema(ctx);
+ await checkSourceConventions(ctx);
+ await checkSecrets(ctx);
+ await checkCoverage(ctx);
+ await checkStructural(ctx);
+ await checkNuances(ctx);
+ await checkCi(ctx);
+ if (!options.skipTests) {
+ await checkTests(ctx);
+ }
+
+ return finish(ctx);
+}
+
+function finish(ctx) {
+ const failed = ctx.results.filter((r) => !r.ok);
+ return {
+ ok: failed.length === 0,
+ dir: ctx.dir,
+ summary: { total: ctx.results.length, passed: ctx.results.length - failed.length, failed: failed.length, warnings: ctx.warnings.length },
+ results: ctx.results,
+ warnings: ctx.warnings,
+ };
+}
+
+function pass(category, name, details = {}) { return { category, name, ok: true, ...details }; }
+function fail(category, name, details = {}) { return { category, name, ok: false, ...details }; }
+
+function readJson(path) {
+ try { return JSON.parse(readFileSync(path, "utf8")); }
+ catch (err) { return { __err: err.message }; }
+}
+
+function readText(path) {
+ try { return readFileSync(path, "utf8"); } catch { return null; }
+}
+
+// ---------- 1. manifests ----------
+
+async function checkManifests(ctx) {
+ const pkgPath = join(ctx.dir, "package.json");
+ const pluginPath = join(ctx.dir, ".claude-plugin/plugin.json");
+ const marketplacePath = join(ctx.dir, ".claude-plugin/marketplace.json");
+
+ if (!existsSync(pkgPath)) { ctx.results.push(fail("manifest", "package.json missing")); return; }
+ const pkg = readJson(pkgPath);
+ if (pkg.__err) { ctx.results.push(fail("manifest", "package.json parse", { error: pkg.__err })); return; }
+
+ for (const k of ["name", "version", "description"]) {
+ if (!pkg[k]) ctx.results.push(fail("manifest", `package.json missing ${k}`));
+ }
+ ctx.results.push(pkg.type === "module" ? pass("manifest", "package.json type=module") : fail("manifest", "package.json type must be module"));
+
+ const engNode = pkg.engines?.node;
+ if (!engNode) ctx.results.push(fail("manifest", "package.json engines.node missing"));
+ else if (!/>=\s*2[0-9]/.test(engNode) && !/>=\s*[3-9][0-9]/.test(engNode)) ctx.results.push(fail("manifest", "package.json engines.node must be >=20", { engNode }));
+ else ctx.results.push(pass("manifest", "package.json engines.node >=20"));
+
+ if (!pkg.bin || typeof pkg.bin !== "object") ctx.results.push(fail("manifest", "package.json missing bin field"));
+ else ctx.results.push(pass("manifest", "package.json bin present"));
+
+ if (!pkg.scripts?.test) ctx.results.push(fail("manifest", "package.json scripts.test missing"));
+ else ctx.results.push(pass("manifest", "package.json scripts.test present"));
+
+ // plugin & marketplace
+ let plugin, marketplace;
+ if (existsSync(pluginPath)) {
+ plugin = readJson(pluginPath);
+ if (plugin.__err) ctx.results.push(fail("manifest", ".claude-plugin/plugin.json parse", { error: plugin.__err }));
+ } else { ctx.results.push(fail("manifest", ".claude-plugin/plugin.json missing")); }
+
+ if (existsSync(marketplacePath)) {
+ marketplace = readJson(marketplacePath);
+ if (marketplace.__err) ctx.results.push(fail("manifest", ".claude-plugin/marketplace.json parse", { error: marketplace.__err }));
+ } else { ctx.results.push(fail("manifest", ".claude-plugin/marketplace.json missing")); }
+
+ if (plugin && !plugin.__err) {
+ for (const k of ["name", "version", "description", "skills", "capabilities"]) {
+ if (plugin[k] === undefined) ctx.results.push(fail("manifest", `plugin.json missing ${k}`));
+ }
+ if (Array.isArray(plugin.skills)) {
+ let resolved = true;
+ for (const s of plugin.skills) {
+ if (!s.source || !existsSync(join(ctx.dir, s.source))) {
+ ctx.results.push(fail("manifest", `plugin.json skill source not found`, { source: s.source }));
+ resolved = false;
+ } else {
+ // Each SKILL.md must have frontmatter name + description + allowed-tools
+ const sm = readText(join(ctx.dir, s.source));
+ if (!sm || !sm.startsWith("---\n")) ctx.results.push(fail("manifest", `${s.source} missing YAML frontmatter`));
+ else {
+ const fm = sm.slice(4, sm.indexOf("\n---", 4));
+ for (const k of ["name:", "description:"]) {
+ if (!fm.includes(k)) ctx.results.push(fail("manifest", `${s.source} frontmatter missing ${k}`));
+ }
+ }
+ }
+ }
+ if (resolved) ctx.results.push(pass("manifest", "every plugin skill source resolves"));
+ }
+
+ if (pkg.name && plugin.name && pkg.name !== plugin.name) ctx.results.push(fail("manifest", "name mismatch package.json vs plugin.json", { pkg: pkg.name, plugin: plugin.name }));
+ else if (pkg.name && plugin.name) ctx.results.push(pass("manifest", "name matches across pkg+plugin"));
+
+ if (pkg.version && plugin.version && pkg.version !== plugin.version) ctx.results.push(fail("manifest", "version mismatch package.json vs plugin.json"));
+ if (pkg.description && plugin.description && pkg.description !== plugin.description) ctx.results.push(fail("manifest", "description mismatch package.json vs plugin.json"));
+ }
+
+ if (marketplace && !marketplace.__err) {
+ for (const k of ["name", "version", "description", "source"]) {
+ if (marketplace[k] === undefined) ctx.results.push(fail("manifest", `marketplace.json missing ${k}`));
+ }
+ if (plugin && !plugin.__err) {
+ if (marketplace.name !== plugin.name) ctx.results.push(fail("manifest", "name mismatch plugin.json vs marketplace.json"));
+ if (marketplace.version !== plugin.version) ctx.results.push(fail("manifest", "version mismatch plugin.json vs marketplace.json"));
+ if (marketplace.description !== plugin.description) ctx.results.push(fail("manifest", "description mismatch plugin.json vs marketplace.json"));
+ }
+ }
+}
+
+// ---------- 2. schema (.clify.json + .env.example) ----------
+
+async function checkSchema(ctx) {
+ const clifyPath = join(ctx.dir, ".clify.json");
+ if (!existsSync(clifyPath)) { ctx.results.push(fail("manifest", ".clify.json missing")); return; }
+ const cfg = readJson(clifyPath);
+ if (cfg.__err) { ctx.results.push(fail("manifest", ".clify.json parse", { error: cfg.__err })); return; }
+
+ for (const k of ["apiName", "docsUrl", "contentHash", "generatedAt", "clifyVersion", "auth"]) {
+ if (cfg[k] === undefined) ctx.results.push(fail("manifest", `.clify.json missing ${k}`));
+ }
+
+ if (cfg.auth) {
+ if (!ALLOWED_AUTH_SCHEMES.has(cfg.auth.scheme)) ctx.results.push(fail("manifest", `.clify.json auth.scheme invalid`, { scheme: cfg.auth.scheme }));
+ else ctx.results.push(pass("manifest", ".clify.json auth.scheme valid"));
+ if (!cfg.auth.envVar) ctx.results.push(fail("manifest", ".clify.json auth.envVar missing"));
+ if (!cfg.auth.validationCommand) ctx.results.push(fail("manifest", ".clify.json auth.validationCommand missing"));
+ }
+
+ // .env.example annotations on auth var (skip when scheme=none)
+ const envExPath = join(ctx.dir, ".env.example");
+ if (!existsSync(envExPath)) ctx.results.push(fail("manifest", ".env.example missing"));
+ else if (cfg.auth?.scheme && cfg.auth.scheme !== "none") {
+ const text = readText(envExPath) || "";
+ if (!text.includes("@required")) ctx.results.push(fail("manifest", ".env.example missing @required annotation on auth var"));
+ if (!text.includes("@how-to-get")) ctx.results.push(fail("manifest", ".env.example missing @how-to-get annotation on auth var"));
+ }
+}
+
+// ---------- 3. source conventions ----------
+
+async function checkSourceConventions(ctx) {
+ // Find the bin file from package.json bin field.
+ const pkg = readJson(join(ctx.dir, "package.json"));
+ if (pkg.__err) return;
+ const binEntries = pkg.bin ? Object.values(pkg.bin) : [];
+ if (binEntries.length === 0) return;
+ const binPath = join(ctx.dir, binEntries[0]);
+ const src = readText(binPath);
+ if (!src) { ctx.results.push(fail("manifest", `bin file not found at ${binEntries[0]}`)); return; }
+
+ // BASE_URL override
+ const apiName = pkg.name.replace(/-cli$/, "").toUpperCase().replace(/-/g, "_");
+ const baseEnv = `${apiName}_BASE_URL`;
+ if (!src.includes(baseEnv)) ctx.results.push(fail("manifest", `CLI does not honor ${baseEnv} env override`));
+ else ctx.results.push(pass("manifest", "CLI honors BASE_URL override"));
+
+ // package.json bin path resolves
+ if (existsSync(binPath)) ctx.results.push(pass("manifest", "package.json bin path resolves"));
+ else ctx.results.push(fail("manifest", "package.json bin path does not resolve", { binPath }));
+}
+
+// ---------- 4. secrets ----------
+
+async function checkSecrets(ctx) {
+ const offenders = [];
+ for (const file of walkSourceFiles(ctx.dir)) {
+ const txt = readText(file);
+ if (!txt) continue;
+ for (const { name, re } of SECRET_PATTERNS) {
+ if (re.test(txt)) offenders.push({ file: relative(ctx.dir, file), pattern: name });
+ }
+ }
+ if (offenders.length === 0) ctx.results.push(pass("secrets", "no hardcoded secrets"));
+ else ctx.results.push(fail("secrets", "hardcoded secret patterns detected", { offenders }));
+}
+
+function walkSourceFiles(dir, acc = []) {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ if (entry.name.startsWith(".") && entry.name !== ".env.example") continue;
+ if (entry.name === "node_modules") continue;
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) walkSourceFiles(full, acc);
+ else if (/\.(mjs|js|json|md|yml|yaml|env\.example)$/.test(entry.name) || entry.name === ".env.example") acc.push(full);
+ }
+ return acc;
+}
+
+// ---------- 5. coverage ----------
+
+async function checkCoverage(ctx) {
+ const path = join(ctx.dir, "coverage.json");
+ if (!existsSync(path)) { ctx.results.push(fail("coverage", "coverage.json missing")); return; }
+ const cov = readJson(path);
+ if (cov.__err) { ctx.results.push(fail("coverage", "coverage.json parse", { error: cov.__err })); return; }
+
+ for (const k of ["totalParsed", "totalIncluded", "totalDropped", "endpoints"]) {
+ if (cov[k] === undefined) { ctx.results.push(fail("coverage", `coverage.json missing ${k}`)); return; }
+ }
+
+ const issues = [];
+ for (const [i, ep] of cov.endpoints.entries()) {
+ if (!ep.method || !ep.path) issues.push({ i, reason: "missing method or path" });
+ if (ep.included === false) {
+ if (ep.dropped !== true) issues.push({ i, reason: "included:false without dropped:true" });
+ else if (!ALLOWED_DROP_REASONS.has(ep.reason)) issues.push({ i, reason: `bad drop reason: ${ep.reason}` });
+ } else if (ep.included === true) {
+ if (!ep.resource || !ep.action) issues.push({ i, reason: "included endpoint missing resource/action" });
+ }
+ }
+ const includedCount = cov.endpoints.filter((e) => e.included === true).length;
+ const droppedCount = cov.endpoints.filter((e) => e.dropped === true).length;
+ if (includedCount !== cov.totalIncluded) issues.push({ i: -1, reason: `totalIncluded mismatch: ${cov.totalIncluded} vs ${includedCount}` });
+ if (droppedCount !== cov.totalDropped) issues.push({ i: -1, reason: `totalDropped mismatch: ${cov.totalDropped} vs ${droppedCount}` });
+
+ if (issues.length === 0) ctx.results.push(pass("coverage", "coverage.json valid", { included: includedCount, dropped: droppedCount }));
+ else ctx.results.push(fail("coverage", "coverage.json invalid", { issues }));
+}
+
+// ---------- 6. structural (resource registry vs help, SKILL.md, etc.) ----------
+
+async function checkStructural(ctx) {
+ const pkg = readJson(join(ctx.dir, "package.json"));
+ if (pkg.__err) return;
+ const binEntries = pkg.bin ? Object.values(pkg.bin) : [];
+ if (binEntries.length === 0) return;
+ const binPath = join(ctx.dir, binEntries[0]);
+ const src = readText(binPath) || "";
+
+ // help: --help should mention every resource
+ const cov = readJson(join(ctx.dir, "coverage.json"));
+ if (cov.__err) return;
+
+ const helpResult = spawnSync(process.execPath, [binPath, "--help"], { encoding: "utf8", env: { ...process.env, FORCE_COLOR: "0" } });
+ const helpOut = (helpResult.stdout || "") + (helpResult.stderr || "");
+ const includedResources = new Set(cov.endpoints.filter((e) => e.included).map((e) => e.resource));
+ const includedActions = new Map();
+ for (const ep of cov.endpoints) {
+ if (!ep.included) continue;
+ if (!includedActions.has(ep.resource)) includedActions.set(ep.resource, new Set());
+ includedActions.get(ep.resource).add(ep.action);
+ }
+
+ let missingFromRoot = [];
+ for (const r of includedResources) if (!helpOut.includes(r)) missingFromRoot.push(r);
+ if (missingFromRoot.length === 0) ctx.results.push(pass("structural", "every resource appears in --help"));
+ else ctx.results.push(fail("structural", "resources missing from --help", { missing: missingFromRoot }));
+
+ // each resource --help should mention every action
+ let resourceHelpFailures = [];
+ for (const r of includedResources) {
+ const rh = spawnSync(process.execPath, [binPath, r, "--help"], { encoding: "utf8" });
+ const out = (rh.stdout || "") + (rh.stderr || "");
+ for (const a of includedActions.get(r) || []) {
+ if (!out.includes(a)) resourceHelpFailures.push(`${r} --help missing action ${a}`);
+ }
+ }
+ if (resourceHelpFailures.length === 0) ctx.results.push(pass("structural", "every action appears in resource --help"));
+ else ctx.results.push(fail("structural", "actions missing from resource --help", { failures: resourceHelpFailures }));
+
+ // SKILL.md mentions each resource
+ if (pkg.bin) {
+ const apiSlug = pkg.name.replace(/-cli$/, "");
+ const skillPath = join(ctx.dir, `skills/${apiSlug}/SKILL.md`);
+ if (!existsSync(skillPath)) {
+ ctx.results.push(fail("structural", `skills/${apiSlug}/SKILL.md missing`));
+ } else {
+ const skill = readText(skillPath);
+ const missing = [];
+ for (const r of includedResources) if (!skill.includes(r)) missing.push(r);
+ if (missing.length === 0) ctx.results.push(pass("structural", "SKILL.md references every resource"));
+ else ctx.results.push(fail("structural", "SKILL.md missing resources", { missing }));
+
+ // knowledge preamble
+ if (skill.includes("knowledge/")) ctx.results.push(pass("structural", "SKILL.md mentions knowledge/"));
+ else ctx.results.push(fail("structural", "SKILL.md must instruct agent to read knowledge/"));
+ }
+ }
+}
+
+// ---------- 7. nuances ----------
+
+async function checkNuances(ctx) {
+ const cfg = readJson(join(ctx.dir, ".clify.json"));
+ if (cfg.__err) return;
+ const nuances = cfg.nuances || {};
+ const pkg = readJson(join(ctx.dir, "package.json"));
+ const binEntries = pkg.bin ? Object.values(pkg.bin) : [];
+ const src = binEntries.length ? (readText(join(ctx.dir, binEntries[0])) || "") : "";
+ const integrationTest = readText(join(ctx.dir, "test/integration.test.mjs")) || "";
+
+ // pagination
+ if (nuances.pagination) {
+ if (/page|cursor|next|offset|link-header/i.test(integrationTest) && /multi.*page|page.*[12]|cursor/i.test(integrationTest)) {
+ ctx.results.push(pass("nuances", `pagination test present (${nuances.pagination})`));
+ } else {
+ ctx.results.push(fail("nuances", `pagination=${nuances.pagination} but no multi-page test detected`));
+ }
+ }
+
+ // idempotency
+ if (Array.isArray(nuances.idempotency) && nuances.idempotency.length > 0) {
+ if (!src.includes("idempotency-key")) ctx.results.push(fail("nuances", "idempotency declared but --idempotency-key not wired in CLI"));
+ else if (!/idempotency.key/i.test(integrationTest)) ctx.results.push(fail("nuances", "idempotency declared but no integration test asserts header"));
+ else ctx.results.push(pass("nuances", "idempotency wiring + test present"));
+ }
+
+ // multipart
+ if (Array.isArray(nuances.multiPart) && nuances.multiPart.length > 0) {
+ if (!src.includes("FormData") && !src.includes("multipart")) ctx.results.push(fail("nuances", "multiPart declared but FormData/multipart not used"));
+ else if (!/--file|FormData/.test(integrationTest)) ctx.results.push(fail("nuances", "multiPart declared but no integration test posts a file"));
+ else ctx.results.push(pass("nuances", "multipart wiring + test present"));
+ }
+
+ // deprecated
+ if (Array.isArray(nuances.deprecated) && nuances.deprecated.length > 0) {
+ let satisfied = false;
+ const cov = readJson(join(ctx.dir, "coverage.json"));
+ if (!cov.__err) {
+ const deprecated = cov.endpoints.filter((e) => e.dropped && e.reason === "deprecated-in-docs");
+ if (deprecated.length > 0) satisfied = true;
+ }
+ const knowledgeDir = join(ctx.dir, "knowledge");
+ if (existsSync(knowledgeDir)) {
+ for (const f of readdirSync(knowledgeDir)) {
+ if (f.startsWith("deprecated-")) { satisfied = true; break; }
+ }
+ }
+ if (satisfied) ctx.results.push(pass("nuances", "deprecated endpoints documented"));
+ else ctx.results.push(fail("nuances", "deprecated endpoints declared but no knowledge file or coverage entry"));
+ }
+
+ // soft warnings
+ if (nuances.rateLimits && !existsSync(join(ctx.dir, "knowledge/rate-limit.md"))) ctx.warnings.push("rateLimits=true but no knowledge/rate-limit.md");
+ if (nuances.authScopes && !existsSync(join(ctx.dir, "knowledge/auth-scopes.md"))) ctx.warnings.push("authScopes=true but no knowledge/auth-scopes.md");
+ if (Array.isArray(nuances.conditional) && nuances.conditional.length > 0 && !src.includes("if-match")) {
+ ctx.warnings.push("conditional declared but --if-match not wired");
+ }
+
+ // business rules
+ if (typeof nuances.businessRules === "number" && nuances.businessRules > 0) {
+ const knowledgeDir = join(ctx.dir, "knowledge");
+ let businessRuleFiles = 0;
+ if (existsSync(knowledgeDir)) {
+ for (const f of readdirSync(knowledgeDir)) {
+ if (!f.endsWith(".md")) continue;
+ const txt = readText(join(knowledgeDir, f)) || "";
+ if (/^type:\s*business-rule/m.test(txt)) businessRuleFiles++;
+ }
+ }
+ if (businessRuleFiles >= 1) ctx.results.push(pass("nuances", `${businessRuleFiles} business-rule knowledge file(s) present`));
+ else ctx.results.push(fail("nuances", "businessRules>0 but no knowledge/*.md with type: business-rule"));
+ }
+
+ // baseline pass when no nuances declared
+ if (HARD_NUANCES.every((k) => !nuances[k] || (Array.isArray(nuances[k]) && nuances[k].length === 0))) {
+ ctx.results.push(pass("nuances", "no hard-fail nuances declared"));
+ }
+}
+
+// ---------- 8. CI ----------
+
+async function checkCi(ctx) {
+ const path = join(ctx.dir, ".github/workflows/test.yml");
+ if (!existsSync(path)) { ctx.results.push(fail("ci", ".github/workflows/test.yml missing")); return; }
+ const txt = readText(path) || "";
+ if (!txt.includes("npm test")) { ctx.results.push(fail("ci", "test.yml does not run npm test")); return; }
+ if (!txt.includes("setup-node")) { ctx.results.push(fail("ci", "test.yml does not use setup-node")); return; }
+ ctx.results.push(pass("ci", ".github/workflows/test.yml valid"));
+}
+
+// ---------- 9. tests ----------
+
+async function checkTests(ctx) {
+ const smokePath = join(ctx.dir, "test/smoke.test.mjs");
+ const integPath = join(ctx.dir, "test/integration.test.mjs");
+ if (!existsSync(smokePath)) ctx.results.push(fail("smoke", "test/smoke.test.mjs missing"));
+ if (!existsSync(integPath)) ctx.results.push(fail("integration", "test/integration.test.mjs missing"));
+ if (!existsSync(smokePath) || !existsSync(integPath)) return;
+
+ const r = spawnSync("npm", ["test"], { cwd: ctx.dir, encoding: "utf8", env: { ...process.env, CI: "1" } });
+ if (r.status === 0) ctx.results.push(pass("tests", "npm test exit 0"));
+ else ctx.results.push(fail("tests", "npm test failed", { code: r.status, stderrTail: (r.stderr || "").slice(-1500) }));
+}
diff --git a/package.json b/package.json
index 4ed13aa..bb5b676 100644
--- a/package.json
+++ b/package.json
@@ -1,18 +1,25 @@
{
"name": "clify",
- "version": "0.1.0",
- "description": "Generate self-updating CLI repos from API documentation URLs.",
+ "version": "0.2.0",
+ "description": "Generate A+ Node.js CLIs from API documentation. Copy a hand-crafted exemplar, mechanically substitute API-specific content, then verify with a deterministic validation gate.",
"type": "module",
+ "bin": {
+ "clify": "./bin/clify.mjs"
+ },
"engines": {
"node": ">=20"
},
+ "scripts": {
+ "test": "node --test test/*.test.mjs",
+ "validate-exemplar": "node bin/clify.mjs validate examples/jsonplaceholder-cli"
+ },
"license": "MIT",
"repository": {
"type": "git",
- "url": "git+https://github.com/derrickko/clify.git"
+ "url": "git+https://github.com/codeyogi911/clify.git"
},
- "homepage": "https://github.com/derrickko/clify",
+ "homepage": "https://github.com/codeyogi911/clify",
"bugs": {
- "url": "https://github.com/derrickko/clify/issues"
+ "url": "https://github.com/codeyogi911/clify/issues"
}
}
diff --git a/plan.md b/plan.md
new file mode 100644
index 0000000..dfcd665
--- /dev/null
+++ b/plan.md
@@ -0,0 +1,315 @@
+# clify — Realign for A+ Codegen
+
+## Context
+
+clify today is a Claude Code plugin with one skill (`/clify `) that turns API docs into a CLI repo. It works, but the generation strategy is **"LLM reads commented skeletons, then writes ~90% of the CLI from scratch and adapts patterns"** — which means 90% of the surface is novel each run, and quality varies.
+
+The user wants:
+- **MCPs are heavy → CLIs are best for agents**: keep the codegen path (no runtime-dispatch idea).
+- **A+ grade output in one go**: every generated CLI should be production-quality, verified, tested.
+- **Aggressive realignment**: keep what's salvageable; throw the rest. Take inspiration from `google/agents-cli` (verb-based binary + per-domain skills + agent-centric design).
+- **No benchmark for now**.
+
+The fix is to switch from "skeleton + adapt" to **"copy exemplar + mechanically substitute"**. The LLM's job becomes a smaller, more verifiable transformation, and we ship a real, working, tested exemplar in the repo as the source of truth.
+
+## What clify Is (binary + skills, agents-cli model)
+
+clify is a Claude Code plugin that ships **both** runtime code and skills, on the same model as `google/agents-cli`:
+
+- **Skills** do the LLM-judgment work: fetch docs, parse free-form HTML/Markdown for endpoints and business rules, consult the user on tradeoffs, rewrite per-API content in the generated SKILL.md and README. These are what install and guide Claude/Codex/other agents.
+- **A small Node binary (`bin/clify.mjs`)** does the deterministic work: `clify validate ` runs the full validation gate as pure JS (no LLM); `clify sync` does hash-diff and manifest updates; `clify scaffold-init ` does the file-copy + rename phase of scaffolding so the LLM never gets the boring substitutions wrong.
+
+Split rule: anything verifiable by code goes in the binary; anything requiring judgment stays in skills. The binary is what makes verification reproducible — same pass/fail for agent and human.
+
+## Target Shape
+
+### clify repo (this repo)
+
+```
+clify/
+├── bin/clify.mjs NEW — top-level CLI shim (verbs)
+├── skills/
+│ ├── clify-scaffold/SKILL.md REWRITE of skills/clify/SKILL.md, copy-exemplar based
+│ ├── clify-sync/SKILL.md NEW — re-fetch docs, diff hash, regenerate, revalidate
+│ └── clify-validate/SKILL.md NEW — run validation gate against any generated repo
+├── examples/
+│ └── jsonplaceholder-cli/ NEW — full hand-crafted A+ exemplar (see below)
+├── references/ (moved from skills/clify/references/)
+│ ├── conventions.md EDIT — point at exemplar, add validation-gate + mock-server contracts
+│ ├── exemplar-walkthrough.md NEW — line-by-line guide to the exemplar
+│ └── validation-gate.md NEW — exhaustive list of A+ checks (each enforceable)
+├── .github/workflows/test.yml NEW — runs exemplar tests on every push
+├── .claude-plugin/plugin.json EDIT — register the three new skills, add capabilities
+├── package.json EDIT — add `bin`, `test` script
+└── README.md REWRITE around exemplar+verbs story
+```
+
+**Throw out:** `skills/clify/SKILL.md`, `skills/clify/references/cli-skeleton.mjs`, `skills/clify/references/smoke-test-skeleton.mjs`, `skills/clify/references/skill-skeleton.md`, the `skills/clify/` directory.
+
+**Salvage** (move/edit, don't rewrite from scratch): error taxonomy, `.env` loader pattern, three-level help, knowledge system schema, setup `@tag` annotations, `.clify.json` shape, plugin/marketplace manifest schemas, resource-action conventions. All currently in `skills/clify/references/conventions.md` — move to top-level `references/conventions.md` and edit to point at exemplar code instead of comment-stub skeletons.
+
+### Generated CLI shape (output of `clify scaffold `)
+
+Identical layout to `examples/jsonplaceholder-cli/`. The generator copies the exemplar, then mechanically substitutes API-specific bits. New files vs current generator:
+
+- `test/integration.test.mjs` — mocked HTTP server tests covering every resource-action's request shape and response handling.
+- `test/_mock-server.mjs` — zero-dep `node:http` mock the integration tests use.
+- `.github/workflows/test.yml` — CI runs `npm test` on every push.
+
+## Pieces to Build
+
+### 1. Exemplar: `examples/jsonplaceholder-cli/`
+
+Hand-craft a complete A+ CLI for [JSONPlaceholder](https://jsonplaceholder.typicode.com) — public, free, no auth, 6 resources with full CRUD. Means CI can run the real API path without secrets.
+
+Must include:
+- `bin/jsonplaceholder-cli.mjs` — single-file CLI, every contract from `references/conventions.md` honored.
+- `skills/jsonplaceholder/SKILL.md` — Triggers, Setup (auth-only-no-defaults variant since JSONPlaceholder has no auth), Quick Reference table for all 6 resources, Global Flags, Error Handling, Knowledge System pointer, Common Workflows (3 worked examples).
+- `skills/sync/SKILL.md` — sync workflow.
+- `test/smoke.test.mjs` — every required smoke test from conventions.md.
+- `test/integration.test.mjs` — for each resource: list returns array, get returns single, create echoes payload, update mutates, delete returns 204; plus error cases (404, 422, 429, 500, network down).
+- `test/_mock-server.mjs` — `mockApi(routes)` helper returning `{ url, close() }`. Routes can be static `{status, body}` or function `(req) => {status, body}`. Asserts on requests captured via `server.requests`.
+- `.github/workflows/test.yml` — `node --test` on Node 20 and 22.
+- `.claude-plugin/{plugin.json, marketplace.json}` — schema-valid.
+- `AGENTS.md`, `.clify.json`, `package.json`, `.env.example`, `.gitignore`, `README.md`, `LICENSE`, `knowledge/.gitkeep`.
+
+Acceptance: `cd examples/jsonplaceholder-cli && npm test` passes; `node bin/jsonplaceholder-cli.mjs posts list` hits real JSONPlaceholder and returns data.
+
+### 2. Mock HTTP server contract: `_mock-server.mjs`
+
+Zero-dep `node:http`. The shape every generated CLI inherits:
+
+```js
+const server = await mockApi({
+ "GET /posts": { status: 200, body: [{ id: 1, title: "x" }] },
+ "GET /posts/:id": (req, params) => ({ status: 200, body: { id: Number(params.id) } }),
+ "POST /posts": (req) => ({ status: 201, body: { id: 101, ...req.body } }),
+ "GET /rate": { status: 429, headers: { "retry-after": "30" } },
+});
+process.env.JSONPLACEHOLDER_BASE_URL = server.url; // CLI honors override
+// ...run CLI...
+assert.equal(server.requests[0].headers.authorization, "Bearer test");
+await server.close();
+```
+
+CLI must read `BASE_URL` from env if set (override the hardcoded default) — this is a new convention to add, otherwise integration tests can't reach the mock. Document in conventions.md as "Test override: every generated CLI honors `_BASE_URL` env var to redirect requests; default is the real API base URL."
+
+### 3. Validation gate: `references/validation-gate.md` + `skills/clify-validate/`
+
+Every check enforceable as a script (lives in `skills/clify-validate/lib/validate.mjs`, callable via `clify validate `). Seven categories:
+
+| Category | Examples |
+|---|---|
+| **Manifest** | `package.json`/`plugin.json`/`marketplace.json` name+version+description match; every `skills[].source` resolves; `engines.node >= 20`. |
+| **Smoke tests** | `npm test` exits 0; all required smoke categories present (every row of the smoke-test table in conventions.md). |
+| **Integration tests** | Auth path tested with mock; every resource has at least one integration test per declared action. |
+| **Structural coverage** | Every resource and action in registry appears in `--help` and resource `--help`; every action mentioned in SKILL.md Quick Reference; every error code referenced in SKILL.md Error Handling. |
+| **API-surface coverage** *(new)* | `coverage.json` exists; for every entry, either `included: true` or has explicit `dropped: true` with `reason`; included count matches CLI registry size; surface coverage % logged. Gate fails on silent drops. |
+| **Nuance artifacts** *(new)* | For each flag in `.clify.json` `nuances` map, the corresponding artifact exists (see Nuance Detection below). |
+| **Business knowledge** *(new)* | If `docs.charCount > 5000` and `knowledge/` is empty (excluding `.gitkeep`), gate warns (not fails). If `.clify.json` `nuances.businessRules > 0`, gate fails when no `knowledge/*.md` with `type: business-rule` exists. |
+| **Secrets** | regex scan source for real key formats (`sk_live_`, `ghp_`, `Bearer [A-Za-z0-9_\-]{20,}`, etc.); fail if found. |
+| **CI** | `.github/workflows/test.yml` exists, parses as YAML, runs `npm test`. |
+| **Schema** | `.clify.json` matches schema; `.env.example` has `@required`+`@how-to-get` on auth var; `auth.validationCommand` and every `defaults[].detectCommand` resolves to a real resource-action. |
+
+`skills/clify-validate/SKILL.md` runs the gate, reports failures by category. The scaffold skill calls `clify validate ./-cli` at Step 7. If any check fails, generator fixes and re-runs (cap 3); on still-failing, surface failing check names to user — never declare done with failures.
+
+#### Coverage report contract: `coverage.json`
+
+Written by the scaffold skill at generation time, lives at the root of the generated repo.
+
+```json
+{
+ "parsedAt": "2026-04-25T12:00:00Z",
+ "totalParsed": 47,
+ "totalIncluded": 41,
+ "totalDropped": 6,
+ "endpoints": [
+ { "method": "GET", "path": "/users", "resource": "users", "action": "list", "included": true },
+ { "method": "POST", "path": "/users/bulk", "resource": null, "action": null, "included": false, "dropped": true, "reason": "user-excluded-step-5" },
+ { "method": "GET", "path": "/legacy/...", "resource": null, "action": null, "included": false, "dropped": true, "reason": "deprecated-in-docs" }
+ ]
+}
+```
+
+Validation gate fails if any entry has `included: false` without `dropped: true` + `reason`. Allowed reasons: `user-excluded-step-5`, `deprecated-in-docs`, `beta-flagged`, `internal-only`, `nesting-depth-cap`, `webhook-not-cli-shaped`, `streaming-not-cli-shaped`.
+
+#### Nuance Detection (new Step 4.5 in scaffold pipeline)
+
+After parsing endpoints (Step 4), scan the spec + prose for nuance categories. Each detected nuance must produce a corresponding artifact, enforced by the gate:
+
+| Nuance | Detection signal | Required artifact |
+|---|---|---|
+| **Pagination style** | `cursor`/`next_page_token`/`page`/`offset`/`Link: rel=next` in responses | `test/integration.test.mjs` includes a multi-page test exercising the strategy; `.clify.json` `nuances.pagination` set to one of `cursor|page|offset|link-header` |
+| **Rate limits** | `X-RateLimit-*` headers, "rate limit" / "requests per" prose | `knowledge/rate-limit.md` with documented limits, retry strategy, and which commands are most expensive |
+| **Auth scopes / permissions** | OAuth scopes list, "requires X permission" prose | `knowledge/auth-scopes.md` mapping each command to required scope(s) |
+| **Deprecated endpoints** | `deprecated: true` in OpenAPI, "deprecated" in prose | `knowledge/deprecated-.md` with replacement guidance OR exclusion in coverage report with reason `deprecated-in-docs` |
+| **Idempotency keys** | `Idempotency-Key` header documented | Mutating endpoints accept `--idempotency-key`; integration test asserts header is sent |
+| **Multi-part uploads** | `multipart/form-data` content type | `--file ` flag wired; integration test posts a fixture file |
+| **Conditional requests** | `If-Match` / `If-None-Match` / `ETag` documented | `--if-match` flag on relevant actions; knowledge note explaining concurrency model |
+| **Enum constraints** | `enum:` in OpenAPI, "must be one of" prose | per-action flag description includes allowed values; integration test for invalid value returns `validation_error` |
+| **Units / formats** | "amounts in cents", "ISO-8601", "UUIDs" prose | `knowledge/.md` with `type: business-rule` |
+| **Sequencing constraints** | "must X before Y" prose | `knowledge/.md` with `type: business-rule` |
+| **Plan/tier limits** | "free tier", "available on plan X" prose | `knowledge/plan-limits.md` |
+
+`.clify.json` records the nuance map:
+
+```json
+"nuances": {
+ "pagination": "cursor",
+ "rateLimits": true,
+ "authScopes": true,
+ "deprecated": ["legacyEndpoint"],
+ "idempotency": ["payments.create", "transfers.create"],
+ "multiPart": ["files.upload"],
+ "conditional": [],
+ "businessRules": 4
+}
+```
+
+#### Business knowledge extraction (Step 4.6)
+
+Second prose pass specifically for non-endpoint rules. Look for:
+- Units ("amounts are in cents", "weights in grams")
+- Time zones / formats ("ISO-8601 UTC", "epoch seconds")
+- Default behaviors ("if `status` is omitted, defaults to `active`")
+- Sequencing ("must verify before send", "create draft, then publish")
+- Plan/quota rules ("100 messages/day on free tier")
+- Error message conventions ("4xx body always has `errors[]`")
+- Identifier formats ("starts with `cus_`", "32-char hex")
+
+Each extracted rule becomes `knowledge/.md`:
+
+```yaml
+---
+type: business-rule
+source: docs
+extracted: 2026-04-25
+confidence: high
+applies-to: ["charges.create", "refunds.create"]
+---
+Amount fields are in the smallest currency unit (cents for USD).
+Pass 500 to charge $5.00, not 5.
+```
+
+The generated SKILL.md must include in its preamble: "Before running any command, read every file in `knowledge/`." This is already stated in the current skill skeleton — it will be reinforced and the gate will check the line is present.
+
+### 4. Scaffold skill rewrite: `skills/clify-scaffold/SKILL.md`
+
+Pipeline (steps 1–3 keep current logic — re-use prose from existing SKILL.md):
+
+1. **Fetch** the docs URL.
+2. **Detect** format (OpenAPI direct-parse vs HTML/Markdown crawl).
+3. **Crawl** if needed (depth 2, rate-limited).
+4. **Parse endpoints** — extract method, path, params, request/response shapes; group into resources.
+5. **Detect nuances** *(new)* — run the nuance scan from §3 above; populate `nuances` map.
+6. **Extract business knowledge** *(new)* — prose-mine for non-endpoint rules; queue knowledge files to write.
+7. **Consult and recommend** — present findings (endpoints, nuances, extracted rules) with opinionated recommendations; resolve gaps with `AskUserQuestion`. User approves or overrides.
+8. **Init repo from exemplar** — call `clify scaffold-init ` (binary verb): copies `examples/jsonplaceholder-cli/` to `./-cli/` and does mechanical renames (file paths, package name, plugin name, env var name placeholder). LLM never does this part.
+9. **Substitute API-specific content** — the LLM rewrites:
+ - Resource handlers in `bin/-cli.mjs` from parsed spec — keep section ordering, helpers (`apiRequest`, `paginated`, `output`, `errorOut`, `checkRequired`, `toParseArgs`, `showHelp`, `main`), and global-flag separation untouched.
+ - Auth (env var name final value, header scheme, validation command).
+ - `test/integration.test.mjs` fixtures matching target API shapes; nuance tests per §3 table.
+ - SKILL.md sections that reference resources (Triggers, Quick Reference, Common Workflows, Error Handling notes).
+ - `.env.example`, `.clify.json` (auth+defaults+nuances+coverage stats), `README.md` examples, `AGENTS.md`.
+10. **Write coverage + knowledge artifacts** — emit `coverage.json` from steps 4+7; write `knowledge/*.md` files queued in step 6.
+11. **Validate** — invoke `clify validate ./-cli/` (binary, no LLM). All seven gate categories must pass. Up to 3 fix-and-retry attempts.
+12. **Simplify** — keep `/simplify` invocation.
+13. **Report**.
+
+### 5. Sync skill: `skills/clify-sync/SKILL.md`
+
+Concept already exists in current SKILL.md Step 7; promote to dedicated skill. Workflow: read `.clify.json` → re-fetch docs → hash → diff against `contentHash` → regenerate changed parts (resources/actions only — never overwrite `knowledge/` or `.env`) → rerun validation gate → review knowledge files for staleness.
+
+### 6. Top-level binary: `bin/clify.mjs`
+
+Real CLI, not just a shim. Lives at `bin/clify.mjs`, registered in `package.json` `bin` field so `npx clify` works. Verbs:
+
+- `clify scaffold-init [--target ]` — deterministic file-copy + rename phase. Copies `examples/jsonplaceholder-cli/` to `/-cli/`, renames files, substitutes `jsonplaceholder` → `` and `JSONPLACEHOLDER` → `` everywhere. LLM-driven scaffold skill calls this at step 8 of its pipeline.
+- `clify validate ` — runs the full validation gate as pure JS, exits 0/1 with structured JSON report. Used by the scaffold skill (step 11), the sync skill, and CI.
+- `clify sync-check ` — re-fetches `docsUrl` from `.clify.json`, recomputes `contentHash`, prints diff summary. Sync skill uses this; LLM decides what to regenerate.
+- `clify scaffold ` — informational; prints "run `/clify-scaffold ` in Claude Code — generation needs LLM judgment".
+- `clify --version`, `clify --help`.
+
+`validate` and `scaffold-init` are the workhorses; both fully deterministic, both testable, both reproducible across agent vendors.
+
+### 7. Conventions update: `references/conventions.md`
+
+Edits, not a rewrite:
+- Replace skeleton references with `examples/jsonplaceholder-cli/` references.
+- Add **Test Override** subsection: every generated CLI honors `_BASE_URL` env var.
+- Add **Mock Server Contract** section linking to `examples/jsonplaceholder-cli/test/_mock-server.mjs`.
+- Add **Validation Gate** section linking to `references/validation-gate.md`.
+- Add **CI Workflow** section: `.github/workflows/test.yml` matrix (Node 20, 22), runs `npm test`.
+
+### 8. README rewrite
+
+Lead with: "Paste a URL. Get a tested, A+ grade CLI." Show the exemplar as proof. Include a section "What 'A+' means" pointing at validation-gate.md.
+
+## Files to Modify / Create / Delete
+
+**Create (new):**
+- `bin/clify.mjs` (verbs: `validate`, `scaffold-init`, `sync-check`, `scaffold` info, `--version`, `--help`)
+- `lib/validate.mjs` — validation gate impl, called from binary; also re-exported for skill use
+- `lib/scaffold-init.mjs` — file-copy + rename impl
+- `lib/sync-check.mjs` — hash-diff impl
+- `test/clify.test.mjs` — unit tests for the three lib modules
+- `skills/clify-scaffold/SKILL.md`
+- `skills/clify-sync/SKILL.md`
+- `skills/clify-validate/SKILL.md`
+- `examples/jsonplaceholder-cli/` (full repo, ~16 files: prior 14 plus `coverage.json` and one or more `knowledge/*.md` business-rule examples)
+- `references/conventions.md` (move from `skills/clify/references/conventions.md`)
+- `references/exemplar-walkthrough.md`
+- `references/validation-gate.md` (incl. coverage + nuance + knowledge layers)
+- `references/nuance-detection.md` (the table from §3 expanded with detection heuristics)
+- `.github/workflows/test.yml`
+
+**Modify:**
+- `.claude-plugin/plugin.json` — add `skills`, `capabilities`
+- `package.json` — add `bin`, `test` script
+- `README.md` — full rewrite
+
+**Delete:**
+- `skills/clify/SKILL.md`
+- `skills/clify/references/cli-skeleton.mjs`
+- `skills/clify/references/smoke-test-skeleton.mjs`
+- `skills/clify/references/skill-skeleton.md`
+- `skills/clify/references/conventions.md` (after move)
+- `skills/clify/` directory
+
+## Build Order
+
+1. **Conventions move + edits first** — `references/conventions.md` is the contract every other piece needs. Move from `skills/clify/references/` to top-level `references/`, add new sections (Test Override, Mock Server, Validation Gate, Coverage Report, Nuance Detection, Knowledge Extraction, CI).
+2. **Nuance detection reference** — `references/nuance-detection.md` with detection heuristics per category.
+3. **Exemplar `jsonplaceholder-cli`** — hand-craft, including `_mock-server.mjs`, integration tests, `coverage.json`, and at least 2 `knowledge/*.md` files (one business-rule, one pattern). Largest single piece (~16 files). Defines the shape every other piece depends on. JSONPlaceholder is auth-free, so the auth-scopes/rate-limit nuances will be empty in the exemplar — that's fine; the second exemplar (post-plan) will exercise those.
+4. **Validator impl** — `lib/validate.mjs` with all seven categories. Bootstrap by running against the exemplar until it passes cleanly. Then add deliberate-break tests in `test/clify.test.mjs`.
+5. **scaffold-init impl** — `lib/scaffold-init.mjs`. Tested by round-tripping the exemplar.
+6. **sync-check impl** — `lib/sync-check.mjs`.
+7. **`bin/clify.mjs`** — wire all verbs.
+8. **`references/validation-gate.md`** — written from `lib/validate.mjs` (impl is the source of truth; doc explains).
+9. **`.github/workflows/test.yml`** — runs `npm test` for clify (which validates the exemplar) and for the exemplar (smoke + integration). Matrix Node 20, 22.
+10. **Skill rewrites** — `clify-scaffold/SKILL.md` (the heaviest, with the new 13-step pipeline and explicit invocations of binary verbs), `clify-sync/SKILL.md`, `clify-validate/SKILL.md`.
+11. **`references/exemplar-walkthrough.md`** — line-by-line guide, written last after exemplar is final.
+12. **README + plugin.json + package.json** updates.
+13. **Delete old `skills/clify/`** tree.
+
+## Verification
+
+End-to-end checks before declaring done:
+
+1. **Exemplar self-test** — `cd examples/jsonplaceholder-cli && npm test` exits 0. Both smoke and integration suites green.
+2. **Exemplar live** — `node examples/jsonplaceholder-cli/bin/jsonplaceholder-cli.mjs posts list` returns real data from JSONPlaceholder. (Network test, run manually.)
+3. **Validation gate green on exemplar** — `node bin/clify.mjs validate examples/jsonplaceholder-cli` exits 0 with all seven categories passing (incl. coverage report, nuance artifacts, knowledge files).
+4. **Validation gate fails on broken exemplar** — manually break one thing per category (remove a help line; mismatch version; drop an endpoint from `coverage.json`; delete a nuance test; remove `knowledge/`); confirm gate fails with the named check. Restore.
+5. **`scaffold-init` round-trip** — `node bin/clify.mjs scaffold-init demo --target /tmp` produces a working repo; `cd /tmp/demo-cli && npm test` passes (it's the exemplar with renames only).
+6. **clify CI** — `.github/workflows/test.yml` runs `npm test` on Node 20 and 22; passes on push to branch.
+7. **Skill discovery** — `plugin.json` lists three skills; each `source` path resolves; each SKILL.md has frontmatter `name`+`description`+`allowed-tools`.
+8. **End-to-end scaffold (manual smoke)** — in a fresh Claude Code session, run `/clify-scaffold https://jsonplaceholder.typicode.com`. Expect output equivalent to `examples/jsonplaceholder-cli/`. All generated tests pass; `clify validate` green.
+9. **End-to-end scaffold against an authenticated API (manual stretch)** — pick a small public API with auth (e.g. The Movie DB). Generated CLI should pass validation gate, smoke tests, and integration tests; nuance artifacts present for any rate-limit / pagination / auth-scope detected; at least one `knowledge/*.md` business rule extracted from the docs.
+
+## Out of Scope
+
+- Runtime-driven dispatcher (Idea 1 dropped, per user).
+- Benchmark harness (skipped, per user).
+- Multiple exemplars (one is enough; auth-bearing exemplar like Calendly is a follow-up).
+- TypeScript / typed agent SDK (zero-dep constraint stays).
+- npm publish automation.
diff --git a/references/conventions.md b/references/conventions.md
new file mode 100644
index 0000000..d71d05a
--- /dev/null
+++ b/references/conventions.md
@@ -0,0 +1,459 @@
+# clify Conventions
+
+Rules and contracts for generated CLI repos. Rigid contracts (error format, `.clify.json` shape, env var override) **must** be followed exactly. Flexible guidance (command structure, nesting) should be adapted to the API.
+
+The hand-crafted exemplar at [`examples/jsonplaceholder-cli/`](../examples/jsonplaceholder-cli/) is the canonical implementation of every rule below. When a contract here is ambiguous, the exemplar wins.
+
+---
+
+## CLI Command Structure
+
+Generated CLIs use the resource-action pattern:
+
+```
+-cli [flags]
+```
+
+### Standard Actions
+
+| HTTP Method | Action | Notes |
+|-------------|--------|-------|
+| GET (collection) | `list` | Returns array |
+| GET (single) | `get` | Requires `--id` |
+| POST | `create` | |
+| PUT/PATCH | `update` | Requires `--id` |
+| DELETE | `delete` | Requires `--id` |
+
+Non-CRUD endpoints use the API's own verb (`capture`, `verify`, `merge-upstream`).
+
+### Nesting Depth
+
+Cap at two levels: ` ` or ` `. Flatten anything deeper with flags.
+
+### Resource Naming
+
+API's own terminology, kebab-case for multi-word (`api-keys`, `pull-requests`). Don't rename to camelCase or PascalCase.
+
+---
+
+## Global Flags
+
+Parsed before resource routing. Use a known-set filter — never `parseArgs({ strict: false })`.
+
+| Flag | Default | Behavior |
+|------|---------|----------|
+| `--json` | true when piped | Structured JSON output |
+| `--dry-run` | false | Show request without executing |
+| `--help`, `-h` | false | Show usage |
+| `--version`, `-v` | false | Print version |
+| `--verbose` | false | Include request/response headers |
+| `--all` | false | Auto-paginate |
+
+Reference impl: [`examples/jsonplaceholder-cli/bin/jsonplaceholder-cli.mjs`](../examples/jsonplaceholder-cli/bin/jsonplaceholder-cli.mjs).
+
+---
+
+## Per-Command Flags
+
+| Source | Flag style |
+|--------|-----------|
+| Path params | `--id`, `--repo` (required) |
+| Query params | Optional flags |
+| Body fields | Individual flags |
+| Raw body | `--body ` escape hatch |
+| Upload | `--file ` (multipart) |
+| Binary download | `--output ` |
+| Idempotency | `--idempotency-key ` (when API supports it) |
+| Concurrency | `--if-match ` (when API supports it) |
+
+---
+
+## Test Override (rigid contract)
+
+**Every generated CLI honors `_BASE_URL` env var to redirect requests to a mock server.** Default is the real API base URL. Without this, integration tests cannot reach the mock; the validation gate fails generation that omits this.
+
+```js
+const BASE_URL = process.env.JSONPLACEHOLDER_BASE_URL || "https://jsonplaceholder.typicode.com";
+```
+
+The env var name is `_BASE_URL` — same prefix used for the API key and other defaults.
+
+---
+
+## Mock Server Contract
+
+`test/_mock-server.mjs` exports a single function:
+
+```js
+const server = await mockApi({
+ "GET /posts": { status: 200, body: [{ id: 1, title: "x" }] },
+ "GET /posts/:id": (req, params) => ({ status: 200, body: { id: Number(params.id) } }),
+ "POST /posts": (req) => ({ status: 201, body: { id: 101, ...req.body } }),
+ "GET /rate": { status: 429, headers: { "retry-after": "30" } },
+});
+process.env.JSONPLACEHOLDER_BASE_URL = server.url; // overrides default in CLI
+// ...run CLI, then:
+assert.equal(server.requests[0].headers.authorization, "Bearer test");
+await server.close();
+```
+
+Determinism rules (must hold on Node 20 and 22):
+
+- `mockApi` listens on `127.0.0.1:0` (kernel-assigned port) and resolves `{ url, requests, close }` only after `listening` fires.
+- `server.url` is `http://127.0.0.1:` with no trailing slash.
+- Header keys in `server.requests[i].headers` are lowercased (Node default).
+- Body parsing: JSON content-type → `JSON.parse(rawBody)`; other → raw string.
+- `close()` returns a Promise that resolves only after all sockets are drained.
+- Routes match `METHOD /path` exactly; `:param` placeholders extract path params; no regex.
+
+Reference impl: [`examples/jsonplaceholder-cli/test/_mock-server.mjs`](../examples/jsonplaceholder-cli/test/_mock-server.mjs).
+
+---
+
+## Structured Error Output
+
+```json
+{
+ "type": "error",
+ "code": "rate_limited",
+ "message": "Rate limited. Retry after 30s.",
+ "retryable": true,
+ "retryAfter": 30
+}
+```
+
+| Code | Retryable | HTTP | Meaning |
+|------|-----------|------|---------|
+| `auth_missing` | no | — | No API key in `.env` |
+| `auth_invalid` | no | 401 | Key rejected |
+| `validation_error` | no | 400, 422 | Bad request |
+| `not_found` | no | 404 | Doesn't exist |
+| `forbidden` | no | 403 | Insufficient permissions |
+| `conflict` | no | 409 | State conflict |
+| `rate_limited` | yes | 429 | Too many requests |
+| `server_error` | yes | 5xx | API server error |
+| `network_error` | yes | — | Connection failed |
+| `timeout` | yes | — | Request exceeded timeout |
+
+Rules:
+- `retryAfter` is optional on any retryable error; populate from `Retry-After` header when present.
+- CLI never retries — retry logic lives in the SKILL.md wrapper.
+- Human-readable errors go to stderr; exit code is always 1 for errors.
+
+---
+
+## `.clify.json` Shape
+
+Root metadata, written by the scaffold skill, read by the validator and sync tooling.
+
+```json
+{
+ "apiName": "jsonplaceholder",
+ "docsUrl": "https://jsonplaceholder.typicode.com",
+ "crawledUrls": ["https://jsonplaceholder.typicode.com/guide/"],
+ "contentHash": "sha256:abc...",
+ "generatedAt": "2026-04-26T00:00:00Z",
+ "clifyVersion": "0.2.0",
+ "nodeMinVersion": "20",
+ "auth": {
+ "envVar": "JSONPLACEHOLDER_API_KEY",
+ "scheme": "none",
+ "validationCommand": "posts list"
+ },
+ "defaults": [],
+ "nuances": {
+ "pagination": null,
+ "rateLimits": false,
+ "authScopes": false,
+ "deprecated": [],
+ "idempotency": [],
+ "multiPart": [],
+ "conditional": [],
+ "businessRules": 0
+ },
+ "coverage": {
+ "totalParsed": 6,
+ "totalIncluded": 6,
+ "totalDropped": 0
+ }
+}
+```
+
+`auth.scheme` ∈ `bearer | api-key-header | basic | none`. `auth` is required (use `none` scheme for auth-free APIs); `defaults` defaults to `[]`.
+
+### Sync Behavior
+
+**Preserved across sync:** `.clify.json` (only `generatedAt`/`contentHash` updated), `knowledge/`, `.env`.
+**Regenerated on sync:** everything else.
+
+---
+
+## Coverage Report Contract
+
+`coverage.json` at repo root, written at generation time.
+
+```json
+{
+ "parsedAt": "2026-04-26T00:00:00Z",
+ "totalParsed": 47,
+ "totalIncluded": 41,
+ "totalDropped": 6,
+ "endpoints": [
+ { "method": "GET", "path": "/users", "resource": "users", "action": "list", "included": true },
+ { "method": "POST", "path": "/users/bulk", "resource": null, "action": null, "included": false, "dropped": true, "reason": "user-excluded-step-7" }
+ ]
+}
+```
+
+Allowed drop reasons: `user-excluded-step-7`, `deprecated-in-docs`, `beta-flagged`, `internal-only`, `nesting-depth-cap`, `webhook-not-cli-shaped`, `streaming-not-cli-shaped`.
+
+Validation gate fails if any entry has `included: false` without `dropped: true` + a valid `reason`. (Mapping correctness — one endpoint to N actions, merged endpoints — is out of scope for v0.2.)
+
+---
+
+## Nuance Detection
+
+Run after endpoint parsing. Each detected nuance produces a corresponding artifact. **Hard-fail** nuances (gate fails on missing artifact) and **soft-warn** nuances (gate warns, doesn't fail) are split:
+
+### Hard-fail (4)
+
+| Nuance | Detection signal | Required artifact |
+|---|---|---|
+| **Pagination** | `cursor`/`next_page_token`/`page`/`offset`/`Link: rel=next` in responses | `test/integration.test.mjs` includes a multi-page test exercising the strategy; `.clify.json` `nuances.pagination` set to `cursor \| page \| offset \| link-header` |
+| **Idempotency keys** | `Idempotency-Key` header documented | Mutating endpoints accept `--idempotency-key`; integration test asserts header is sent |
+| **Multipart uploads** | `multipart/form-data` content type | `--file ` flag wired; integration test posts a fixture file |
+| **Deprecated endpoints** | `deprecated: true` in OpenAPI, "deprecated" in prose | `knowledge/deprecated-.md` with replacement OR exclusion in `coverage.json` with reason `deprecated-in-docs` |
+
+### Soft-warn (downgraded for v0.2 — too prose-heavy to hard-fail)
+
+| Nuance | Detection signal | Suggested artifact |
+|---|---|---|
+| Rate limits | `X-RateLimit-*` headers, "rate limit" prose | `knowledge/rate-limit.md` |
+| Auth scopes | OAuth scopes, "requires X permission" | `knowledge/auth-scopes.md` |
+| Conditional requests | `If-Match` / `ETag` documented | `--if-match` flag + concurrency note |
+| Enum constraints | OpenAPI `enum:`, "must be one of" | per-action flag description includes allowed values |
+| Units / formats | "amounts in cents", "ISO-8601" | `knowledge/.md` `type: business-rule` |
+| Sequencing | "must X before Y" | `knowledge/.md` `type: business-rule` |
+| Plan/tier limits | "free tier", "available on plan X" | `knowledge/plan-limits.md` |
+
+Detection heuristics for each row are documented inline in [`references/validation-gate.md`](validation-gate.md).
+
+---
+
+## .env Rules
+
+- Read from REPO ROOT only.
+- Use `node:fs` — no dotenv library.
+- Don't override existing env vars (shell wins).
+- Strip surrounding quotes; skip blank lines and `#` comments.
+- `.env` is gitignored; `.env.example` documents required keys with placeholder values.
+- Auth env var: `_API_KEY` (uppercase, underscores).
+- Test override env var: `_BASE_URL` (see Test Override above).
+
+---
+
+## Setup Convention
+
+Setup lives in the generated SKILL.md — no CLI binary changes. The LLM follows API-specific instructions to collect credentials, validate auth, detect defaults.
+
+### `.env.example` Annotations
+
+| Tag | Meaning |
+|-----|---------|
+| `@required` | Setup must collect this |
+| `@optional` | Improves UX but not strictly needed |
+| `@how-to-get ` | Where to obtain |
+| `@format ` | Expected format |
+| `@validation-command ` | CLI command exercising this credential |
+| `@detect-command ` | Lists possible values |
+
+Reference: [`examples/jsonplaceholder-cli/.env.example`](../examples/jsonplaceholder-cli/.env.example).
+
+### Placeholder Detection
+
+Values matching `your_*_here` / `*_your_*_here` (case-insensitive), empty string, or the exact value from `.env.example` mean "not set".
+
+---
+
+## Knowledge File Schema
+
+Live in `knowledge/` in the generated repo:
+
+```yaml
+---
+type: gotcha | pattern | shortcut | quirk | business-rule
+command: "posts list" # optional
+applies-to: ["posts.create"] # optional, business-rule
+learned: 2026-04-26
+source: docs | runtime
+confidence: high | medium | low
+---
+
+Free-form markdown body.
+```
+
+Generated SKILL.md preamble must include: **"Before running any command, read every file in `knowledge/`."** Validation gate checks this line is present.
+
+---
+
+## CLI Source Conventions
+
+- Node.js ESM (`.mjs`)
+- `"type": "module"`, `"engines": { "node": ">=20" }`
+- **Zero external npm dependencies**
+- Native `fetch`, `node:util` `parseArgs`, `node:fs`, `node:path`, `node:crypto`, `node:http`
+
+### Code Structure
+
+```
+bin/-cli.mjs single-file CLI, all resources
+test/smoke.test.mjs smoke tests
+test/integration.test.mjs mock-server-driven integration tests
+test/_mock-server.mjs mock-server helper
+```
+
+Resource handlers are plain objects (not classes). One `apiRequest()` handles auth, dry-run, verbose, error mapping. Version read from package.json.
+
+### Three-level help
+
+`--help` → resources & actions. ` --help` → actions. ` --help` → per-action flags with required/optional + descriptions.
+
+Generated from the resource registry — agents can call ` --help` to learn flags without reading SKILL.md.
+
+---
+
+## Smoke Test Requirements
+
+Verify CLI structure, NOT API responses. Pass with no `.env` present.
+
+| Test | What it verifies |
+|------|------------------|
+| `--version` | Prints version from package.json |
+| `--help` | Lists all resources |
+| ` --help` | Lists actions per resource |
+| ` --help` | Per-action flags with descriptions |
+| Auth missing | Returns `auth_missing` error (when scheme ≠ none) |
+| `--dry-run` | Doesn't make real requests |
+| Unknown resource | Returns `validation_error` |
+| Unknown action | Returns `validation_error` listing available actions |
+| Required flag missing | Returns `validation_error` |
+| No hardcoded secrets | Source scan for API key patterns |
+| Resource coverage | Every resource & action reachable |
+
+`node:test`. Helpers `run(...args)`, `runJson(...args)`. Strip API key from env in helper. 5s timeout.
+
+Reference: [`examples/jsonplaceholder-cli/test/smoke.test.mjs`](../examples/jsonplaceholder-cli/test/smoke.test.mjs).
+
+---
+
+## Integration Test Requirements
+
+For each resource, exercise every declared action against `_mock-server.mjs`:
+
+- `list` → returns array; multi-page test if pagination detected
+- `get` → returns single object; 404 → `not_found`
+- `create` → echoes payload; 422 → `validation_error`
+- `update` → mutates; 404 → `not_found`
+- `delete` → 204
+- Rate-limit path: 429 with `Retry-After` → `rate_limited` with `retryAfter`
+- Auth path (when scheme ≠ none): missing key → `auth_missing`; bad key → `auth_invalid`
+- Network down: dial unreachable port → `network_error`
+
+Reference: [`examples/jsonplaceholder-cli/test/integration.test.mjs`](../examples/jsonplaceholder-cli/test/integration.test.mjs).
+
+---
+
+## CI Workflow
+
+`.github/workflows/test.yml` in every generated repo:
+
+- Triggers: push, pull_request
+- Matrix: Node 20, 22
+- Step: `npm test` (runs both smoke and integration)
+
+Reference: [`examples/jsonplaceholder-cli/.github/workflows/test.yml`](../examples/jsonplaceholder-cli/.github/workflows/test.yml).
+
+---
+
+## Generated Repo Structure
+
+```
+-cli/
+├── bin/-cli.mjs
+├── skills/
+│ ├── /SKILL.md
+│ └── sync/SKILL.md
+├── knowledge/ # initially has .gitkeep + any business-rules extracted
+├── test/
+│ ├── smoke.test.mjs
+│ ├── integration.test.mjs
+│ └── _mock-server.mjs
+├── .claude-plugin/
+│ ├── plugin.json
+│ └── marketplace.json
+├── .github/workflows/test.yml
+├── .clify.json
+├── coverage.json
+├── package.json
+├── .env.example
+├── .gitignore
+├── AGENTS.md
+├── README.md
+└── LICENSE
+```
+
+---
+
+## package.json for Generated Repos
+
+```json
+{
+ "name": "-cli",
+ "version": "0.1.0",
+ "description": "CLI for the API. Generated by clify.",
+ "type": "module",
+ "bin": { "-cli": "./bin/-cli.mjs" },
+ "engines": { "node": ">=20" },
+ "scripts": { "test": "node --test test/*.test.mjs" },
+ "license": "MIT"
+}
+```
+
+No dependencies. No devDependencies.
+
+---
+
+## Plugin Files (`.claude-plugin/`)
+
+`package.json` is authoritative for `name`, `version`, `description`. `plugin.json` and `marketplace.json` must match exactly. `engines` lives only in `package.json`.
+
+### plugin.json
+
+```json
+{
+ "name": "-cli",
+ "version": "0.1.0",
+ "description": "CLI for the API. Generated by clify.",
+ "author": { "name": "" },
+ "license": "MIT",
+ "skills": [
+ { "name": "", "source": "skills//SKILL.md" },
+ { "name": "sync", "source": "skills/sync/SKILL.md" }
+ ],
+ "capabilities": ["network"]
+}
+```
+
+### marketplace.json
+
+```json
+{
+ "name": "-cli",
+ "description": "CLI for the API. Generated by clify.",
+ "version": "0.1.0",
+ "author": { "name": "" },
+ "source": "./"
+}
+```
+
+The validator checks: every required field present; `name`/`version`/`description` match across all three manifests; every `skills[].source` path resolves.
diff --git a/references/validation-gate.md b/references/validation-gate.md
new file mode 100644
index 0000000..8cf87be
--- /dev/null
+++ b/references/validation-gate.md
@@ -0,0 +1,127 @@
+# Validation Gate
+
+`clify validate ` runs the gate as pure JS. The implementation lives in [`lib/validate.mjs`](../lib/validate.mjs); this doc explains the contract.
+
+The gate has **eight categories**. A category passes when all its checks pass. The gate as a whole passes when every category passes; warnings never fail the gate.
+
+```
+clify validate ./-cli # human-readable
+clify validate ./-cli --json # JSON report
+```
+
+Exit code: 0 on pass, 1 on any failure. The scaffold skill calls this at step 11 and surfaces named failures back to the LLM, which gets up to 3 fix-and-retry attempts before stopping.
+
+---
+
+## 1. manifest
+
+| Check | Pass criterion |
+|---|---|
+| `package.json` present | File exists at root |
+| `package.json` shape | `name`, `version`, `description`, `type: "module"`, `engines.node >= 20`, `bin` object, `scripts.test` |
+| `.claude-plugin/plugin.json` present | File exists |
+| `.claude-plugin/plugin.json` shape | `name`, `version`, `description`, `skills`, `capabilities` |
+| Skill `source` paths resolve | Every `skills[].source` points to an existing SKILL.md |
+| Skill frontmatter | Each SKILL.md begins with `---` and contains `name:` + `description:` |
+| `.claude-plugin/marketplace.json` present | File exists |
+| `.claude-plugin/marketplace.json` shape | `name`, `version`, `description`, `source` |
+| Cross-manifest match | `name`, `version`, `description` match across `package.json` ↔ `plugin.json` ↔ `marketplace.json` |
+| `.clify.json` present + valid | Required fields: `apiName`, `docsUrl`, `contentHash`, `generatedAt`, `clifyVersion`, `auth` (with `envVar`, `scheme ∈ {bearer,api-key-header,basic,none}`, `validationCommand`) |
+| `.env.example` present | If `auth.scheme ≠ none`, must include `@required` and `@how-to-get` annotations on the auth var |
+| BASE_URL override | CLI source references `_BASE_URL` env var |
+| `bin` path resolves | The path in `package.json` `bin` exists |
+
+## 2. coverage
+
+| Check | Pass criterion |
+|---|---|
+| `coverage.json` present + parseable | |
+| Required fields | `totalParsed`, `totalIncluded`, `totalDropped`, `endpoints` |
+| Per-endpoint shape | Each entry has `method`, `path`. Included entries have `resource` + `action`. Dropped entries have `dropped: true` + `reason ∈ {user-excluded-step-7, deprecated-in-docs, beta-flagged, internal-only, nesting-depth-cap, webhook-not-cli-shaped, streaming-not-cli-shaped}` |
+| Counts match | `totalIncluded` and `totalDropped` match what's in `endpoints[]` |
+| No silent drops | `included: false` always paired with `dropped: true` + a valid reason |
+
+> **Out of scope for v0.2:** the gate does not validate endpoint-to-action mapping correctness (one endpoint to N actions, merged endpoints). Mapping is LLM judgment captured in `coverage.json` and reviewed by the user at step 7 of the scaffold pipeline.
+
+## 3. structural
+
+| Check | Pass criterion |
+|---|---|
+| `--help` lists every resource | Every included resource appears in root `--help` output |
+| Resource `--help` lists every action | For each resource, ` --help` lists every action declared for it |
+| SKILL.md references every resource | Generated `skills//SKILL.md` mentions every included resource |
+| SKILL.md mentions `knowledge/` | The preamble instructs the agent to read every file in `knowledge/` |
+
+## 4. nuances
+
+The hard-fail set is intentionally small (4 signals). Soft signals produce **warnings** — visible in the report but never failing the gate. The full table is in [`conventions.md`](conventions.md#nuance-detection).
+
+### Hard-fail signals
+
+| `.clify.json` field | Signal in docs | Required artifact |
+|---|---|---|
+| `nuances.pagination` set to `cursor \| page \| offset \| link-header` | `cursor`, `next_page_token`, `page`, `offset`, `Link: rel=next` in responses | `test/integration.test.mjs` includes a multi-page test exercising the strategy |
+| `nuances.idempotency` non-empty | `Idempotency-Key` header documented | CLI source contains `idempotency-key`; integration test asserts the header is sent |
+| `nuances.multiPart` non-empty | `multipart/form-data` content type | CLI source contains `FormData`/`multipart`; integration test posts a file via `--file` |
+| `nuances.deprecated` non-empty | `deprecated: true` in OpenAPI; "deprecated" prose | Either a `knowledge/deprecated-*.md` file or a `coverage.json` entry with `reason: deprecated-in-docs` |
+| `nuances.businessRules > 0` | Prose-mined rules (units, sequencing, tier limits) | At least one `knowledge/*.md` with `type: business-rule` frontmatter |
+
+### Soft-warn signals
+
+`rateLimits`, `authScopes`, `conditional`, enums, units, sequencing, plan/tier limits — see [`conventions.md`](conventions.md#nuance-detection). These are emitted to `warnings[]` in the report.
+
+### Detection heuristics (used by the scaffold skill, not the gate)
+
+The scaffold skill is the one that *populates* `nuances`. The gate just verifies that whatever was claimed has a corresponding artifact. Heuristics:
+
+- **pagination** — search response schemas for `cursor`/`nextPageToken`/`next_cursor` (cursor); `page`/`per_page`/`pageSize` (page); `offset`/`limit` only (offset); `Link` header with `rel="next"` (link-header).
+- **rate limits** — response headers matching `^x-ratelimit` or prose containing "rate limit"/"requests per".
+- **auth scopes** — OpenAPI `securitySchemes` with `flows` containing `scopes`, or prose mentioning "requires X permission/scope".
+- **deprecated** — OpenAPI `deprecated: true`, or `` / strikethrough / "deprecated" prose adjacent to an endpoint.
+- **idempotency** — header `Idempotency-Key` documented on at least one mutating endpoint.
+- **multipart** — request body `content` with `multipart/form-data`.
+- **conditional** — `If-Match`, `If-None-Match`, or `ETag` documented.
+- **enums** — OpenAPI `enum:`, "must be one of" prose.
+- **business-rule** — prose pattern-matched on units ("amounts in cents", "weights in grams"), formats ("ISO-8601", "epoch seconds", "starts with `cus_`"), defaults ("if X is omitted, defaults to Y"), sequencing ("must X before Y"), plan/tier ("free tier", "available on plan X").
+
+## 5. secrets
+
+Regex scan over all `.mjs`, `.js`, `.json`, `.md`, `.yml`, and `.env.example` files. Patterns:
+
+- Stripe live keys: `sk_live_[A-Za-z0-9]{20,}`
+- GitHub PATs: `ghp_…`, `github_pat_…`
+- Slack bot: `xoxb-…`
+- AWS access key: `AKIA[0-9A-Z]{16}`
+- OpenAI key: `sk-[A-Za-z0-9]{32,}`
+- Bearer literal: `Bearer\s+[A-Za-z0-9_\-]{30,}`
+
+A match in any file fails the gate.
+
+## 6. ci
+
+| Check | Pass criterion |
+|---|---|
+| `.github/workflows/test.yml` present | |
+| Runs `npm test` | The YAML body contains `npm test` |
+| Uses `setup-node` | The YAML body uses `actions/setup-node` |
+
+YAML is checked as plain text — no parser dependency.
+
+## 7. tests
+
+`npm test` runs in the repo and exits 0. (The exemplar's smoke + integration suites both run under one `node --test test/*.test.mjs` script.) Disabled with `--skip-tests` for fast manifest-only iteration.
+
+## 8. (reserved)
+
+The implementation tracks a separate `warnings[]` channel that surfaces soft-warn nuances and other advisory findings. Warnings never block the gate.
+
+---
+
+## Adding a new check
+
+1. Add the check to the appropriate category function in `lib/validate.mjs`.
+2. Push to `ctx.results` with `pass(category, name, details)` or `fail(category, name, details)`.
+3. Add a deliberate-break test to `test/clify.test.mjs` that breaks one input and asserts the failure surfaces.
+4. Document the row in this file.
+
+The implementation is the source of truth — when this doc and `lib/validate.mjs` disagree, the implementation wins. Update this doc to match.
diff --git a/skills/clify-scaffold/SKILL.md b/skills/clify-scaffold/SKILL.md
new file mode 100644
index 0000000..7425d82
--- /dev/null
+++ b/skills/clify-scaffold/SKILL.md
@@ -0,0 +1,129 @@
+---
+name: clify-scaffold
+description: Generate an A+ Node.js CLI repo from an API documentation URL. Use when the user says "/clify-scaffold ", "/clify ", "generate a CLI for this API", or provides API docs and asks for an agent-friendly wrapper. Works by copying the bundled JSONPlaceholder exemplar and mechanically substituting API-specific content, then running the validation gate.
+allowed-tools:
+ - Read
+ - Write
+ - Edit
+ - Bash
+ - Glob
+ - Grep
+ - WebFetch
+ - WebSearch
+ - AskUserQuestion
+---
+
+# clify-scaffold
+
+Generate a self-updating CLI repo from an API documentation URL. Output is installable as a Claude Code plugin, Codex agent, or standalone Node CLI.
+
+The strategy is **copy exemplar + mechanically substitute**, not "write from scratch." Quality comes from the exemplar (which is hand-crafted, tested, validated) — your job is the smaller, more verifiable transformation of API-specific content.
+
+## Triggers
+
+- `/clify-scaffold `, `/clify `
+- "Generate a CLI for this API"
+- User provides an API docs URL and asks for a wrapper
+
+## Read these first
+
+1. [`references/conventions.md`](../../references/conventions.md) — every contract you must honor.
+2. [`references/validation-gate.md`](../../references/validation-gate.md) — every check the gate enforces. The gate is the source of truth for "done."
+3. [`examples/jsonplaceholder-cli/`](../../examples/jsonplaceholder-cli/) — the canonical exemplar. Section ordering, helper signatures, and code structure must match it.
+
+## Pipeline (13 steps)
+
+### 1. Fetch the docs
+
+`WebFetch ` for the user-provided URL.
+
+### 2. Detect format
+
+- **OpenAPI/Swagger** (JSON or YAML with `openapi` or `swagger` key) → parse directly. Skip crawling.
+- **HTML / Markdown** → proceed to step 3.
+
+### 3. Crawl (HTML/Markdown only)
+
+Identify links to more API doc pages (skip nav, marketing, changelog). Fetch up to depth 2, dedupe by normalized URL, rate-limit at 1s between fetches. If the page is JS-rendered with empty content, ask the user for an OpenAPI URL instead.
+
+### 4. Parse endpoints
+
+Extract method, path, params, request/response shapes. Group by first path segment as resource. Apply standard verb mapping from `conventions.md` (CRUD); use the API's own verb for non-CRUD endpoints.
+
+### 5. Detect nuances
+
+Scan spec + prose for the nuances table in [`references/validation-gate.md`](../../references/validation-gate.md#nuances). Populate the `.clify.json` `nuances` map. **Hard-fail nuances** (pagination, idempotency, multipart, deprecated) require corresponding artifacts; **soft-warn nuances** are advisory.
+
+### 6. Extract business knowledge
+
+Second prose pass for non-endpoint rules (units, formats, defaults, sequencing, plan limits, identifier formats). Each rule queues a `knowledge/.md` file with `type: business-rule` frontmatter. See `examples/jsonplaceholder-cli/knowledge/writes-do-not-persist.md` for the shape.
+
+### 7. Consult and recommend
+
+Present findings to the user with opinionated recommendations. Use `AskUserQuestion` for unresolved tradeoffs (resource grouping, ambiguous verbs, what to drop). Record dropped endpoints with one of the allowed reasons (`user-excluded-step-7`, `deprecated-in-docs`, etc.) — never silently drop.
+
+### 8. Init repo from exemplar (binary verb)
+
+```
+clify scaffold-init --target
+```
+
+This is the **deterministic copy + rename phase** — never do it by hand. Copies `examples/jsonplaceholder-cli/` to `/-cli/` and rewrites `jsonplaceholder` → ``, `JSONPLACEHOLDER` → ``, `JSONPlaceholder` → `` everywhere.
+
+### 9. Substitute API-specific content
+
+Edit only what changes per API; preserve section ordering and helper signatures from the exemplar.
+
+- `bin/-cli.mjs`: replace the resource registry with the parsed endpoints. Keep `loadEnv`, `apiRequest`, `output`, `errorOut`, `splitGlobal`, `toParseArgs`, `checkRequired`, `showRootHelp`, `showResourceHelp`, `showActionHelp`, `main` exactly as in the exemplar. Body builders (`buildPayload`, `bodyFlagsFor`) get rewritten per API.
+- Auth: set `auth.scheme` and add the auth header in `apiRequest` (`Authorization: Bearer ...`, `X-API-Key: ...`, etc.). For `scheme: none`, leave the helper untouched.
+- `test/integration.test.mjs`: rewrite per-resource fixtures to match target API shapes; add nuance tests as required by §5.
+- `skills//SKILL.md`: rewrite Triggers, Quick Reference table, Common Workflows, Error Handling notes. Leave structure intact.
+- `.env.example`: set the auth env var with `@required`/`@how-to-get` annotations (skip for `scheme: none`).
+- `.clify.json`: fill `auth`, `defaults`, `nuances`, `coverage`, `contentHash` from the parsed spec.
+- `README.md`, `AGENTS.md`: regenerate examples that mention specific resources.
+
+### 10. Write coverage + knowledge artifacts
+
+Emit `coverage.json` with every parsed endpoint marked `included: true` or `dropped: true` + reason. Write knowledge files queued in step 6.
+
+### 11. Validate (binary verb)
+
+```
+clify validate ./-cli
+```
+
+All eight categories must pass. Per-failure remediation:
+
+- **manifest** — fix the named field; cross-manifest mismatches usually mean `package.json` was edited but `plugin.json` wasn't.
+- **coverage** — every `included: false` needs `dropped: true` + a valid `reason`.
+- **structural** — a resource or action you generated isn't reachable; check the registry vs the help-text generator.
+- **nuances** — declared a hard-fail nuance but didn't add the artifact. Either add it or set the nuance to empty.
+- **secrets** — a real key pattern leaked; remove it.
+- **ci** — `.github/workflows/test.yml` missing or doesn't run `npm test`.
+- **tests** — exemplar smoke/integration tests fail because of a typo in the renamed registry. Read the test output and fix the source.
+
+Up to **3 fix-and-retry attempts**. On still-failing, surface the failed check names to the user and stop — do not declare done with failures.
+
+### 12. Simplify
+
+Run `/simplify` over the changed files. Remove dead code, collapse duplicated patterns, but never break a contract from `conventions.md`.
+
+### 13. Report
+
+Summarize to the user: API name, resources × actions count, dropped endpoints with reasons, declared nuances, knowledge files written, validation gate result. Include the path to the generated repo and `cd && npm test` as the next step.
+
+## Anti-patterns
+
+- ❌ Don't write the CLI from scratch — copy the exemplar via `scaffold-init`.
+- ❌ Don't skip validation — the gate is the contract.
+- ❌ Don't mark a generated repo "done" while validation is failing.
+- ❌ Don't silently drop endpoints — every drop needs a reason in `coverage.json`.
+- ❌ Don't write nuance prose into `knowledge/` if the corresponding `.clify.json.nuances.*` field isn't set; the gate cross-references them.
+- ❌ Don't put auth tokens in source. The gate scans for them and will flag real-shaped tokens.
+
+## Edge cases
+
+- **API has no auth** → `auth.scheme: "none"`, leave `auth.envVar` set to `_API_KEY` for shape consistency, skip the `@required` annotation in `.env.example`. The gate accepts this.
+- **API uses cookie auth** → not supported in v0.2; ask the user for a session-token alternative or stop with a clear message.
+- **API only documented as a curl tutorial** (no spec, no structured docs) → ask the user for an OpenAPI URL or to confirm the resources by hand.
+- **Resource has 12 actions** → flatten with sub-resources or hyphenated action names; do not exceed the two-level nesting cap.
diff --git a/skills/clify-sync/SKILL.md b/skills/clify-sync/SKILL.md
new file mode 100644
index 0000000..47c0090
--- /dev/null
+++ b/skills/clify-sync/SKILL.md
@@ -0,0 +1,85 @@
+---
+name: clify-sync
+description: Re-fetch the docs for a generated clify CLI, detect upstream drift via content hash, and regenerate the resource registry, integration tests, and SKILL.md if the docs have changed. Preserves knowledge/, .env, and immutable .clify.json fields. Use when the user says "/clify-sync ", "sync this CLI", or asks if a generated CLI is up to date.
+allowed-tools:
+ - Bash
+ - Read
+ - Write
+ - Edit
+ - WebFetch
+---
+
+# clify-sync
+
+Re-fetch upstream docs and detect drift for a generated clify CLI. If the content hash has changed, regenerate the parts that derive from docs (CLI source, integration tests, SKILL.md sections, coverage). Preserve user-managed state (`knowledge/`, `.env`, `.clify.json` apiName + docsUrl).
+
+## Triggers
+
+- `/clify-sync `
+- "Sync this CLI" / "Is this CLI up to date with the docs?"
+
+## Workflow
+
+### 1. Read state
+
+```
+cat /.clify.json
+```
+
+Note `apiName`, `docsUrl`, `crawledUrls`, `contentHash`, `clifyVersion`.
+
+### 2. Hash check (binary verb)
+
+```
+clify sync-check --json
+```
+
+Re-fetches every URL in `crawledUrls`, recomputes the SHA-256, and reports `{ changed: bool, oldHash, newHash, fetched[] }`. This is **advisory only** — it does not regenerate.
+
+If `changed: false`, exit with "no upstream changes since last generation".
+
+### 3. Re-parse + re-detect
+
+If `changed: true`:
+- Re-fetch the docs (the `sync-check` already fetched once; re-use the bodies if convenient).
+- Re-parse endpoints (step 4 of the scaffold pipeline).
+- Re-detect nuances (step 5).
+- Re-extract business knowledge (step 6) — but **do not** overwrite existing `knowledge/*.md` files; queue suggested additions.
+
+### 4. Regenerate docs-derived files
+
+- `bin/-cli.mjs` — resource registry only (preserve helpers).
+- `test/integration.test.mjs` — fixtures + nuance tests.
+- `skills//SKILL.md` — Triggers, Quick Reference, Common Workflows.
+- `coverage.json` — full rewrite.
+- `.clify.json` — update `contentHash`, `generatedAt`, `nuances`, `coverage`. Keep `apiName`, `docsUrl`, `auth`.
+
+**Do NOT touch:** `knowledge/`, `.env`, `package.json` `name`/`version` (user controls bumping), `LICENSE`, `README.md` (unless the user opts in).
+
+### 5. Validate
+
+```
+clify validate
+```
+
+Same loop as the scaffold skill: up to 3 fix attempts.
+
+### 6. Knowledge review
+
+For every file in `knowledge/`, check if `applies-to:` references resources/actions still in the new registry. If not, flag for the user (don't auto-delete).
+
+### 7. Report
+
+Summarize:
+- old vs new hash
+- endpoints added / removed / unchanged
+- knowledge files flagged for review
+- nuances changes
+- validation gate result
+
+## Anti-patterns
+
+- ❌ Don't overwrite `knowledge/`, `.env`, or user-edited README content.
+- ❌ Don't auto-bump `package.json` version — the user owns that.
+- ❌ Don't run regeneration without first checking if the hash actually changed.
+- ❌ Don't claim sync is "complete" if the validation gate fails on the regenerated repo.
diff --git a/skills/clify-validate/SKILL.md b/skills/clify-validate/SKILL.md
new file mode 100644
index 0000000..84ab208
--- /dev/null
+++ b/skills/clify-validate/SKILL.md
@@ -0,0 +1,63 @@
+---
+name: clify-validate
+description: Run the clify validation gate against a generated CLI repo and report failures by category. Use when the user says "/clify-validate", "validate this repo", or after generation/modification of a clify-shaped CLI to confirm it still meets A+ standards.
+allowed-tools:
+ - Bash
+ - Read
+---
+
+# clify-validate
+
+Run the validation gate (eight categories: manifest, coverage, structural, nuances, secrets, ci, tests, plus advisory warnings) against a generated CLI repo. Report failures grouped by category. Never claim "valid" while any check is failing.
+
+The gate is implemented in [`lib/validate.mjs`](../../lib/validate.mjs) and documented in [`references/validation-gate.md`](../../references/validation-gate.md).
+
+## Triggers
+
+- `/clify-validate `
+- "Validate this CLI"
+- After editing a clify-shaped repo, before committing
+
+## Workflow
+
+1. Run the gate with structured output:
+
+ ```
+ clify validate --json
+ ```
+
+2. Parse the JSON report:
+ - `summary.failed > 0` → at least one check failed
+ - `warnings[]` → soft-warn nuances, never fail the gate
+
+3. Group failures by `results[].category`. Quote the failure `name` and any `error` / `missing` / `failures` / `issues` / `offenders` field.
+
+4. Fix the underlying issue. Common patterns:
+
+ | Category | Likely cause |
+ |---|---|
+ | manifest | `package.json` ↔ `plugin.json` ↔ `marketplace.json` desync, or `bin` path renamed without updating the manifest. |
+ | coverage | An endpoint was dropped without `dropped: true` + a valid `reason`. |
+ | structural | A resource or action exists in the registry but isn't in `--help` output (or vice versa). |
+ | nuances | A nuance was declared in `.clify.json` but the artifact (test, knowledge file, CLI flag) is missing. |
+ | secrets | A real-shaped API key is hardcoded in a source file. |
+ | ci | `.github/workflows/test.yml` missing or doesn't invoke `npm test`. |
+ | tests | The repo's `npm test` script failed — read `stderrTail`. |
+
+5. Re-run `clify validate `. Loop until 0 failures, capped at 3 attempts. If still failing, surface the failed check names verbatim and stop.
+
+## Anti-patterns
+
+- ❌ Don't claim a repo passes when warnings exist but you didn't read them — the user may want to act on warnings.
+- ❌ Don't bypass a check by editing `lib/validate.mjs`. The check is there for a reason; fix the input.
+- ❌ Don't run `clify validate` on a non-generated repo and expect it to pass.
+
+## Quick reference
+
+```
+clify validate # human-readable
+clify validate --json # machine-parseable
+clify validate --skip-tests # manifests only, fast
+```
+
+Exit code: 0 on full pass, 1 on any failure.
diff --git a/skills/clify/SKILL.md b/skills/clify/SKILL.md
deleted file mode 100644
index c61b80a..0000000
--- a/skills/clify/SKILL.md
+++ /dev/null
@@ -1,361 +0,0 @@
----
-name: clify
-description: Generate a self-updating CLI repo from an API documentation URL. Use when the user says "/clify ", "generate a CLI for this API", or wants to create agent-friendly API wrappers.
-allowed-tools:
- - Read
- - Write
- - Bash
- - Glob
- - Grep
- - WebFetch
- - WebSearch
- - Edit
- - AskUserQuestion
- - Agent
----
-
-# clify — Generate CLI Repos from API Docs
-
-Generate a complete, self-updating CLI repo from an API documentation URL. The output is installable as a Claude Code plugin, Codex agent, or standalone Node.js CLI.
-
-## Triggers
-
-Use when the user:
-- Says `/clify `
-- Asks to "generate a CLI for this API"
-- Wants to create an agent-friendly API wrapper
-- Provides an API documentation URL and asks for a CLI
-
-## Prerequisites
-
-- Node.js >= 20 (for `fetch`, `parseArgs`)
-- Internet access (to fetch API docs)
-
-## Read References Before Generating
-
-Before generating any code, read these files:
-
-1. **Conventions:** `references/conventions.md` — contracts and rules for generated output
-2. **CLI skeleton:** `references/cli-skeleton.mjs` — annotated code pattern for the CLI executable
-3. **Test skeleton:** `references/smoke-test-skeleton.mjs` — annotated code pattern for smoke tests
-4. **Skill skeleton:** `references/skill-skeleton.md` — annotated template for the generated SKILL.md (includes setup flow)
-
-Adapt the patterns to the target API — don't copy verbatim.
-
-## Pipeline
-
-### Step 1: Fetch the docs
-
-Fetch the user-provided URL:
-
-```
-WebFetch
-```
-
-### Step 2: Detect format
-
-Check if the response is a structured API spec:
-
-- **OpenAPI/Swagger:** JSON or YAML with an `openapi` or `swagger` key → parse the spec directly. Skip crawling — structured specs are far more accurate than crawled HTML.
-- **HTML/Markdown docs:** Proceed to Step 3 (crawl).
-
-For OpenAPI specs, extract directly:
-- Base URL from `servers[0].url`
-- Auth scheme from `securityDefinitions` or `components.securitySchemes`
-- All paths, methods, parameters, request bodies, responses
-- Group endpoints by the first path segment (resource)
-
-### Step 3: Crawl (HTML/Markdown docs only)
-
-If the docs are HTML or Markdown (not a structured spec):
-
-1. Read the fetched content and identify links that point to more API documentation
-2. Distinguish doc links from navigation, marketing, and changelog links
-3. Fetch identified doc links up to depth 2 (configurable)
-4. Deduplicate by URL (normalize trailing slashes, strip query params)
-5. Rate-limit: wait 1 second between fetches
-6. Combine all fetched content into a single document
-
-**Edge cases:**
-- JS-rendered SPA with empty content → warn user: "This appears to be a JavaScript-rendered page. Consider providing an OpenAPI spec URL instead."
-- Auth-required docs → ask user for cookies or headers, pass them on subsequent fetches
-- 404/timeout on sub-pages → skip with warning, continue with available content
-
-**Recovery:** If crawled content is minimal (<500 chars or zero endpoint signatures found), stop and ask the user via `AskUserQuestion`:
-- "Crawling returned very little usable content. Do you have an OpenAPI/Swagger spec URL, or can you paste the API reference directly?"
-- Do NOT proceed to Step 4 with insufficient content — parsing will fail silently.
-
-### Step 4: Parse
-
-From the combined doc content, extract a structured API specification:
-
-- **API name** (from title, branding, or domain)
-- **Base URL**
-- **Auth scheme** (Bearer token, API key header, Basic auth, OAuth2, etc.)
-- **Auth env var name** (e.g., `GITHUB_TOKEN`, `STRIPE_API_KEY`)
-- **Endpoints** — for each:
- - HTTP method
- - Path (with path params identified)
- - Description
- - Parameters (path, query, body) with types and required flags
- - Response shape (if documented)
-- **Resources** — group endpoints by resource (first path segment)
-- **Actions** — map each endpoint to a resource-action pair following conventions
-
-**Completeness check** — after parsing, assess the spec:
-
-| Gap | Action |
-|---|---|
-| <3 endpoints found | Flag as "incomplete spec" — present what you found in Step 5 and ask user to fill gaps |
-| Auth scheme missing or unclear | Ask in Step 5 — don't guess. Default to Bearer if docs show `Authorization` header examples |
-| No base URL found | Infer from doc URL domain, confirm in Step 5 |
-| Response shapes undocumented | Acceptable — note in generated SKILL.md as "response shape unspecified" |
-| >50 endpoints detected | Flag as "large API" — in Step 5, offer to generate all or focus on a resource subset |
-
-Do NOT silently proceed with an incomplete spec. Step 5 exists to resolve gaps with the user.
-
-**Pervasive parameter scan:** After extracting endpoints, identify parameters that appear in >50% of non-list endpoints (excluding standard pagination: `limit`, `offset`, `cursor`, `page`, `after`, `before`). These become "default candidates" — values the user should configure once in `.env` rather than pass on every command. For each candidate, check if there's a `list` or `get` action for the matching resource (e.g., `workspaces list` for `workspace_id`) — record it as the detection command.
-
-### Step 5: Consult and recommend
-
-This step has two phases: **present findings with recommendations**, then **ask clarifying questions** using `AskUserQuestion`. Use your judgment — not every API needs every question. Only ask what matters for this specific API.
-
-#### 5a: Present findings and recommendations
-
-Present the parsed spec with opinionated recommendations. Every recommendation must include a **why**.
-
-```
-Detected API: ****
-Base URL: https://api.example.com
-Auth: Bearer token via EXAMPLE_API_KEY
-
-Resources (X endpoints):
- — list, get, create, update, delete
- — list, get, create, verify
- — list, create, delete
-
-CLI name: -cli
-Output: ./-cli/
-```
-
-If pervasive parameter scan (Step 4) found default candidates, present them:
-
-```
-Setup defaults (parameters used across many endpoints):
- - `workspace_id` (used in 80% of endpoints) → ACME_WORKSPACE_ID
- Discoverable via: workspaces list
- Recommendation: include in setup flow
-```
-
-If no defaults were detected, note: "Setup: auth only (no pervasive parameters detected)."
-
-Then present recommendations as a numbered list. Each must include a **why**:
-
-```
-Recommendations:
-
-1. **CLI name: `-cli`** — matches the API's brand name and
- npm naming convention.
-
-2. **Drop `` resource** — only 1 endpoint, beta-flagged in
- docs, response shape undocumented. Can add later via sync.
-
-3. **Map `POST /` → ` `** (not `
- create`) — the API's own docs call this " ".
- Use their verb.
-```
-
-#### 5b: Ask clarifying questions
-
-Use `AskUserQuestion` to resolve ambiguities that would affect the generated output. Ask only what you can't confidently decide from the docs alone. Batch related questions into a single ask when possible.
-
-**When to ask** (use judgment — skip if the answer is obvious from the docs):
-
-| Signal from docs | Question to consider |
-|---|---|
-| Large API (50+ endpoints) | Which resources to include? Or generate all? Recommend chunking by resource group if >100 |
-| Small API (1–3 endpoints) | Confirm this is the full API — may be a single resource with no need for resource routing |
-| Multiple auth schemes (API key + OAuth) | Which auth flow to support? |
-| Non-standard auth (mTLS, Digest, IP whitelist) | How to handle in CLI — may need a custom auth function |
-| Ambiguous API name (docs say one thing, domain says another) | Confirm CLI name |
-| Non-standard pagination (not cursor or offset) | Confirm strategy — cursor, offset/limit, page number, or keyset? |
-| Deeply nested resources (3+ levels) | Confirm flattening approach |
-| Sparse/missing response schemas | Acceptable, or should you infer from examples? |
-| Deprecated endpoints still documented | Include or skip? |
-| Rate limit warnings in docs | Seed a knowledge file for it? |
-| Webhooks or streaming endpoints | Include as commands or out of scope? |
-| Existing output directory | Overwrite or abort? |
-| Incomplete parse (flagged from Step 4) | Present what was found, ask user to fill gaps or provide better docs |
-
-**When NOT to ask** — don't ask about things you can confidently decide:
-- Standard CRUD mapping (obvious from HTTP methods)
-- kebab-case naming (convention, not a choice)
-- Global flags (fixed set, not negotiable)
-- File structure (convention, not a choice)
-
-**Format:** Batch questions into a single `AskUserQuestion` call. Present your recommendation for each, so the user can just approve or override:
-
-```
-Before generating, a few questions:
-
-1. The API has 47 endpoints across 8 resources. Generate all, or focus
- on a subset? **Recommendation: generate all** — sync can prune later,
- and the smoke tests will cover everything.
-
-2. Docs show both API key auth and OAuth2 (for user-scoped actions).
- **Recommendation: API key only** — simpler for CLI/agent use.
- OAuth2 can be added as a knowledge file pattern later.
-
-3. The `/v2/events` endpoints are marked "beta" with a disclaimer.
- **Recommendation: include them** but flag as beta in the SKILL.md
- help text, so agents know to expect instability.
-```
-
-Wait for the user's response before proceeding. The user can:
-- Approve all recommendations
-- Override specific ones
-- Change the CLI name
-- Add/remove resources
-- Provide context the parser missed
-
-**Conflict resolution:** If the user's response contradicts the parsed spec, prefer the user's input — they know their API better than the docs. Update the parsed spec accordingly before generating.
-
-### Step 6: Generate
-
-Generate all output files in `./-cli/`. Read `references/conventions.md` first.
-
-#### 6a: CLI executable (`bin/-cli.mjs`)
-
-Follow the structure in `references/cli-skeleton.mjs`. The generated CLI must satisfy these contracts:
-
-- Zero external dependencies — Node.js built-ins only
-- API key read from `.env` at repo root, never overriding shell env vars
-- Global flags (`--json`, `--dry-run`, `--help`, `--verbose`, `--all`, `--version`) must not interfere with per-command flag parsing
-- All HTTP calls support auth, dry-run preview, verbose logging, and error taxonomy mapping (see conventions.md)
-- Per-action flags declared via `_flags` metadata — single source of truth for both `parseArgs` and `--help` output
-- Three-level help: top-level (resources), resource (actions), action (flags with descriptions)
-- Standard CRUD → `list`, `get`, `create`, `update`, `delete`
-- Non-CRUD endpoints → use the API's own verb (e.g., `send`, `verify`, `cancel`)
-- Cap nesting at 2 levels; flatten deeper paths with flags
-- `--body ` escape hatch on every mutating action
-
-#### 6b: Smoke tests (`test/smoke.test.mjs`)
-
-Follow the structure in `references/smoke-test-skeleton.mjs`. Must cover every required test category from conventions.md and pass with no `.env` present.
-
-#### 6c: API skill (`skills//SKILL.md`)
-
-Follow the structure in `references/skill-skeleton.md`. Adapt lines marked `<-- adapt` to the target API.
-
-The Setup section must use the validation command and detect commands identified in Steps 4-5. For auth-only APIs (no detected defaults), omit step 4 (detect defaults) from the setup flow.
-
-Permissions must cover autonomous operation — reading knowledge, running commands, and writing learned patterns — so agents don't stall on permission prompts.
-
-#### 6d: Supporting files
-
-Generate these after the core CLI (6a), tests (6b), and API skill (6c) are written. Templates and schemas are in conventions.md — reference them, don't reinvent.
-
-**Sync skill** (`skills/sync/SKILL.md`):
-
-```yaml
----
-name: sync
-description: Check for API doc changes and regenerate the CLI. Use when the user says "sync", "check for updates", or "update the CLI".
-allowed-tools:
- - Bash
- - Read
- - Write
- - Edit
- - Glob
- - Grep
- - WebFetch
- - WebSearch
- - AskUserQuestion
----
-```
-
-Content: attribution line, sync workflow (read `.clify.json` → re-fetch → hash → compare → regenerate if changed → run tests → review knowledge for staleness).
-
-**Plugin config** (`.claude-plugin/`): Generate `plugin.json` and `marketplace.json` per conventions.md plugin file schemas.
-
-**AGENTS.md**: Setup instructions, resource/action listing, error handling summary, knowledge pointer. Keep under 50 lines.
-
-**README.md**: Skill-focused. Attribution line, quick start, skills table (resources + actions), install-as-plugin command, global flags table, knowledge section. See conventions.md for the full template.
-
-**Boilerplate**: `package.json` (with `bin` field), `.env.example` (annotated with `@tag` comments per conventions.md Setup Convention — at minimum `@required` and `@how-to-get` on the auth var), `.clify.json` (from parsed spec — include `auth` and `defaults` fields per conventions.md), `.gitignore`, `knowledge/.gitkeep`, `.docs-snapshot/`, `LICENSE` (MIT).
-
-### Step 7: Validate
-
-After generating all files:
-
-1. **Contract checks:**
- - `.clify.json` matches the schema from conventions.md
- - `.claude-plugin/plugin.json` has required fields: `name`, `version`, `description`, `author`, `skills`, `capabilities`
- - `package.json` has `engines` field (single source of truth for runtime requirements)
- - `.claude-plugin/marketplace.json` has required fields: `name`, `description`, `version`, `author`, `source`
- - `name` and `version` match across `package.json`, `plugin.json`, and `marketplace.json`
- - Every entry in `plugin.json` `skills` array points to an existing SKILL.md file
- - Every generated SKILL.md contains `> Generated by [clify](https://github.com/derrickko/clify)` attribution
- - Every generated SKILL.md `allowed-tools` includes at minimum: Bash, Read, Glob (API skill adds Write, Edit, Grep; sync skill adds Write, Edit, Grep, WebFetch, WebSearch, AskUserQuestion)
- - README.md contains clify attribution and skill documentation for every resource
- - Error output in CLI source follows the taxonomy
- - Global flags are all present
- - Every resource-action is reachable
- - Generated SKILL.md contains a `## Setup` section with `### Check readiness` and `### First-time setup` subsections
- - `.env.example` has `@required` annotation on at least the auth var
- - `.clify.json` has `auth` field with `envVar`, `scheme`, and `validationCommand`
- - Every `validationCommand` and `detectCommand` in `.clify.json` maps to an actual resource-action in the generated CLI
-
-2. **Smoke tests:**
- ```bash
- cd ./-cli && npm test
- ```
- If tests fail, fix the generated code and re-run. Up to 3 attempts.
-
-3. **Secret scan:**
- - Grep generated source for API key patterns (common prefixes: `sk_`, `pk_`, `re_`, `ghp_`, `Bearer [a-zA-Z0-9]{20,}`)
- - Flag any matches
-
-4. **Self-review:**
- - Read the generated SKILL.md — does it reference every command?
- - Does it include error handling for every error code?
- - Are all resources from the parsed spec covered?
-
-### Step 7.5: Simplify
-
-After validation passes, spawn an Agent to review and simplify the generated code:
-
-```
-Agent: In ./-cli/, run /simplify to review the generated code
-for reuse opportunities and efficiency. Then run `npm test` to verify
-all tests still pass. If tests fail, fix the issues and re-run tests
-until they pass.
-```
-
-The agent runs `/simplify` in a fresh context — this gives a distinct review pass separate from the generation context. The generated code already passed Step 7 validation, so the agent has a working baseline to iterate from.
-
-### Step 8: Report
-
-```
-Generated ./-cli/ with:
- - X resources, Y actions
- - CLI: bin/-cli.mjs
- - Skills: skills//SKILL.md, skills/sync/SKILL.md
- - Tests: Y passing
-
-Install as Claude Code plugin:
- claude plugin add ./-cli
-
-The skill will guide you through setup on first use.
-Or say: "set up -cli"
-```
-
-## Important Rules
-
-- **Zero external dependencies** in generated CLI code — only Node.js built-ins
-- **Never auto-execute** generated code without smoke tests passing
-- **Confirm with user** before generating (Step 5) — always show parsed endpoints
-- **Adapt, don't copy** — the skeletons show patterns; generated code should match the target API's structure and terminology
-- **Secrets in generated code** — explicitly strip example API keys found in docs; smoke test regex scan catches any missed
-- **OpenAPI specs** — detect and parse directly (much more accurate than HTML crawling)
-- **Don't proceed with incomplete data** — if crawling, parsing, or user input leaves gaps, resolve them before generating
diff --git a/skills/clify/references/cli-skeleton.mjs b/skills/clify/references/cli-skeleton.mjs
deleted file mode 100644
index b5d8a3f..0000000
--- a/skills/clify/references/cli-skeleton.mjs
+++ /dev/null
@@ -1,285 +0,0 @@
-// clify CLI skeleton — adapt to target API. See conventions.md for contracts.
-// Section ordering and function signatures are the contract.
-// Lines marked "<-- adapt" must change per API.
-
-import { parseArgs } from "node:util";
-import { readFileSync, existsSync } from "node:fs";
-import { resolve, dirname } from "node:path";
-import { fileURLToPath } from "node:url";
-
-// --- ESM preamble (every generated CLI uses this) ---
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
-const ROOT = resolve(__dirname, "..");
-const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8"));
-const VERSION = pkg.version;
-
-// --- .env loader (zero deps, repo root only) ---
-// Don't override existing env vars — user's shell takes precedence.
-function loadEnv() {
- const envPath = resolve(ROOT, ".env");
- if (!existsSync(envPath)) return;
- const lines = readFileSync(envPath, "utf8").split("\n");
- for (const line of lines) {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith("#")) continue;
- const eqIdx = trimmed.indexOf("=");
- if (eqIdx === -1) continue;
- const key = trimmed.slice(0, eqIdx).trim();
- const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, "");
- if (!(key in process.env)) process.env[key] = val;
- }
-}
-loadEnv();
-
-// --- Global flag separation (known-set filter) ---
-// Manual split so per-command flags aren't consumed as booleans.
-const GLOBAL_FLAGS = new Set([
- "--json", "--dry-run", "--help", "-h", "--version", "-v", "--verbose", "--all"
-]);
-const rawArgs = process.argv.slice(2);
-const globalArgv = [];
-const remainingArgv = [];
-for (const arg of rawArgs) {
- if (GLOBAL_FLAGS.has(arg)) globalArgv.push(arg);
- else remainingArgv.push(arg);
-}
-
-const { values: globalFlags } = parseArgs({
- args: globalArgv,
- options: {
- json: { type: "boolean", default: false },
- "dry-run": { type: "boolean", default: false },
- help: { type: "boolean", short: "h", default: false },
- version: { type: "boolean", short: "v", default: false },
- verbose: { type: "boolean", default: false },
- all: { type: "boolean", default: false },
- },
- strict: true,
-});
-const positionals = remainingArgv;
-const jsonOutput = globalFlags.json || !process.stdout.isTTY;
-
-// --- Output helpers ---
-// output() for success data, errorOut() for structured errors.
-function output(data) {
- if (jsonOutput) {
- console.log(JSON.stringify(data));
- } else {
- for (const [key, val] of Object.entries(data)) {
- if (typeof val === "object" && val !== null) {
- console.log(`${key}:`);
- for (const [k, v] of Object.entries(val)) console.log(` ${k}: ${v}`);
- } else {
- console.log(`${key}: ${val}`);
- }
- }
- }
-}
-
-function errorOut(code, message, opts = {}) {
- const err = { type: "error", code, message, retryable: opts.retryable ?? false };
- if (opts.retryAfter != null) err.retryAfter = opts.retryAfter;
- if (jsonOutput) {
- console.log(JSON.stringify(err));
- } else {
- console.error(`Error [${code}]: ${message}`);
- }
- process.exit(1);
-}
-
-// --- apiRequest() — single HTTP function for all endpoints ---
-// Handles: auth check, dry-run preview, verbose logging, error taxonomy mapping.
-const BASE_URL = "https://api.example.com"; // <-- adapt per API
-async function apiRequest(method, path, { body, query } = {}) {
- const apiKey = process.env.EXAMPLE_API_KEY; // <-- adapt env var name
- if (!apiKey) errorOut("auth_missing", "EXAMPLE_API_KEY not set. Add it to .env or export it.");
-
- let url = `${BASE_URL}${path}`;
- if (query) {
- const params = new URLSearchParams();
- for (const [k, v] of Object.entries(query)) {
- if (v != null) params.set(k, String(v));
- }
- const qs = params.toString();
- if (qs) url += `?${qs}`;
- }
-
- const headers = {
- Authorization: `Bearer ${apiKey}`, // <-- adapt auth scheme
- "Content-Type": "application/json",
- };
-
- if (globalFlags["dry-run"]) {
- output({ type: "dry_run", request: { method, url, headers: { ...headers, Authorization: "Bearer ***" }, ...(body && { body }) } });
- process.exit(0);
- }
- if (globalFlags.verbose) console.error(`> ${method} ${url}`);
-
- let res;
- try {
- res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined });
- } catch (err) {
- errorOut("network_error", `Connection failed: ${err.message}`, { retryable: true });
- }
-
- if (globalFlags.verbose) console.error(`< ${res.status} ${res.statusText}`);
-
- if (!res.ok) {
- let detail = "";
- try { const b = await res.json(); detail = b.message || b.error || JSON.stringify(b); } catch { detail = res.statusText; }
- const statusMap = {
- 400: { code: "validation_error", retryable: false },
- 401: { code: "auth_invalid", retryable: false },
- 403: { code: "forbidden", retryable: false },
- 404: { code: "not_found", retryable: false },
- 409: { code: "conflict", retryable: false },
- 422: { code: "validation_error", retryable: false },
- 429: { code: "rate_limited", retryable: true },
- };
- const mapped = statusMap[res.status] || (res.status >= 500 ? { code: "server_error", retryable: true } : { code: "validation_error", retryable: false });
- const opts = { retryable: mapped.retryable };
- const retryAfter = res.headers.get("retry-after");
- if (retryAfter && mapped.retryable) opts.retryAfter = parseInt(retryAfter, 10) || undefined;
- errorOut(mapped.code, detail, opts);
- }
-
- if (res.status === 204) return null;
- return res.json();
-}
-
-// --- Pagination helper ---
-// Returns one page by default. --all auto-paginates.
-// Adapt cursor field name per API (cursor, page, offset, etc.).
-async function paginated(path, query = {}) {
- if (!globalFlags.all) return apiRequest("GET", path, { query });
- let allData = [], cursor;
- do {
- const q = { ...query };
- if (cursor) q.cursor = cursor;
- const data = await apiRequest("GET", path, { query: q });
- const items = data.data || data;
- allData = allData.concat(Array.isArray(items) ? items : [items]);
- cursor = data.next_cursor || null;
- } while (cursor);
- return { data: allData };
-}
-
-// --- Flag registry ---
-// Each resource declares _flags: a map of action → flag specs.
-// Used by BOTH parseArgs (runtime) and showHelp (discovery).
-// { type, required?, description } — type is "string" or "boolean".
-function toParseArgs(flags) {
- const options = {};
- for (const [name, spec] of Object.entries(flags)) {
- options[name] = { type: spec.type };
- }
- return options;
-}
-
-function checkRequired(flags, values) {
- const missing = Object.entries(flags)
- .filter(([name, spec]) => spec.required && !values[name])
- .map(([name]) => `--${name}`);
- if (missing.length) errorOut("validation_error", `Required: ${missing.join(", ")}`);
-}
-
-// --- Resource handlers (one object per resource) ---
-// _flags is the single source of truth for per-action flags.
-// Action handlers read from _flags via toParseArgs().
-const things = {
- _flags: {
- list: {},
- get: {
- id: { type: "string", required: true, description: "Thing ID" },
- },
- create: {
- name: { type: "string", required: true, description: "Thing name" },
- body: { type: "string", description: "Raw JSON body (overrides flags)" },
- },
- },
- async list() {
- const data = await paginated("/things");
- output(data);
- },
- async get(args) {
- const { values } = parseArgs({ args, options: toParseArgs(things._flags.get), strict: true });
- checkRequired(things._flags.get, values);
- output(await apiRequest("GET", `/things/${values.id}`));
- },
- async create(args) {
- const { values } = parseArgs({ args, options: toParseArgs(things._flags.create), strict: true });
- const payload = values.body ? JSON.parse(values.body) : (() => {
- checkRequired(things._flags.create, values);
- return { name: values.name };
- })();
- output(await apiRequest("POST", "/things", { body: payload }));
- },
-};
-
-// --- Resource registry (routing table) ---
-const resources = { things };
-
-// --- Help text (generated from registry + _flags) ---
-// Three levels: no args → all resources, resource → actions, resource action → flags.
-function showHelp(resource, action) {
- // Action-level: show flags for a specific action
- if (resource && action && resources[resource]?._flags?.[action]) {
- const flags = resources[resource]._flags[action];
- console.log(`Usage: example-cli ${resource} ${action} [flags]\n`);
- if (Object.keys(flags).length === 0) {
- console.log("No action-specific flags.");
- } else {
- console.log("Flags:");
- for (const [name, spec] of Object.entries(flags)) {
- const req = spec.required ? " (required)" : "";
- console.log(` --${name.padEnd(16)} ${spec.description || ""}${req}`);
- }
- }
- return;
- }
- // Resource-level: show actions
- if (resource && resources[resource]) {
- const actionNames = Object.keys(resources[resource]).filter(k => k !== "_flags");
- console.log(`Usage: example-cli ${resource} [flags]\n`);
- console.log(`Actions: ${actionNames.join(", ")}\n`);
- console.log(`Run: example-cli ${resource} --help for flag details.`);
- return;
- }
- // Top-level: show all resources
- console.log(`example-cli v${VERSION}\n`);
- console.log("Usage: example-cli [flags]\n");
- console.log("Resources:");
- for (const [name, res] of Object.entries(resources)) {
- const actionNames = Object.keys(res).filter(k => k !== "_flags");
- console.log(` ${name.padEnd(14)} ${actionNames.join(", ")}`);
- }
- console.log("\nGlobal flags:");
- console.log(" --json JSON output (default when piped)");
- console.log(" --dry-run Show request without executing");
- console.log(" --verbose Include request/response details");
- console.log(" --all Auto-paginate");
- console.log(" --help, -h Show help");
- console.log(" --version Show version");
-}
-
-// --- Main router ---
-// First two non-flag positionals are resource and action.
-// Everything else passes through to the action handler.
-async function main() {
- if (globalFlags.version) { console.log(VERSION); process.exit(0); }
- const resource = positionals[0]?.startsWith("-") ? undefined : positionals[0];
- const action = positionals[1]?.startsWith("-") ? undefined : positionals[1];
- const rest = positionals.slice(resource ? (action ? 2 : 1) : 0);
-
- if (globalFlags.help && resource && action) { showHelp(resource, action); process.exit(0); }
- if (globalFlags.help || !resource) { showHelp(resource); process.exit(0); }
- if (!resources[resource]) errorOut("validation_error", `Unknown resource: ${resource}. Run --help for available resources.`);
- if (!action || globalFlags.help) { showHelp(resource); process.exit(0); }
- if (!resources[resource][action]) {
- errorOut("validation_error", `Unknown action: ${resource} ${action}. Available: ${Object.keys(resources[resource]).filter(k => k !== "_flags").join(", ")}`);
- }
- await resources[resource][action](rest);
-}
-
-main().catch((err) => errorOut("network_error", err.message, { retryable: true }));
diff --git a/skills/clify/references/conventions.md b/skills/clify/references/conventions.md
deleted file mode 100644
index 0125f1f..0000000
--- a/skills/clify/references/conventions.md
+++ /dev/null
@@ -1,498 +0,0 @@
-# clify Conventions
-
-Rules and contracts for generated CLI repos. Rigid contracts (error format, `.clify.json` shape) must be followed exactly. Flexible guidance (command structure, nesting) should be adapted to the API.
-
----
-
-## CLI Command Structure
-
-Generated CLIs use the resource-action pattern:
-
-```
--cli [flags]
-```
-
-### Standard Actions
-
-Map HTTP methods to actions:
-
-| HTTP Method | Action | Notes |
-|-------------|--------|-------|
-| GET (collection) | `list` | Returns array |
-| GET (single) | `get` | Requires `--id` |
-| POST (create) | `create` | |
-| PUT/PATCH | `update` | Requires `--id` |
-| DELETE | `delete` | Requires `--id` |
-
-### Non-CRUD Actions
-
-When an endpoint doesn't map to standard CRUD, use the API's own verb:
-
-```
-stripe-cli charges capture --id ch_xxx
-github-cli repos merge-upstream --repo owner/name
-twilio-cli messages send --to +1234567890 --from +0987654321
-```
-
-Don't force non-CRUD actions into CRUD semantics. If the API calls it "verify", the action is `verify`.
-
-### Nesting Depth
-
-Cap at two levels: ` ` or ` `.
-
-Flatten anything deeper with flags:
-
-```
-# Good (2 levels):
-github-cli repos pulls list --repo owner/name
-
-# Bad (3 levels):
-github-cli repos pulls comments list --repo owner/name
-
-# Good (flattened):
-github-cli pull-comments list --repo owner/name --pull 42
-```
-
-### Resource Naming
-
-- Use the API's own terminology (kebab-case if multi-word)
-- Don't rename to camelCase or PascalCase
-- Hyphenated names are fine: `api-keys`, `pull-requests`
-
----
-
-## Global Flags
-
-Every command supports these flags. They are parsed before resource routing.
-
-| Flag | Type | Default | Behavior |
-|------|------|---------|----------|
-| `--json` | boolean | true when piped | Structured JSON output |
-| `--dry-run` | boolean | false | Show request without executing |
-| `--help`, `-h` | boolean | false | Show usage |
-| `--version`, `-v` | boolean | false | Print version |
-| `--verbose` | boolean | false | Include request/response headers |
-| `--all` | boolean | false | Auto-paginate (fetch all pages) |
-
-### Global Flag Parsing
-
-Global flags must be separated from per-command flags before parsing. Use a known-set filter — do NOT use `parseArgs` with `strict: false` for global parsing, as it consumes unknown flags as booleans and breaks per-command string flags.
-
-```js
-const GLOBAL_FLAGS = new Set(["--json", "--dry-run", "--help", "-h", "--version", "-v", "--verbose", "--all"]);
-const globalArgv = [];
-const remainingArgv = [];
-for (const arg of process.argv.slice(2)) {
- if (GLOBAL_FLAGS.has(arg)) globalArgv.push(arg);
- else remainingArgv.push(arg);
-}
-```
-
----
-
-## Per-Command Flags
-
-Generated from the API's documented parameters:
-
-| Parameter Type | Flag Style |
-|---------------|------------|
-| Path params | `--id`, `--repo` (required) |
-| Query params | Optional flags |
-| Body fields | Individual flags |
-| Raw JSON body | `--body` escape hatch |
-
-### Special Flags
-
-| Flag | When | Behavior |
-|------|------|----------|
-| `--all` | List endpoints with pagination | Auto-paginate |
-| `--file ` | Upload endpoints | Read file, use FormData |
-| `--output ` | Binary response endpoints | Write response to file |
-| `--body ` | Any mutating endpoint | Raw JSON body (overrides individual flags) |
-
-### Pagination
-
-Return one page by default. Include `next_cursor` in JSON output when more pages exist. `--all` fetches every page and returns the combined result.
-
----
-
-## Structured Error Output
-
-When `--json` is active (or stdout is piped), errors emit:
-
-```json
-{
- "type": "error",
- "code": "rate_limited",
- "message": "Rate limited. Retry after 30s.",
- "retryable": true,
- "retryAfter": 30
-}
-```
-
-### Error Taxonomy
-
-| Code | Retryable | HTTP Status | Meaning |
-|------|-----------|-------------|---------|
-| `auth_missing` | no | — | No API key in `.env` |
-| `auth_invalid` | no | 401 | Key rejected |
-| `validation_error` | no | 400, 422 | Bad request |
-| `not_found` | no | 404 | Resource doesn't exist |
-| `forbidden` | no | 403 | Insufficient permissions |
-| `conflict` | no | 409 | State conflict |
-| `rate_limited` | yes | 429 | Too many requests |
-| `server_error` | yes | 5xx | API server error |
-| `network_error` | yes | — | Connection failed/timeout |
-| `timeout` | yes | — | Request exceeded timeout |
-
-### Rules
-
-- `retryAfter` is optional on ALL retryable errors (not just `rate_limited`)
-- Parse `Retry-After` header when present on any retryable error
-- CLI never retries — retry logic lives in the SKILL.md wrapper (agent decides)
-- Human-readable format uses `console.error` (stderr), not stdout
-- Exit code is always 1 for errors
-
----
-
-## `.clify.json` Shape
-
-Every generated repo has this at the root:
-
-```json
-{
- "apiName": "github",
- "docsUrl": "https://docs.github.com/en/rest",
- "crawledUrls": ["https://docs.github.com/en/rest/repos", "..."],
- "contentHash": "sha256:abc123...",
- "generatedAt": "2026-04-06T12:00:00Z",
- "clifyVersion": "0.1.0",
- "nodeMinVersion": "20"
-}
-```
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `apiName` | string | Lowercase, kebab-case API name |
-| `docsUrl` | string | Original URL provided by user |
-| `crawledUrls` | string[] | All URLs fetched during generation |
-| `contentHash` | string | `sha256:` of combined fetched content |
-| `generatedAt` | string | ISO 8601 timestamp |
-| `clifyVersion` | string | Version of clify that generated this |
-| `nodeMinVersion` | string | Minimum Node.js version |
-
-### Sync Behavior
-
-**Preserved across sync:** `.clify.json`, `knowledge/`, `.env`
-**Regenerated on sync:** everything else
-
----
-
-## .env Rules
-
-- Generated CLI reads `.env` from the REPO ROOT only (not CWD, not `$HOME`)
-- Use `node:fs` to read — no dotenv library
-- Don't override existing env vars (shell takes precedence)
-- Strip quotes from values
-- Skip blank lines and `#` comments
-- `.env.example` documents required keys with placeholder values
-- `.env` is gitignored
-- Auth env var name: `_API_KEY` (uppercase, underscores)
-
----
-
-## Setup Convention
-
-Setup lives in the generated SKILL.md — no CLI binary changes. The LLM follows API-specific instructions to collect credentials, validate auth, and detect defaults before executing any command. See `skill-skeleton.md` for the full template.
-
-### `.env.example` Annotations
-
-Structured `@tag` comments above each env var. The CLI's `.env` loader already skips `#` comments, so annotations are invisible to the CLI.
-
-| Tag | Meaning | Example |
-|-----|---------|---------|
-| `@required` | Setup must collect this | `# @required` |
-| `@optional` | Improves UX but not strictly needed | `# @optional` |
-| `@how-to-get ` | Where to obtain the value | `# @how-to-get https://dashboard.example.com/api-keys` |
-| `@format ` | Expected format | `# @format act_XXXXXXXXX` |
-| `@validation-command ` | CLI command to validate this credential | `# @validation-command users me` |
-| `@detect-command ` | CLI command that lists possible values | `# @detect-command workspaces list` |
-
-Example:
-
-```env
-# @required
-# @how-to-get https://dashboard.example.com/api-keys
-# @validation-command users me
-EXAMPLE_API_KEY=your_api_key_here
-
-# @optional
-# @format ws_XXXXXXXXX
-# @detect-command workspaces list
-EXAMPLE_WORKSPACE_ID=ws_your_workspace_id_here
-```
-
-### `.clify.json` Setup Fields
-
-Add `auth` and `defaults` to the existing schema:
-
-```json
-{
- "auth": {
- "envVar": "EXAMPLE_API_KEY",
- "scheme": "bearer",
- "validationCommand": "users me"
- },
- "defaults": [
- {
- "envVar": "EXAMPLE_WORKSPACE_ID",
- "detectCommand": "workspaces list",
- "description": "Default workspace ID",
- "format": "ws_XXXXXXXXX"
- }
- ]
-}
-```
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `auth.envVar` | string | Env var name for the primary credential |
-| `auth.scheme` | string | Auth scheme: `bearer`, `api-key-header`, `basic` |
-| `auth.validationCommand` | string | Simplest read-only command that exercises auth |
-| `defaults` | array | Pervasive parameters the user should configure once |
-| `defaults[].envVar` | string | Env var name for this default |
-| `defaults[].detectCommand` | string | Command that lists possible values |
-| `defaults[].description` | string | Human-readable description |
-| `defaults[].format` | string | Expected format pattern |
-
-`auth` is required. `defaults` is optional (empty array for auth-only APIs).
-
-### Placeholder Detection
-
-During setup readiness check, these values mean "not set":
-- Matches `your_*_here` or `*_your_*_here` (case-insensitive)
-- Empty string
-- The exact value from `.env.example` (e.g., `ws_your_workspace_id_here`)
-
----
-
-## Knowledge File Schema
-
-Knowledge files live in `knowledge/` in the generated repo:
-
-```yaml
----
-type: gotcha | pattern | shortcut | quirk
-command: "repos list"
-learned: 2026-04-06
-confidence: high | medium | low
----
-
-Upload endpoint returns 422 if Content-Type header is missing.
-Always include `--content-type application/octet-stream` for binary uploads.
-```
-
-### Types
-
-| Type | Meaning | Example |
-|------|---------|---------|
-| `gotcha` | Error → recovery pair | "422 on upload → add Content-Type header" |
-| `pattern` | Common flag combo | "`--from noreply@co.com --reply-to support@co.com`" |
-| `shortcut` | Multi-step workflow promoted to compound command | "send-welcome = create contact + send email" |
-| `quirk` | API contradicts docs | "Pagination uses `page` not `cursor` despite docs" |
-
-### Rules
-
-- SKILL.md reads all knowledge files and lets the LLM match by context
-- No indexing — simple file scan (scale: tens to low hundreds per API)
-- Promotion from pattern → shortcut is agent judgment, no fixed threshold
-- After sync, review knowledge files for staleness (endpoint removed, behavior changed)
-
----
-
-## CLI Source Conventions
-
-### Language & Runtime
-
-- Node.js ESM (`.mjs` extensions)
-- `"type": "module"` in package.json
-- `"engines": { "node": ">=20" }`
-- Zero external npm dependencies
-- Native `fetch` (Node 20+), `node:util` `parseArgs`, `node:fs`, `node:path`, `node:crypto`
-
-### Code Structure
-
-```
-bin/-cli.mjs ← single file CLI, all resources
-test/smoke.test.mjs ← smoke tests
-```
-
-- Named exports where useful, but the CLI is a single executable file
-- Resource handlers are plain objects (not classes)
-- One `apiRequest()` function handles auth, dry-run, verbose, error mapping
-- Version read from package.json (single source of truth)
-
-### CLI Skeleton
-
-See `cli-skeleton.mjs` for the full annotated pattern. Adapt to the target API — lines marked `<-- adapt` must change per API. The section ordering and function signatures are the contract.
-
-### Help Text
-
-Three levels of help, all generated from the resource registry and `_flags` — stays in sync automatically:
-
-- `--help` — list all resources and their actions
-- ` --help` — list actions for that resource
-- ` --help` — show per-action flags with required/optional and descriptions
-
-This enables runtime discovery: an agent can call ` --help` to learn flags without reading the SKILL.md.
-
----
-
-## Smoke Test Requirements
-
-Smoke tests verify CLI structure, NOT API responses. They must pass with no `.env` present (no API key required).
-
-### Required Tests
-
-| Test | What it verifies |
-|------|-----------------|
-| `--version` | Prints version from package.json |
-| `--help` | Shows usage with all resources listed |
-| ` --help` | Shows actions for each resource |
-| ` --help` | Shows per-action flags with descriptions |
-| Auth missing | Returns `auth_missing` error (not a crash) |
-| `--dry-run` | Doesn't make real requests |
-| Unknown resource | Returns `validation_error` with helpful message |
-| Unknown action | Returns `validation_error` with available actions |
-| Required flag missing | Returns `validation_error` (not crash) |
-| No hardcoded secrets | Regex scan of source for API key patterns |
-| Resource coverage | Every resource and action is reachable |
-
-### Test Conventions
-
-- Use `node:test` (`node --test test/*.test.mjs`)
-- Helper: `run(...args)` → `{ stdout, stderr, exitCode }`
-- Helper: `runJson(...args)` → adds `--json`, parses output
-- Strip API key from env in test helper (prevent accidental real requests)
-- Timeout: 5s per test (these are fast — no network calls)
-
-### Smoke Test Skeleton
-
-See `smoke-test-skeleton.mjs` for the full annotated pattern. Adapt resource names, actions, and required flags to the target API.
-
----
-
-## Generated Repo Structure
-
-```
--cli/
- ├── bin/-cli.mjs ← CLI executable
- ├── skills//SKILL.md ← Claude Code skill (API wrapper)
- ├── skills/sync/SKILL.md ← self-update skill
- ├── knowledge/ ← learned patterns (empty initially)
- ├── .claude-plugin/
- │ ├── plugin.json
- │ └── marketplace.json
- ├── .docs-snapshot/ ← raw fetched docs (gitignored)
- ├── AGENTS.md ← Codex/OpenAI instructions
- ├── .clify.json ← metadata
- ├── package.json ← npm metadata + bin field
- ├── .env.example ← API key template
- ├── .gitignore
- ├── test/smoke.test.mjs ← smoke tests
- ├── LICENSE ← MIT
- └── README.md
-```
-
----
-
-## package.json for Generated Repos
-
-```json
-{
- "name": "-cli",
- "version": "0.1.0",
- "description": "CLI for the API. Generated by clify.",
- "type": "module",
- "bin": {
- "-cli": "./bin/-cli.mjs"
- },
- "engines": {
- "node": ">=20"
- },
- "scripts": {
- "test": "node --test test/*.test.mjs"
- },
- "license": "MIT"
-}
-```
-
-No dependencies. No devDependencies.
-
----
-
-## Plugin Files (`.claude-plugin/`)
-
-Two files: `plugin.json` (runtime manifest) and `marketplace.json` (discovery manifest).
-
-### Source of Truth
-
-`package.json` is authoritative for `name`, `version`, `description`, and `engines`. Both plugin files must match `name`, `version`, and `description` exactly. Engine requirements live exclusively in `package.json` (npm standard) — plugin.json does not duplicate them. On a version bump, update all three files.
-
-### plugin.json
-
-Runtime manifest read by the plugin system. The plugin system reads `engines` from `package.json` — do not duplicate it here.
-
-```json
-{
- "name": "-cli",
- "version": "0.1.0",
- "description": "CLI for the API. Generated by clify.",
- "author": { "name": "" },
- "license": "MIT",
- "skills": [
- { "name": "", "source": "skills//SKILL.md" },
- { "name": "sync", "source": "skills/sync/SKILL.md" }
- ],
- "capabilities": ["network"]
-}
-```
-
-| Field | Required | Description |
-|-------|----------|-------------|
-| `name` | yes | Must match `package.json` name |
-| `version` | yes | Must match `package.json` version |
-| `description` | yes | Must match `package.json` description |
-| `author` | yes | `{ "name": "" }`, optionally with `"url"` |
-| `license` | yes | License identifier |
-| `skills` | yes | Array of `{ "name", "source" }` — every SKILL.md the plugin provides |
-| `capabilities` | yes | What the plugin does: `"network"`, `"codegen"`, `"file-write"` |
-
-### marketplace.json
-
-Flat discovery manifest for registries. One plugin per repo — no nested arrays.
-
-```json
-{
- "name": "-cli",
- "description": "CLI for the API. Generated by clify.",
- "version": "0.1.0",
- "author": { "name": "" },
- "source": "./"
-}
-```
-
-| Field | Required | Description |
-|-------|----------|-------------|
-| `name` | yes | Must match `plugin.json` name |
-| `description` | yes | Must match `plugin.json` description |
-| `version` | yes | Must match `plugin.json` version |
-| `author` | yes | Must match `plugin.json` author |
-| `source` | yes | Relative path to plugin root |
-
-### Validation (Step 7)
-
-During generation, verify:
-- All required fields are present in both files
-- `name`, `version`, `description` match across `package.json`, `plugin.json`, `marketplace.json`
-- Every `skills[].source` path resolves to an existing SKILL.md
diff --git a/skills/clify/references/skill-skeleton.md b/skills/clify/references/skill-skeleton.md
deleted file mode 100644
index 29f876b..0000000
--- a/skills/clify/references/skill-skeleton.md
+++ /dev/null
@@ -1,156 +0,0 @@
-# Skill Skeleton
-
-Annotated template for generated SKILL.md files. Lines marked `<-- adapt` must change per API. Section ordering is the contract — don't reorder.
-
----
-
-```markdown
----
-name: # <-- adapt
-description: . Use for "", "". # <-- adapt
-allowed-tools:
- - Bash
- - Read
- - Write
- - Edit
- - Glob
- - Grep
----
-
-> Generated by [clify](https://github.com/derrickko/clify)
-
-# CLI # <-- adapt
-
- # <-- adapt
-
-## Triggers
-
-Use when the user wants to: # <-- adapt: list 3-6 trigger phrases
--
--
-
-## Setup
-
-Before executing any command, verify the CLI is configured.
-
-### Check readiness
-
-1. Read `.env` in the repo root — if missing or `` is unset/placeholder, run First-time setup # <-- adapt: env var name
-2. If configured, skip to the user's request
-
-### First-time setup (or reconfigure)
-
-Run when `.env` is missing/incomplete, auth fails, or user says "set up" / "reconfigure".
-
-1. **Collect credentials** # <-- adapt: API-specific instructions
- Ask the user for their .
- Where to get it:
- Required permissions:
-
-2. **Write `.env`**
- Copy `.env.example` to `.env` and set ``. # <-- adapt: env var name
-
-3. **Validate auth**
- ```bash
- node bin/