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/exemplar-cli/ is the canonical implementation of every rule below. When a contract here is ambiguous, the exemplar wins. The exemplar is structurally inspired by google/agents-cli — hierarchical subcommands, one file per resource under commands/, shared machinery under lib/, modular skills.
Generated CLIs use the resource-action pattern:
<api>-cli <resource> <action> [flags]
| 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).
Cap at two levels: <resource> <action> or <resource> <sub-resource> <action>. Flatten anything deeper with flags.
API's own terminology, kebab-case for multi-word (api-keys, pull-requests). Don't rename to camelCase or PascalCase.
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/exemplar-cli/bin/exemplar-cli.mjs.
| Source | Flag style |
|---|---|
| Path params | --id, --repo (required) |
| Query params | Optional flags |
| Body fields | Individual flags |
| Raw body | --body <json> escape hatch |
| Upload | --file <path> (multipart) |
| Binary download | --output <path> |
| Idempotency | --idempotency-key <key> (when API supports it) |
| Concurrency | --if-match <etag> (when API supports it) |
Two optional keys on an action def let bin/<api>-cli.mjs handle common
quirks without per-resource glue:
-
queryFlags: ["foreignId"]— flag names that ride on the URL query string instead of the JSON body. Use for "convert from X"POSTcreates whose docs list the parent FK as a query parameter, not a body field (e.g.POST /creditnotes ?invoice_id=on Zoho). The body equivalent is silently dropped on these APIs and the resulting record has no structural link to the source. Verify with--dry-run: the URL must contain?<flag>=<value>and the body must NOT. -
brokenListFilters: ["customerId"]— list-action filters the upstream API silently ignores (HTTP 200 with the unfiltered list). Runtime drops them from the wire, fetches the full list via cursor pagination, filters client-side (case-insensitive equals OR substring), and writes a one-linenote: …to stderr. Detect via probe: passFAKE-NONEXISTENTand compare row count against the unfiltered baseline.
Substrate lives in examples/exemplar-cli/lib/quirks.mjs (helpers:
pickQueryFlags, stripQueryFlags, pickBrokenFilters, clientFilter)
and is unit-tested in test/quirks.test.mjs. The exemplar's own resources
do NOT pre-populate either annotation — the substrate is dormant unless a
generated CLI opts in. For a worked real-world opt-in, see
zoho-inventory-cli/commands/credit-notes.mjs
(create.queryFlags = ["invoice_id", "ignore_auto_number_generation"])
and zoho-inventory-cli/commands/sales-returns.mjs
(brokenListFilters: [...]).
Contract: knowledge/query-flags-and-broken-list-filters.md.
Every generated CLI honors <API_NAME>_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.
const BASE_URL = process.env.EXEMPLAR_BASE_URL || "https://api.exemplar.test";The env var name is <API_NAME_UPPER_SNAKE>_BASE_URL — same prefix used for the API key and other defaults.
test/_mock-server.mjs exports a single function:
const server = await mockApi({
"GET /items": { status: 200, body: { items: [{ id: "1" }], nextCursor: null } },
"GET /items/:id": (req, params) => ({ status: 200, body: { id: params.id } }),
"POST /items": (req) => ({ status: 201, body: { id: "new", ...req.body } }),
"GET /orders": { status: 429, headers: { "retry-after": "30" } },
});
process.env.EXEMPLAR_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):
mockApilistens on127.0.0.1:0(kernel-assigned port) and resolves{ url, requests, close }only afterlisteningfires.server.urlishttp://127.0.0.1:<assigned-port>with no trailing slash.- Header keys in
server.requests[i].headersare 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 /pathexactly;:paramplaceholders extract path params; no regex.
Reference impl: examples/exemplar-cli/test/_mock-server.mjs.
--dry-run prints the request that would be sent without sending it. The dry-run JSON dump MUST NOT contain credential-shaped values.
Rules:
- The
Authorization,X-Api-Key,Proxy-Authorization,Cookie, andSet-Cookieheader keys are always replaced with<redacted>. - Any header key matching
/(token|secret|key|cookie|auth|password)/iis also replaced with<redacted>. - The opt-in
--show-secretsflag suppresses redaction. It exists for debugging only and is never the default — never document it in user-facing workflows. - The
lib/api.mjsreference impl shows the redaction pattern (seeredactHeaders). The validation gate spawns<bin> <r> list --dry-run --jsonand scans output for credential patterns; any leak is a hard-fail.
The redaction rule applies even to the body and URL: if the API encodes credentials in the URL (?token=...) or body, redact those fields too.
For APIs that use refresh-token grant (Zoho, Google, Notion, GitHub Apps, Slack, Stripe Connect, …), set auth.scheme: "oauth-refresh" in .clify.json and rely on the exemplar's built-in oauth-refresh branch in lib/auth.mjs. Never re-author the OAuth logic. The exemplar handles refresh, expiry, and the precedence rules below; the LLM only substitutes API-specific constants:
const TOKEN_URL = "https://accounts.<provider>/oauth/v2/token";
const REFRESH_ENV = "<API>_REFRESH_TOKEN";
const CLIENT_ID_ENV = "<API>_CLIENT_ID";
const CLIENT_SECRET_ENV = "<API>_CLIENT_SECRET";
const NO_CACHE_ENV = "<API>_NO_CACHE";
const OAUTH_WIRE_PREFIX = "Bearer"; // some APIs override (e.g. "Zoho-oauthtoken").clify.json declares the matching wiring fields (the validator hard-fails their absence under oauth-refresh):
"auth": {
"scheme": "oauth-refresh",
"envVar": "<API>_API_KEY",
"tokenUrl": "https://accounts.<provider>/oauth/v2/token",
"refreshEnvVar": "<API>_REFRESH_TOKEN",
"clientIdEnvVar": "<API>_CLIENT_ID",
"clientSecretEnvVar": "<API>_CLIENT_SECRET",
"validationCommand": "<resource> <action>"
}When applyAuth is called under oauth-refresh:
process.env[<API>_API_KEY]— pre-minted access token. Wins over everything.- Env refresh creds (
<API>_REFRESH_TOKEN+<API>_CLIENT_ID+<API>_CLIENT_SECRET) → mint viaTOKEN_URL. Env always trumps cache. - Cached access token from
~/.config/<api>-cli/credentials.json— only if the cache'srefreshTokenHashmatches the current source's refresh token. Account-switch protection: if a previous run cached a token under refresh-A and the current env has refresh-B, the cache is invalidated rather than reused. - Stored refresh creds → mint.
- Legacy stored static
token(from<bin> login --token). - Fail with
auth_missing(NOTauth_invalid).
<API>_NO_CACHE=1 (or =true) skips writing credentials.json after a successful refresh. Required for ephemeral CI runs and account-switching flows where on-disk cache is undesired. The exemplar reads this env var inside the OAuth branch — generated CLIs must preserve the behaviour.
Every endpoint matching POST /<r>/:id/status/<state> MUST map to action mark-<state>. No exceptions, no per-resource variation. The validator hard-fails on void / confirm / <state> (bare verb) when the path matches the status-mutation pattern.
| Path | Action |
|---|---|
POST /invoices/:id/status/sent |
mark-sent |
POST /invoices/:id/status/void |
mark-void |
POST /sales-orders/:id/status/confirmed |
mark-confirmed |
Every generated repo's README.md MUST have these two sections, in order, before any other:
## Install— git clone,npm install,npm link, smoke check (<bin> --version).## Authenticate— env-var path AND<bin> loginpath. Scheme-aware: static schemes show--token;oauth-refreshshows the three OAuth flags and the env-var triplet.
After those two, the existing ## Layout / ## Use / ## Test sections follow. Reference: examples/exemplar-cli/README.md. The validator scans heading lines and hard-fails if either section is absent.
{
"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:
retryAfteris optional on any retryable error; populate fromRetry-Afterheader 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.
Root metadata, written by the scaffold skill, read by the validator and sync tooling.
{
"apiName": "exemplar",
"docsUrl": "https://docs.exemplar.test/api/v1",
"crawledUrls": ["https://docs.exemplar.test/api/v1"],
"contentHash": "sha256:abc...",
"generatedAt": "2026-04-26T00:00:00Z",
"clifyVersion": "0.4.0",
"nodeMinVersion": "20",
"auth": {
"envVar": "EXEMPLAR_API_KEY",
"scheme": "bearer",
"validationCommand": "items list"
},
"defaults": [],
"nuances": {
"pagination": "cursor",
"rateLimits": true,
"authScopes": false,
"deprecated": [],
"idempotency": ["items.create", "orders.create"],
"multiPart": ["orders.upload"],
"conditional": ["items.update"],
"businessRules": 1
},
"coverage": {
"totalParsed": 11,
"totalIncluded": 11,
"totalDropped": 0
}
}auth.scheme ∈ bearer | api-key-header | basic | none | oauth-refresh. auth is required (use none scheme for auth-free APIs); defaults defaults to []. For oauth-refresh, see the Auth (OAuth-refresh) section above for the additional required wiring fields (tokenUrl, refreshEnvVar, clientIdEnvVar, clientSecretEnvVar).
Preserved across sync: .clify.json (only generatedAt/contentHash updated), knowledge/, .env.
Regenerated on sync: everything else.
coverage.json at repo root, written at generation time.
{
"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, sibling-asymmetry-confirmed (use only when an API genuinely lacks a sub-action that ≥3 sibling resources expose; see family-consistency rule).
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.)
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:
| 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 <path> flag wired; integration test posts a fixture file |
| Deprecated endpoints | deprecated: true in OpenAPI, "deprecated" in prose |
knowledge/deprecated-<resource>.md with replacement OR exclusion in coverage.json with reason deprecated-in-docs |
| 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/<topic>.md type: business-rule |
| Sequencing | "must X before Y" | knowledge/<topic>.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.
- 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. .envis gitignored;.env.exampledocuments required keys with placeholder values.- Auth env var:
<API_NAME>_API_KEY(uppercase, underscores). - Test override env var:
<API_NAME>_BASE_URL(see Test Override above).
Setup lives in the generated SKILL.md — no CLI binary changes. The LLM follows API-specific instructions to collect credentials, validate auth, detect defaults.
| Tag | Meaning |
|---|---|
@required |
Setup must collect this |
@optional |
Improves UX but not strictly needed |
@how-to-get <url> |
Where to obtain |
@format <pattern> |
Expected format |
@validation-command <res> <act> |
CLI command exercising this credential |
@detect-command <res> <act> |
Lists possible values |
Reference: examples/exemplar-cli/.env.example.
Values matching your_*_here / *_your_*_here (case-insensitive), empty string, or the exact value from .env.example mean "not set".
Live in knowledge/ in the generated repo:
---
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.
- Node.js ESM (
.mjs) "type": "module","engines": { "node": ">=20" }- Zero external npm dependencies
- Native
fetch,node:utilparseArgs,node:fs,node:path,node:crypto,node:http
The exemplar shape (preferred — every newly generated CLI inherits it):
bin/<api>-cli.mjs thin dispatcher
lib/api.mjs apiRequest + cursor pagination iterator
lib/auth.mjs pluggable auth (bearer | api-key-header | basic | none | oauth-refresh)
lib/output.mjs output() + errorOut()
lib/config.mjs ~/.config/<api>-cli/credentials.json store (used by login)
lib/env.mjs .env loader (zero-dep)
lib/args.mjs splitGlobal + parseArgs adapters
lib/help.mjs help-text generators (read the registry)
commands/<resource>.mjs one file per resource, default-exports { name, actions, buildPayload? }
commands/login.mjs token persistence + --status (only when scheme ≠ none)
test/smoke.test.mjs smoke tests
test/integration.test.mjs mock-server-driven integration tests
test/auth.test.mjs bearer/api-key wiring + login --status
test/_mock-server.mjs mock-server helper
test/_helpers.mjs run/runJson child-process harness
Legacy single-file shape (still passes the gate, but new generation uses the split above):
bin/<api>-cli.mjs everything in one file
Resource handlers are plain objects (not classes). One apiRequest() handles auth, dry-run, verbose, error mapping. Version read from package.json.
Every command is one of:
<api>-cli <resource> <action> [flags]— the default shape<api>-cli login [--token <t>] [--status]— auth management (when scheme ≠ none)
Resources, actions, and the registry that maps them to method/path/flags all live under commands/<resource>.mjs. The bin file imports each, builds a single REGISTRY object, and dispatches.
lib/auth.mjs exports applyAuth(headers). The function:
- Reads
process.env.<API>_API_KEY(or stored credential fromlib/config.mjs). - Branches on
SCHEME— one ofbearer | api-key-header | basic | none | oauth-refresh. - Mutates the
headersmap with the right header. - Returns
{ ok, reason }soapiRequestcan fail fast withauth_missing.
Adding a new scheme is a registry edit — branch on SCHEME, set the header, done. Don't fork apiRequest.
For oauth-refresh, the exemplar's applyAuth is async (it may await a refresh-token mint). apiRequest already awaits it. See the Auth (OAuth-refresh) section earlier in this file for the precedence rules and the <API>_NO_CACHE env var.
Generated repos ship four skills under skills/<api>-cli-<role>/SKILL.md:
| Skill | Purpose |
|---|---|
<api>-cli-workflow |
End-to-end workflows. Mentions every resource and the knowledge/ dir. The validation gate looks for this file. |
<api>-cli-auth |
Auth setup, login, troubleshooting 401/403 |
<api>-cli-resources |
Resource × action × flag quick reference |
<api>-cli-knowledge |
How to write and consume knowledge/*.md |
The legacy single-skill layout (skills/<api-slug>/SKILL.md) still passes the validation gate as a fallback, but new generation uses the modular layout.
--help → resources & actions. <resource> --help → actions. <resource> <action> --help → per-action flags with required/optional + descriptions.
Generated from the resource registry — agents can call <cli> <r> <a> --help to learn flags without reading SKILL.md.
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 |
<resource> --help |
Lists actions per resource |
<resource> <action> --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/exemplar-cli/test/smoke.test.mjs.
For each resource, exercise every declared action against _mock-server.mjs:
list→ returns array; multi-page test if pagination detectedget→ returns single object; 404 →not_foundcreate→ echoes payload; 422 →validation_errorupdate→ mutates; 404 →not_founddelete→ 204- Rate-limit path: 429 with
Retry-After→rate_limitedwithretryAfter - Auth path (when scheme ≠ none): missing key →
auth_missing; bad key →auth_invalid - Network down: dial unreachable port →
network_error
Reference: examples/exemplar-cli/test/integration.test.mjs.
.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/exemplar-cli/.github/workflows/test.yml.
<api-name>-cli/
├── bin/<api-name>-cli.mjs
├── lib/ # api, auth, output, config, env, args, help
├── commands/ # one file per resource + login
├── skills/
│ ├── <api-name>-cli-workflow/SKILL.md # primary; validate looks here
│ ├── <api-name>-cli-auth/SKILL.md
│ ├── <api-name>-cli-resources/SKILL.md
│ └── <api-name>-cli-knowledge/SKILL.md
├── knowledge/ # business-rules + patterns extracted from docs
├── test/
│ ├── smoke.test.mjs
│ ├── integration.test.mjs
│ ├── auth.test.mjs
│ ├── _helpers.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
{
"name": "<api-name>-cli",
"version": "0.1.0",
"description": "CLI for the <API Name> API. Generated by clify.",
"type": "module",
"bin": { "<api-name>-cli": "./bin/<api-name>-cli.mjs" },
"engines": { "node": ">=20" },
"scripts": { "test": "node --test test/*.test.mjs" },
"license": "MIT"
}No dependencies. No devDependencies.
package.json is authoritative for name, version, description. plugin.json and marketplace.json must match exactly. engines lives only in package.json.
{
"name": "<api-name>-cli",
"version": "0.1.0",
"description": "CLI for the <API Name> API. Generated by clify.",
"author": { "name": "<user>" },
"license": "MIT",
"skills": "./skills",
"capabilities": ["network"]
}{
"name": "<api-name>-cli",
"description": "CLI for the <API Name> API. Generated by clify.",
"version": "0.1.0",
"author": { "name": "<user>" },
"source": "./"
}The validator checks: every required field present; name/version/description match across all three manifests; every skills[].source path resolves.