diff --git a/.changeset/skip-skill-tip-when-installed.md b/.changeset/skip-skill-tip-when-installed.md new file mode 100644 index 00000000..44b83658 --- /dev/null +++ b/.changeset/skip-skill-tip-when-installed.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Hide the "install the Clerk skills" tip in `clerk --help` and bare `clerk` output when the Clerk agent skill is already installed for one of the common local agents (Claude Code, Codex, Cursor, Windsurf, Zed, Cline, VS Code, GitHub Copilot). diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index b79d4258..e1ee982d 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -1,5 +1,9 @@ -import { test, expect, describe } from "bun:test"; -import { formatApiBody } from "./cli-program.ts"; +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { createProgram, formatApiBody } from "./cli-program.ts"; +import { STANDARD_AGENT_DIRS, EXTRA_REL_PATHS } from "./lib/skill-detection.ts"; describe("formatApiBody", () => { // --- Single error with meta --- @@ -200,3 +204,80 @@ describe("formatApiBody", () => { expect(result).toBe("Plan limitation"); }); }); + +describe("help: clerk skill install tip", () => { + const TIP_SUBSTR = "Give AI agents better Clerk context"; + + // Capture help output including `addHelpText("after", ...)`. The custom + // formatter in lib/help.ts rebuilds help from scratch, so the "after" + // listener only fires during outputHelp (which writes via stdout). + function renderHelp(): string { + const program = createProgram(); + let captured = ""; + const origWrite = process.stdout.write.bind(process.stdout); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdout as unknown as { write: (chunk: any) => boolean }).write = (chunk) => { + captured += typeof chunk === "string" ? chunk : chunk.toString(); + return true; + }; + try { + program.outputHelp(); + } finally { + (process.stdout as unknown as { write: typeof origWrite }).write = origWrite; + } + return captured; + } + + let tmpHome: string; + let tmpCwd: string; + let originalHome: string | undefined; + let originalCwd: string; + + beforeEach(() => { + originalHome = process.env.HOME; + originalCwd = process.cwd(); + tmpHome = mkdtempSync(join(tmpdir(), "clerk-help-home-")); + tmpCwd = mkdtempSync(join(tmpdir(), "clerk-help-cwd-")); + process.env.HOME = tmpHome; + process.chdir(tmpCwd); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + rmSync(tmpHome, { recursive: true, force: true }); + rmSync(tmpCwd, { recursive: true, force: true }); + }); + + test("shows the tip when no skill is installed anywhere", () => { + const help = renderHelp(); + expect(help).toContain(TIP_SUBSTR); + expect(help).toContain("clerk skill install"); + }); + + for (const dir of STANDARD_AGENT_DIRS) { + test(`hides the tip when ${dir}/skills/clerk/SKILL.md exists under HOME`, () => { + const target = join(tmpHome, dir, "skills/clerk"); + mkdirSync(target, { recursive: true }); + writeFileSync(join(target, "SKILL.md"), "ok"); + expect(renderHelp()).not.toContain(TIP_SUBSTR); + }); + + test(`hides the tip when ${dir}/skills/clerk/SKILL.md exists under cwd`, () => { + const target = join(tmpCwd, dir, "skills/clerk"); + mkdirSync(target, { recursive: true }); + writeFileSync(join(target, "SKILL.md"), "ok"); + expect(renderHelp()).not.toContain(TIP_SUBSTR); + }); + } + + for (const rel of EXTRA_REL_PATHS) { + test(`hides the tip when ${rel} exists under cwd`, () => { + const full = join(tmpCwd, rel); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, "ok"); + expect(renderHelp()).not.toContain(TIP_SUBSTR); + }); + } +}); diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index c480d4b6..1d6b18ef 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -42,6 +42,7 @@ import { isAgent } from "./mode.ts"; import { log } from "./lib/log.ts"; import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; import { update } from "./commands/update/index.ts"; +import { isClerkSkillInstalled } from "./lib/skill-detection.ts"; export function createProgram() { const program = new Command() @@ -56,9 +57,10 @@ export function createProgram() { "Force interaction mode (human or agent). Defaults to auto-detect based on TTY.", ) .option("--verbose", "Show detailed output (enables debug messages)") - .addHelpText( - "after", - ` + .addHelpText("after", () => + isClerkSkillInstalled() + ? "" + : ` Give AI agents better Clerk context: install the Clerk skills $ clerk skill install`, ); diff --git a/packages/cli-core/src/lib/skill-detection.ts b/packages/cli-core/src/lib/skill-detection.ts new file mode 100644 index 00000000..6569c441 --- /dev/null +++ b/packages/cli-core/src/lib/skill-detection.ts @@ -0,0 +1,52 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** + * Agents that host skills under the uniform + * `/skills//SKILL.md` layout. Each is checked under + * both `$HOME//skills/clerk/SKILL.md` (global) and + * `//skills/clerk/SKILL.md` (project-local). + */ +export const STANDARD_AGENT_DIRS = [ + ".claude", // Claude Code + ".agents", // generic / shared + ".codex", // OpenAI Codex CLI + ".cursor", // Cursor + ".windsurf", // Windsurf / Codeium + ".zed", // Zed editor + ".cline", // Cline VS Code extension +] as const; + +const STANDARD_SKILL_REL = "skills/clerk/SKILL.md"; + +/** + * Paths for agents that don't follow the uniform + * `/skills//SKILL.md` layout. These are project-local only + * and are checked under `` (not `$HOME`), because + * `clerk skill install` does not install these layouts globally. + */ +export const EXTRA_REL_PATHS = [ + ".vscode/skills/clerk/SKILL.md", // VS Code (project-local) + ".github/prompts/clerk.md", // GitHub Copilot (project-local) +] as const; + +/** + * Best-effort synchronous check for whether the bundled `clerk` skill + * is installed for at least one local agent. Returns `true` on the first + * hit. A missed match just means the install tip keeps showing — no + * false positives harm the user. + */ +export function isClerkSkillInstalled(): boolean { + const home = process.env.HOME ?? homedir(); + const cwd = process.cwd(); + + for (const dir of STANDARD_AGENT_DIRS) { + if (existsSync(join(home, dir, STANDARD_SKILL_REL))) return true; + if (existsSync(join(cwd, dir, STANDARD_SKILL_REL))) return true; + } + for (const rel of EXTRA_REL_PATHS) { + if (existsSync(join(cwd, rel))) return true; + } + return false; +}