Skip to content

Commit d7b5c2b

Browse files
committed
feat(init): support local debugging of clerk-cli skill source
Extends the clerk-cli skill source resolver from a CLI_VERSION-only lookup to a four-tier flow: 1. CLERK_CLI_SKILL_SOURCE env var (explicit override, any mode) 2. CLI_VERSION → matching v${version} tag URL (released binary) 3. Local working-tree path resolved via import.meta.url (bun run dev from a checked-out repo) 4. tree/main/skills/clerk-cli (compiled local binary fallback) The local working-tree path resolution is the killer feature for developer iteration: edit skills/clerk-cli/SKILL.md in your checkout, re-run `bun run dev -- init`, and the installer reads your working tree directly. No push, no rebuild, uncommitted edits visible. resolveSkillSource is exported as a pure function (takes its three inputs as plain arguments) so it's testable without mocking process.env, CLI_VERSION, or the filesystem. Adds 9 unit tests covering every branch.
1 parent 510829c commit d7b5c2b

3 files changed

Lines changed: 211 additions & 30 deletions

File tree

packages/cli-core/src/commands/init/README.md

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,17 +160,36 @@ After scaffolding (and after env keys are pulled or keyless instructions are pri
160160

161161
Two install commands run, sharing one runner:
162162

163-
### 1. The version-pinned `clerk-cli` skill
163+
### 1. The `clerk-cli` skill (from this repo)
164164

165165
The `clerk-cli` skill ships **from this repo** at [`<repo-root>/skills/clerk-cli/`](../../../../../skills/clerk-cli/). It is **not** bundled into the binary, it's fetched at install time by the `skills` CLI, the same way any other skill would be.
166166

167-
The source URL is constructed from `CLI_VERSION` (the compile-time global injected by `scripts/build.ts`):
167+
The source is resolved by [`resolveSkillSource()`](./skills.ts) in this priority order:
168168

169-
```
170-
https://github.com/clerk/cli/tree/v<CLI_VERSION>/skills/clerk-cli
171-
```
169+
1. **`CLERK_CLI_SKILL_SOURCE` env var**, explicit override. Accepts any source the `skills` CLI accepts (github URL, gitlab URL, local path, git URL). Wins over everything else, in any build mode. Use this for testing forks, feature branches, or custom builds. Label: `custom`.
170+
2. **`CLI_VERSION` injected at compile time**, released binary path. Builds the URL `https://github.com/clerk/cli/tree/v${CLI_VERSION}/skills/clerk-cli`. Every published release (stable, canary, snapshot) tags as `v${CLI_VERSION}` (see `.github/workflows/release.yml` and `scripts/snapshot.ts`), so the same template works for all of them. Label: `v0.0.2` (or whatever the version is).
171+
3. **Local working-tree path**, dev mode auto-detection. When running via `bun run dev` from a checked-out copy of this repo, [`resolveLocalSkillPath()`](./skills.ts) walks back from `import.meta.url` to `<repo-root>/skills/clerk-cli/` and passes the **filesystem path** as the source. Working-tree edits (committed or not) are picked up immediately, no push, no rebuild. In compiled binaries, `import.meta.url` resolves to a virtual bundle path that doesn't exist on disk, so `existsSync` returns false and this branch is skipped. Label: `local`.
172+
4. **`tree/main/skills/clerk-cli` fallback**, last resort for compiled local binaries (`bun run build:compile` without `--version`) where neither the env var nor a local working tree is available. Label: `main`.
173+
174+
The label appears in the install heading (e.g. `Installing clerk-cli skill (local) with bunx...`) so the developer can tell at a glance which source the installer is hitting.
175+
176+
#### Local debugging
172177

173-
Every published release (stable, canary, and snapshot) tags as `v${CLI_VERSION}` (see `.github/workflows/release.yml`), so the same `tree/v<version>/skills/clerk-cli` URL works for all of them. The only case without a matching tag is the local-dev sentinel `0.0.0-dev` (set by `scripts/build.ts` when no `--version` is passed), which falls back to `main`.
178+
Two ways to test changes to the skill content:
179+
180+
```sh
181+
# 1. Edit-then-run loop (the most common case): just edit the file and re-run.
182+
# bun run dev auto-detects the local working-tree path, so uncommitted
183+
# edits are visible to the installer immediately.
184+
$EDITOR skills/clerk-cli/SKILL.md
185+
bun run dev -- init # Installing clerk-cli skill (local) with ...
186+
187+
# 2. Test against a remote source: set CLERK_CLI_SKILL_SOURCE to override.
188+
# Useful for QA-ing a feature branch, a fork, or a release candidate
189+
# before it's tagged.
190+
CLERK_CLI_SKILL_SOURCE='https://github.com/clerk/cli/tree/feature-x/skills/clerk-cli' \
191+
bun run dev -- init
192+
```
174193

175194
### 2. The upstream framework-pattern skills
176195

packages/cli-core/src/commands/init/skills.test.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect, describe } from "bun:test";
2-
import { buildSkillsArgs } from "./skills.ts";
2+
import { buildSkillsArgs, resolveSkillSource } from "./skills.ts";
33

44
describe("buildSkillsArgs", () => {
55
const skills = ["clerk", "clerk-setup", "clerk-nextjs-patterns"];
@@ -42,3 +42,102 @@ describe("buildSkillsArgs", () => {
4242
expect(args).not.toContain("--skill");
4343
});
4444
});
45+
46+
const REPO = "https://github.com/clerk/cli";
47+
48+
describe("resolveSkillSource", () => {
49+
test("env override wins over everything else", () => {
50+
const result = resolveSkillSource({
51+
override: "https://github.com/fork/cli/tree/main/skills/clerk-cli",
52+
cliVersion: "0.0.2",
53+
localPath: "/home/dev/clerk/cli/skills/clerk-cli",
54+
});
55+
expect(result.source).toBe("https://github.com/fork/cli/tree/main/skills/clerk-cli");
56+
expect(result.label).toBe("custom");
57+
});
58+
59+
test("env override accepts a local path", () => {
60+
const result = resolveSkillSource({
61+
override: "./my-skills/clerk-cli",
62+
cliVersion: undefined,
63+
localPath: null,
64+
});
65+
expect(result.source).toBe("./my-skills/clerk-cli");
66+
expect(result.label).toBe("custom");
67+
});
68+
69+
test("empty override string falls through to next branch", () => {
70+
const result = resolveSkillSource({
71+
override: "",
72+
cliVersion: "0.0.2",
73+
localPath: null,
74+
});
75+
expect(result.source).toBe(`${REPO}/tree/v0.0.2/skills/clerk-cli`);
76+
expect(result.label).toBe("v0.0.2");
77+
});
78+
79+
test("released stable build → matching tag URL", () => {
80+
const result = resolveSkillSource({
81+
override: undefined,
82+
cliVersion: "0.0.2",
83+
localPath: "/home/dev/clerk/cli/skills/clerk-cli",
84+
});
85+
// CLI_VERSION beats local path — released binaries should always use
86+
// their pinned tag, even if a checked-out copy of the repo is reachable.
87+
expect(result.source).toBe(`${REPO}/tree/v0.0.2/skills/clerk-cli`);
88+
expect(result.label).toBe("v0.0.2");
89+
});
90+
91+
test("released canary build → matching canary tag URL", () => {
92+
const result = resolveSkillSource({
93+
override: undefined,
94+
cliVersion: "0.0.2-canary.v20260407010352",
95+
localPath: null,
96+
});
97+
expect(result.source).toBe(`${REPO}/tree/v0.0.2-canary.v20260407010352/skills/clerk-cli`);
98+
expect(result.label).toBe("v0.0.2-canary.v20260407010352");
99+
});
100+
101+
test('"0.0.0-dev" sentinel is treated as "no version"', () => {
102+
const result = resolveSkillSource({
103+
override: undefined,
104+
cliVersion: "0.0.0-dev",
105+
localPath: "/home/dev/clerk/cli/skills/clerk-cli",
106+
});
107+
expect(result.source).toBe("/home/dev/clerk/cli/skills/clerk-cli");
108+
expect(result.label).toBe("local");
109+
});
110+
111+
test("undefined cliVersion + local path available → local path", () => {
112+
const result = resolveSkillSource({
113+
override: undefined,
114+
cliVersion: undefined,
115+
localPath: "/Users/dev/clerk/cli/skills/clerk-cli",
116+
});
117+
expect(result.source).toBe("/Users/dev/clerk/cli/skills/clerk-cli");
118+
expect(result.label).toBe("local");
119+
});
120+
121+
test("no override, no version, no local path → main fallback", () => {
122+
const result = resolveSkillSource({
123+
override: undefined,
124+
cliVersion: undefined,
125+
localPath: null,
126+
});
127+
expect(result.source).toBe(`${REPO}/tree/main/skills/clerk-cli`);
128+
expect(result.label).toBe("main");
129+
});
130+
131+
test('"0.0.0-dev" sentinel without local path → main fallback', () => {
132+
// This is the compiled-local-binary case: bun run build:compile produced
133+
// a binary with no --version arg, no env var override, and no source
134+
// files reachable on disk.
135+
const result = resolveSkillSource({
136+
override: undefined,
137+
cliVersion: "0.0.0-dev",
138+
localPath: null,
139+
});
140+
expect(result.source).toBe(`${REPO}/tree/main/skills/clerk-cli`);
141+
expect(result.label).toBe("main");
142+
});
143+
});

packages/cli-core/src/commands/init/skills.ts

Lines changed: 86 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
*
44
* Two installer calls share one runner detection:
55
*
6-
* 1. The `clerk-cli` skill ships from this repo (`clerk/cli`) at the
7-
* same git tag as the CLI binary, so the skill content always matches
8-
* the binary the user is running. The source URL is constructed from
9-
* `CLI_VERSION` (compile-time global, falls back to "main" for the
10-
* local-dev sentinel "0.0.0-dev").
6+
* 1. The `clerk-cli` skill ships from this repo (`clerk/cli`). Source
7+
* resolution honours, in order:
8+
* a. The `CLERK_CLI_SKILL_SOURCE` env var (any source the `skills`
9+
* CLI accepts: github URL, gitlab URL, local path, etc.)
10+
* b. `CLI_VERSION` injected at compile time → matching `v${version}`
11+
* tag URL
12+
* c. A local working-tree path resolved via `import.meta.url`, when
13+
* running `bun run dev` from a checked-out copy of this repo
14+
* d. `tree/main/skills/clerk-cli` as the last-resort fallback
1115
*
1216
* 2. The framework-pattern skills (`clerk`, `clerk-setup`,
1317
* `clerk-<framework>-patterns`) ship from the upstream `clerk/skills`
@@ -19,6 +23,10 @@
1923
* so it runs unattended with global scope and auto-detected agents.
2024
*/
2125

26+
import { existsSync } from "node:fs";
27+
import { dirname, resolve } from "node:path";
28+
import { fileURLToPath } from "node:url";
29+
2230
import { dim, cyan, yellow } from "../../lib/color.js";
2331
import { isHuman } from "../../mode.js";
2432
import { confirm, select } from "../../lib/prompts.js";
@@ -55,6 +63,9 @@ const UPSTREAM_SKILLS_SOURCE = "clerk/skills";
5563
/** Repo for the version-pinned clerk-cli skill (this repo). */
5664
const CLERK_CLI_REPO = "https://github.com/clerk/cli";
5765

66+
/** Env var that overrides the clerk-cli skill source for local debugging. */
67+
const SKILL_SOURCE_OVERRIDE_ENV = "CLERK_CLI_SKILL_SOURCE";
68+
5869
function resolveUpstreamSkills(frameworkDep: string | undefined): string[] {
5970
const skills = [...BASE_SKILLS];
6071
if (frameworkDep && FRAMEWORK_SKILL_MAP[frameworkDep]) {
@@ -64,21 +75,73 @@ function resolveUpstreamSkills(frameworkDep: string | undefined): string[] {
6475
}
6576

6677
/**
67-
* Resolve the git ref to use when installing the clerk-cli skill from this
68-
* repo. Every release (stable, canary, snapshot) tags as `v${CLI_VERSION}` —
69-
* see `.github/workflows/release.yml` and `scripts/snapshot.ts`. The only
70-
* case without a matching tag is the local-dev sentinel `0.0.0-dev` (set
71-
* by `scripts/build.ts` when no `--version` is passed), which falls back
72-
* to `main`.
78+
* A resolved skill source, plus a short label used in user-facing log lines
79+
* (e.g. "v0.0.2", "main", "local", "custom") so the developer can tell at
80+
* a glance which source the installer is hitting.
81+
*/
82+
export type SkillSource = { source: string; label: string };
83+
84+
/**
85+
* Pure resolver — given the three inputs, return the source URL/path and a
86+
* short label. Exposed for unit testing without touching env vars, globals,
87+
* or the filesystem.
88+
*
89+
* Resolution order: env override → released-binary tag → local working-tree
90+
* path → main branch fallback. Empty strings count as "not set".
91+
*/
92+
export function resolveSkillSource(opts: {
93+
override: string | undefined;
94+
cliVersion: string | undefined;
95+
localPath: string | null;
96+
}): SkillSource {
97+
if (opts.override) {
98+
return { source: opts.override, label: "custom" };
99+
}
100+
101+
if (opts.cliVersion && opts.cliVersion !== "0.0.0-dev") {
102+
return {
103+
source: `${CLERK_CLI_REPO}/tree/v${opts.cliVersion}/skills/clerk-cli`,
104+
label: `v${opts.cliVersion}`,
105+
};
106+
}
107+
108+
if (opts.localPath) {
109+
return { source: opts.localPath, label: "local" };
110+
}
111+
112+
return {
113+
source: `${CLERK_CLI_REPO}/tree/main/skills/clerk-cli`,
114+
label: "main",
115+
};
116+
}
117+
118+
/**
119+
* Try to resolve the local checkout's `skills/clerk-cli/` directory, walking
120+
* up from this module's path. Returns null in compiled binaries (where
121+
* `import.meta.url` resolves to a virtual bundle path that doesn't exist on
122+
* disk) and in npm-installed copies of the package (where the skills/ dir
123+
* sits outside the published package).
124+
*
125+
* The math: skills.ts → init/ → commands/ → src/ → cli-core/ → packages/ →
126+
* <repo-root>/. Five `..` segments to reach the repo root.
73127
*/
74-
function clerkCliSkillRef(): string {
75-
const version = typeof CLI_VERSION !== "undefined" ? CLI_VERSION : "0.0.0-dev";
76-
if (version === "0.0.0-dev") return "main";
77-
return `v${version}`;
128+
function resolveLocalSkillPath(): string | null {
129+
try {
130+
const here = dirname(fileURLToPath(import.meta.url));
131+
const candidate = resolve(here, "../../../../../skills/clerk-cli");
132+
return existsSync(candidate) ? candidate : null;
133+
} catch {
134+
return null;
135+
}
78136
}
79137

80-
function clerkCliSkillUrl(): string {
81-
return `${CLERK_CLI_REPO}/tree/${clerkCliSkillRef()}/skills/clerk-cli`;
138+
/** Wrapper around {@link resolveSkillSource} that wires up the live inputs. */
139+
function getClerkCliSkillSource(): SkillSource {
140+
return resolveSkillSource({
141+
override: process.env[SKILL_SOURCE_OVERRIDE_ENV],
142+
cliVersion: typeof CLI_VERSION !== "undefined" ? CLI_VERSION : undefined,
143+
localPath: resolveLocalSkillPath(),
144+
});
82145
}
83146

84147
/**
@@ -156,7 +219,7 @@ export async function installSkills(
156219
skipPrompt: boolean,
157220
): Promise<void> {
158221
const upstreamSkills = resolveUpstreamSkills(frameworkDep);
159-
const skillRef = clerkCliSkillRef();
222+
const cliSkillSource = getClerkCliSkillSource();
160223
const skillList = ["clerk-cli", ...upstreamSkills].join(", ");
161224

162225
if (isHuman() && !skipPrompt) {
@@ -197,16 +260,16 @@ export async function installSkills(
197260

198261
const interactive = isHuman() && !skipPrompt;
199262

200-
// Install the version-pinned clerk-cli skill from this repo, then the
201-
// upstream framework patterns. Each call soft-fails independently so a
202-
// problem with one source doesn't block the other.
263+
// Install the clerk-cli skill from this repo, then the upstream framework
264+
// patterns. Each call soft-fails independently so a problem with one source
265+
// doesn't block the other.
203266
const cliSkillOk = await runSkillsAdd(
204267
runner,
205268
cwd,
206-
clerkCliSkillUrl(),
269+
cliSkillSource.source,
207270
[],
208271
interactive,
209-
`clerk-cli skill (${skillRef})`,
272+
`clerk-cli skill (${cliSkillSource.label})`,
210273
);
211274

212275
const upstreamOk = await runSkillsAdd(

0 commit comments

Comments
 (0)