Skip to content

Commit 266ab75

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 2b430c5 commit 266ab75

File tree

3 files changed

+211
-30
lines changed

3 files changed

+211
-30
lines changed

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";
@@ -54,6 +62,9 @@ const UPSTREAM_SKILLS_SOURCE = "clerk/skills";
5462
/** Repo for the version-pinned clerk-cli skill (this repo). */
5563
const CLERK_CLI_REPO = "https://github.com/clerk/cli";
5664

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

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

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

83146
/**
@@ -155,7 +218,7 @@ export async function installSkills(
155218
skipPrompt: boolean,
156219
): Promise<void> {
157220
const upstreamSkills = resolveUpstreamSkills(frameworkDep);
158-
const skillRef = clerkCliSkillRef();
221+
const cliSkillSource = getClerkCliSkillSource();
159222
const skillList = ["clerk-cli", ...upstreamSkills].join(", ");
160223

161224
if (isHuman() && !skipPrompt) {
@@ -195,16 +258,16 @@ export async function installSkills(
195258

196259
const interactive = isHuman() && !skipPrompt;
197260

198-
// Install the version-pinned clerk-cli skill from this repo, then the
199-
// upstream framework patterns. Each call soft-fails independently so a
200-
// problem with one source doesn't block the other.
261+
// Install the clerk-cli skill from this repo, then the upstream framework
262+
// patterns. Each call soft-fails independently so a problem with one source
263+
// doesn't block the other.
201264
const cliSkillOk = await runSkillsAdd(
202265
runner,
203266
cwd,
204-
clerkCliSkillUrl(),
267+
cliSkillSource.source,
205268
[],
206269
interactive,
207-
`clerk-cli skill (${skillRef})`,
270+
`clerk-cli skill (${cliSkillSource.label})`,
208271
);
209272

210273
const upstreamOk = await runSkillsAdd(

0 commit comments

Comments
 (0)