From 223aa3370ab51367d5540fce919e5ecc01661e85 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 14 Apr 2026 12:48:03 -0600 Subject: [PATCH 1/3] feat(init): support CLERK_SKILL_SOURCE env override Adds an escape hatch for skill authors iterating on the clerk skill without rebuilding the CLI: set CLERK_SKILL_SOURCE to any value the `skills` CLI accepts (github URL, org/repo shorthand, absolute or relative local path), and `clerk init` passes it straight to ` skills add ` in place of the bundled source. The override path skips the staged temp dir and the --copy flag, so a local working-tree path installs via the default symlink mode and edits to the source are picked up immediately by already-installed projects. The override has no effect on the upstream framework-pattern skills. --- .changeset/clerk-skill-local-debug.md | 5 ++ packages/cli-core/src/commands/init/README.md | 18 +++++++ packages/cli-core/src/commands/init/skills.ts | 6 +-- .../cli-core/src/commands/skill/README.md | 16 +++++- .../src/commands/skill/install.test.ts | 30 ++++++++++- .../cli-core/src/commands/skill/install.ts | 52 ++++++++++++++++++- 6 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 .changeset/clerk-skill-local-debug.md diff --git a/.changeset/clerk-skill-local-debug.md b/.changeset/clerk-skill-local-debug.md new file mode 100644 index 00000000..7032ddb1 --- /dev/null +++ b/.changeset/clerk-skill-local-debug.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `CLERK_SKILL_SOURCE` env override to install the `clerk` skill from a local path, fork, or PR branch instead of the bundled content. Intended for skill authors iterating without rebuilding the CLI. diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index e4ec809a..4bb90069 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -197,6 +197,24 @@ At install time, [`skills.ts`](./skills.ts) stages the bundled content into a fr 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). +#### Local debugging + +For skill authors iterating on the `clerk` skill without rebuilding the CLI, set `CLERK_SKILL_SOURCE` to any value the `skills` CLI accepts, and the bundled content is bypassed entirely: + +```sh +# Absolute path to a working-tree skill dir (default symlink install — +# edits to the source are reflected in the installed skill immediately). +CLERK_SKILL_SOURCE="$PWD/skills/clerk" clerk init + +# A fork or PR branch on GitHub. +CLERK_SKILL_SOURCE="https://github.com/me/cli/tree/wip/skills/clerk" clerk init + +# Shorthand for the default repo (installs from main branch). +CLERK_SKILL_SOURCE="clerk/cli" clerk init +``` + +When the override is active, `clerk init` logs the value being used and hands it straight to ` skills add ` without `--copy`, so the install mode matches whatever the `skills` CLI would do for a regular remote or local source. The override has no effect on the upstream skills. + ### 2. The upstream skills A fixed default set is always installed from [`clerk/skills`](https://github.com/clerk/skills), covering the `core/` and `features/` directories: diff --git a/packages/cli-core/src/commands/init/skills.ts b/packages/cli-core/src/commands/init/skills.ts index d113da78..7860dab1 100644 --- a/packages/cli-core/src/commands/init/skills.ts +++ b/packages/cli-core/src/commands/init/skills.ts @@ -104,9 +104,9 @@ export async function installSkills( 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. + // Install the bundled clerk skill (respecting CLERK_SKILL_SOURCE if set), + // 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); log.debug(`skills: upstream install — ${upstreamSkills.join(", ")}`); diff --git a/packages/cli-core/src/commands/skill/README.md b/packages/cli-core/src/commands/skill/README.md index 166b3544..c557d504 100644 --- a/packages/cli-core/src/commands/skill/README.md +++ b/packages/cli-core/src/commands/skill/README.md @@ -27,6 +27,20 @@ clerk skill install --pm bun | `-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 | +## Local debugging (`CLERK_SKILL_SOURCE`) + +Skill authors iterating on `clerk` can set `CLERK_SKILL_SOURCE` to bypass the bundled content and point `skills add` at any source the `skills` CLI accepts (absolute path, GitHub URL, or `org/repo` shorthand): + +```sh +# Absolute path to a working-tree skill dir (symlink install — edits are live). +CLERK_SKILL_SOURCE="$PWD/skills/clerk" clerk skill install + +# A fork or PR branch on GitHub. +CLERK_SKILL_SOURCE="https://github.com/me/cli/tree/wip/skills/clerk" clerk skill install +``` + +When the override is active, the CLI logs the value being used and passes it straight to ` skills add ` without `--copy`. The override applies to both `clerk skill install` and the skills step in `clerk init`. + ## 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. +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 (or the `CLERK_SKILL_SOURCE` override when set). diff --git a/packages/cli-core/src/commands/skill/install.test.ts b/packages/cli-core/src/commands/skill/install.test.ts index 73b13be1..42c802d1 100644 --- a/packages/cli-core/src/commands/skill/install.test.ts +++ b/packages/cli-core/src/commands/skill/install.test.ts @@ -3,7 +3,12 @@ import { existsSync } from "node:fs"; import { readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import YAML from "yaml"; -import { buildSkillsArgs, renderSkillVersionPlaceholder, withStagedClerkSkill } from "./install.ts"; +import { + buildSkillsArgs, + renderSkillVersionPlaceholder, + resolveClerkSkillOverride, + withStagedClerkSkill, +} from "./install.ts"; describe("buildSkillsArgs", () => { const skills = ["clerk", "clerk-setup", "clerk-nextjs-patterns"]; @@ -190,3 +195,26 @@ describe("renderSkillVersionPlaceholder", () => { expect(renderSkillVersionPlaceholder(input, "1.2.3")).toBe(input); }); }); + +describe("resolveClerkSkillOverride", () => { + test("returns undefined when env var is unset", () => { + expect(resolveClerkSkillOverride({})).toBeUndefined(); + }); + + test("returns undefined when env var is empty or whitespace", () => { + expect(resolveClerkSkillOverride({ CLERK_SKILL_SOURCE: "" })).toBeUndefined(); + expect(resolveClerkSkillOverride({ CLERK_SKILL_SOURCE: " " })).toBeUndefined(); + }); + + test("returns trimmed value when env var is set", () => { + expect(resolveClerkSkillOverride({ CLERK_SKILL_SOURCE: "clerk/cli" })).toBe("clerk/cli"); + expect(resolveClerkSkillOverride({ CLERK_SKILL_SOURCE: " /tmp/my-skill " })).toBe( + "/tmp/my-skill", + ); + expect( + resolveClerkSkillOverride({ + CLERK_SKILL_SOURCE: "https://github.com/me/fork/tree/wip/skills/clerk", + }), + ).toBe("https://github.com/me/fork/tree/wip/skills/clerk"); + }); +}); diff --git a/packages/cli-core/src/commands/skill/install.ts b/packages/cli-core/src/commands/skill/install.ts index 8e865649..f334c3cf 100644 --- a/packages/cli-core/src/commands/skill/install.ts +++ b/packages/cli-core/src/commands/skill/install.ts @@ -41,6 +41,29 @@ import clerkAuthMd from "../../../../../skills/clerk/references/auth.md" with { import clerkRecipesMd from "../../../../../skills/clerk/references/recipes.md" with { type: "text" }; import clerkAgentModeMd from "../../../../../skills/clerk/references/agent-mode.md" with { type: "text" }; +/** + * Env var that overrides the bundled `clerk` skill source. Accepts any + * value the `skills` CLI recognises (github/gitlab URL, `org/repo` shorthand, + * absolute or relative local path). Intended for skill authors testing an + * in-progress version without recompiling the CLI. + */ +const SKILL_SOURCE_OVERRIDE_ENV = "CLERK_SKILL_SOURCE"; + +/** + * Read the clerk skill source override from the given env. Returns the + * trimmed value when set and non-empty, otherwise `undefined`. Exposed for + * tests so the precedence rule can be asserted without mutating + * `process.env`. + */ +export function resolveClerkSkillOverride( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const raw = env[SKILL_SOURCE_OVERRIDE_ENV]; + if (!raw) return undefined; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + /** * The bundled clerk skill, as `(relativePath, content)` pairs. Text * imports resolve live from `/skills/clerk/` during @@ -211,8 +234,17 @@ export async function resolveSkillsRunner( } /** - * Install the bundled clerk skill using a pre-resolved runner. Does not - * prompt; callers handle any UX around confirmation and runner selection. + * Install the clerk skill using a pre-resolved runner. Does not prompt; + * callers handle any UX around confirmation and runner selection. + * + * Default path: stage the bundled content into a temp dir and invoke + * `skills add --copy` (the --copy is mandatory; the default symlink + * mode would point at a dir we're about to delete). + * + * Override path: when `CLERK_SKILL_SOURCE` is set, hand the value straight + * to `skills add` unchanged. No staging, no --copy — the caller owns the + * source and the install mode. Used by skill authors iterating on clerk + * without rebuilding the CLI. * * Shared with the init flow so runner detection happens once when installing * clerk alongside the upstream framework-pattern skills. @@ -222,6 +254,22 @@ export async function installClerkSkillCore( cwd: string, interactive: boolean, ): Promise { + const override = resolveClerkSkillOverride(); + if (override) { + log.blank(); + log.info( + `Using \`${SKILL_SOURCE_OVERRIDE_ENV}=${override}\` in place of the bundled clerk skill.`, + ); + return runSkillsAdd( + runner, + cwd, + override, + [], + interactive, + false, + `clerk skill (${override})`, + ); + } return withStagedClerkSkill(resolveCliVersion(), (stageDir) => runSkillsAdd(runner, cwd, stageDir, [], interactive, true, "clerk skill"), ); From 9dd398d25f1bb15e60b366867f3b28da16965988 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 17 Apr 2026 09:32:14 -0600 Subject: [PATCH 2/3] style: apply oxfmt to clerk skill install.ts The runSkillsAdd() call in install.ts was formatted across multiple lines but oxfmt (as run by format:check) wants it collapsed to one line. Apply the formatter to keep CI green on this branch. --- packages/cli-core/src/commands/skill/install.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/cli-core/src/commands/skill/install.ts b/packages/cli-core/src/commands/skill/install.ts index f334c3cf..22916e96 100644 --- a/packages/cli-core/src/commands/skill/install.ts +++ b/packages/cli-core/src/commands/skill/install.ts @@ -260,15 +260,7 @@ export async function installClerkSkillCore( log.info( `Using \`${SKILL_SOURCE_OVERRIDE_ENV}=${override}\` in place of the bundled clerk skill.`, ); - return runSkillsAdd( - runner, - cwd, - override, - [], - interactive, - false, - `clerk skill (${override})`, - ); + return runSkillsAdd(runner, cwd, override, [], interactive, false, `clerk skill (${override})`); } return withStagedClerkSkill(resolveCliVersion(), (stageDir) => runSkillsAdd(runner, cwd, stageDir, [], interactive, true, "clerk skill"), From 0e9207d300cafe07cf311cc8968065e3214836d6 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 21 Apr 2026 11:09:07 -0600 Subject: [PATCH 3/3] test(skill): cover installClerkSkillCore override vs staged routing Asserts Bun.spawn argv contains the override (copy:false) when CLERK_SKILL_SOURCE is set, and a staged temp dir (copy:true) when unset. --- .../src/commands/skill/install.test.ts | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/skill/install.test.ts b/packages/cli-core/src/commands/skill/install.test.ts index 42c802d1..55c8d783 100644 --- a/packages/cli-core/src/commands/skill/install.test.ts +++ b/packages/cli-core/src/commands/skill/install.test.ts @@ -1,14 +1,17 @@ -import { test, expect, describe } from "bun:test"; +import { test, expect, describe, spyOn, afterEach } from "bun:test"; import { existsSync } from "node:fs"; import { readFile, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import YAML from "yaml"; import { buildSkillsArgs, + installClerkSkillCore, renderSkillVersionPlaceholder, resolveClerkSkillOverride, withStagedClerkSkill, } from "./install.ts"; +import type { Runner } from "../../lib/runners.ts"; describe("buildSkillsArgs", () => { const skills = ["clerk", "clerk-setup", "clerk-nextjs-patterns"]; @@ -196,6 +199,68 @@ describe("renderSkillVersionPlaceholder", () => { }); }); +describe("installClerkSkillCore wiring", () => { + const runner: Runner = { + id: "bunx", + binary: "bunx", + prefixArgs: [], + display: "bunx", + }; + + const originalOverride = process.env.CLERK_SKILL_SOURCE; + const spawnSpy = spyOn(Bun, "spawn"); + + afterEach(() => { + if (originalOverride === undefined) delete process.env.CLERK_SKILL_SOURCE; + else process.env.CLERK_SKILL_SOURCE = originalOverride; + spawnSpy.mockReset(); + }); + + function stubSpawnSuccess() { + spawnSpy.mockImplementation( + () => ({ exited: Promise.resolve(0) }) as unknown as ReturnType, + ); + } + + test("routes to override with copy:false when CLERK_SKILL_SOURCE is set", async () => { + process.env.CLERK_SKILL_SOURCE = "clerk/cli"; + stubSpawnSuccess(); + + const ok = await installClerkSkillCore(runner, process.cwd(), false); + expect(ok).toBe(true); + + expect(spawnSpy).toHaveBeenCalledTimes(1); + const call = spawnSpy.mock.calls[0]; + if (!call) throw new Error("spawn was not called"); + const argv = call[0] as string[]; + expect(argv[0]).toBe("bunx"); + expect(argv.slice(1, 4)).toEqual(["skills", "add", "clerk/cli"]); + expect(argv).not.toContain("--copy"); + expect(argv).not.toContain("--skill"); + }); + + test("routes to withStagedClerkSkill with copy:true when unset", async () => { + delete process.env.CLERK_SKILL_SOURCE; + stubSpawnSuccess(); + + const ok = await installClerkSkillCore(runner, process.cwd(), false); + expect(ok).toBe(true); + + expect(spawnSpy).toHaveBeenCalledTimes(1); + const call = spawnSpy.mock.calls[0]; + if (!call) throw new Error("spawn was not called"); + const argv = call[0] as string[]; + expect(argv[0]).toBe("bunx"); + expect(argv.slice(1, 3)).toEqual(["skills", "add"]); + const source = argv[3]; + if (!source) throw new Error("spawn argv missing source arg"); + expect(source.startsWith(tmpdir())).toBe(true); + expect(source).toContain("clerk-skill-"); + expect(argv).toContain("--copy"); + expect(argv).not.toContain("--skill"); + }); +}); + describe("resolveClerkSkillOverride", () => { test("returns undefined when env var is unset", () => { expect(resolveClerkSkillOverride({})).toBeUndefined();