Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/skip-skill-tip-when-installed.md
Original file line number Diff line number Diff line change
@@ -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).
85 changes: 83 additions & 2 deletions packages/cli-core/src/cli-program.test.ts
Original file line number Diff line number Diff line change
@@ -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 ---
Expand Down Expand Up @@ -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);
});
}
});
8 changes: 5 additions & 3 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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`,
);
Expand Down
52 changes: 52 additions & 0 deletions packages/cli-core/src/lib/skill-detection.ts
Original file line number Diff line number Diff line change
@@ -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
* `<agent-dir>/skills/<name>/SKILL.md` layout. Each is checked under
* both `$HOME/<dir>/skills/clerk/SKILL.md` (global) and
* `<cwd>/<dir>/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
* `<dir>/skills/<name>/SKILL.md` layout. These are project-local only
* and are checked under `<cwd>` (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;
}
Loading