From bb2a5c60232a73d757223ddbc398f53259926b85 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 7 Apr 2026 08:49:59 -0600 Subject: [PATCH 1/7] feat(lib): add package runner detection helpers Adds `lib/runners.ts` with a Runner type, RUNNERS constant (bunx, npx, pnpm dlx, yarn dlx), `detectAvailableRunners()` (uses Bun.which), and `preferredRunner()` (picks the runner matching the project's package manager). Also adds a `select` wrapper to lib/prompts.ts mirroring the existing `confirm` wrapper for piped-stdin safety. Uses the new helpers in init/skills.ts: `installSkills` now picks a runner instead of hardcoding npx, prompts the user to choose when multiple runners are available (with the project's pm runner labelled "(detected)" as the default), and threads packageManager through from init/index.ts. --- .../cli-core/src/commands/init/index.test.ts | 7 +- packages/cli-core/src/commands/init/index.ts | 2 +- .../cli-core/src/commands/init/skills.test.ts | 1 - packages/cli-core/src/commands/init/skills.ts | 87 +++++++--- packages/cli-core/src/lib/prompts.ts | 19 +- packages/cli-core/src/lib/runners.test.ts | 162 ++++++++++++++++++ packages/cli-core/src/lib/runners.ts | 100 +++++++++++ 7 files changed, 353 insertions(+), 25 deletions(-) create mode 100644 packages/cli-core/src/lib/runners.test.ts create mode 100644 packages/cli-core/src/lib/runners.ts diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index a86c6854..d59607fa 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -236,7 +236,12 @@ describe("init", () => { await init({ yes: true }); - expect(skillsMod.installSkills).toHaveBeenCalledWith(FAKE_BOOTSTRAP.projectDir, "react", true); + expect(skillsMod.installSkills).toHaveBeenCalledWith( + FAKE_BOOTSTRAP.projectDir, + "react", + "npm", + true, + ); }); test("--starter in agent mode prints guidance without bootstrap", async () => { diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index df9a21f3..17128c74 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -100,7 +100,7 @@ export async function init(options: InitOptions = {}) { if (options.skills !== false) { bar(); - await installSkills(ctx.cwd, ctx?.framework.dep, options.yes ?? false); + await installSkills(ctx.cwd, ctx?.framework.dep, ctx?.packageManager, options.yes ?? false); } outro("Done"); diff --git a/packages/cli-core/src/commands/init/skills.test.ts b/packages/cli-core/src/commands/init/skills.test.ts index 5690b44b..95921418 100644 --- a/packages/cli-core/src/commands/init/skills.test.ts +++ b/packages/cli-core/src/commands/init/skills.test.ts @@ -7,7 +7,6 @@ describe("buildSkillsArgs", () => { test("interactive mode: no -y or -g, lets skills CLI take over", () => { const args = buildSkillsArgs(skills, true); expect(args).toEqual([ - "npx", "skills", "add", "clerk/skills", diff --git a/packages/cli-core/src/commands/init/skills.ts b/packages/cli-core/src/commands/init/skills.ts index 47e86744..3f634acb 100644 --- a/packages/cli-core/src/commands/init/skills.ts +++ b/packages/cli-core/src/commands/init/skills.ts @@ -2,12 +2,25 @@ * Install Clerk agent skills after scaffolding. * * Maps the detected framework to the appropriate skill set from - * github.com/clerk/skills, then installs via `npx skills add`. + * github.com/clerk/skills, then installs via the user's package runner + * (bunx, npx, pnpm dlx, or yarn dlx). + * + * The skills CLI itself 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. */ import { dim, cyan, yellow } from "../../lib/color.js"; import { isHuman } from "../../mode.js"; -import { confirm } from "../../lib/prompts.js"; +import { confirm, select } from "../../lib/prompts.js"; +import { + type Runner, + detectAvailableRunners, + preferredRunner, + runnerCommand, +} from "../../lib/runners.js"; +import type { ProjectContext } from "./frameworks/types.js"; /** Skills installed regardless of framework. */ const BASE_SKILLS = ["clerk", "clerk-setup"]; @@ -37,23 +50,26 @@ function resolveSkills(frameworkDep: string | undefined): string[] { } /** - * Build the argv for `npx skills add`. + * 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). Non-interactive: pass `-y -g` so it - * runs unattended with global scope and auto-detected agents. + * 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 ["npx", "skills", "add", SKILLS_SOURCE, ...skillFlags, ...extraFlags]; + 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); @@ -67,18 +83,53 @@ export async function installSkills( if (!install) return; } - console.log(`\nInstalling skills: ${cyan(skillList)}`); + // Detect runners after the user accepts — no point probing PATH if they decline. + const available = detectAvailableRunners(); + if (available.length === 0) { + console.log( + yellow( + "\nNo package runner found on PATH (looked for bunx, npx, pnpm, yarn). " + + `Install one and run \`npx skills add ${SKILLS_SOURCE}\` manually.`, + ), + ); + return; + } + + const preferred = preferredRunner(packageManager, available); + if (!preferred) { + // Defensive: detectAvailableRunners returned a non-empty array above, so + // preferredRunner should always find something. This guards against any + // future change that decouples the two. + console.log( + yellow( + `\nCould not select a package runner. Run \`npx skills add ${SKILLS_SOURCE}\` manually.`, + ), + ); + return; + } + + // 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 args = buildSkillsArgs(skills, interactive); + const command = runnerCommand(runner, buildSkillsArgs(skills, interactive)); + const displayCommand = `${runner.display} skills add ${SKILLS_SOURCE}`; + + console.log(`\nInstalling skills with ${cyan(runner.display)}: ${cyan(skillList)}`); - // Skills are optional — soft-fail with a warning rather than tearing down - // a successful scaffold. Bun.spawn can throw synchronously when the binary - // is missing (e.g. `npx` not on PATH on a minimal CI image), so the - // try/catch is needed in addition to the exit code check below. let exitCode: number; try { - const proc = Bun.spawn(args, { + const proc = Bun.spawn(command, { cwd, stdin: "inherit", stdout: "inherit", @@ -86,19 +137,13 @@ export async function installSkills( }); exitCode = await proc.exited; } catch { - console.log( - yellow( - `\nCould not run \`npx skills add\`. You can install manually later: npx skills add ${SKILLS_SOURCE}`, - ), - ); + console.log(yellow(`\nCould not run \`${displayCommand}\`. You can install manually later.`)); return; } if (exitCode !== 0) { console.log( - yellow( - `\nSkills installation failed. You can install manually: npx skills add ${SKILLS_SOURCE}`, - ), + yellow(`\nSkills installation failed. You can install manually: ${displayCommand}`), ); return; } diff --git a/packages/cli-core/src/lib/prompts.ts b/packages/cli-core/src/lib/prompts.ts index 7497a6ee..3580c018 100644 --- a/packages/cli-core/src/lib/prompts.ts +++ b/packages/cli-core/src/lib/prompts.ts @@ -10,7 +10,7 @@ */ import { createReadStream } from "node:fs"; -import { confirm as inquirerConfirm } from "@inquirer/prompts"; +import { confirm as inquirerConfirm, select as inquirerSelect } from "@inquirer/prompts"; /** OS-specific path to the controlling terminal's input stream. */ const TTY_PATH = process.platform === "win32" ? "CONIN$" : "/dev/tty"; @@ -28,3 +28,20 @@ export async function confirm(config: { message: string; default?: boolean }): P ttyInput?.close(); } } + +/** + * Like `select()` from @inquirer/prompts, but with the same piped-stdin + * fallback as {@link confirm} above. + */ +export async function select(config: { + message: string; + choices: ReadonlyArray<{ name: string; value: T; description?: string }>; + default?: T; +}): Promise { + const ttyInput = process.stdin.isTTY ? undefined : createReadStream(TTY_PATH); + try { + return await inquirerSelect(config, ttyInput ? { input: ttyInput } : undefined); + } finally { + ttyInput?.close(); + } +} diff --git a/packages/cli-core/src/lib/runners.test.ts b/packages/cli-core/src/lib/runners.test.ts new file mode 100644 index 00000000..6c88790a --- /dev/null +++ b/packages/cli-core/src/lib/runners.test.ts @@ -0,0 +1,162 @@ +import { test, expect, describe, afterEach } from "bun:test"; +import { + RUNNERS, + type Runner, + detectAvailableRunners, + preferredRunner, + runnerCommand, +} from "./runners.ts"; + +// Bun.which is a native global. We patch it directly the same way +// commands/auth/login.test.ts patches Bun.spawn — wrapped in try/catch +// because some runtimes mark globals as non-writable. +const origWhich = Bun.which; + +function mockWhich(present: ReadonlySet) { + try { + (Bun as unknown as { which: (bin: string) => string | null }).which = (bin) => + present.has(bin) ? `/usr/local/bin/${bin}` : null; + } catch { + // Bun.which may not be writable on some runtimes + } +} + +function restoreWhich() { + try { + (Bun as unknown as { which: typeof Bun.which }).which = origWhich; + } catch { + // Bun.which may not be writable on some runtimes + } +} + +describe("RUNNERS", () => { + test("includes the four expected runner ids", () => { + expect(RUNNERS.map((r) => r.id)).toEqual(["bunx", "npx", "pnpm", "yarn"]); + }); + + test("dlx-style runners have the dlx prefix arg", () => { + const pnpm = RUNNERS.find((r) => r.id === "pnpm")!; + const yarn = RUNNERS.find((r) => r.id === "yarn")!; + expect(pnpm.prefixArgs).toEqual(["dlx"]); + expect(yarn.prefixArgs).toEqual(["dlx"]); + }); + + test("bunx and npx have no prefix args", () => { + const bunx = RUNNERS.find((r) => r.id === "bunx")!; + const npx = RUNNERS.find((r) => r.id === "npx")!; + expect(bunx.prefixArgs).toEqual([]); + expect(npx.prefixArgs).toEqual([]); + }); +}); + +describe("runnerCommand", () => { + const bunx = RUNNERS.find((r) => r.id === "bunx")!; + const npx = RUNNERS.find((r) => r.id === "npx")!; + const pnpm = RUNNERS.find((r) => r.id === "pnpm")!; + const yarn = RUNNERS.find((r) => r.id === "yarn")!; + + test("prepends the runner binary for prefix-less runners", () => { + expect(runnerCommand(bunx, ["skills", "add", "clerk/skills"])).toEqual([ + "bunx", + "skills", + "add", + "clerk/skills", + ]); + expect(runnerCommand(npx, ["prettier", "--write", "x.ts"])).toEqual([ + "npx", + "prettier", + "--write", + "x.ts", + ]); + }); + + test("inserts dlx between binary and args for pnpm/yarn", () => { + expect(runnerCommand(pnpm, ["prettier", "--write", "x.ts"])).toEqual([ + "pnpm", + "dlx", + "prettier", + "--write", + "x.ts", + ]); + expect(runnerCommand(yarn, ["skills", "add"])).toEqual(["yarn", "dlx", "skills", "add"]); + }); + + test("handles empty args", () => { + expect(runnerCommand(bunx, [])).toEqual(["bunx"]); + expect(runnerCommand(pnpm, [])).toEqual(["pnpm", "dlx"]); + }); +}); + +describe("preferredRunner", () => { + const bunx = RUNNERS.find((r) => r.id === "bunx")!; + const npx = RUNNERS.find((r) => r.id === "npx")!; + const pnpm = RUNNERS.find((r) => r.id === "pnpm")!; + const yarn = RUNNERS.find((r) => r.id === "yarn")!; + + test("returns undefined when no runners are available", () => { + expect(preferredRunner("bun", [])).toBeUndefined(); + expect(preferredRunner(undefined, [])).toBeUndefined(); + }); + + test("returns the runner matching the project's package manager", () => { + const all = [bunx, npx, pnpm, yarn]; + expect(preferredRunner("bun", all)?.id).toBe("bunx"); + expect(preferredRunner("npm", all)?.id).toBe("npx"); + expect(preferredRunner("pnpm", all)?.id).toBe("pnpm"); + expect(preferredRunner("yarn", all)?.id).toBe("yarn"); + }); + + test("falls back to first available when the preferred pm runner is missing", () => { + expect(preferredRunner("bun", [npx, pnpm])?.id).toBe("npx"); + expect(preferredRunner("yarn", [pnpm])?.id).toBe("pnpm"); + }); + + test("returns first available when no package manager is given", () => { + expect(preferredRunner(undefined, [npx, pnpm, yarn])?.id).toBe("npx"); + expect(preferredRunner(undefined, [yarn])?.id).toBe("yarn"); + }); + + test("preserves RUNNERS preference order in fallback", () => { + expect(preferredRunner(undefined, RUNNERS)?.id).toBe("bunx"); + }); +}); + +describe("detectAvailableRunners", () => { + afterEach(() => { + restoreWhich(); + }); + + test("returns empty when no runner binaries are on PATH", () => { + mockWhich(new Set()); + expect(detectAvailableRunners()).toEqual([]); + }); + + test("returns only the runners whose binaries Bun.which finds", () => { + mockWhich(new Set(["bunx", "pnpm"])); + const result = detectAvailableRunners(); + expect(result.map((r) => r.id)).toEqual(["bunx", "pnpm"]); + }); + + test("preserves RUNNERS order in the output", () => { + mockWhich(new Set(["yarn", "bunx", "npx"])); + const result = detectAvailableRunners(); + expect(result.map((r) => r.id)).toEqual(["bunx", "npx", "yarn"]); + }); + + test("returns all four when every binary is present", () => { + mockWhich(new Set(["bunx", "npx", "pnpm", "yarn"])); + const result = detectAvailableRunners(); + expect(result).toHaveLength(4); + expect(result.map((r) => r.id)).toEqual(["bunx", "npx", "pnpm", "yarn"]); + }); + + test("integrates cleanly with preferredRunner + runnerCommand", () => { + mockWhich(new Set(["npx", "pnpm"])); + const available = detectAvailableRunners(); + const runner = preferredRunner("pnpm", available); + expect(runner?.id).toBe("pnpm"); + + const command = runnerCommand(runner as Runner, ["prettier", "--write", "src/x.ts"]); + expect(command).toEqual(["pnpm", "dlx", "prettier", "--write", "src/x.ts"]); + }); +}); diff --git a/packages/cli-core/src/lib/runners.ts b/packages/cli-core/src/lib/runners.ts new file mode 100644 index 00000000..1cf009fa --- /dev/null +++ b/packages/cli-core/src/lib/runners.ts @@ -0,0 +1,100 @@ +/** + * Package runner detection. + * + * A "runner" is a way to invoke an npm package binary without installing it + * globally: `bunx skills add ...`, `npx prettier ...`, `pnpm dlx skills ...`, + * `yarn dlx skills ...`. Different runners are tied to different package + * managers, so a project's preferred runner is usually the one matching the + * package manager that produced its lockfile. + * + * This module exposes: + * - `Runner` — a tagged record describing one runner + * - `RUNNERS` — the four runners we know about, in preference order + * - `detectAvailableRunners()` — filters RUNNERS to those on the user's PATH + * - `preferredRunner()` — picks the best runner for a given package manager + */ + +import type { ProjectContext } from "../commands/init/frameworks/types.js"; + +/** + * One way to invoke an npm-published binary without installing it globally. + * + * `binary` is what we look up via `Bun.which()`. `prefixArgs` are the args + * that come between the runner binary and the actual command — empty for + * `bunx`/`npx`, `["dlx"]` for pnpm/yarn. `display` is the human-readable + * label used in prompts and log lines. + */ +export type Runner = { + readonly id: "bunx" | "npx" | "pnpm" | "yarn"; + readonly binary: string; + readonly prefixArgs: readonly string[]; + readonly display: string; +}; + +/** + * Known runners in preference order. When no project package manager is + * provided, the first available runner from this list wins. + */ +export const RUNNERS: readonly Runner[] = [ + { id: "bunx", binary: "bunx", prefixArgs: [], display: "bunx" }, + { id: "npx", binary: "npx", prefixArgs: [], display: "npx" }, + { id: "pnpm", binary: "pnpm", prefixArgs: ["dlx"], display: "pnpm dlx" }, + { id: "yarn", binary: "yarn", prefixArgs: ["dlx"], display: "yarn dlx" }, +]; + +/** + * Maps a project's package manager (from `ctx.packageManager`, detected from + * lockfiles in init/context.ts) to its preferred runner id. + */ +const PM_TO_RUNNER: Record = { + bun: "bunx", + npm: "npx", + pnpm: "pnpm", + yarn: "yarn", +}; + +/** + * Returns the subset of {@link RUNNERS} that are actually installed on the + * user's PATH. Uses `Bun.which()`, which returns the resolved binary path + * or `null`. + */ +export function detectAvailableRunners(): Runner[] { + return RUNNERS.filter((r) => Bun.which(r.binary) !== null); +} + +/** + * Pick the best runner from a set of available runners. Prefers the project's + * own package-manager runner if it's installed (e.g. bun project + bunx → + * bunx). Otherwise falls back to the first available runner in {@link RUNNERS} + * order. + * + * Returns `undefined` only if `available` is empty. + */ +export function preferredRunner( + packageManager: ProjectContext["packageManager"] | undefined, + available: readonly Runner[], +): Runner | undefined { + if (available.length === 0) return undefined; + if (packageManager) { + const preferredId = PM_TO_RUNNER[packageManager]; + const match = available.find((r) => r.id === preferredId); + if (match) return match; + } + return available[0]; +} + +/** + * Build the full spawn argv for invoking a command via a runner. + * + * @example + * ```ts + * runnerCommand(bunx, ["skills", "add", "clerk/skills"]) + * // => ["bunx", "skills", "add", "clerk/skills"] + * + * runnerCommand(pnpm, ["prettier", "--write", "file.ts"]) + * // => ["pnpm", "dlx", "prettier", "--write", "file.ts"] + * ``` + */ +export function runnerCommand(runner: Runner, args: readonly string[]): string[] { + return [runner.binary, ...runner.prefixArgs, ...args]; +} From 287476aa8e539aa31d7ae5591120835c7c6e1e21 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 7 Apr 2026 15:50:32 -0600 Subject: [PATCH 2/7] refactor(init): use detected runner in skills warnings and docs Adds runnerForPackageManager() helper so the skills install fallbacks suggest a runner that matches the project's package manager (e.g. `bunx skills add` for a Bun project) instead of hardcoding `npx`. Updates the init README to describe runner detection. --- packages/cli-core/src/commands/init/README.md | 8 ++++---- packages/cli-core/src/commands/init/skills.ts | 7 +++++-- packages/cli-core/src/lib/runners.test.ts | 20 +++++++++++++++++++ packages/cli-core/src/lib/runners.ts | 15 ++++++++++++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index fac03730..b5d7ca0c 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -51,7 +51,7 @@ When running in agent mode (`--mode agent` or non-TTY), outputs a framework-spec 14. Prints a summary of created, modified, and skipped files with recommendations 15. **Authenticated mode**: pulls development instance API keys via `clerk env pull` 16. **Unauthenticated mode**: prints instructions for development without API keys and how to connect a Clerk account later -17. Optionally installs framework-specific Clerk agent skills via `npx skills add` (see [Agent skills install](#agent-skills-install)) +17. Optionally installs framework-specific Clerk agent skills via the project's package runner (see [Agent skills install](#agent-skills-install)) ## Framework Detection @@ -165,10 +165,10 @@ 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 `npx skills add`. This step is optional and non-fatal: if `npx` is missing or the install command exits non-zero, init prints a yellow warning with the manual install 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 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. -- **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. -- **Agent mode / `--prompt`**: `clerk init` exits early before the skills step runs (see the `if (options.prompt || isAgent()) { ... return }` branch in [`index.ts`](./index.ts)), so nothing is installed. Agent users should run `npx skills add clerk/skills` manually, or have their agent do it. +- **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 / `--prompt`**: `clerk init` exits early before the skills step runs (see the `if (options.prompt || isAgent()) { ... return }` branch in [`index.ts`](./index.ts)), so nothing is installed. Agent users should run `skills add clerk/skills` via their preferred runner manually, or have their agent do it. The base skills `clerk` and `clerk-setup` are always included. The detected framework dependency adds a matching skill: diff --git a/packages/cli-core/src/commands/init/skills.ts b/packages/cli-core/src/commands/init/skills.ts index 3f634acb..c14ac2be 100644 --- a/packages/cli-core/src/commands/init/skills.ts +++ b/packages/cli-core/src/commands/init/skills.ts @@ -19,6 +19,7 @@ import { detectAvailableRunners, preferredRunner, runnerCommand, + runnerForPackageManager, } from "../../lib/runners.js"; import type { ProjectContext } from "./frameworks/types.js"; @@ -86,10 +87,11 @@ export async function installSkills( // Detect runners after the user accepts — no point probing PATH if they decline. const available = detectAvailableRunners(); if (available.length === 0) { + const suggested = runnerForPackageManager(packageManager); console.log( yellow( "\nNo package runner found on PATH (looked for bunx, npx, pnpm, yarn). " + - `Install one and run \`npx skills add ${SKILLS_SOURCE}\` manually.`, + `Install one and run \`${suggested.display} skills add ${SKILLS_SOURCE}\` manually.`, ), ); return; @@ -100,9 +102,10 @@ export async function installSkills( // Defensive: detectAvailableRunners returned a non-empty array above, so // preferredRunner should always find something. This guards against any // future change that decouples the two. + const suggested = runnerForPackageManager(packageManager); console.log( yellow( - `\nCould not select a package runner. Run \`npx skills add ${SKILLS_SOURCE}\` manually.`, + `\nCould not select a package runner. Run \`${suggested.display} skills add ${SKILLS_SOURCE}\` manually.`, ), ); return; diff --git a/packages/cli-core/src/lib/runners.test.ts b/packages/cli-core/src/lib/runners.test.ts index 6c88790a..bdf903f6 100644 --- a/packages/cli-core/src/lib/runners.test.ts +++ b/packages/cli-core/src/lib/runners.test.ts @@ -5,6 +5,7 @@ import { detectAvailableRunners, preferredRunner, runnerCommand, + runnerForPackageManager, } from "./runners.ts"; // Bun.which is a native global. We patch it directly the same way @@ -121,6 +122,25 @@ describe("preferredRunner", () => { }); }); +describe("runnerForPackageManager", () => { + test("returns the matching runner for each package manager", () => { + expect(runnerForPackageManager("bun").id).toBe("bunx"); + expect(runnerForPackageManager("npm").id).toBe("npx"); + expect(runnerForPackageManager("pnpm").id).toBe("pnpm"); + expect(runnerForPackageManager("yarn").id).toBe("yarn"); + }); + + test("falls back to the first runner when packageManager is undefined", () => { + expect(runnerForPackageManager(undefined).id).toBe("bunx"); + }); + + test("does not consult PATH (returns a Runner regardless of installed binaries)", () => { + mockWhich(new Set()); + expect(runnerForPackageManager("pnpm").id).toBe("pnpm"); + restoreWhich(); + }); +}); + describe("detectAvailableRunners", () => { afterEach(() => { restoreWhich(); diff --git a/packages/cli-core/src/lib/runners.ts b/packages/cli-core/src/lib/runners.ts index 1cf009fa..ec82f167 100644 --- a/packages/cli-core/src/lib/runners.ts +++ b/packages/cli-core/src/lib/runners.ts @@ -62,6 +62,21 @@ export function detectAvailableRunners(): Runner[] { return RUNNERS.filter((r) => Bun.which(r.binary) !== null); } +/** + * Returns the {@link Runner} spec matching a project's package manager, + * regardless of whether it's installed on PATH. Useful for building + * suggested-install messages when no runner is available locally yet. + * Falls back to the first entry in {@link RUNNERS} when `packageManager` + * is undefined. + */ +export function runnerForPackageManager( + packageManager: ProjectContext["packageManager"] | undefined, +): Runner { + if (!packageManager) return RUNNERS[0]; + const id = PM_TO_RUNNER[packageManager]; + return RUNNERS.find((r) => r.id === id) ?? RUNNERS[0]; +} + /** * Pick the best runner from a set of available runners. Prefers the project's * own package-manager runner if it's installed (e.g. bun project + bunx → From 57254162629c88a0fbd631c4b06e870941eb9a1d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 7 Apr 2026 16:06:07 -0600 Subject: [PATCH 3/7] fix(lib): gate yarn runner on dlx support probe Yarn Classic (v1) lacks the `dlx` subcommand, so detecting `yarn` on PATH via `Bun.which()` was not enough to safely advertise `yarn dlx`. Probe `yarn dlx --help` and only include the runner when it exits 0 (Berry). --- packages/cli-core/src/lib/runners.test.ts | 52 +++++++++++++++++++++-- packages/cli-core/src/lib/runners.ts | 27 +++++++++++- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/lib/runners.test.ts b/packages/cli-core/src/lib/runners.test.ts index bdf903f6..f65da5f8 100644 --- a/packages/cli-core/src/lib/runners.test.ts +++ b/packages/cli-core/src/lib/runners.test.ts @@ -8,10 +8,11 @@ import { runnerForPackageManager, } from "./runners.ts"; -// Bun.which is a native global. We patch it directly the same way -// commands/auth/login.test.ts patches Bun.spawn — wrapped in try/catch -// because some runtimes mark globals as non-writable. +// Bun.which / Bun.spawnSync are native globals. We patch them directly the +// same way commands/auth/login.test.ts patches Bun.spawn — wrapped in +// try/catch because some runtimes mark globals as non-writable. const origWhich = Bun.which; +const origSpawnSync = Bun.spawnSync; function mockWhich(present: ReadonlySet) { try { @@ -30,6 +31,32 @@ function restoreWhich() { } } +/** + * Stubs `Bun.spawnSync` for the `yarn dlx --help` probe in + * `detectAvailableRunners`. `yarnDlxExitCode` controls what the probe sees: + * 0 simulates Yarn Berry, non-zero simulates Yarn Classic. + */ +function mockSpawnSync(yarnDlxExitCode: number) { + try { + (Bun as unknown as { spawnSync: (cmd: string[]) => { exitCode: number } }).spawnSync = ( + cmd, + ) => { + if (cmd[0] === "yarn" && cmd[1] === "dlx") return { exitCode: yarnDlxExitCode }; + return { exitCode: 0 }; + }; + } catch { + // Bun.spawnSync may not be writable on some runtimes + } +} + +function restoreSpawnSync() { + try { + (Bun as unknown as { spawnSync: typeof Bun.spawnSync }).spawnSync = origSpawnSync; + } catch { + // Bun.spawnSync may not be writable on some runtimes + } +} + describe("RUNNERS", () => { test("includes the four expected runner ids", () => { expect(RUNNERS.map((r) => r.id)).toEqual(["bunx", "npx", "pnpm", "yarn"]); @@ -144,6 +171,7 @@ describe("runnerForPackageManager", () => { describe("detectAvailableRunners", () => { afterEach(() => { restoreWhich(); + restoreSpawnSync(); }); test("returns empty when no runner binaries are on PATH", () => { @@ -159,17 +187,33 @@ describe("detectAvailableRunners", () => { test("preserves RUNNERS order in the output", () => { mockWhich(new Set(["yarn", "bunx", "npx"])); + mockSpawnSync(0); const result = detectAvailableRunners(); expect(result.map((r) => r.id)).toEqual(["bunx", "npx", "yarn"]); }); - test("returns all four when every binary is present", () => { + test("returns all four when every binary is present and yarn supports dlx", () => { mockWhich(new Set(["bunx", "npx", "pnpm", "yarn"])); + mockSpawnSync(0); const result = detectAvailableRunners(); expect(result).toHaveLength(4); expect(result.map((r) => r.id)).toEqual(["bunx", "npx", "pnpm", "yarn"]); }); + test("excludes yarn when `yarn dlx --help` exits non-zero (Yarn Classic)", () => { + mockWhich(new Set(["bunx", "npx", "pnpm", "yarn"])); + mockSpawnSync(1); + const result = detectAvailableRunners(); + expect(result.map((r) => r.id)).toEqual(["bunx", "npx", "pnpm"]); + }); + + test("includes yarn when `yarn dlx --help` exits 0 (Yarn Berry)", () => { + mockWhich(new Set(["yarn"])); + mockSpawnSync(0); + const result = detectAvailableRunners(); + expect(result.map((r) => r.id)).toEqual(["yarn"]); + }); + test("integrates cleanly with preferredRunner + runnerCommand", () => { mockWhich(new Set(["npx", "pnpm"])); const available = detectAvailableRunners(); diff --git a/packages/cli-core/src/lib/runners.ts b/packages/cli-core/src/lib/runners.ts index ec82f167..f7783b1b 100644 --- a/packages/cli-core/src/lib/runners.ts +++ b/packages/cli-core/src/lib/runners.ts @@ -53,13 +53,36 @@ const PM_TO_RUNNER: Record = { yarn: "yarn", }; +/** + * Probes the installed `yarn` binary for `dlx` support by running + * `yarn dlx --help`. Yarn Berry (>=2) exits 0; Yarn Classic (v1) lacks the + * subcommand and exits non-zero with `Command "dlx" not found.`. Returns + * `false` on any spawn failure so a broken yarn never gets advertised. + */ +function yarnSupportsDlx(): boolean { + try { + const proc = Bun.spawnSync(["yarn", "dlx", "--help"], { + stdout: "ignore", + stderr: "ignore", + }); + return proc.exitCode === 0; + } catch { + return false; + } +} + /** * Returns the subset of {@link RUNNERS} that are actually installed on the * user's PATH. Uses `Bun.which()`, which returns the resolved binary path - * or `null`. + * or `null`. The `yarn` runner is additionally gated on a `yarn dlx --help` + * probe so Yarn Classic (v1, no `dlx`) is not advertised. */ export function detectAvailableRunners(): Runner[] { - return RUNNERS.filter((r) => Bun.which(r.binary) !== null); + return RUNNERS.filter((r) => { + if (Bun.which(r.binary) === null) return false; + if (r.id === "yarn") return yarnSupportsDlx(); + return true; + }); } /** From a52528268f66da7c8c002416b52e099cd47ac1ce Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 9 Apr 2026 16:42:16 -0600 Subject: [PATCH 4/7] refactor(init): replace console.log with log.* in skills and bootstrap Switch all output in skills.ts and bootstrap.ts to use the central log object (log.info, log.warn, log.success, log.blank) so output respects log levels, throttling, and test capture. Removes direct color function wrappers (cyan, yellow, dim) in favour of log method semantics: log.warn handles yellow, log.success handles green, and backtick spans in messages handle cyan highlighting. --- .../cli-core/src/commands/init/bootstrap.ts | 19 ++++++----- packages/cli-core/src/commands/init/skills.ts | 33 ++++++++++--------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/packages/cli-core/src/commands/init/bootstrap.ts b/packages/cli-core/src/commands/init/bootstrap.ts index 249734c5..f866972c 100644 --- a/packages/cli-core/src/commands/init/bootstrap.ts +++ b/packages/cli-core/src/commands/init/bootstrap.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { search, confirm, input } from "@inquirer/prompts"; -import { cyan, yellow } from "../../lib/color.js"; import { throwUserAbort, CliError } from "../../lib/errors.js"; +import { log } from "../../lib/log.js"; import type { FrameworkInfo } from "../../lib/framework.js"; import { hasPackageJson } from "./context.js"; import { @@ -84,7 +84,9 @@ async function askProjectName(entry: BootstrapEntry): Promise { } async function generateProject(label: string, command: string[], cwd: string): Promise { - console.log(`\nCreating ${cyan(label)} project...\n`); + log.blank(); + log.info(`Creating \`${label}\` project...`); + log.blank(); const exitCode = await spawnInherited(command, cwd); if (exitCode !== 0) { @@ -93,14 +95,15 @@ async function generateProject(label: string, command: string[], cwd: string): P } async function installDependencies(pm: PackageManager, cwd: string): Promise { - console.log(`\nInstalling dependencies...\n`); + log.blank(); + log.info("Installing dependencies..."); + log.blank(); const exitCode = await spawnInherited(PM_INSTALL_COMMANDS[pm], cwd); if (exitCode !== 0) { - console.log( - yellow( - `\nDependency installation failed. Run manually: ${PM_INSTALL_COMMANDS[pm].join(" ")}`, - ), + log.blank(); + log.warn( + `Dependency installation failed. Run manually: \`${PM_INSTALL_COMMANDS[pm].join(" ")}\``, ); } } @@ -163,6 +166,6 @@ export async function promptAndBootstrap( await installDependencies(pm, projectDir); - console.log(); + log.blank(); return { projectDir, projectName, packageManager: pm }; } diff --git a/packages/cli-core/src/commands/init/skills.ts b/packages/cli-core/src/commands/init/skills.ts index c14ac2be..7a826d51 100644 --- a/packages/cli-core/src/commands/init/skills.ts +++ b/packages/cli-core/src/commands/init/skills.ts @@ -11,8 +11,9 @@ * so it runs unattended with global scope and auto-detected agents. */ -import { dim, cyan, yellow } from "../../lib/color.js"; +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, @@ -88,11 +89,10 @@ export async function installSkills( const available = detectAvailableRunners(); if (available.length === 0) { const suggested = runnerForPackageManager(packageManager); - console.log( - yellow( - "\nNo package runner found on PATH (looked for bunx, npx, pnpm, yarn). " + - `Install one and run \`${suggested.display} skills add ${SKILLS_SOURCE}\` manually.`, - ), + 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; } @@ -103,10 +103,9 @@ export async function installSkills( // preferredRunner should always find something. This guards against any // future change that decouples the two. const suggested = runnerForPackageManager(packageManager); - console.log( - yellow( - `\nCould not select a package runner. Run \`${suggested.display} skills add ${SKILLS_SOURCE}\` manually.`, - ), + log.blank(); + log.warn( + `Could not select a package runner. Run \`${suggested.display} skills add ${SKILLS_SOURCE}\` manually.`, ); return; } @@ -128,7 +127,8 @@ export async function installSkills( const command = runnerCommand(runner, buildSkillsArgs(skills, interactive)); const displayCommand = `${runner.display} skills add ${SKILLS_SOURCE}`; - console.log(`\nInstalling skills with ${cyan(runner.display)}: ${cyan(skillList)}`); + log.blank(); + log.info(`Installing skills with \`${runner.display}\`: \`${skillList}\``); let exitCode: number; try { @@ -140,16 +140,17 @@ export async function installSkills( }); exitCode = await proc.exited; } catch { - console.log(yellow(`\nCould not run \`${displayCommand}\`. You can install manually later.`)); + log.blank(); + log.warn(`Could not run \`${displayCommand}\`. You can install manually later.`); return; } if (exitCode !== 0) { - console.log( - yellow(`\nSkills installation failed. You can install manually: ${displayCommand}`), - ); + log.blank(); + log.warn(`Skills installation failed. You can install manually: \`${displayCommand}\``); return; } - console.log(dim("\nAgent skills installed. AI agents now have Clerk context in this project.")); + log.blank(); + log.success("Agent skills installed. AI agents now have Clerk context in this project."); } From 7a19aaa401ce2de6e095d17bf2f6beb64c9ded5f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Sat, 11 Apr 2026 00:21:44 -0600 Subject: [PATCH 5/7] refactor(lib): rename RUNNERS to KNOWN_RUNNERS for clarity --- packages/cli-core/src/lib/runners.test.ts | 36 +++++++++++------------ packages/cli-core/src/lib/runners.ts | 20 ++++++------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/cli-core/src/lib/runners.test.ts b/packages/cli-core/src/lib/runners.test.ts index f65da5f8..4c064944 100644 --- a/packages/cli-core/src/lib/runners.test.ts +++ b/packages/cli-core/src/lib/runners.test.ts @@ -1,6 +1,6 @@ import { test, expect, describe, afterEach } from "bun:test"; import { - RUNNERS, + KNOWN_RUNNERS, type Runner, detectAvailableRunners, preferredRunner, @@ -57,31 +57,31 @@ function restoreSpawnSync() { } } -describe("RUNNERS", () => { +describe("KNOWN_RUNNERS", () => { test("includes the four expected runner ids", () => { - expect(RUNNERS.map((r) => r.id)).toEqual(["bunx", "npx", "pnpm", "yarn"]); + expect(KNOWN_RUNNERS.map((r) => r.id)).toEqual(["bunx", "npx", "pnpm", "yarn"]); }); test("dlx-style runners have the dlx prefix arg", () => { - const pnpm = RUNNERS.find((r) => r.id === "pnpm")!; - const yarn = RUNNERS.find((r) => r.id === "yarn")!; + const pnpm = KNOWN_RUNNERS.find((r) => r.id === "pnpm")!; + const yarn = KNOWN_RUNNERS.find((r) => r.id === "yarn")!; expect(pnpm.prefixArgs).toEqual(["dlx"]); expect(yarn.prefixArgs).toEqual(["dlx"]); }); test("bunx and npx have no prefix args", () => { - const bunx = RUNNERS.find((r) => r.id === "bunx")!; - const npx = RUNNERS.find((r) => r.id === "npx")!; + const bunx = KNOWN_RUNNERS.find((r) => r.id === "bunx")!; + const npx = KNOWN_RUNNERS.find((r) => r.id === "npx")!; expect(bunx.prefixArgs).toEqual([]); expect(npx.prefixArgs).toEqual([]); }); }); describe("runnerCommand", () => { - const bunx = RUNNERS.find((r) => r.id === "bunx")!; - const npx = RUNNERS.find((r) => r.id === "npx")!; - const pnpm = RUNNERS.find((r) => r.id === "pnpm")!; - const yarn = RUNNERS.find((r) => r.id === "yarn")!; + const bunx = KNOWN_RUNNERS.find((r) => r.id === "bunx")!; + const npx = KNOWN_RUNNERS.find((r) => r.id === "npx")!; + const pnpm = KNOWN_RUNNERS.find((r) => r.id === "pnpm")!; + const yarn = KNOWN_RUNNERS.find((r) => r.id === "yarn")!; test("prepends the runner binary for prefix-less runners", () => { expect(runnerCommand(bunx, ["skills", "add", "clerk/skills"])).toEqual([ @@ -116,10 +116,10 @@ describe("runnerCommand", () => { }); describe("preferredRunner", () => { - const bunx = RUNNERS.find((r) => r.id === "bunx")!; - const npx = RUNNERS.find((r) => r.id === "npx")!; - const pnpm = RUNNERS.find((r) => r.id === "pnpm")!; - const yarn = RUNNERS.find((r) => r.id === "yarn")!; + const bunx = KNOWN_RUNNERS.find((r) => r.id === "bunx")!; + const npx = KNOWN_RUNNERS.find((r) => r.id === "npx")!; + const pnpm = KNOWN_RUNNERS.find((r) => r.id === "pnpm")!; + const yarn = KNOWN_RUNNERS.find((r) => r.id === "yarn")!; test("returns undefined when no runners are available", () => { expect(preferredRunner("bun", [])).toBeUndefined(); @@ -144,8 +144,8 @@ describe("preferredRunner", () => { expect(preferredRunner(undefined, [yarn])?.id).toBe("yarn"); }); - test("preserves RUNNERS preference order in fallback", () => { - expect(preferredRunner(undefined, RUNNERS)?.id).toBe("bunx"); + test("preserves KNOWN_RUNNERS preference order in fallback", () => { + expect(preferredRunner(undefined, KNOWN_RUNNERS)?.id).toBe("bunx"); }); }); @@ -185,7 +185,7 @@ describe("detectAvailableRunners", () => { expect(result.map((r) => r.id)).toEqual(["bunx", "pnpm"]); }); - test("preserves RUNNERS order in the output", () => { + test("preserves KNOWN_RUNNERS order in the output", () => { mockWhich(new Set(["yarn", "bunx", "npx"])); mockSpawnSync(0); const result = detectAvailableRunners(); diff --git a/packages/cli-core/src/lib/runners.ts b/packages/cli-core/src/lib/runners.ts index f7783b1b..8c85a927 100644 --- a/packages/cli-core/src/lib/runners.ts +++ b/packages/cli-core/src/lib/runners.ts @@ -9,8 +9,8 @@ * * This module exposes: * - `Runner` — a tagged record describing one runner - * - `RUNNERS` — the four runners we know about, in preference order - * - `detectAvailableRunners()` — filters RUNNERS to those on the user's PATH + * - `KNOWN_RUNNERS` — the four runners we know about, in preference order + * - `detectAvailableRunners()` — filters KNOWN_RUNNERS to those on the user's PATH * - `preferredRunner()` — picks the best runner for a given package manager */ @@ -35,7 +35,7 @@ export type Runner = { * Known runners in preference order. When no project package manager is * provided, the first available runner from this list wins. */ -export const RUNNERS: readonly Runner[] = [ +export const KNOWN_RUNNERS: readonly Runner[] = [ { id: "bunx", binary: "bunx", prefixArgs: [], display: "bunx" }, { id: "npx", binary: "npx", prefixArgs: [], display: "npx" }, { id: "pnpm", binary: "pnpm", prefixArgs: ["dlx"], display: "pnpm dlx" }, @@ -72,13 +72,13 @@ function yarnSupportsDlx(): boolean { } /** - * Returns the subset of {@link RUNNERS} that are actually installed on the - * user's PATH. Uses `Bun.which()`, which returns the resolved binary path + * Returns the subset of {@link KNOWN_RUNNERS} that are actually installed on + * the user's PATH. Uses `Bun.which()`, which returns the resolved binary path * or `null`. The `yarn` runner is additionally gated on a `yarn dlx --help` * probe so Yarn Classic (v1, no `dlx`) is not advertised. */ export function detectAvailableRunners(): Runner[] { - return RUNNERS.filter((r) => { + return KNOWN_RUNNERS.filter((r) => { if (Bun.which(r.binary) === null) return false; if (r.id === "yarn") return yarnSupportsDlx(); return true; @@ -89,21 +89,21 @@ export function detectAvailableRunners(): Runner[] { * Returns the {@link Runner} spec matching a project's package manager, * regardless of whether it's installed on PATH. Useful for building * suggested-install messages when no runner is available locally yet. - * Falls back to the first entry in {@link RUNNERS} when `packageManager` + * Falls back to the first entry in {@link KNOWN_RUNNERS} when `packageManager` * is undefined. */ export function runnerForPackageManager( packageManager: ProjectContext["packageManager"] | undefined, ): Runner { - if (!packageManager) return RUNNERS[0]; + if (!packageManager) return KNOWN_RUNNERS[0]; const id = PM_TO_RUNNER[packageManager]; - return RUNNERS.find((r) => r.id === id) ?? RUNNERS[0]; + return KNOWN_RUNNERS.find((r) => r.id === id) ?? KNOWN_RUNNERS[0]; } /** * Pick the best runner from a set of available runners. Prefers the project's * own package-manager runner if it's installed (e.g. bun project + bunx → - * bunx). Otherwise falls back to the first available runner in {@link RUNNERS} + * bunx). Otherwise falls back to the first available runner in {@link KNOWN_RUNNERS} * order. * * Returns `undefined` only if `available` is empty. From 1f62461bb60e199b889af8730cbe8eec7ada10ff Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Sat, 11 Apr 2026 00:28:06 -0600 Subject: [PATCH 6/7] docs: extract logging conventions to .claude/rules/ and dedupe CLAUDE.md Logging guidance now lives in .claude/rules/logging.md, scoped to packages/cli-core/src/** so it auto-loads only when working on CLI source. Strips the Logging, Error Handling, Testing, and Commands sections from CLAUDE.md since each is already covered by an autoloaded rule file (.claude/rules/{logging,errors,testing,commands}.md), and removes the explicit .claude/rules/e2e.md reference for the same reason. --- CLAUDE.md | 96 +------------------------------------------------------ 1 file changed, 1 insertion(+), 95 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bb51420b..cc928ee3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,18 +35,6 @@ Default to using Bun instead of Node.js. - Prefer `Bun.file` over `node:fs`'s readFile/writeFile - Bun.$`ls` instead of execa. -## Testing - -Use `bun test` to run tests. - -```ts#index.test.ts -import { test, expect } from "bun:test"; - -test("hello world", () => { - expect(1).toBe(1); -}); -``` - ## CI Checks After modifying files, run these commands to match what CI enforces on pull requests: @@ -56,7 +44,7 @@ bun run format # Format with oxfmt (writes changes) bun run lint # Lint with oxlint bun run test # Run unit tests bun run test:e2e:op # Run E2E tests with secrets resolved from 1Password (preferred locally) -bun run test:e2e # Run E2E tests with env vars already set (used by CI; see .claude/rules/e2e.md) +bun run test:e2e # Run E2E tests with env vars already set (used by CI) ``` Locally, prefer `bun run test:e2e:op` so secrets are injected from 1Password in-memory and never written to disk. `bun run test:e2e` is for CI or for cases where the required env vars are already exported. @@ -66,85 +54,3 @@ CI runs `bun run format:check` (fails if unformatted), `bun run lint`, `bun test ## Versioning The `CLI_VERSION` global is injected at compile time via `bun build --compile --define "CLI_VERSION=..."`. Local `build:compile` omits it, so the binary reports `0.0.0-dev`. The CI release workflow injects the real version. - -## Commands - -Every CLI command lives in its own directory under `packages/cli-core/src/commands//`. Each directory must contain a `README.md` that documents: - -- What the command does -- Usage and options -- Clerk API endpoints the command calls (method, path, description) -- Whether the command (or parts of it) is mocked/stubbed — call this out prominently with a blockquote at the top of the README if so - -When adding a new command, create its directory and README. When modifying a command's behavior, options, or API calls, update its README to match. - -When creating or modifying a command, evaluate whether it needs an agent mode. Commands with interactive prompts (menus, wizards, multi-step flows) should check `isAgent()` from `packages/cli-core/src/mode.ts` and, when in agent mode, output a structured prompt that an AI agent can follow instead of running the interactive flow. Commands that are already non-interactive (e.g., single API calls, browser-based OAuth) typically don't need agent mode. - -### Root README - -`README.md` at the project root contains the CLI help output. When commands are added, removed, or their options change, update the help output in `README.md` to stay in sync. You can regenerate it by running `bun run dev -- --help`. - -## Logging - -All output goes through the `log` object from `packages/cli-core/src/lib/log.ts`. **Never use `console.log`, `console.error`, `console.warn`, `console.info`, or `process.stderr.write` directly** — use `log.*` methods so output respects log levels, throttling, and test capture. - -See [.claude/rules/logging.md](.claude/rules/logging.md) for the full method reference, debug logging, tagged loggers, inline highlighting, and testing patterns. - -## Error Handling - -All error classes and helpers live in `packages/cli-core/src/lib/errors.ts`. The global error handler in `packages/cli-core/src/cli.ts` catches thrown errors and formats them for the user. **Never call `console.error` + `process.exit` directly in commands** — throw an error instead and let the global handler deal with output and exit codes. - -### Known failures — `CliError` - -For user-facing errors (missing config, invalid input, resource not found), throw a `CliError`: - -```ts -import { CliError } from "../../lib/errors.ts"; - -throw new CliError("No Clerk project linked. Run `clerk link` first."); - -// With a docs URL (automatically gets .md appended in agent mode for Clerk URLs): -throw new CliError("Not authenticated.", { - docsUrl: "https://clerk.com/docs/guides/development/clerk-environment-variables", -}); -``` - -### Usage/validation errors — `throwUsageError` - -For invalid arguments or options, use `throwUsageError` (exits with code 2): - -```ts -import { throwUsageError } from "../../lib/errors.ts"; - -if (!secretKey) { - throwUsageError("No secret key found. Set CLERK_SECRET_KEY or use --secret-key."); -} -``` - -### User cancellation — `throwUserAbort` - -When the user cancels a prompt or confirmation, call `throwUserAbort()`. The global handler exits cleanly with no error output: - -```ts -import { throwUserAbort } from "../../lib/errors.ts"; - -const confirmed = await confirm({ message: "Proceed?" }); -if (!confirmed) throwUserAbort(); -``` - -### API errors — `withApiContext` - -Wrap API calls with `withApiContext` to attach a human-readable context string. The global handler extracts the first error message from the response body and prints it with the context prefix: - -```ts -import { withApiContext } from "../../lib/errors.ts"; - -const config = await withApiContext( - fetchInstanceConfig(appId, instanceId), - "Failed to fetch config", -); -``` - -### API error classes - -`BapiError` and `PlapiError` (both extend `ApiError`) are thrown by the API helpers in `packages/cli-core/src/commands/api/bapi.ts` and `packages/cli-core/src/lib/plapi.ts` respectively. Don't construct these in commands — they're thrown automatically by the fetch wrappers. Use `withApiContext` to add context when calling those helpers. From 2e4e3a84f205b823ad2c2307e254054801ac058a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Sat, 11 Apr 2026 00:33:12 -0600 Subject: [PATCH 7/7] chore(lint): enforce no-console in cli-core production source Adds the eslint no-console rule via oxlint, scoped to production code under packages/cli-core/src so all output is forced through the log helper. Test files and scripts/ are exempt since they legitimately use console for spy/mock plumbing and release tooling output. --- .claude/rules/logging.md | 2 +- .oxlintrc.json | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.claude/rules/logging.md b/.claude/rules/logging.md index 899c25a5..5e767c9a 100644 --- a/.claude/rules/logging.md +++ b/.claude/rules/logging.md @@ -5,7 +5,7 @@ paths: alwaysApply: false --- -All output goes through the `log` object from `src/lib/log.ts`. **Never use `console.log`, `console.error`, `console.warn`, `console.info`, or `process.stderr.write` directly** — use `log.*` methods so output respects log levels, throttling, and test capture. +All output goes through the `log` object from `src/lib/log.ts`. **Never use `console.log`, `console.error`, `console.warn`, `console.info`, or `process.stderr.write` directly** — use `log.*` methods so output respects log levels, throttling, and test capture. The `no-console` oxlint rule enforces this in production source; test files (`*.test.ts`, `src/test/**`) and `scripts/**` are exempt. ```ts import { log } from "/lib/log.ts"; diff --git a/.oxlintrc.json b/.oxlintrc.json index ab439091..2ab140b4 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,7 @@ { "rules": { - "unicorn/no-process-exit": "error" + "unicorn/no-process-exit": "error", + "no-console": "error" }, "overrides": [ { @@ -12,6 +13,16 @@ "rules": { "unicorn/no-process-exit": "off" } + }, + { + "files": [ + "scripts/**", + "packages/cli-core/src/**/*.test.ts", + "packages/cli-core/src/test/**" + ], + "rules": { + "no-console": "off" + } } ] }