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" + } } ] } 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. 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/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/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..7a826d51 100644 --- a/packages/cli-core/src/commands/init/skills.ts +++ b/packages/cli-core/src/commands/init/skills.ts @@ -2,12 +2,27 @@ * 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 { dim } from "../../lib/color.js"; import { isHuman } from "../../mode.js"; -import { confirm } from "../../lib/prompts.js"; +import { log } from "../../lib/log.js"; +import { confirm, select } from "../../lib/prompts.js"; +import { + type Runner, + detectAvailableRunners, + preferredRunner, + runnerCommand, + runnerForPackageManager, +} from "../../lib/runners.js"; +import type { ProjectContext } from "./frameworks/types.js"; /** Skills installed regardless of framework. */ const BASE_SKILLS = ["clerk", "clerk-setup"]; @@ -37,23 +52,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 +85,54 @@ 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) { + const suggested = runnerForPackageManager(packageManager); + 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; + } + + 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. + const suggested = runnerForPackageManager(packageManager); + log.blank(); + log.warn( + `Could not select a package runner. Run \`${suggested.display} 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}`; + + log.blank(); + log.info(`Installing skills with \`${runner.display}\`: \`${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,22 +140,17 @@ 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}`, - ), - ); + 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: npx skills add ${SKILLS_SOURCE}`, - ), - ); + 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."); } 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..4c064944 --- /dev/null +++ b/packages/cli-core/src/lib/runners.test.ts @@ -0,0 +1,226 @@ +import { test, expect, describe, afterEach } from "bun:test"; +import { + KNOWN_RUNNERS, + type Runner, + detectAvailableRunners, + preferredRunner, + runnerCommand, + runnerForPackageManager, +} from "./runners.ts"; + +// 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 { + (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 + } +} + +/** + * 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("KNOWN_RUNNERS", () => { + test("includes the four expected runner ids", () => { + expect(KNOWN_RUNNERS.map((r) => r.id)).toEqual(["bunx", "npx", "pnpm", "yarn"]); + }); + + test("dlx-style runners have the dlx prefix arg", () => { + 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 = 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 = 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([ + "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 = 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(); + 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 KNOWN_RUNNERS preference order in fallback", () => { + expect(preferredRunner(undefined, KNOWN_RUNNERS)?.id).toBe("bunx"); + }); +}); + +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(); + restoreSpawnSync(); + }); + + 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 KNOWN_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 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(); + 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..8c85a927 --- /dev/null +++ b/packages/cli-core/src/lib/runners.ts @@ -0,0 +1,138 @@ +/** + * 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 + * - `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 + */ + +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 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" }, + { 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", +}; + +/** + * 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 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 KNOWN_RUNNERS.filter((r) => { + if (Bun.which(r.binary) === null) return false; + if (r.id === "yarn") return yarnSupportsDlx(); + return true; + }); +} + +/** + * 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 KNOWN_RUNNERS} when `packageManager` + * is undefined. + */ +export function runnerForPackageManager( + packageManager: ProjectContext["packageManager"] | undefined, +): Runner { + if (!packageManager) return KNOWN_RUNNERS[0]; + const id = PM_TO_RUNNER[packageManager]; + 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 KNOWN_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]; +}