Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clerk-skill-local-debug.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions packages/cli-core/src/commands/init/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<runner> skills add <value>` 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:
Expand Down
6 changes: 3 additions & 3 deletions packages/cli-core/src/commands/init/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(", ")}`);
Expand Down
16 changes: 15 additions & 1 deletion packages/cli-core/src/commands/skill/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <manager>` | 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 `<runner> skills add <value>` 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).
97 changes: 95 additions & 2 deletions packages/cli-core/src/commands/skill/install.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +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, renderSkillVersionPlaceholder, withStagedClerkSkill } from "./install.ts";
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"];
Expand Down Expand Up @@ -190,3 +198,88 @@ describe("renderSkillVersionPlaceholder", () => {
expect(renderSkillVersionPlaceholder(input, "1.2.3")).toBe(input);
});
});

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<typeof Bun.spawn>,
);
}

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();
});

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");
});
Comment thread
wyattjoh marked this conversation as resolved.
});
44 changes: 42 additions & 2 deletions packages/cli-core/src/commands/skill/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<repo-root>/skills/clerk/` during
Expand Down Expand Up @@ -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 <tmpdir> --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.
Expand All @@ -222,6 +254,14 @@ export async function installClerkSkillCore(
cwd: string,
interactive: boolean,
): Promise<boolean> {
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"),
);
Expand Down
Loading