diff --git a/.changeset/clerk-skill.md b/.changeset/clerk-skill.md new file mode 100644 index 00000000..313ca79d --- /dev/null +++ b/.changeset/clerk-skill.md @@ -0,0 +1,7 @@ +--- +"clerk": minor +--- + +Add `clerk skill install` to install the bundled `clerk` Claude Code skill into your project. The skill ships with the CLI and is pinned to the CLI's version, and `clerk init` now offers to install it alongside the framework-pattern skills. + +The bundled skill's command reference and agent-mode docs have also been resynced with the CLI: `clerk init --app`, `clerk config patch`/`put` `--app` and `--instance`, and `clerk update` are now documented, agent-mode errors are documented as structured JSON on stderr, the `clerk doctor --json` shape is spelled out in full (`detail`, `fix` alongside `remedy`), `apps create` is noted as auto-emitting JSON in agent mode (same as `apps list`), and the OpenAPI catalog cache TTL is corrected to 1 hour. The auth docs now list the `signup`/`signin`/`sign-in` and `signout`/`sign-out` aliases plus the top-level `clerk login`/`clerk logout` shortcuts, `config patch` explains `--destructive` the same way `config put` does, `config` commands are noted as Platform-API-only (they ignore `--secret-key`), and the agent-mode reference maps each failing `clerk doctor` check to the manual command that would remediate it when `--fix` is unavailable. Hardcoded `~/.clerk/config.json` and `~/.clerk/cache/` paths are replaced with platform-agnostic guidance (run `clerk doctor --verbose` to see resolved paths; override with `CLERK_CONFIG_DIR`), and `CLERK_CONFIG_DIR` is added to the environment variables table. diff --git a/README.md b/README.md index c2a61ad7..3442bf6a 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ Commands: ls [filter] List available API endpoints (no args) Interactive request builder (TTY only) doctor [options] Check your project's Clerk integration health + skill Manage the bundled Clerk CLI agent skill + install [options] Install the bundled clerk agent skill switch-env [environment] Switch the active Clerk CLI environment completion [shell] Generate shell autocompletion script update [options] Update the Clerk CLI to the latest version @@ -198,6 +200,14 @@ clerk doctor $ clerk doctor --fix Auto-fix detected issues $ clerk doctor --spotlight Only show warnings and failures +clerk skill install + -y, --yes Skip prompts and run the `skills` CLI unattended + --pm Package manager hint for runner detection + Examples: + $ clerk skill install Install with an interactive runner picker + $ clerk skill install -y Install unattended + $ clerk skill install --pm bun Force bunx as the runner + clerk completion shell: bash, zsh, fish, powershell diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 12390679..800f6f7b 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -20,6 +20,8 @@ import { getEnvironment } from "./lib/config.ts"; import { setCurrentEnv, isValidEnv, getCurrentEnvName } from "./lib/environment.ts"; import { completion, SUPPORTED_SHELLS } from "./commands/completion/index.ts"; import { FRAMEWORK_NAMES } from "./lib/framework.ts"; +import { PACKAGE_MANAGERS } from "./lib/package-manager.ts"; +import { skillInstall } from "./commands/skill/install.ts"; import { CliError, UserAbortError, @@ -98,7 +100,7 @@ export function createProgram() { createOption( "--pm ", "Package manager to use (skips prompt/auto-detection)", - ).choices(["bun", "pnpm", "yarn", "npm"]), + ).choices(PACKAGE_MANAGERS), ) .option("--name ", "Project name for --starter (skips prompt)") .option("--app ", "Application ID to link (skips interactive picker)") @@ -481,6 +483,36 @@ Tutorial — enable completions for your shell: ) .action(completion); + const skill = program + .command("skill") + .description("Manage the bundled Clerk CLI agent skill") + .setExamples([ + { command: "clerk skill install", description: "Install the clerk agent skill" }, + { + command: "clerk skill install -y", + description: "Install non-interactively (auto-detect agents, global scope)", + }, + ]); + + skill + .command("install") + .description("Install the bundled clerk agent skill") + .option("-y, --yes", "Skip prompts and run the `skills` CLI unattended") + .addOption( + createOption("--pm ", "Package manager hint for runner detection").choices( + PACKAGE_MANAGERS, + ), + ) + .setExamples([ + { command: "clerk skill install", description: "Install with an interactive runner picker" }, + { command: "clerk skill install -y", description: "Install unattended" }, + { + command: "clerk skill install --pm bun", + description: "Force bunx as the runner", + }, + ]) + .action(skillInstall); + program .command("update") .description("Update the Clerk CLI to the latest version") diff --git a/packages/cli-core/src/commands/api/README.md b/packages/cli-core/src/commands/api/README.md index 450fb050..c6b18809 100644 --- a/packages/cli-core/src/commands/api/README.md +++ b/packages/cli-core/src/commands/api/README.md @@ -119,7 +119,7 @@ Base URL: `https://api.clerk.com` (overridable via `CLERK_PLATFORM_API_URL`) Lists available API endpoints from the Clerk OpenAPI spec. - Fetches the spec from `clerk/openapi-specs` on GitHub -- Caches locally in `~/.clerk/cache/` for 24 hours +- Caches locally in `~/.clerk/cache/` for 1 hour - Supports `--platform` to list Platform API endpoints - Optional filter keyword matches against path, summary, tag, and operation ID diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 4bee01f0..5f21e1ac 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -181,13 +181,25 @@ If no entry file is found, a post-instruction is printed pointing to the Clerk J ## Agent skills install -After scaffolding (and after env keys are pulled or keyless instructions are printed), `clerk init` offers to install Clerk's framework-specific agent skills from [`clerk/skills`](https://github.com/clerk/skills) via the [`skills`](https://www.npmjs.com/package/skills) CLI. The runner is detected from the project's package manager (`bunx`, `npx`, `pnpm dlx`, or `yarn dlx`), so a Bun project installs via `bunx skills add clerk/skills`, a pnpm project via `pnpm dlx skills add clerk/skills`, and so on. This step is optional and non-fatal: if no package runner is available on PATH or the install command exits non-zero, init prints a yellow warning with a runner-appropriate manual command and still exits successfully. +After scaffolding (and after env keys are pulled or keyless instructions are printed), `clerk init` offers to install Clerk's agent skills via the [`skills`](https://www.npmjs.com/package/skills) CLI. The runner is detected from the project's package manager (`bunx`, `npx`, `pnpm dlx`, or `yarn dlx`), so a Bun project installs via `bunx skills add ...`, a pnpm project via `pnpm dlx skills add ...`, and so on. This step is optional and non-fatal: if no package runner is available on PATH or an install command exits non-zero, init prints a yellow warning with a runner-appropriate manual command and still exits successfully. - **Human mode**: prompts `Install agent skills? (...)` defaulting to yes. Pass `--no-skills` to suppress the prompt entirely, or `-y/--yes` to accept it without confirmation. When more than one runner is available, a second prompt picks which one to use (the project's package manager wins by default). - **Agent mode**: skills are installed non-interactively with `-y -g` flags (no prompt shown). Pass `--no-skills` to skip entirely. -- **`--prompt`**: exits before the skills step runs. Agent users should run `skills add clerk/skills` via their preferred runner manually. +- **`--prompt`**: exits before the skills step runs. Agent users should run `skills add clerk/skills` via their preferred runner manually; the bundled `clerk` skill is only installable via `clerk init` itself, since its source lives inside the CLI binary. -The base skills `clerk` and `clerk-setup` are always included. The detected framework dependency adds a matching skill: +Two install commands run, sharing one runner: + +### 1. The bundled `clerk` skill + +The `clerk` skill ships **inside the CLI binary**. Its markdown files at [`/skills/clerk/`](../../../../../skills/clerk/) are pulled into [`skills.ts`](./skills.ts) as [text imports](https://bun.com/docs/bundler/loaders#text) (`import md from "./SKILL.md" with { type: "text" }`) and embedded by `bun build --compile`, so the skill content always matches the binary running it. No network, no tag, no version fallback. + +At install time, [`skills.ts`](./skills.ts) stages the bundled content into a fresh temp directory (`mkdtemp`) and invokes ` skills add --copy`. The `--copy` flag is required: the default symlink mode would point each agent's skill dir at the temp dir, which we delete immediately after the install completes. + +The `skills` CLI writes the installed files into each agent's skill directory (`.claude/skills/clerk/`, `.cursor/skills/clerk/`, etc.) and records the entry in the project's `skills-lock.json` with `sourceType: "local"`, which correctly excludes it from `skills update` (the skill can only change when the CLI itself is upgraded). + +### 2. The upstream framework-pattern skills + +The base skills `clerk` and `clerk-setup` are always included from [`clerk/skills`](https://github.com/clerk/skills). The detected framework dependency adds a matching skill: | Framework dep | Added skill | | ----------------------- | ----------------------------- | @@ -202,7 +214,13 @@ The base skills `clerk` and `clerk-setup` are always included. The detected fram | `express` | `clerk-backend-api` | | `fastify` | `clerk-backend-api` | -Implementation lives in [`skills.ts`](./skills.ts). Note that the E2E fixture setup runs `clerk init --yes --no-skills` because the skill templates reference framework-generated types (e.g. React Router's `./+types/root`) that don't exist outside a real app directory and would break the fixture's `tsc` step. +These skills version independently of the CLI, so no pin is applied. + +### Failure handling + +The two install commands fail independently: a problem with the bundled `clerk` skill install (e.g. the `skills` CLI can't be fetched by the runner) does not block the upstream skills install, and vice versa. Each failure prints its own yellow warning with a manual install command (where applicable — the bundled `clerk` skill has no standalone manual command, since its source lives in the binary). Init continues and exits successfully either way. + +Implementation lives in [`skills.ts`](./skills.ts). Note that the E2E fixture setup runs `clerk init --yes --no-skills` because the framework template skills reference auto-generated types (e.g. React Router's `./+types/root`) that don't exist outside a real app directory and would break the fixture's `tsc` step. ## API Endpoints diff --git a/packages/cli-core/src/commands/init/bootstrap-registry.ts b/packages/cli-core/src/commands/init/bootstrap-registry.ts index 6f0dafcd..0b9ca792 100644 --- a/packages/cli-core/src/commands/init/bootstrap-registry.ts +++ b/packages/cli-core/src/commands/init/bootstrap-registry.ts @@ -1,6 +1,4 @@ -import type { ProjectContext } from "./frameworks/types.js"; - -export type PackageManager = ProjectContext["packageManager"]; +import type { PackageManager } from "../../lib/package-manager.ts"; export type BootstrapEntry = { label: string; diff --git a/packages/cli-core/src/commands/init/bootstrap.ts b/packages/cli-core/src/commands/init/bootstrap.ts index 44cf2934..f640bc7e 100644 --- a/packages/cli-core/src/commands/init/bootstrap.ts +++ b/packages/cli-core/src/commands/init/bootstrap.ts @@ -4,10 +4,10 @@ import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js"; import { log } from "../../lib/log.js"; import type { FrameworkInfo } from "../../lib/framework.js"; import { dirExists, hasPackageJson } from "./context.js"; +import type { PackageManager } from "../../lib/package-manager.ts"; import { BOOTSTRAP_REGISTRY, PM_INSTALL_COMMANDS, - type PackageManager, type BootstrapEntry, } from "./bootstrap-registry.js"; @@ -63,7 +63,17 @@ async function pickPackageManager(): Promise { }); } -const PM_PRIORITY: PackageManager[] = ["bun", "pnpm", "yarn", "npm"]; +const PM_PRIORITY = ["bun", "pnpm", "yarn", "npm"] as const satisfies readonly PackageManager[]; + +// Exhaustiveness guard: breaks the build if a PackageManager variant is +// missing from PM_PRIORITY (the `satisfies` above only ensures each entry +// is a valid PackageManager, not that every variant is present). +type _AllPackageManagersCovered = + Exclude extends never + ? true + : ["PM_PRIORITY missing:", Exclude]; +const _pmPriorityExhaustive: _AllPackageManagersCovered = true; +void _pmPriorityExhaustive; /** * Auto-select the first available package manager by priority: bun → pnpm → yarn → npm. diff --git a/packages/cli-core/src/commands/init/context.ts b/packages/cli-core/src/commands/init/context.ts index a2a508a2..88866935 100644 --- a/packages/cli-core/src/commands/init/context.ts +++ b/packages/cli-core/src/commands/init/context.ts @@ -4,7 +4,9 @@ import { detectFramework, readDeps } from "../../lib/framework.js"; import type { FrameworkInfo } from "../../lib/framework.js"; import { findExistingEnvFile } from "../../lib/dotenv.js"; import type { ProjectContext } from "./frameworks/types.js"; -import type { PackageManager } from "./bootstrap-registry.js"; +import { detectPackageManager, type PackageManager } from "../../lib/package-manager.ts"; + +export { detectPackageManager }; export async function fileExists(path: string): Promise { return Bun.file(path).exists(); @@ -19,21 +21,6 @@ export async function dirExists(path: string): Promise { } } -async function detectPackageManager(cwd: string): Promise { - const checks: Array<{ files: string[]; pm: ProjectContext["packageManager"] }> = [ - { files: ["bun.lockb", "bun.lock"], pm: "bun" }, - { files: ["yarn.lock"], pm: "yarn" }, - { files: ["pnpm-lock.yaml"], pm: "pnpm" }, - ]; - - for (const { files, pm } of checks) { - for (const file of files) { - if (await fileExists(join(cwd, file))) return pm; - } - } - return "npm"; -} - // Re-export for modules that import readDeps from context (e.g., format.ts) export { readDeps } from "../../lib/framework.js"; diff --git a/packages/cli-core/src/commands/init/frameworks/types.ts b/packages/cli-core/src/commands/init/frameworks/types.ts index 18a01f65..b9e0c460 100644 --- a/packages/cli-core/src/commands/init/frameworks/types.ts +++ b/packages/cli-core/src/commands/init/frameworks/types.ts @@ -1,11 +1,12 @@ import type { FrameworkInfo } from "../../../lib/framework.js"; +import type { PackageManager } from "../../../lib/package-manager.js"; export interface ProjectContext { cwd: string; framework: FrameworkInfo; typescript: boolean; srcDir: boolean; - packageManager: "bun" | "yarn" | "pnpm" | "npm"; + packageManager: PackageManager; existingClerk: boolean; deps: Record; envFile: string; diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index b7af5492..31fdef9a 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -32,7 +32,7 @@ import { type BootstrapResult, } from "./bootstrap.js"; import type { ProjectContext } from "./frameworks/types.js"; -import type { PackageManager } from "./bootstrap-registry.js"; +import type { PackageManager } from "../../lib/package-manager.ts"; type InitOptions = { /** Framework to set up (skips auto-detection). */ diff --git a/packages/cli-core/src/commands/init/skills.test.ts b/packages/cli-core/src/commands/init/skills.test.ts deleted file mode 100644 index 95921418..00000000 --- a/packages/cli-core/src/commands/init/skills.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { test, expect, describe } from "bun:test"; -import { buildSkillsArgs } from "./skills.ts"; - -describe("buildSkillsArgs", () => { - const skills = ["clerk", "clerk-setup", "clerk-nextjs-patterns"]; - - test("interactive mode: no -y or -g, lets skills CLI take over", () => { - const args = buildSkillsArgs(skills, true); - expect(args).toEqual([ - "skills", - "add", - "clerk/skills", - "--skill", - "clerk", - "--skill", - "clerk-setup", - "--skill", - "clerk-nextjs-patterns", - ]); - expect(args).not.toContain("-y"); - expect(args).not.toContain("-g"); - expect(args).not.toContain("--agent"); - }); - - test("non-interactive mode: includes -y and -g for global auto-detect", () => { - const args = buildSkillsArgs(skills, false); - expect(args).toContain("-y"); - expect(args).toContain("-g"); - expect(args).not.toContain("--agent"); - }); - - test("never passes --agent (lets skills CLI auto-detect)", () => { - expect(buildSkillsArgs(skills, true)).not.toContain("--agent"); - expect(buildSkillsArgs(skills, false)).not.toContain("--agent"); - }); -}); diff --git a/packages/cli-core/src/commands/init/skills.ts b/packages/cli-core/src/commands/init/skills.ts index 96ddef55..47de35ae 100644 --- a/packages/cli-core/src/commands/init/skills.ts +++ b/packages/cli-core/src/commands/init/skills.ts @@ -1,9 +1,15 @@ /** * Install Clerk agent skills after scaffolding. * - * Maps the detected framework to the appropriate skill set from - * github.com/clerk/skills, then installs via the user's package runner - * (bunx, npx, pnpm dlx, or yarn dlx). + * Two installer calls share one runner detection: + * + * 1. The bundled `clerk` skill (embedded in the binary via text + * imports). Delegated to the `clerk skill install` core helpers in + * `commands/skill/install.ts`. + * + * 2. The framework-pattern skills (`clerk-setup`, + * `clerk--patterns`) ship from the upstream `clerk/skills` + * repo and version independently of the CLI. * * The skills CLI itself handles agent auto-detection and scope selection: * in interactive mode we hand off entirely (no `--agent` / `-y`), so the @@ -11,24 +17,16 @@ * so it runs unattended with global scope and auto-detected agents. */ -import { dim } from "../../lib/color.js"; import { isHuman } from "../../mode.js"; import { log } from "../../lib/log.js"; -import { confirm, select } from "../../lib/prompts.js"; -import { - type Runner, - detectAvailableRunners, - preferredRunner, - runnerCommand, - runnerForPackageManager, -} from "../../lib/runners.js"; -import { isNonEmpty } from "../../lib/helpers/arrays.js"; +import { confirm } from "../../lib/prompts.js"; import type { ProjectContext } from "./frameworks/types.js"; +import { installClerkSkillCore, resolveSkillsRunner, runSkillsAdd } from "../skill/install.js"; -/** Skills installed regardless of framework. */ -const BASE_SKILLS = ["clerk", "clerk-setup"]; +/** Upstream skills installed regardless of framework. */ +const BASE_SKILLS = ["clerk-setup"]; -/** Maps framework dep (from package.json) to the skill name in clerk/skills. */ +/** Maps framework dep (from package.json) to the upstream skill name. */ const FRAMEWORK_SKILL_MAP: Record = { next: "clerk-nextjs-patterns", react: "clerk-react-patterns", @@ -42,9 +40,10 @@ const FRAMEWORK_SKILL_MAP: Record = { fastify: "clerk-backend-api", }; -const SKILLS_SOURCE = "clerk/skills"; +/** Source for upstream framework-pattern skills. */ +const UPSTREAM_SKILLS_SOURCE = "clerk/skills"; -function resolveSkills(frameworkDep: string | undefined): string[] { +function resolveUpstreamSkills(frameworkDep: string | undefined): string[] { const skills = [...BASE_SKILLS]; if (frameworkDep && FRAMEWORK_SKILL_MAP[frameworkDep]) { skills.push(FRAMEWORK_SKILL_MAP[frameworkDep]); @@ -52,31 +51,14 @@ function resolveSkills(frameworkDep: string | undefined): string[] { return skills; } -/** - * Build the runner-agnostic argv for `skills add ...`. The caller prepends - * the runner (bunx / npx / pnpm dlx / yarn dlx) via {@link runnerCommand}. - * - * Interactive mode: hand off to the skills CLI's native UX (auto-detect - * installed agents, scope picker) by omitting `--agent` and `-y`. - * Non-interactive: pass `-y -g` so it runs unattended with global scope - * and auto-detected agents. - * - * Exported for tests. - */ -export function buildSkillsArgs(skills: string[], interactive: boolean): string[] { - const skillFlags = skills.flatMap((s) => ["--skill", s]); - const extraFlags = interactive ? [] : ["-y", "-g"]; - return ["skills", "add", SKILLS_SOURCE, ...skillFlags, ...extraFlags]; -} - export async function installSkills( cwd: string, frameworkDep: string | undefined, packageManager: ProjectContext["packageManager"] | undefined, skipPrompt: boolean, ): Promise { - const skills = resolveSkills(frameworkDep); - const skillList = skills.join(", "); + const upstreamSkills = resolveUpstreamSkills(frameworkDep); + const skillList = ["clerk", ...upstreamSkills].join(", "); if (isHuman() && !skipPrompt) { const install = await confirm({ @@ -86,61 +68,29 @@ export async function installSkills( if (!install) return; } - // Detect runners after the user accepts — no point probing PATH if they decline. - const available = detectAvailableRunners(); - if (!isNonEmpty(available)) { - const suggested = runnerForPackageManager(packageManager); - log.blank(); - log.warn( - "No package runner found on PATH (looked for bunx, npx, pnpm, yarn). " + - `Install one and run \`${suggested.display} skills add ${SKILLS_SOURCE}\` manually.`, - ); - return; - } - - const preferred = preferredRunner(packageManager, available); - - // Only prompt when there's an actual choice and the user is interactive. - let runner = preferred; - if (isHuman() && !skipPrompt && available.length > 1) { - runner = await select({ - message: "Which package runner should install the skills?", - choices: available.map((r) => ({ - name: r.id === preferred.id ? `${r.display} ${dim("(detected)")}` : r.display, - value: r, - })), - default: preferred, - }); - } - const interactive = isHuman() && !skipPrompt; - const command = runnerCommand(runner, buildSkillsArgs(skills, interactive)); - const displayCommand = `${runner.display} skills add ${SKILLS_SOURCE}`; - - log.blank(); - log.info(`Installing skills with \`${runner.display}\`: \`${skillList}\``); - let exitCode: number; - try { - const proc = Bun.spawn(command, { - cwd, - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - }); - exitCode = await proc.exited; - } catch { + // Detect runner after the user accepts — no point probing PATH if they decline. + const runner = await resolveSkillsRunner(packageManager, interactive); + if (!runner) return; + + // Install the bundled clerk skill from a staged temp dir, then the + // upstream framework patterns. Each call soft-fails independently so a + // problem with one source doesn't block the other. + const cliSkillOk = await installClerkSkillCore(runner, cwd, interactive); + + const upstreamOk = await runSkillsAdd( + runner, + cwd, + UPSTREAM_SKILLS_SOURCE, + upstreamSkills, + interactive, + false, + upstreamSkills.join(", "), + ); + + if (cliSkillOk && upstreamOk) { log.blank(); - log.warn(`Could not run \`${displayCommand}\`. You can install manually later.`); - return; + log.success("Agent skills installed. AI agents now have Clerk context in this project."); } - - if (exitCode !== 0) { - log.blank(); - log.warn(`Skills installation failed. You can install manually: \`${displayCommand}\``); - return; - } - - log.blank(); - log.success("Agent skills installed. AI agents now have Clerk context in this project."); } diff --git a/packages/cli-core/src/commands/skill/README.md b/packages/cli-core/src/commands/skill/README.md new file mode 100644 index 00000000..166b3544 --- /dev/null +++ b/packages/cli-core/src/commands/skill/README.md @@ -0,0 +1,32 @@ +# Skill Command + +Manages the bundled `clerk` agent skill. The skill is embedded in the CLI binary at compile time via text imports from `skills/clerk/`, so it always matches the version of the CLI in use. + +## Subcommands + +### `clerk skill install` + +Installs the bundled `clerk` skill for any locally detected AI agents (Claude Code, Cursor, etc.). The actual agent detection and scope selection is delegated to the external [`skills`](https://www.npmjs.com/package/skills) CLI, which is invoked via the preferred package runner on PATH (`bunx`, `pnpm dlx`, `yarn dlx`, or `npx`). + +Interactive mode hands off entirely to the `skills` CLI picker. Non-interactive mode (`-y`, agent mode, or no TTY) passes `-y -g` so the skills CLI runs unattended against global scope with auto-detected agents. + +This command is delegated to by `clerk init` as part of its post-scaffold agent skills step; running it standalone is useful when adding the skill to an existing project that was set up before the skill was bundled, or when reinstalling after upgrading the CLI. + +## Usage + +```sh +clerk skill install +clerk skill install -y +clerk skill install --pm bun +``` + +## Options + +| Option | Description | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `-y, --yes` | Skip prompts; auto-select the preferred package runner and pass `-y -g` to the `skills` CLI | +| `--pm ` | Package manager hint for runner detection (`bun`, `pnpm`, `yarn`, `npm`). Defaults to lockfile detection in the current dir | + +## Clerk API endpoints + +None. This command does not make any Clerk API calls; it only spawns the external `skills` CLI against a staged copy of the bundled skill. diff --git a/packages/cli-core/src/commands/skill/install.test.ts b/packages/cli-core/src/commands/skill/install.test.ts new file mode 100644 index 00000000..c3338677 --- /dev/null +++ b/packages/cli-core/src/commands/skill/install.test.ts @@ -0,0 +1,170 @@ +import { test, expect, describe } from "bun:test"; +import { existsSync } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { buildSkillsArgs, renderSkillVersionPlaceholder, withStagedClerkSkill } from "./install.ts"; + +describe("buildSkillsArgs", () => { + const skills = ["clerk", "clerk-setup", "clerk-nextjs-patterns"]; + const upstream = "clerk/skills"; + + test("interactive mode: no -y or -g, lets skills CLI take over", () => { + const args = buildSkillsArgs(upstream, skills, true, false); + expect(args).toEqual([ + "skills", + "add", + "clerk/skills", + "--skill", + "clerk", + "--skill", + "clerk-setup", + "--skill", + "clerk-nextjs-patterns", + ]); + expect(args).not.toContain("-y"); + expect(args).not.toContain("-g"); + expect(args).not.toContain("--agent"); + expect(args).not.toContain("--copy"); + }); + + test("non-interactive mode: includes -y and -g for global auto-detect", () => { + const args = buildSkillsArgs(upstream, skills, false, false); + expect(args).toContain("-y"); + expect(args).toContain("-g"); + expect(args).not.toContain("--agent"); + }); + + test("never passes --agent (lets skills CLI auto-detect)", () => { + expect(buildSkillsArgs(upstream, skills, true, false)).not.toContain("--agent"); + expect(buildSkillsArgs(upstream, skills, false, false)).not.toContain("--agent"); + }); + + test("empty skillNames omits --skill flags (used for the clerk source)", () => { + const stageDir = "/tmp/clerk-skill-abc"; + const args = buildSkillsArgs(stageDir, [], true, true); + expect(args).toEqual(["skills", "add", stageDir, "--copy"]); + expect(args).not.toContain("--skill"); + }); + + test("copy=true appends --copy flag (required for the staged clerk dir)", () => { + const args = buildSkillsArgs("/tmp/clerk-skill-xyz", [], false, true); + expect(args).toContain("--copy"); + // --copy should trail -y / -g, not replace them. + expect(args).toContain("-y"); + expect(args).toContain("-g"); + }); +}); + +describe("withStagedClerkSkill", () => { + test("stages all bundled files into a fresh temp dir and cleans up after", async () => { + let observed: { dir: string; files: Record } | null = null; + + await withStagedClerkSkill(undefined, async (dir) => { + const files = { + "clerk/SKILL.md": await readFile(join(dir, "clerk/SKILL.md"), "utf-8"), + "clerk/references/auth.md": await readFile(join(dir, "clerk/references/auth.md"), "utf-8"), + "clerk/references/recipes.md": await readFile( + join(dir, "clerk/references/recipes.md"), + "utf-8", + ), + "clerk/references/agent-mode.md": await readFile( + join(dir, "clerk/references/agent-mode.md"), + "utf-8", + ), + }; + observed = { dir, files }; + + const entry = await stat(join(dir, "clerk")); + expect(entry.isDirectory()).toBe(true); + }); + + expect(observed).not.toBeNull(); + const { dir, files } = observed!; + + // Every file has some content (bundled imports are non-empty). + for (const [rel, content] of Object.entries(files)) { + expect(content.length).toBeGreaterThan(0); + expect(content, rel).toMatch(/\S/); + } + + // SKILL.md should at least contain the YAML frontmatter marker. + expect(files["clerk/SKILL.md"]).toContain("---"); + + // Temp dir is removed once the callback returns. + expect(existsSync(dir)).toBe(false); + }); + + test("propagates callback errors after cleaning up", async () => { + let capturedDir: string | null = null; + + await expect( + withStagedClerkSkill(undefined, async (dir) => { + capturedDir = dir; + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + expect(capturedDir).not.toBeNull(); + expect(existsSync(capturedDir!)).toBe(false); + }); +}); + +const ALL_BUNDLED_FILES = [ + "clerk/SKILL.md", + "clerk/references/auth.md", + "clerk/references/recipes.md", + "clerk/references/agent-mode.md", +] as const; + +describe("withStagedClerkSkill version rendering", () => { + test("substitutes CLI_VERSION in every staged file", async () => { + await withStagedClerkSkill("4.5.6", async (stageDir) => { + for (const rel of ALL_BUNDLED_FILES) { + const content = await readFile(join(stageDir, rel), "utf8"); + expect(content, rel).not.toContain("{{CLI_VERSION}}"); + } + }); + }); + + test("resolves undefined version to `latest` in every staged file", async () => { + await withStagedClerkSkill(undefined, async (stageDir) => { + for (const rel of ALL_BUNDLED_FILES) { + const content = await readFile(join(stageDir, rel), "utf8"); + expect(content, rel).not.toContain("{{CLI_VERSION}}"); + } + }); + }); +}); + +describe("renderSkillVersionPlaceholder", () => { + test("replaces {{CLI_VERSION}} with the provided version", () => { + const result = renderSkillVersionPlaceholder("Pinned: `bunx clerk@{{CLI_VERSION}}`.", "1.2.3"); + expect(result).toBe("Pinned: `bunx clerk@1.2.3`."); + }); + + test("replaces every occurrence, not just the first", () => { + const result = renderSkillVersionPlaceholder( + "v={{CLI_VERSION}} and again v={{CLI_VERSION}}", + "9.9.9", + ); + expect(result).toBe("v=9.9.9 and again v=9.9.9"); + }); + + test("falls back to `latest` when version is undefined", () => { + const result = renderSkillVersionPlaceholder( + "Install: `npx -y clerk@{{CLI_VERSION}}`.", + undefined, + ); + expect(result).toBe("Install: `npx -y clerk@latest`."); + }); + + test("falls back to `latest` when version is the dev sentinel", () => { + const result = renderSkillVersionPlaceholder("v={{CLI_VERSION}}", "0.0.0-dev"); + expect(result).toBe("v=latest"); + }); + + test("returns content unchanged when no placeholder is present", () => { + const input = "No placeholder here."; + expect(renderSkillVersionPlaceholder(input, "1.2.3")).toBe(input); + }); +}); diff --git a/packages/cli-core/src/commands/skill/install.ts b/packages/cli-core/src/commands/skill/install.ts new file mode 100644 index 00000000..b1dd2584 --- /dev/null +++ b/packages/cli-core/src/commands/skill/install.ts @@ -0,0 +1,253 @@ +/** + * `clerk skill install` — installs the bundled `clerk` skill for local + * agents. + * + * The `clerk` skill is embedded in the CLI binary via text imports at + * compile time. At install time we stage it to a temp dir and invoke + * `skills add --copy`, so the installed files are full copies + * (not symlinks into the temp dir, which would break when cleaned up). + * + * The external `skills` CLI handles agent auto-detection and scope + * selection: in interactive mode we hand off entirely (no `--agent` / `-y`), + * so the user gets the native picker. In non-interactive mode we pass + * `-y -g` so it runs unattended with global scope and auto-detected agents. + * + * The `init` command also imports `installClerkSkillCore` and + * `resolveSkillsRunner` from here so a single runner detection is shared + * with the upstream framework-pattern skills install. + */ + +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { dim } from "../../lib/color.js"; +import { isHuman } from "../../mode.js"; +import { log } from "../../lib/log.js"; +import { DEV_CLI_VERSION, resolveCliVersion } from "../../lib/version.js"; +import { select } from "../../lib/prompts.js"; +import { + type Runner, + detectAvailableRunners, + preferredRunner, + runnerCommand, + runnerForPackageManager, +} from "../../lib/runners.js"; +import { isNonEmpty } from "../../lib/helpers/arrays.js"; +import { detectPackageManager, type PackageManager } from "../../lib/package-manager.js"; + +import clerkSkillMd from "../../../../../skills/clerk/SKILL.md" with { type: "text" }; +import clerkAuthMd from "../../../../../skills/clerk/references/auth.md" with { type: "text" }; +import clerkRecipesMd from "../../../../../skills/clerk/references/recipes.md" with { type: "text" }; +import clerkAgentModeMd from "../../../../../skills/clerk/references/agent-mode.md" with { type: "text" }; + +/** + * The bundled clerk skill, as `(relativePath, content)` pairs. Text + * imports resolve live from `/skills/clerk/` during + * `bun run dev` and get embedded into the compiled binary by + * `bun build --compile`, so the content always matches the CLI being run. + */ +const BUNDLED_CLERK_SKILL: ReadonlyArray = [ + ["clerk/SKILL.md", clerkSkillMd], + ["clerk/references/auth.md", clerkAuthMd], + ["clerk/references/recipes.md", clerkRecipesMd], + ["clerk/references/agent-mode.md", clerkAgentModeMd], +]; + +/** + * Substitute `{{CLI_VERSION}}` in a skill asset with the provided version. + * When the version is absent (undefined) or is the dev-build sentinel, the + * placeholder resolves to `latest` so the runnable commands in the skill + * remain copy-pasteable on unversioned binaries. + * + * Exported for tests. + */ +export function renderSkillVersionPlaceholder( + content: string, + version: string | undefined, +): string { + const resolved = version && version !== DEV_CLI_VERSION ? version : "latest"; + return content.replaceAll("{{CLI_VERSION}}", resolved); +} + +/** + * Write the bundled clerk skill to a fresh temp dir and call `fn` with + * its path. Every asset has `{{CLI_VERSION}}` rendered against `version` first + * (see {@link renderSkillVersionPlaceholder}). The dir is deleted on return, + * so `fn` must finish any work that reads from it before returning. + * + * Exported for tests. + */ +export async function withStagedClerkSkill( + version: string | undefined, + fn: (stageDir: string) => Promise, +): Promise { + const stageDir = await mkdtemp(join(tmpdir(), "clerk-skill-")); + try { + for (const [rel, content] of BUNDLED_CLERK_SKILL) { + const dest = join(stageDir, rel); + await mkdir(dirname(dest), { recursive: true }); + await writeFile(dest, renderSkillVersionPlaceholder(content, version)); + } + return await fn(stageDir); + } finally { + await rm(stageDir, { recursive: true, force: true }); + } +} + +/** + * Build the runner-agnostic argv for `skills add ...`. The caller + * prepends the runner (bunx / npx / pnpm dlx / yarn dlx) via + * {@link runnerCommand}. + * + * `skillNames` becomes `--skill ` pairs; leave empty to install every + * skill from `source` (what we do for the bundled clerk source). + * + * `copy` forces the `skills` CLI to copy files into each agent dir instead + * of symlinking. Required for sources that live in an ephemeral directory + * (our staged clerk skill); optional otherwise. + * + * Interactive mode: hand off to the skills CLI's native UX (auto-detect + * installed agents, scope picker) by omitting `--agent` and `-y`. + * Non-interactive: pass `-y -g` so it runs unattended with global scope + * and auto-detected agents. + * + * Exported for tests. + */ +export function buildSkillsArgs( + source: string, + skillNames: readonly string[], + interactive: boolean, + copy: boolean, +): string[] { + const skillFlags = skillNames.flatMap((s) => ["--skill", s]); + const extraFlags = interactive ? [] : ["-y", "-g"]; + const copyFlag = copy ? ["--copy"] : []; + return ["skills", "add", source, ...skillFlags, ...extraFlags, ...copyFlag]; +} + +/** + * Run a single `skills add ...` invocation. Returns true on success, false + * on any failure (spawn error, non-zero exit). Failures print a yellow + * warning but never throw — skills are optional and shouldn't tear down + * a successful scaffold. + */ +export async function runSkillsAdd( + runner: Runner, + cwd: string, + source: string, + skillNames: readonly string[], + interactive: boolean, + copy: boolean, + label: string, +): Promise { + const command = runnerCommand(runner, buildSkillsArgs(source, skillNames, interactive, copy)); + const displayCommand = `${runner.display} skills add ${source}`; + + log.blank(); + log.info(`Installing \`${label}\` with \`${runner.display}\`...`); + + let exitCode: number; + try { + const proc = Bun.spawn(command, { + cwd, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + exitCode = await proc.exited; + } catch { + log.blank(); + log.warn(`Could not run \`${displayCommand}\`. You can install manually later.`); + return false; + } + + if (exitCode !== 0) { + log.blank(); + log.warn(`\`${label}\` installation failed. You can install manually: \`${displayCommand}\``); + return false; + } + + return true; +} + +/** + * Resolve a runner for the `skills` CLI. Prompts the user to pick one in + * interactive mode when multiple are available; otherwise picks the + * preferred runner for `packageManager`. + * + * Returns `null` if no runner is on PATH. In that case a warning is logged + * so the caller can simply return without further output. + */ +export async function resolveSkillsRunner( + packageManager: PackageManager | undefined, + interactive: boolean, +): Promise { + const available = detectAvailableRunners(); + if (!isNonEmpty(available)) { + const suggested = runnerForPackageManager(packageManager); + log.blank(); + log.warn( + "No package runner found on PATH (looked for bunx, npx, pnpm, yarn). " + + `Install one and run \`${suggested.display} skills add \` manually.`, + ); + return null; + } + + const preferred = preferredRunner(packageManager, available); + + if (interactive && available.length > 1) { + return await select({ + message: "Which package runner should install the skills?", + choices: available.map((r) => ({ + name: r.id === preferred.id ? `${r.display} ${dim("(detected)")}` : r.display, + value: r, + })), + default: preferred, + }); + } + + return preferred; +} + +/** + * Install the bundled clerk skill using a pre-resolved runner. Does not + * prompt; callers handle any UX around confirmation and runner selection. + * + * Shared with the init flow so runner detection happens once when installing + * clerk alongside the upstream framework-pattern skills. + */ +export async function installClerkSkillCore( + runner: Runner, + cwd: string, + interactive: boolean, +): Promise { + return withStagedClerkSkill(resolveCliVersion(), (stageDir) => + runSkillsAdd(runner, cwd, stageDir, [], interactive, true, "clerk skill"), + ); +} + +export interface SkillInstallOptions { + yes?: boolean; + pm?: PackageManager; +} + +/** + * `clerk skill install` — standalone install of the bundled clerk skill. + */ +export async function skillInstall(options: SkillInstallOptions): Promise { + const cwd = process.cwd(); + const skipPrompt = options.yes ?? false; + const interactive = isHuman() && !skipPrompt; + + const packageManager = options.pm ?? (await detectPackageManager(cwd)); + + const runner = await resolveSkillsRunner(packageManager, interactive); + if (!runner) return; + + const ok = await installClerkSkillCore(runner, cwd, interactive); + if (ok) { + log.blank(); + log.success("clerk skill installed. AI agents now have Clerk context in this project."); + } +} diff --git a/packages/cli-core/src/lib/package-manager.ts b/packages/cli-core/src/lib/package-manager.ts new file mode 100644 index 00000000..06c67266 --- /dev/null +++ b/packages/cli-core/src/lib/package-manager.ts @@ -0,0 +1,25 @@ +import { join } from "node:path"; + +/** + * Canonical list of package managers the CLI recognizes. Single source of + * truth for both the `PackageManager` type and the Commander `--pm` choices. + */ +export const PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"] as const; + +export type PackageManager = (typeof PACKAGE_MANAGERS)[number]; + +/** Detects the package manager in use by checking for lockfiles in `cwd`. */ +export async function detectPackageManager(cwd: string): Promise { + const checks: Array<{ files: string[]; pm: PackageManager }> = [ + { files: ["bun.lockb", "bun.lock"], pm: "bun" }, + { files: ["yarn.lock"], pm: "yarn" }, + { files: ["pnpm-lock.yaml"], pm: "pnpm" }, + ]; + + for (const { files, pm } of checks) { + for (const file of files) { + if (await Bun.file(join(cwd, file)).exists()) return pm; + } + } + return "npm"; +} diff --git a/packages/cli-core/src/lib/update-check.ts b/packages/cli-core/src/lib/update-check.ts index dbea10d2..0eb19a4e 100644 --- a/packages/cli-core/src/lib/update-check.ts +++ b/packages/cli-core/src/lib/update-check.ts @@ -9,6 +9,7 @@ import { UPDATE_CACHE_FILE, } from "./constants.ts"; import { log } from "./log.ts"; +import { DEV_CLI_VERSION } from "./version.ts"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -36,11 +37,11 @@ export function getUpdateChannel(): string { // ── Version helpers ─────────────────────────────────────────────────────────── export function getCurrentVersion(): string { - return typeof CLI_VERSION !== "undefined" ? CLI_VERSION : "0.0.0-dev"; + return typeof CLI_VERSION !== "undefined" ? CLI_VERSION : DEV_CLI_VERSION; } export function isDevVersion(version: string): boolean { - return version === "0.0.0-dev"; + return version === DEV_CLI_VERSION; } export function compareSemver(a: string, b: string): number { diff --git a/packages/cli-core/src/lib/version.ts b/packages/cli-core/src/lib/version.ts new file mode 100644 index 00000000..4fdf8adc --- /dev/null +++ b/packages/cli-core/src/lib/version.ts @@ -0,0 +1,20 @@ +/** + * The string printed by `clerk --version` when the compile-time `CLI_VERSION` + * global is undefined (dev builds via `bun run dev`). Used in two places that + * must stay in lockstep: the CLI's own version flag fallback, and the skill + * installer's detection of "this binary isn't really versioned" so it can tell + * the installed skill to pin against `latest` instead of a fake number. + */ +export const DEV_CLI_VERSION = "0.0.0-dev"; + +/** + * Resolve the current CLI version, or `undefined` when running an unversioned + * dev build. Anything downstream that wants to *display* a version should use + * `DEV_CLI_VERSION` as a fallback; anything that wants to *decide* whether + * this binary is meaningfully versioned should check for `undefined` here. + */ +export function resolveCliVersion(): string | undefined { + if (typeof CLI_VERSION === "undefined") return undefined; + if (CLI_VERSION === DEV_CLI_VERSION) return undefined; + return CLI_VERSION; +} diff --git a/scripts/build.ts b/scripts/build.ts index 2754cf9a..bb291d7b 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,13 +1,14 @@ import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { parseArgs } from "node:util"; +import { DEV_CLI_VERSION } from "../packages/cli-core/src/lib/version.ts"; import { targets } from "./lib/targets.ts"; const { values } = parseArgs({ args: Bun.argv.slice(2), options: { target: { type: "string" }, - version: { type: "string", default: "0.0.0-dev" }, + version: { type: "string", default: DEV_CLI_VERSION }, "env-profiles-path": { type: "string" }, }, }); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index e516b7fa..629469b1 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../tsconfig.json", - "include": ["**/*.ts"] + "include": ["**/*.ts", "../packages/cli-core/src/globals.d.ts"] } diff --git a/skills/clerk/SKILL.md b/skills/clerk/SKILL.md new file mode 100644 index 00000000..9d09b88f --- /dev/null +++ b/skills/clerk/SKILL.md @@ -0,0 +1,170 @@ +--- +name: clerk +description: Operate the Clerk CLI (`clerk` binary) for authentication, user/org/session management, instance config, env keys, and any Clerk Backend or Platform API call. Use when the user mentions Clerk management tasks, "list clerk users", "create a clerk user", "update organization", "pull clerk config", "clerk env pull", "clerk doctor", "clerk api", or any ad-hoc Clerk API request. Prefer the CLI over raw HTTP: it handles auth, key resolution, app/instance targeting, and formatting automatically. +--- + +# Clerk CLI + +The `clerk` binary is a pre-authenticated gateway to Clerk's Backend API and Platform API, plus project-level tooling (auth, linking, env pulls, instance config). When the user asks anything that touches a Clerk resource, reach for `clerk` first instead of hand-rolling `curl`. + +> This skill was installed by `clerk init` (or `clerk skill install`) and is pinned to clerk `{{CLI_VERSION}}`. If `clerk --version` disagrees, refresh it with `clerk skill install` (or `bunx clerk@{{CLI_VERSION}} skill install`). The binary is always the source of truth, so run `clerk --help` to verify anything this skill claims. + +## Invoking the CLI + +Before running any `clerk` command, figure out which binary to invoke and bind that choice for the rest of the session: + +```sh +# 1. Prefer a globally installed binary when it matches the skill's pinned version. +command -v clerk >/dev/null 2>&1 && clerk --version +``` + +If that prints `{{CLI_VERSION}}` (or any version you trust), use bare `clerk` for the rest of the session. + +Otherwise fall back to a package runner, in this order (matches the CLI's own `preferredRunner` logic, which prefers the runner that matches the project's lockfile): + +| Project package manager | Invocation | +| ------------------------- | -------------------------------- | +| bun (`bun.lock*`) | `bunx clerk@{{CLI_VERSION}}` | +| npm (`package-lock.json`) | `npx -y clerk@{{CLI_VERSION}}` | +| pnpm (`pnpm-lock.yaml`) | `pnpm dlx clerk@{{CLI_VERSION}}` | +| yarn >= 2 (`yarn.lock`) | `yarn dlx clerk@{{CLI_VERSION}}` | + +Yarn Classic (v1) has no `dlx`; treat those projects as "no preferred runner" and fall back to the first runner from the list above that's on PATH. + +The published npm package is **`clerk`**, not `@clerk/cli`. Never teach `npm install -g clerk` as the primary path. The bundled skill is versioned alongside the binary, so a globally installed mismatched version will drift. If `clerk --version` disagrees with `{{CLI_VERSION}}`, either upgrade the global install or fall back to the pinned-runner form above. + +## Prerequisites (run at session start) + +Before running any other Clerk command in a session, verify the CLI is authenticated, linked, and healthy: + +```sh +clerk --version # confirm the binary is on PATH +clerk doctor --json # structured health check; exit 1 if anything failed +``` + +**Always run `clerk doctor --json` first.** It catches the common setup failures (not logged in, project not linked, missing keys, outdated bundled skill) up front, so later commands don't fail with confusing errors. Each result has `name`, `status` (`pass`/`warn`/`fail`), `message`, optional `detail`, optional `remedy` (how to fix it), and optional `fix` (label for auto-fixable issues). Parse that and act on it, or surface it to the user. Rerun `clerk doctor --json` whenever a later command starts misbehaving. + +If `clerk skill --help` reports a newer CLI than the skill you're reading, run `clerk skill install` to refresh the bundled skill. The CLI binary is always the source of truth. + +## The mental model + +| Layer | What it does | Commands | +| ------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| **Session / project** | Auth, link a repo to a Clerk app, pull env keys | `auth login`, `link`, `unlink`, `whoami`, `env pull`, `doctor` | +| **Instance config** | Manage the configuration (social providers, session lifetimes, etc.) for a specific instance | `config pull`, `config schema`, `config patch`, `config put` | +| **Backend API (default)** | Runtime data: users, orgs, sessions, invitations, JWT templates, webhooks | `clerk api ` | +| **Platform API (`--platform`)** | Account-level: applications, instances, billing | `clerk api --platform ` | + +A project is "linked" to an application via `clerk link`. Once linked, most commands auto-resolve the target app and dev instance from the repo's git remote. To target something else, pass `--app ` and/or `--instance dev|prod|`. See [references/auth.md](references/auth.md) for the full resolution order. + +## Discover endpoints — don't memorize them + +The CLI ships with the Clerk OpenAPI catalog. Always discover endpoints dynamically instead of guessing paths: + +```sh +clerk api ls # list every Backend API endpoint +clerk api ls users # filter by keyword (matches path, summary, tag, operationId) +clerk api ls --platform apps # list Platform API endpoints +``` + +Use this before `clerk api `. If you don't see the endpoint you expected, it probably isn't exposed. + +## The `clerk api` command (the workhorse) + +`clerk api` makes authenticated HTTP calls. It auto-resolves keys, auto-detects method from body presence, supports stdin, and can preview mutations with `--dry-run`. + +```sh +# GET requests +clerk api /users # list users +clerk api /users/user_abc123 # fetch one +clerk api /users?limit=5&order_by=-created_at # query params work inline + +# Mutating requests +clerk api /users -d '{"email_address":["a@b.co"]}' # POST (auto-detected from body) +clerk api /users/user_abc123 -X PATCH -d '{"first_name":"A"}' +clerk api /users/user_abc123 -X DELETE + +# Body from file or stdin +clerk api /users --file payload.json +cat payload.json | clerk api /users + +# Always preview mutations first +clerk api /users/user_abc123 -X DELETE --dry-run +clerk api /users/user_abc123 -X DELETE --yes # skip confirmation once you've verified + +# Target a specific app/instance +clerk api /users --app app_abc123 --instance prod + +# Include response headers when debugging +clerk api /users --include + +# Platform API (account-level, not tenant data) +clerk api /v1/platform/applications --platform +``` + +**Always `--dry-run` a mutation before running it for real.** Then re-run without `--dry-run` (add `--yes` if you're sure). In agent mode, interactive confirmation is bypassed, so `--dry-run` is the only safety net for destructive calls. + +**JSON bodies must be valid JSON.** The CLI validates and rejects malformed payloads. + +**Endpoint paths may be given with or without `/v1/` prefix** — both work for Backend API calls. The CLI normalizes. + +See [references/recipes.md](references/recipes.md) for concrete patterns: listing/filtering users, creating orgs, impersonation sessions, etc. + +## Core commands at a glance + +| Command | Purpose | Key flags | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `clerk init` | Scaffold Clerk into a project, or emit an agent handoff with `--prompt`. | `--framework`, `--pm`, `--name` (with `--starter`), `--app`, `--prompt`, `--starter`, `-y`, `--no-skills` | +| `clerk auth login` | OAuth browser login (stores token). Agent mode: no-op if already logged in, else prints guidance. Aliases: `signup`, `signin`, `sign-in`. Top-level shortcut: `clerk login`. | — | +| `clerk auth logout` | Clear stored credentials. Aliases: `signout`, `sign-out`. Top-level shortcut: `clerk logout`. | — | +| `clerk whoami` | Print the logged-in email. | — | +| `clerk link` | Link this repo to a Clerk app. | `--app ` | +| `clerk unlink` | Remove the link. | `--yes` | +| `clerk env pull` | Write publishable + secret keys to `.env.local` (merge, not clobber). | `--app`, `--instance`, `--file` | +| `clerk config pull` | Fetch instance config JSON. | `--app`, `--instance`, `--output`, `--keys` | +| `clerk config schema` | Fetch the JSON Schema for the instance config. | `--app`, `--instance`, `--output`, `--keys` | +| `clerk config patch` | Partial update (PATCH) of instance config. Pass `--destructive` to actually delete sub-resources touched by the patch rather than resetting them to defaults. | `--app`, `--instance`, `--file`, `--json`, `--dry-run`, `--yes`, `--destructive` | +| `clerk config put` | Full replacement (PUT) of instance config. Pass `--destructive` to actually delete removed sub-resources rather than resetting them to defaults. | `--app`, `--instance`, `--file`, `--json`, `--dry-run`, `--yes`, `--destructive` | +| `clerk apps list` | List Clerk applications. | `--json` | +| `clerk apps create ` | Create a new Clerk application. | `--json` | +| `clerk open [subpath]` | Open the linked app's dashboard in a browser. Agent mode: prints a JSON descriptor instead of opening. | `--print` | +| `clerk doctor` | Health check. | `--json`, `--spotlight`, `--verbose`, `--fix` | +| `clerk api [path]` | Authenticated HTTP to Backend/Platform API. | `-X`, `-d`, `--file`, `--dry-run`, `--yes`, `--include`, `--app`, `--secret-key`, `--instance`, `--platform` | +| `clerk api ls [filter]` | Discover endpoints from the bundled OpenAPI catalog. | `--platform` | +| `clerk completion [shell]` | Print a shell completion script (`bash`, `zsh`, `fish`, `powershell`). | — | +| `clerk update` | Update the CLI to the latest version. | `--channel`, `-y` | +| `clerk skill install` | Reinstall the bundled `clerk` skill. Run after upgrading the CLI so the skill matches the new binary. | `-y`, `--pm` | + +**`clerk --help` is the source of truth for flags.** This table is a hint, not a spec. Before running an unfamiliar command or flag combination, run `clerk --help` once per session. Every command also defines `setExamples([...])` in source, which `--help` renders as a copy-pasteable Examples block, so you rarely need to guess syntax. + +## Agent-mode behavior (important) + +The CLI auto-detects agent mode when stdout is not a TTY, or when `--mode agent` / `CLERK_MODE=agent` is set. In agent mode: + +- **Interactive prompts are disabled.** Commands that would normally show pickers (`link` without `--app`, interactive `api`, `unlink` without `--yes`) either auto-resolve, print structured guidance, or exit. Always pass explicit flags (`--app`, `--yes`) in scripted calls. +- **Mutations still require `--yes`** unless you accept per-call confirmation is impossible. +- **`doctor --fix` is ignored.** Parse `doctor --json` output's `remedy` field and act on it yourself. +- **`apps list` and `apps create` default to JSON** when piped. +- **`clerk init --prompt`** prints a short agent-oriented handoff telling the agent to run `clerk init -y` (it is NOT a framework-specific integration guide; use the runtime `clerk init` output itself for that). + +Full matrix in [references/agent-mode.md](references/agent-mode.md). + +## Output format and errors + +- **JSON output:** `--json` on `apps list` and `doctor`. For `clerk api`, the response body is the raw API JSON, so pipe into `jq` freely. +- **Exit codes:** `0` success, `1` runtime error, `2` usage/validation error. `doctor` returns `1` if any check failed. +- **Error format:** User-facing errors print a single line to stderr and set a non-zero exit code. Use `--verbose` for stack traces when debugging. + +## Safety rules for autonomous use + +1. **Discover before acting:** `clerk api ls ` before `clerk api `. +2. **Preview mutations:** `--dry-run` on every `config patch`, `config put`, `api -X POST/PATCH/PUT/DELETE`. +3. **Target explicitly in production:** pass `--instance prod` rather than relying on defaults, and confirm with the user before any production mutation. +4. **Never commit secrets:** `env pull` writes to `.env.local` (which should be gitignored). Don't paste secret keys into code or chat. +5. **Use `doctor --json`** to diagnose before assuming the CLI is broken. + +## References + +- [references/auth.md](references/auth.md) — auth flow, key resolution order, `--app`/`--instance` targeting, Backend vs Platform API. +- [references/recipes.md](references/recipes.md) — copy-pasteable recipes for common Clerk tasks. +- [references/agent-mode.md](references/agent-mode.md) — agent-mode behavior matrix, exit codes, error format. diff --git a/skills/clerk/references/agent-mode.md b/skills/clerk/references/agent-mode.md new file mode 100644 index 00000000..d07ed71c --- /dev/null +++ b/skills/clerk/references/agent-mode.md @@ -0,0 +1,137 @@ +# Clerk CLI — Agent Mode Reference + +The Clerk CLI has a first-class "agent" mode that's designed for non-interactive and AI-driven use. Read this before writing scripts or letting an LLM drive the CLI. + +## How agent mode is detected + +Priority (first match wins): + +1. `--mode agent` flag on the command line +2. `CLERK_MODE=agent` environment variable +3. Stdout is not a TTY (piped, redirected, or running under an agent harness) + +Force human mode with `--mode human` or `CLERK_MODE=human`. Typical AI-agent invocations automatically land in agent mode because stdout is piped. + +## What changes in agent mode + +| Behavior | Human mode | Agent mode | +| ---------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Interactive pickers (`link` without `--app`, `api` with no args) | Show a TUI picker | Print structured guidance and exit, or auto-resolve | +| Confirmation prompts (`unlink`, `config patch`, `api -X DELETE`) | Prompt y/n | Require `--yes`, otherwise error | +| `clerk doctor --fix` | Interactively offers fixes | **Ignored**; output the `remedy` field and let the caller act | +| `clerk apps list` default output | Table | JSON (when piped) | +| `clerk apps create ` output | Human-readable summary | JSON (auto-detected, same as `apps list`); `--json` also works explicitly | +| `clerk open [subpath]` | Opens the browser to the URL | Does not open a browser. Prints a JSON descriptor (`{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}`) on stdout so the agent can surface it | +| `clerk auth login` when already authenticated | Prompt to re-auth | Silent no-op | +| `clerk init` | Full interactive scaffold flow | Skips the interactive scaffold and either runs non-interactively with `--yes` or, with `--prompt`, emits a short agent handoff pointing the agent at `clerk init -y`. | +| Color / spinners | Enabled | Disabled | + +**Rule of thumb:** always pass `--yes` for mutations, `--json` for structured output where available, and `--app` / `--instance` explicitly instead of relying on pickers. + +## Exit codes + +| Code | Meaning | +| ---- | ---------------------------------------------------------------------------- | +| `0` | Success | +| `1` | Runtime error (auth failure, API error, file I/O, etc.) | +| `2` | Usage or validation error (bad flags, malformed JSON body, unknown endpoint) | + +`clerk doctor` exits `1` when any check fails (warnings alone still exit `0`). + +## Error output format + +**Human mode:** + +- Single-line error message on stderr. +- Stack traces hidden unless `--verbose` is passed. +- API errors include the first message from the response body, prefixed with a human context string (e.g., `Failed to fetch config: unauthorized`). + +**Agent mode:** + +- Structured JSON on stderr: `{"error":{"code":"...","message":"...","docsUrl?":"...","errors?":[...]}}`. +- `code` is a machine-readable error code (e.g., `auth_required`, `api_error`, `unexpected_error`). +- `errors` array is present for API errors and mirrors the Clerk API error shape (`{code?, message?, meta?}`). +- `docsUrl` is present when the error has associated documentation. + +**Both modes:** + +- User-aborted commands exit cleanly with no error output. +- When handling errors programmatically, read stderr, check the exit code, and re-run with `--verbose` to get a trace if you need to debug. + +## Structured outputs you can rely on + +| Command | Structured output | +| ---------------------------- | --------------------------------------------------------------------------------------- | +| `clerk doctor --json` | `[{name, status, message, detail?, remedy?, fix?}]` | +| `clerk apps list --json` | Array of application objects | +| `clerk apps create --json` | Single application object | +| `clerk api ` | Raw API JSON (Backend or Platform) on stdout | +| `clerk api --include` | Response headers on stderr, body on stdout | +| `clerk config pull` | Instance config JSON | +| `clerk config schema` | JSON Schema | +| `clerk open [subpath]` | `{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}` (agent mode) | +| `clerk open --print` | Plain dashboard URL on stdout | +| Any command (agent mode) | On error: `{"error":{"code","message","docsUrl?","errors?"}}` on stderr | + +For commands without an explicit `--json` flag, `clerk api` is your escape hatch: hit the underlying endpoint directly. + +## Patterns for agent-driven use + +### Diagnose before acting + +```sh +clerk doctor --json --spotlight +``` + +Parse the output, then for each failing check read `remedy` and act. Never call `--fix` from an agent — it's interactive. + +### Preview every mutation + +```sh +# Dry run first +clerk api /users/user_abc123 -X DELETE --dry-run +# If the preview is what you expected, run it with --yes +clerk api /users/user_abc123 -X DELETE --yes +``` + +### Target explicitly + +```sh +# Don't rely on the linked profile for critical operations +clerk api /users --app app_abc123 --instance prod +``` + +### Use the catalog, not hard-coded paths + +```sh +clerk api ls users # discover available user endpoints +clerk api ls --platform apps # platform-side endpoints +``` + +### Surface doctor remedies to the user + +When `clerk doctor --json` reports a failure, show the user the `name`, `message`, and `remedy` — don't just silently try to fix it, because the underlying fix (e.g., `clerk auth login`) usually requires human interaction. + +`clerk doctor --fix` is disabled in agent mode, so you cannot rely on it. If a caller wants to attempt remediation anyway, map the failing check to the command that would fix it in human mode. Each check exposes this mapping via the optional `fix.label` field on the JSON result: + +| Failing check | Manual remediation | +| ----------------------- | -------------------------------------------- | +| `Logged in` | `clerk auth login` | +| `Authentication valid` | `clerk auth login` | +| `CLI configuration` | `clerk auth login` | +| `Project linked` | `clerk link` | +| `Application reachable` | `clerk link` | +| `Instance IDs` | `clerk link` | +| `Environment variables` | `clerk env pull` | +| `CLI version` | (no auto-fix; run `clerk update`) | +| `Shell completion` | (no auto-fix; see `clerk completion --help`) | + +All three remediation commands are themselves interactive by default: `auth login` opens a browser, `link` prompts for an app when `--app` is omitted, and `env pull` writes a file. Prefer surfacing the mapping to the user rather than invoking these blindly. + +## What NOT to do in agent mode + +- **Don't call `clerk auth login` from an agent and expect it to work** — it opens a browser and waits for a callback. Instead, export `CLERK_PLATFORM_API_KEY`. +- **Don't call interactive `clerk link` without `--app`** — it will print guidance, not pick an app. +- **Don't run `clerk config put` without `--dry-run` first** — it's a full replacement and is destructive. +- **Don't skip `--yes` on mutations and expect them to work** — agent mode disables prompts, so commands that require confirmation will error. +- **Don't leak secret keys into logs** — the CLI never prints the raw secret key, and you shouldn't either. diff --git a/skills/clerk/references/auth.md b/skills/clerk/references/auth.md new file mode 100644 index 00000000..cdeff8b9 --- /dev/null +++ b/skills/clerk/references/auth.md @@ -0,0 +1,120 @@ +# Clerk CLI — Authentication & Targeting Reference + +Everything you need to know about how the CLI authenticates, resolves keys, and targets the right application/instance. + +## Two APIs, two auth paths + +Clerk exposes two HTTP APIs. The CLI speaks both. + +| API | Base URL | Auth | Used for | CLI flag | +| ------------------------ | --------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------ | +| **Backend API (BAPI)** | `https://api.clerk.dev/v1/` | Instance **secret key** (`sk_...`) | Tenant data: users, orgs, sessions, invitations, JWT templates, webhooks. | (default) | +| **Platform API (PLAPI)** | `https://api.clerk.com/v1/` | **Platform API key** (`ak_...`) or OAuth token from `clerk auth login` | Account-level: listing your applications, fetching app/instance metadata, pulling config, billing. | `--platform` | + +You override the base URLs via `CLERK_BACKEND_API_URL` and `CLERK_PLATFORM_API_URL` when testing against non-production Clerk environments. + +### Backend API secret key resolution order + +When you run `clerk api /users` (no `--platform`), the CLI picks the `sk_` key in this order: + +1. `--secret-key ` flag (explicit override) +2. `CLERK_SECRET_KEY` environment variable +3. Auto-resolved from `--app ` (uses `CLERK_PLATFORM_API_KEY` or stored OAuth token to fetch the app's secret key) +4. Auto-resolved from the linked project profile (same mechanism as #3, but the app ID comes from the repo's link) + +The CLI validates prefixes: passing `ak_...` where `sk_...` is expected (or vice versa) throws an error immediately with guidance on which key type to use. + +### Platform API auth resolution order + +When you run `clerk api --platform ...`, or any command that already uses PLAPI (`apps list`, `config pull`, `link`, etc.), the CLI picks the bearer token in this order: + +1. `CLERK_PLATFORM_API_KEY` environment variable +2. Stored OAuth token from `clerk auth login` +3. If neither is present, the CLI errors: "Not authenticated. Run `clerk auth login` or set `CLERK_PLATFORM_API_KEY`." + +Set `CLERK_PLATFORM_API_KEY` for CI and scripted agent usage. Use `clerk auth login` for local interactive development. + +> **`config` commands do not accept `--secret-key`.** They target the Platform API and authenticate via the PLAPI chain above (`CLERK_PLATFORM_API_KEY` or the stored OAuth token). If you need to script `config pull/schema/patch/put` in CI, export `CLERK_PLATFORM_API_KEY`; a Backend API `sk_...` key will not work. + +## Project linking + +`clerk link` stores a mapping from your repo to a Clerk application in the CLI config file (run `clerk doctor --verbose` to see the resolved path; override with `CLERK_CONFIG_DIR`). The key is the normalized git remote URL (e.g., `github.com/org/repo`), which means the link is shared across all clones and worktrees of the same repo automatically. + +When you run a command without `--app`/`--instance`: + +1. The CLI resolves the current repo's profile (normalized git remote → git common dir → current working directory). +2. If linked, it uses the stored app ID and instance IDs. +3. If not linked, it errors with guidance to run `clerk link`. + +## `--app` and `--instance` targeting + +Most commands accept `--app ` and `--instance ` to override the linked profile: + +- `--app ` — Clerk application ID (starts with `app_`). Works from any directory; no link required. +- `--instance ` — One of: + - `dev` (development instance, the default) + - `prod` (production instance) + - a full instance ID (starts with `ins_`) + +Examples: + +```sh +# Operate on a specific app without linking the repo +clerk api /users --app app_abc123 + +# Pull production env keys (dangerous — only when you know what you're doing) +clerk env pull --app app_abc123 --instance prod + +# Target a specific instance directly +clerk config pull --instance ins_2aB3c... +``` + +## Auth commands + +### `clerk auth login` + +Aliases: `signup`, `signin`, `sign-in`. Top-level shortcut: `clerk login`. + +OAuth 2.0 PKCE flow against the Clerk OAuth system instance (`https://clerk.clerk.com` by default, overridable via `CLERK_OAUTH_BASE_URL`): + +1. Generates PKCE parameters. +2. Starts a local callback server on `127.0.0.1`. +3. Opens the browser to `/oauth/authorize`. +4. Exchanges the code at `/oauth/token` for an access token. +5. Fetches user info from `/oauth/userinfo`. +6. Stores the token in the OS credential store. + +In agent mode, if already authenticated, it's a no-op. If not, it prints guidance rather than opening a browser. + +### `clerk auth logout` + +Aliases: `signout`, `sign-out`. Top-level shortcut: `clerk logout`. + +Clears the stored token. No API calls. + +### `clerk whoami` + +Hits `GET /oauth/userinfo` with the stored token and prints the email. Exits with a message if not logged in. + +## Environment variables the CLI honors + +| Variable | Effect | +| ------------------------ | --------------------------------------------------------------- | +| `CLERK_MODE` | Force `human` or `agent` mode (overrides TTY detection). | +| `CLERK_SECRET_KEY` | BAPI secret key (bypasses linked project / `--app` resolution). | +| `CLERK_PLATFORM_API_KEY` | PLAPI bearer key. | +| `CLERK_BACKEND_API_URL` | Override Backend API base URL. | +| `CLERK_PLATFORM_API_URL` | Override Platform API base URL. | +| `CLERK_OAUTH_BASE_URL` | Override OAuth base URL (advanced / internal). | +| `CLERK_CONFIG_DIR` | Override config, cache, and credential directory (advanced). | + +## Common auth failure modes + +| Symptom | Likely cause | Fix | +| --------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------ | +| `Not authenticated` | No token stored, no `CLERK_PLATFORM_API_KEY` | `clerk auth login` or export `CLERK_PLATFORM_API_KEY` | +| `No Clerk project linked` | Running a command that needs a linked profile with no `--app` | `clerk link` or pass `--app ` | +| `Invalid secret key prefix` | Passed `ak_...` where `sk_...` expected (or vice versa) | Check which API the command hits; pass the matching key type | +| `Unauthorized` from API | Key belongs to a different instance | Verify `--instance` and ensure the key matches | + +When in doubt: `clerk doctor --json` walks through all of this and tells you exactly what's wrong. diff --git a/skills/clerk/references/recipes.md b/skills/clerk/references/recipes.md new file mode 100644 index 00000000..b0f2eb49 --- /dev/null +++ b/skills/clerk/references/recipes.md @@ -0,0 +1,207 @@ +# Clerk CLI — Recipes + +Copy-pasteable patterns for common tasks. Treat these as starting points; confirm exact paths and parameters with `clerk api ls ` and `clerk --help`, since the Clerk API evolves. + +## Discovery first + +```sh +clerk api ls # everything Backend API exposes +clerk api ls users # filter by keyword +clerk api ls --platform # Platform API (account-level) +``` + +The bundled catalog is cached locally for 1 hour; run `clerk api ls` to force a refresh if needed. + +## Users + +```sh +# List users (paginated) +clerk api /users +clerk api '/users?limit=10&offset=0&order_by=-created_at' + +# Count users +clerk api /users/count + +# Fetch a user +clerk api /users/user_abc123 + +# Search by email +clerk api '/users?email_address=alice@example.com' + +# Create a user +clerk api /users -d '{ + "email_address": ["alice@example.com"], + "password": "SuperSecret123!", + "first_name": "Alice", + "last_name": "Doe" +}' + +# Update (PATCH merges) +clerk api /users/user_abc123 -X PATCH -d '{"first_name":"Alicia"}' + +# Ban / unban +clerk api /users/user_abc123/ban -X POST +clerk api /users/user_abc123/unban -X POST + +# Lock / unlock +clerk api /users/user_abc123/lock -X POST +clerk api /users/user_abc123/unlock -X POST + +# Delete (PREVIEW FIRST) +clerk api /users/user_abc123 -X DELETE --dry-run +clerk api /users/user_abc123 -X DELETE --yes +``` + +## Organizations + +```sh +# List +clerk api /organizations +clerk api '/organizations?limit=20&query=acme' + +# Fetch +clerk api /organizations/org_abc123 + +# Create +clerk api /organizations -d '{"name":"Acme","created_by":"user_abc123"}' + +# Update +clerk api /organizations/org_abc123 -X PATCH -d '{"name":"Acme Inc."}' + +# Members +clerk api /organizations/org_abc123/memberships +clerk api /organizations/org_abc123/memberships -d '{"user_id":"user_xyz","role":"org:member"}' +clerk api /organizations/org_abc123/memberships/user_xyz -X PATCH -d '{"role":"org:admin"}' +clerk api /organizations/org_abc123/memberships/user_xyz -X DELETE --dry-run + +# Invitations +clerk api /organizations/org_abc123/invitations -d '{"email_address":"new@acme.com","role":"org:member"}' +``` + +## Sessions + +```sh +# List active sessions for a user +clerk api '/sessions?user_id=user_abc123&status=active' + +# Revoke a session +clerk api /sessions/sess_abc123/revoke -X POST + +# Create an impersonation / sign-in token (for testing) +clerk api /sign_in_tokens -d '{"user_id":"user_abc123"}' +``` + +## Invitations (top-level, not org-scoped) + +```sh +clerk api /invitations +clerk api /invitations -d '{"email_address":"new@example.com","redirect_url":"https://example.com/welcome"}' +clerk api /invitations/inv_abc123/revoke -X POST +``` + +## JWT templates + +```sh +clerk api /jwt_templates +clerk api /jwt_templates/jtmp_abc123 +clerk api /jwt_templates -d '{ + "name": "supabase", + "claims": {"aud": "authenticated", "role": "authenticated"}, + "lifetime": 60 +}' +``` + +## Instance configuration + +Prefer the dedicated `config` commands over raw `api` calls — they handle confirmation, dry-run, and formatting. + +```sh +# Pull the current dev config +clerk config pull +clerk config pull --output config.dev.json + +# Pull production +clerk config pull --instance prod --output config.prod.json + +# Look at the schema to know what's available +clerk config schema --keys session sign_in social + +# PATCH: surgical updates +clerk config patch --json '{"session":{"lifetime":3600}}' --dry-run +clerk config patch --json '{"session":{"lifetime":3600}}' --yes + +# PUT: replace everything (destructive — always --dry-run first) +clerk config put --file config.prod.json --dry-run +clerk config put --file config.prod.json --instance prod --yes +``` + +## Environment variables + +```sh +# Pull dev keys into .env.local (auto-detects framework and key names) +clerk env pull + +# Pull production keys +clerk env pull --instance prod + +# Target a specific file +clerk env pull --file .env +``` + +`env pull` merges into the existing file: existing Clerk keys are updated in place; new ones are appended under a `# Clerk` header; everything else is preserved. + +## Applications (Platform API) + +```sh +# List your apps +clerk apps list +clerk apps list --json + +# Fetch one (raw API) +clerk api /v1/platform/applications/app_abc123 --platform +``` + +## Scripting patterns + +### Pipe to `jq` + +```sh +# Get a list of user IDs +clerk api /users | jq -r '.[] | .id' + +# Count banned users +clerk api /users | jq '[.[] | select(.banned)] | length' +``` + +### Read body from stdin + +```sh +echo '{"first_name":"Bob"}' | clerk api /users/user_abc123 -X PATCH +jq -n '{email_address:["c@d.co"]}' | clerk api /users +``` + +### Loop safely + +```sh +# Always --dry-run first across the whole set +for id in $(clerk api /users | jq -r '.[] | .id'); do + clerk api /users/$id -X PATCH -d '{"public_metadata":{"migrated":true}}' --dry-run +done +# Re-run without --dry-run once the previews look right +``` + +### Target multiple instances + +```sh +# Copy config from dev to staging for review +clerk config pull --instance dev --output /tmp/dev-config.json +clerk config patch --instance ins_staging --file /tmp/dev-config.json --dry-run +``` + +## When in doubt + +```sh +clerk api ls # find the right endpoint +clerk --help # authoritative flag list +clerk doctor --json # health check +```