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.

- License + License Claude Code plugin Node 20+

--- -``` -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/-cli.mjs --json # <-- adapt: simplest read-only command - ``` - - Success → continue to step 4 (or confirm if no defaults) - - `auth_invalid` → tell user, ask for new credentials - - `network_error` → check connectivity, retry - -4. **Detect defaults** # <-- adapt: OMIT this step for auth-only APIs - ```bash - node bin/-cli.mjs --json # <-- adapt: command that lists options - ``` - Parse the response: - - Single result → set `` automatically # <-- adapt: env var name - - Multiple results → present list, let user choose - - Write chosen value to `.env` - -5. **Confirm** - "Setup complete. ." # <-- adapt: mention token + any defaults - Proceed with the user's original request. - -## Prerequisites # <-- adapt: only if there's info beyond what Setup covers - -Set these environment variables (or add to `.env` in the repo root): - -| Variable | Required | Description | -|---|---|---| -| `` | Yes | | # <-- adapt -| `` | No | ; can use `--` instead | # <-- adapt: one row per default - - # <-- adapt - -## Quick Reference - -| Resource | Action | Key Flags | # <-- adapt: full table -|---|---|---| -| `` | `` | `--` (required), `--` | - -For full flag details, run: `-cli --help` - -## Global Flags - -| Flag | Description | -|---|---| -| `--json` | Force JSON output (default when piped) | -| `--dry-run` | Show request without executing | -| `--verbose` | Log request/response details to stderr | -| `--all` | Auto-paginate (fetch all pages) | -| `--help` | Show help | -| `--version` | Show version | - -## Error Handling - -| Error Code | Retryable | Action | # <-- adapt: API-specific guidance -|---|---|---| -| `auth_missing` | No | Run setup | -| `auth_invalid` | No | Credentials expired; re-run setup | -| `validation_error` | No | Check parameters; use `--help` | -| `not_found` | No | Verify object ID exists | -| `forbidden` | No | Check credential permissions | -| `rate_limited` | Yes | Wait `retryAfter` seconds, then retry | -| `server_error` | Yes | Retry with exponential backoff | -| `network_error` | Yes | Check connectivity, then retry | - -## Knowledge System - -Before executing, read any files in `knowledge/` that match the current command context. After encountering a gotcha or discovering a useful pattern, write it to `knowledge/` so future runs benefit. - -## Common Workflows # <-- adapt: 2-3 multi-step examples - -### -```bash -# Step 1 --cli -- - -# Step 2 --cli -- -``` -``` - ---- - -## Design Notes - -### Section ordering rationale - -1. **Triggers** — match user intent first -2. **Setup** — runs before any command; must come before Quick Reference so the agent checks config before executing -3. **Prerequisites** — reference table for env vars (Setup is the flow, Prerequisites is the lookup) -4. **Quick Reference** — command lookup for the common case -5. **Global Flags** — same for all CLIs -6. **Error Handling** — recovery guidance -7. **Knowledge System** — learning loop -8. **Common Workflows** — multi-step examples - -### Setup section rules - -- **Auto-detect:** Setup runs automatically when `.env` is missing or auth var is unset/placeholder. The agent doesn't wait for the user to ask. -- **Explicit trigger:** User can say "set up", "reconfigure", or "reset config" to re-run setup even when `.env` exists. -- **Placeholder detection:** Values matching `your_*_here`, `*_your_*_here`, empty string, or the exact value from `.env.example` are treated as "not set." -- **Validation command:** Must be the simplest read-only endpoint that exercises auth. Prefer `get` over `list` (less data). Never use a mutating endpoint. -- **Detect command:** Must return a collection the user can choose from. Only include when the API has pervasive parameters (identified in Step 4 of the generator pipeline). -- **Auth-only APIs:** Omit step 4 entirely. Steps are 1→2→3→5. -- **Error recovery during setup:** If validation returns `auth_invalid`, loop back to step 1. If `network_error`, suggest checking connectivity. Don't proceed with unvalidated credentials. diff --git a/skills/clify/references/smoke-test-skeleton.mjs b/skills/clify/references/smoke-test-skeleton.mjs deleted file mode 100644 index 4067a41..0000000 --- a/skills/clify/references/smoke-test-skeleton.mjs +++ /dev/null @@ -1,126 +0,0 @@ -// clify smoke test skeleton — adapt to target API. See conventions.md for required test categories. -// Lines marked "<-- adapt" must change per API. - -import { describe, it } from "node:test"; -import { strict as assert } from "node:assert"; -import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const CLI = resolve(__dirname, "..", "bin", "-cli.mjs"); // <-- adapt -const ROOT = resolve(__dirname, ".."); - -// --- Test helpers --- -// Strip the API key env var so tests never hit the real API. -function run(...args) { - const { API_KEY_VAR: _, ...cleanEnv } = process.env; // <-- adapt var name - try { - const stdout = execFileSync("node", [CLI, ...args], { - encoding: "utf8", env: cleanEnv, timeout: 5000, - }); - return { stdout, stderr: "", exitCode: 0 }; - } catch (err) { - return { stdout: err.stdout || "", stderr: err.stderr || "", exitCode: err.status }; - } -} - -function runJson(...args) { - const result = run("--json", ...args); - if (result.stdout.trim()) result.parsed = JSON.parse(result.stdout.trim()); - return result; -} - -// --- Required test categories --- -// Adapt assertions to match the target API's resources and actions. - -describe("--version", () => { - it("prints version from package.json", () => { - const { stdout, exitCode } = run("--version"); - const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")); - assert.equal(stdout.trim(), pkg.version); - assert.equal(exitCode, 0); - }); -}); - -describe("--help", () => { - it("shows usage with all resources", () => { - const { stdout, exitCode } = run(); - assert.match(stdout, /Usage:/); - // assert.match(stdout, //); <-- one per resource - assert.equal(exitCode, 0); - }); - it("shows resource help", () => { - const { stdout, exitCode } = run("", "--help"); // <-- adapt - assert.match(stdout, /Actions:/); - assert.equal(exitCode, 0); - }); - it("shows action-level help with flags", () => { - const { stdout, exitCode } = run("", "", "--help"); // <-- adapt to an action with flags - assert.match(stdout, /Flags:/); - assert.equal(exitCode, 0); - }); -}); - -describe("auth missing", () => { - it("returns auth_missing error", () => { - // Invoke any endpoint that requires auth - const result = runJson("", "" /* , ...required flags */); - assert.equal(result.parsed.type, "error"); - assert.equal(result.parsed.code, "auth_missing"); - assert.equal(result.parsed.retryable, false); - }); -}); - -describe("unknown resource", () => { - it("returns validation_error", () => { - const result = runJson("nonexistent", "list"); - assert.equal(result.parsed.code, "validation_error"); - assert.match(result.parsed.message, /Unknown resource/); - }); -}); - -describe("unknown action", () => { - it("returns validation_error", () => { - const result = runJson("", "nonexistent"); // <-- adapt - assert.equal(result.parsed.code, "validation_error"); - assert.match(result.parsed.message, /Unknown action/); - }); -}); - -describe("validation", () => { - it("missing required flags returns validation_error", () => { - // Use a dummy key to bypass auth, then omit required flags - try { - execFileSync("node", [CLI, "--json", "", ""], { - encoding: "utf8", - env: { ...process.env, API_KEY_VAR: "test_fake_key" }, // <-- adapt - timeout: 5000, - }); - assert.fail("Should have thrown"); - } catch (err) { - const parsed = JSON.parse(err.stdout.trim()); - assert.equal(parsed.code, "validation_error"); - } - }); -}); - -describe("no hardcoded secrets", () => { - it("source contains no API keys", () => { - const source = readFileSync(resolve(ROOT, "bin", "-cli.mjs"), "utf8"); // <-- adapt - // Adapt patterns to the target API's key format - assert.doesNotMatch(source, /sk_[a-zA-Z0-9]{20,}/); - assert.doesNotMatch(source, /Bearer [a-zA-Z0-9]{20,}/); - }); -}); - -describe("resource coverage", () => { - it("all resources appear in help", () => { - const { stdout } = run("--help"); - // for (const r of ["", ""]) { <-- adapt - // assert.match(stdout, new RegExp(r)); - // } - }); - // Add one test per resource checking its actions appear in resource help -}); diff --git a/test/clify.test.mjs b/test/clify.test.mjs new file mode 100644 index 0000000..a7b6b99 --- /dev/null +++ b/test/clify.test.mjs @@ -0,0 +1,197 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, cpSync, writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs"; +import { join, resolve, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { validate } from "../lib/validate.mjs"; +import { scaffoldInit, substitute } 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 EXEMPLAR = join(REPO_ROOT, "examples/jsonplaceholder-cli"); + +function freshCopy() { + const dir = mkdtempSync(join(tmpdir(), "clify-test-")); + const dest = join(dir, "jsonplaceholder-cli"); + cpSync(EXEMPLAR, dest, { recursive: true }); + return { root: dir, repo: dest }; +} + +// ---------- baseline ---------- + +test("validate: clean exemplar passes (skip live tests for speed)", async () => { + const r = await validate(EXEMPLAR, { skipTests: true }); + assert.equal(r.ok, true, JSON.stringify(r.results.filter((x) => !x.ok), null, 2)); +}); + +// ---------- substitute helper ---------- + +test("substitute: handles upper/title/lower in correct order", () => { + const text = "JSONPLACEHOLDER_API_KEY for JSONPlaceholder via jsonplaceholder-cli"; + const out = substitute(text, "demo-api", "DEMO_API", "Demo Api"); + assert.equal(out, "DEMO_API_API_KEY for Demo Api via demo-api-cli"); +}); + +// ---------- scaffold-init ---------- + +test("scaffoldInit: produces a working renamed copy", () => { + const tmp = mkdtempSync(join(tmpdir(), "clify-init-")); + try { + const result = scaffoldInit({ apiName: "movie-db", target: tmp }); + assert.match(result.dir, /movie-db-cli$/); + const pkg = JSON.parse(readFileSync(join(result.dir, "package.json"), "utf8")); + assert.equal(pkg.name, "movie-db-cli"); + const cli = readFileSync(join(result.dir, "bin/movie-db-cli.mjs"), "utf8"); + assert.match(cli, /MOVIE_DB_BASE_URL/); + assert.match(cli, /movie-db-cli/); + assert.ok(!cli.includes("jsonplaceholder")); + } finally { rmSync(tmp, { recursive: true, force: true }); } +}); + +test("scaffoldInit: rejects bad apiName", () => { + assert.throws(() => scaffoldInit({ apiName: "Movie_DB", target: tmpdir() }), /apiName must match/); + assert.throws(() => scaffoldInit({ apiName: "" }), /apiName required/); +}); + +test("scaffoldInit: refuses to overwrite", () => { + const tmp = mkdtempSync(join(tmpdir(), "clify-init-")); + try { + scaffoldInit({ apiName: "demo", target: tmp }); + assert.throws(() => scaffoldInit({ apiName: "demo", target: tmp }), /already exists/); + } finally { rmSync(tmp, { recursive: true, force: true }); } +}); + +// ---------- deliberate-break tests for validator ---------- + +test("validate: missing help-text resource → structural fail", async () => { + const { root, repo } = freshCopy(); + try { + // strip 'photos' from the resource registry but leave it in coverage.json + const cliPath = join(repo, "bin/jsonplaceholder-cli.mjs"); + const src = readFileSync(cliPath, "utf8"); + const broken = src.replace(/photos:\s*crudResource\("photos"\),\s*\n/, ""); + writeFileSync(cliPath, broken); + const r = await validate(repo, { skipTests: true }); + const failures = r.results.filter((x) => !x.ok && x.category === "structural"); + assert.ok(failures.length >= 1, "expected structural failure when resource missing from --help"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("validate: package.json/plugin.json version mismatch → manifest fail", async () => { + const { root, repo } = freshCopy(); + try { + const ppath = join(repo, ".claude-plugin/plugin.json"); + const p = JSON.parse(readFileSync(ppath, "utf8")); + p.version = "9.9.9"; + writeFileSync(ppath, JSON.stringify(p, null, 2)); + const r = await validate(repo, { skipTests: true }); + assert.ok(r.results.some((x) => !x.ok && /version mismatch/.test(x.name))); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("validate: silent drop in coverage.json → coverage fail", async () => { + const { root, repo } = freshCopy(); + try { + const cpath = join(repo, "coverage.json"); + const cov = JSON.parse(readFileSync(cpath, "utf8")); + cov.endpoints.push({ method: "GET", path: "/secret", resource: null, action: null, included: false }); + cov.totalParsed += 1; + writeFileSync(cpath, JSON.stringify(cov, null, 2)); + const r = await validate(repo, { skipTests: true }); + assert.ok(r.results.some((x) => !x.ok && x.category === "coverage")); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("validate: hardcoded secret → secrets fail", async () => { + const { root, repo } = freshCopy(); + try { + const path = join(repo, "bin/jsonplaceholder-cli.mjs"); + const src = readFileSync(path, "utf8"); + // Build the secret at runtime so this source file itself doesn't contain a + // matchable Stripe-pattern literal (would trigger GitHub secret scanning). + const probe = "sk_" + "live_" + "abcdefghijklmnopqrstuv1234"; + writeFileSync(path, src + `\n// const k = '${probe}';\n`); + const r = await validate(repo, { skipTests: true }); + assert.ok(r.results.some((x) => !x.ok && x.category === "secrets")); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("validate: missing CI workflow → ci fail", async () => { + const { root, repo } = freshCopy(); + try { + rmSync(join(repo, ".github/workflows/test.yml")); + const r = await validate(repo, { skipTests: true }); + assert.ok(r.results.some((x) => !x.ok && x.category === "ci")); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("validate: nuance declared without artifact → nuances fail", async () => { + const { root, repo } = freshCopy(); + try { + const cpath = join(repo, ".clify.json"); + const cfg = JSON.parse(readFileSync(cpath, "utf8")); + cfg.nuances.idempotency = ["posts.create"]; + writeFileSync(cpath, JSON.stringify(cfg, null, 2)); + const r = await validate(repo, { skipTests: true }); + assert.ok(r.results.some((x) => !x.ok && x.category === "nuances" && /idempotency/.test(x.name))); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("validate: SKILL.md missing knowledge/ preamble → structural fail", async () => { + const { root, repo } = freshCopy(); + try { + const path = join(repo, "skills/jsonplaceholder/SKILL.md"); + const src = readFileSync(path, "utf8").replace(/knowledge\//g, "kb/"); + writeFileSync(path, src); + const r = await validate(repo, { skipTests: true }); + assert.ok(r.results.some((x) => !x.ok && x.category === "structural" && /knowledge/.test(x.name))); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("validate: missing BASE_URL override → manifest fail", async () => { + const { root, repo } = freshCopy(); + try { + const path = join(repo, "bin/jsonplaceholder-cli.mjs"); + const src = readFileSync(path, "utf8").replace(/JSONPLACEHOLDER_BASE_URL/g, "JSONPLACEHOLDER_URL"); + writeFileSync(path, src); + const r = await validate(repo, { skipTests: true }); + assert.ok(r.results.some((x) => !x.ok && /BASE_URL/.test(x.name))); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +// ---------- syncCheck ---------- + +test("syncCheck: detects identical content as unchanged", async () => { + const { root, repo } = freshCopy(); + try { + // Stub fetcher returns deterministic body; compute hash and write it into .clify.json. + const fakeBody = "deterministic doc body"; + const cpath = join(repo, ".clify.json"); + const cfg = JSON.parse(readFileSync(cpath, "utf8")); + cfg.crawledUrls = ["https://example.test/docs"]; + const { createHash } = await import("node:crypto"); + cfg.contentHash = "sha256:" + createHash("sha256").update(fakeBody).digest("hex"); + writeFileSync(cpath, JSON.stringify(cfg, null, 2)); + const stubFetch = async () => ({ status: 200, async text() { return fakeBody; } }); + const result = await syncCheck(repo, { fetcher: stubFetch }); + assert.equal(result.changed, false); + assert.equal(result.oldHash, cfg.contentHash); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("syncCheck: detects drift", async () => { + const { root, repo } = freshCopy(); + try { + const cpath = join(repo, ".clify.json"); + const cfg = JSON.parse(readFileSync(cpath, "utf8")); + cfg.crawledUrls = ["https://example.test/docs"]; + cfg.contentHash = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + writeFileSync(cpath, JSON.stringify(cfg, null, 2)); + const stubFetch = async () => ({ status: 200, async text() { return "new body"; } }); + const result = await syncCheck(repo, { fetcher: stubFetch }); + assert.equal(result.changed, true); + assert.notEqual(result.oldHash, result.newHash); + } finally { rmSync(root, { recursive: true, force: true }); } +});