From c95da7ecab6eb0814ae640bdc7771df88cde1872 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 18 Aug 2026 10:00:18 +0100 Subject: [PATCH 1/5] Add bunny skills install for project and global agent skills --- .changeset/agent-skills.md | 5 + AGENTS.md | 18 +++ README.md | 2 + packages/cli/src/cli.ts | 2 + .../cli/src/commands/skills/content.test.ts | 28 ++++ packages/cli/src/commands/skills/content.ts | 55 +++++++ packages/cli/src/commands/skills/index.ts | 8 + packages/cli/src/commands/skills/install.ts | 78 +++++++++ packages/cli/src/core/agent-skill.test.ts | 148 ++++++++++++++++++ packages/cli/src/core/agent-skill.ts | 105 +++++++++++++ packages/cli/src/markdown.d.ts | 4 + skills/bunny-cli/SKILL.md | 3 + 12 files changed, 456 insertions(+) create mode 100644 .changeset/agent-skills.md create mode 100644 packages/cli/src/commands/skills/content.test.ts create mode 100644 packages/cli/src/commands/skills/content.ts create mode 100644 packages/cli/src/commands/skills/index.ts create mode 100644 packages/cli/src/commands/skills/install.ts create mode 100644 packages/cli/src/core/agent-skill.test.ts create mode 100644 packages/cli/src/core/agent-skill.ts create mode 100644 packages/cli/src/markdown.d.ts diff --git a/.changeset/agent-skills.md b/.changeset/agent-skills.md new file mode 100644 index 00000000..57cdbb1d --- /dev/null +++ b/.changeset/agent-skills.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": minor +--- + +feat(skills): `bunny skills install` installs the bunny agent skill so AI coding tools know how to use the CLI; a project install upserts a marked block into AGENTS.md and, when the project uses Claude Code, writes the full skill with references to `.claude/skills/bunny-cli/`, while `--global` writes it to `~/.claude/skills/bunny-cli/` for every project; the installed content is the shipped `skills/bunny-cli/` skill embedded at build time diff --git a/AGENTS.md b/AGENTS.md index 99aae59f..15eec2de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,6 +174,8 @@ bunny-cli/ │ ├── cli.ts # Root yargs instance, global flags, command registration │ │ │ ├── core/ +│ │ ├── agent-skill.ts # Generic project skill installer: marked AGENTS.md block upsert + .claude/skills writes (Claude-gated for projects, ~/.claude/skills for --global) +│ │ ├── agent-skill.test.ts # Tests for install/upsert idempotency, marker scoping, Claude gating │ │ ├── client-options.ts # clientOptions() helper — builds ClientOptions from ResolvedConfig │ │ ├── define-command.ts # Command factory (see "Command Pattern" below) │ │ ├── define-namespace.ts # Namespace/group factory for subcommand trees @@ -416,6 +418,11 @@ bunny-cli/ │ │ │ └── remove.ts # Remove registry │ │ ├── docs.ts # Open bunny.net documentation in browser (top-level: bunny docs) │ │ ├── open.ts # Open bunny.net dashboard in browser (top-level: bunny open) +│ │ ├── skills/ +│ │ │ ├── index.ts # defineNamespace("skills", ...) registers skills commands +│ │ │ ├── content.ts # BUNNY_CLI_SKILL: embeds skills/bunny-cli/** at bundle time via Bun text imports (single source of truth) + compact AGENTS.md section +│ │ │ ├── content.test.ts # Guards: every reference SKILL.md routes to is embedded; section stays compact +│ │ │ └── install.ts # bunny skills install [--global]: project (AGENTS.md + Claude-gated .claude/skills) or global (~/.claude/skills) │ │ └── scripts/ │ │ ├── index.ts # defineNamespace("scripts", ...) — registers all script commands │ │ ├── constants.ts # SCRIPT_MANIFEST, SCRIPT_TYPE_LABELS @@ -1131,6 +1138,8 @@ bunny │ ├── unlink Remove .bunny/site.json │ ├── upgrade-router [site] [--link] Republish the site's router script with the CLI's current source │ └── delete [site] [--force] [--keep-storage] Delete pull zone → router → storage zone (typed-name confirmation, so unattended runs need --force; best-effort so re-runs finish a partial delete) +├── skills +│ └── install (alias: add) [--global] Install the bunny agent skill: marked AGENTS.md block + .claude/skills/bunny-cli/ when the project uses Claude Code; --global writes ~/.claude/skills/bunny-cli/ for every project ├── docs Open bunny.net documentation in browser ├── open [--print] Open bunny.net dashboard in browser (or print URL) ├── --profile, -p Profile to use (default: "default") @@ -1325,6 +1334,15 @@ handler: async ({ output, profile, apiKey }) => { }; ``` +### Agent skill installer (`bunny skills install`) + +So coding agents discover the CLI at all, `bunny skills install` writes the shipped `skills/bunny-cli/` skill into the user's environment. The generic machinery lives in `packages/cli/src/core/agent-skill.ts` so future per-resource skills can reuse it: + +- **Project install (default)**: upserts a marked block (``) into the project's `AGENTS.md` (created if missing, replaced in place on reinstall; markers are per-skill so multiple blocks coexist). When the project uses Claude Code (`.claude/` or `CLAUDE.md` exists) it also writes the full skill with all references to `.claude/skills/bunny-cli/`. +- **Global install (`--global`)**: writes the skill to `~/.claude/skills/bunny-cli/` so Claude Code picks it up in every project; nothing project-local is touched. +- **Single source of truth**: `packages/cli/src/commands/skills/content.ts` embeds `skills/bunny-cli/**` at bundle time via Bun text imports (`with { type: "text" }`), so the installed skill is always the shipped one; only the compact AGENTS.md section is authored separately. `content.test.ts` fails if SKILL.md routes to a reference that isn't embedded. +- Commands that create project resources can offer this install (via `isProjectSkillInstalled()` + `confirm()`) at natural first-use moments. + --- ## Local Context (`.bunny/` Manifest) diff --git a/README.md b/README.md index 31a567f6..af60ca0b 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ bun ny # Examples bun ny login bun ny db list +bun ny skills install # install the bunny agent skill into this project (AGENTS.md block + .claude/skills when Claude Code is used) so AI coding tools know how to use the CLI +bun ny skills install --global # install to ~/.claude/skills for every project bun ny apps deploy ghcr.io/me/api:v1.2 # deploy a pre-built image bun ny apps deploy --dockerfile # build ./Dockerfile and deploy bun ny apps deploy # first run? Imports docker-compose.yml if present; otherwise auto-detects Dockerfile(s) (including monorepo subdirs) so you can pick one or many, or falls back to a pre-built image. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a80a685d..c454b1b0 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -16,6 +16,7 @@ import { registryNamespace } from "./commands/registry/index.ts"; import { sandboxNamespace } from "./commands/sandbox/index.ts"; import { scriptsNamespace } from "./commands/scripts/index.ts"; import { sitesNamespace } from "./commands/sites/index.ts"; +import { skillsNamespace } from "./commands/skills/index.ts"; import { storageNamespace } from "./commands/storage/index.ts"; import { whoamiCommand } from "./commands/whoami.ts"; import { bunny } from "./core/colors.ts"; @@ -32,6 +33,7 @@ const commands: CommandModule[] = [ sandboxNamespace, sitesNamespace, configNamespace, + skillsNamespace, docsCommand, openCommand, apiCommand, diff --git a/packages/cli/src/commands/skills/content.test.ts b/packages/cli/src/commands/skills/content.test.ts new file mode 100644 index 00000000..3bf3f108 --- /dev/null +++ b/packages/cli/src/commands/skills/content.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { BUNNY_CLI_SKILL } from "./content.ts"; + +describe("BUNNY_CLI_SKILL", () => { + test("embeds the shipped SKILL.md with its frontmatter", () => { + const skill = BUNNY_CLI_SKILL.files["SKILL.md"]; + expect(skill).toStartWith("---\nname: bunny-cli\n"); + expect(skill).toContain("bunny login"); + }); + + test("embeds every reference the SKILL.md decision tree points at", () => { + const skill = BUNNY_CLI_SKILL.files["SKILL.md"] as string; + const referenced = [...skill.matchAll(/references\/[a-z-]+\.md/g)].map( + (m) => m[0], + ); + expect(referenced.length).toBeGreaterThan(0); + for (const ref of referenced) { + expect(BUNNY_CLI_SKILL.files[ref]).toBeDefined(); + expect((BUNNY_CLI_SKILL.files[ref] as string).length).toBeGreaterThan(0); + } + }); + + test("agents section is compact and self-contained", () => { + expect(BUNNY_CLI_SKILL.agentsSection).toContain("bunny login"); + expect(BUNNY_CLI_SKILL.agentsSection).toContain("--output json"); + expect(BUNNY_CLI_SKILL.agentsSection.split("\n").length).toBeLessThan(20); + }); +}); diff --git a/packages/cli/src/commands/skills/content.ts b/packages/cli/src/commands/skills/content.ts new file mode 100644 index 00000000..d2c27774 --- /dev/null +++ b/packages/cli/src/commands/skills/content.ts @@ -0,0 +1,55 @@ +import apiMd from "../../../../../skills/bunny-cli/references/api.md" with { + type: "text", +}; +import authMd from "../../../../../skills/bunny-cli/references/auth.md" with { + type: "text", +}; +import databaseMd from "../../../../../skills/bunny-cli/references/database.md" with { + type: "text", +}; +import dnsMd from "../../../../../skills/bunny-cli/references/dns.md" with { + type: "text", +}; +import sandboxMd from "../../../../../skills/bunny-cli/references/sandbox.md" with { + type: "text", +}; +import scriptsMd from "../../../../../skills/bunny-cli/references/scripts.md" with { + type: "text", +}; +import sitesMd from "../../../../../skills/bunny-cli/references/sites.md" with { + type: "text", +}; +import storageMd from "../../../../../skills/bunny-cli/references/storage.md" with { + type: "text", +}; +import skillMd from "../../../../../skills/bunny-cli/SKILL.md" with { + type: "text", +}; +import type { ProjectSkill } from "../../core/agent-skill.ts"; + +const AGENTS_SECTION = `## bunny.net CLI + +This project uses bunny.net. Manage its resources with the \`bunny\` CLI: databases, DNS, storage, Edge Scripts, static sites, Magic Containers, and cloud sandboxes. + +- Authenticate once with \`bunny login\` (or set \`BUNNYNET_API_KEY\`); verify with \`bunny api GET /user\`. +- Discover commands with \`bunny --help\` and \`bunny --help\`; every command supports \`--output json\` for machine-readable output. +- Interactive prompts are skipped automatically in non-TTY runs; pass \`--force\` explicitly on destructive commands in scripts. +- Key namespaces: \`bunny db\` (Bunny Database: create, shell, studio, tokens), \`bunny dns\` (zones, records, presets), \`bunny sites\` (static hosting and deploys), \`bunny scripts\` (Edge Scripts), \`bunny storage\` (zones and files), \`bunny apps\` (Magic Containers), \`bunny sandbox\` (cloud sandboxes). +- When the CLI has no command for something, fall back to \`bunny api \` against api.bunny.net.`; + +/** The shipped bunny-cli skill, embedded at bundle time from skills/bunny-cli/. */ +export const BUNNY_CLI_SKILL: ProjectSkill = { + name: "bunny-cli", + agentsSection: AGENTS_SECTION, + files: { + "SKILL.md": skillMd, + "references/api.md": apiMd, + "references/auth.md": authMd, + "references/database.md": databaseMd, + "references/dns.md": dnsMd, + "references/sandbox.md": sandboxMd, + "references/scripts.md": scriptsMd, + "references/sites.md": sitesMd, + "references/storage.md": storageMd, + }, +}; diff --git a/packages/cli/src/commands/skills/index.ts b/packages/cli/src/commands/skills/index.ts new file mode 100644 index 00000000..86a2c83f --- /dev/null +++ b/packages/cli/src/commands/skills/index.ts @@ -0,0 +1,8 @@ +import { defineNamespace } from "../../core/define-namespace.ts"; +import { skillsInstallCommand } from "./install.ts"; + +export const skillsNamespace = defineNamespace( + "skills", + "Install agent skills for AI coding tools.", + [skillsInstallCommand], +); diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts new file mode 100644 index 00000000..96cb32ea --- /dev/null +++ b/packages/cli/src/commands/skills/install.ts @@ -0,0 +1,78 @@ +import { + installGlobalSkill, + installProjectSkill, +} from "../../core/agent-skill.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { logger } from "../../core/logger.ts"; +import { BUNNY_CLI_SKILL } from "./content.ts"; + +const COMMAND = "install"; +const ALIASES = ["add"] as const; +const DESCRIPTION = + "Install the bunny agent skill so AI coding tools know how to use the CLI."; + +interface InstallArgs { + global?: boolean; +} + +/** + * Install the bunny-cli agent skill. + * + * Project install (default) maintains a marked block in AGENTS.md, which most + * coding agents read, and writes the full skill with references under + * .claude/skills/bunny-cli/ when the project uses Claude Code. A global install + * writes the skill to ~/.claude/skills/bunny-cli/ so Claude Code picks it up in + * every project. + * + * @example + * ```bash + * bunny skills install + * bunny skills install --global + * ``` + */ +export const skillsInstallCommand = defineCommand({ + command: COMMAND, + aliases: ALIASES, + describe: DESCRIPTION, + examples: [ + [ + "$0 skills install", + "Install into this project (AGENTS.md and .claude/skills)", + ], + [ + "$0 skills install --global", + "Install to ~/.claude/skills for every project", + ], + ], + + builder: (yargs) => + yargs.option("global", { + type: "boolean", + default: false, + describe: "Install to ~/.claude/skills instead of the current project", + }), + + handler: async ({ global: isGlobal, output }) => { + const files = isGlobal + ? installGlobalSkill(BUNNY_CLI_SKILL) + : installProjectSkill(process.cwd(), BUNNY_CLI_SKILL); + + if (output === "json") { + logger.log( + JSON.stringify( + { scope: isGlobal ? "global" : "project", files }, + null, + 2, + ), + ); + return; + } + + for (const file of files) logger.success(`Wrote ${file}`); + logger.dim( + isGlobal + ? "Claude Code now knows how to use the bunny CLI in every project." + : "AI coding tools working in this project now know how to use the bunny CLI.", + ); + }, +}); diff --git a/packages/cli/src/core/agent-skill.test.ts b/packages/cli/src/core/agent-skill.test.ts new file mode 100644 index 00000000..e0502a0a --- /dev/null +++ b/packages/cli/src/core/agent-skill.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AGENTS_FILE, + agentsMarkers, + installGlobalSkill, + installProjectSkill, + isProjectSkillInstalled, + type ProjectSkill, + usesClaude, +} from "./agent-skill.ts"; + +const SKILL: ProjectSkill = { + name: "bunny-test", + agentsSection: "## Test skill\n\nUse `bunny test` for testing.", + files: { + "SKILL.md": "---\nname: bunny-test\n---\n\n# Test\n", + "references/extra.md": "# Extra\n", + }, +}; + +let cwd: string; + +beforeEach(() => { + cwd = realpathSync(mkdtempSync(join(tmpdir(), "bunny-agent-skill-"))); +}); + +afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); +}); + +describe("installProjectSkill", () => { + test("creates AGENTS.md with a marked block when missing", () => { + const files = installProjectSkill(cwd, SKILL); + expect(files).toEqual([AGENTS_FILE]); + const content = readFileSync(join(cwd, AGENTS_FILE), "utf8"); + expect(content).toContain(agentsMarkers("bunny-test").start); + expect(content).toContain("Use `bunny test` for testing."); + }); + + test("appends to an existing AGENTS.md without touching its content", () => { + writeFileSync(join(cwd, AGENTS_FILE), "# My project\n\nUse tabs.\n"); + installProjectSkill(cwd, SKILL); + const content = readFileSync(join(cwd, AGENTS_FILE), "utf8"); + expect(content.startsWith("# My project\n\nUse tabs.\n")).toBe(true); + expect(content).toContain(agentsMarkers("bunny-test").start); + }); + + test("reinstall replaces the marked block instead of duplicating it", () => { + installProjectSkill(cwd, SKILL); + installProjectSkill(cwd, { + ...SKILL, + agentsSection: "## Test skill\n\nUpdated guidance.", + }); + const content = readFileSync(join(cwd, AGENTS_FILE), "utf8"); + expect(content.split(agentsMarkers("bunny-test").start).length).toBe(2); + expect(content).toContain("Updated guidance."); + expect(content).not.toContain("Use `bunny test` for testing."); + }); + + test("two skills coexist in one AGENTS.md", () => { + installProjectSkill(cwd, SKILL); + installProjectSkill(cwd, { + name: "bunny-other", + agentsSection: "## Other\n\nOther guidance.", + files: {}, + }); + const content = readFileSync(join(cwd, AGENTS_FILE), "utf8"); + expect(content).toContain(agentsMarkers("bunny-test").start); + expect(content).toContain(agentsMarkers("bunny-other").start); + }); + + test("skips .claude/skills when the project does not use Claude", () => { + installProjectSkill(cwd, SKILL); + expect(existsSync(join(cwd, ".claude"))).toBe(false); + }); + + test("writes all skill files when .claude/ exists", () => { + mkdirSync(join(cwd, ".claude")); + const files = installProjectSkill(cwd, SKILL); + expect(files).toEqual([ + AGENTS_FILE, + ".claude/skills/bunny-test/SKILL.md", + ".claude/skills/bunny-test/references/extra.md", + ]); + const skill = readFileSync( + join(cwd, ".claude/skills/bunny-test/SKILL.md"), + "utf8", + ); + expect(skill).toContain("name: bunny-test"); + }); + + test("writes skill files when CLAUDE.md exists", () => { + writeFileSync(join(cwd, "CLAUDE.md"), "# Claude\n"); + const files = installProjectSkill(cwd, SKILL); + expect(files).toContain(".claude/skills/bunny-test/SKILL.md"); + }); +}); + +describe("installGlobalSkill", () => { + test("writes skill files under the home .claude/skills dir", () => { + const files = installGlobalSkill(SKILL, cwd); + expect(files).toEqual([ + join(cwd, ".claude/skills/bunny-test/SKILL.md"), + join(cwd, ".claude/skills/bunny-test/references/extra.md"), + ]); + expect(existsSync(files[0] as string)).toBe(true); + expect(existsSync(join(cwd, AGENTS_FILE))).toBe(false); + }); +}); + +describe("isProjectSkillInstalled", () => { + test("false without AGENTS.md", () => { + expect(isProjectSkillInstalled(cwd, "bunny-test")).toBe(false); + }); + + test("false when AGENTS.md lacks the marker", () => { + writeFileSync(join(cwd, AGENTS_FILE), "# My project\n"); + expect(isProjectSkillInstalled(cwd, "bunny-test")).toBe(false); + }); + + test("true after install, scoped by name", () => { + installProjectSkill(cwd, SKILL); + expect(isProjectSkillInstalled(cwd, "bunny-test")).toBe(true); + expect(isProjectSkillInstalled(cwd, "bunny-other")).toBe(false); + }); +}); + +describe("usesClaude", () => { + test("false in a bare project", () => { + expect(usesClaude(cwd)).toBe(false); + }); + + test("true with .claude/ or CLAUDE.md", () => { + mkdirSync(join(cwd, ".claude")); + expect(usesClaude(cwd)).toBe(true); + }); +}); diff --git a/packages/cli/src/core/agent-skill.ts b/packages/cli/src/core/agent-skill.ts new file mode 100644 index 00000000..7d9290d5 --- /dev/null +++ b/packages/cli/src/core/agent-skill.ts @@ -0,0 +1,105 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +/** AGENTS.md is the cross-agent instruction file; managed blocks are created or replaced there. */ +export const AGENTS_FILE = "AGENTS.md"; + +/** A skill installable into a project or the user's global Claude Code setup. */ +export interface ProjectSkill { + name: string; + agentsSection: string; + files: Record; +} + +/** Markers delimiting a managed AGENTS.md block so reinstalls update in place. */ +export function agentsMarkers(name: string): { start: string; end: string } { + return { start: ``, end: `` }; +} + +/** True when the named managed block is already present in the project's AGENTS.md. */ +export function isProjectSkillInstalled(cwd: string, name: string): boolean { + const path = join(cwd, AGENTS_FILE); + if (!existsSync(path)) return false; + return readFileSync(path, "utf8").includes(agentsMarkers(name).start); +} + +/** True when the project shows Claude Code usage, gating .claude/skills writes. */ +export function usesClaude(cwd: string): boolean { + return existsSync(join(cwd, ".claude")) || existsSync(join(cwd, "CLAUDE.md")); +} + +function wrapSection(name: string, body: string): string { + const { start, end } = agentsMarkers(name); + return `${start}\n\n${body.trim()}\n\n${end}`; +} + +function upsertAgentsBlock(cwd: string, name: string, body: string): string { + const path = join(cwd, AGENTS_FILE); + const section = wrapSection(name, body); + if (!existsSync(path)) { + writeFileSync(path, `# Agent instructions\n\n${section}\n`); + return AGENTS_FILE; + } + const current = readFileSync(path, "utf8"); + const { start, end } = agentsMarkers(name); + const startAt = current.indexOf(start); + const endAt = current.indexOf(end); + if (startAt !== -1 && endAt !== -1) { + const before = current.slice(0, startAt); + const after = current.slice(endAt + end.length); + writeFileSync(path, `${before}${section}${after}`); + return AGENTS_FILE; + } + const separator = current.endsWith("\n") ? "\n" : "\n\n"; + writeFileSync(path, `${current}${separator}${section}\n`); + return AGENTS_FILE; +} + +function writeSkillFiles(root: string, skill: ProjectSkill): string[] { + const written: string[] = []; + for (const [relPath, content] of Object.entries(skill.files)) { + const target = join(root, relPath); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, content); + written.push(target); + } + return written; +} + +/** + * Install or update a skill in the current project. + * + * Always maintains a marked block in AGENTS.md; additionally writes the skill + * files under .claude/skills// when the project already uses Claude Code. + * Returns the relative paths written. Idempotent: reruns refresh the same files. + */ +export function installProjectSkill( + cwd: string, + skill: ProjectSkill, +): string[] { + const written: string[] = [ + upsertAgentsBlock(cwd, skill.name, skill.agentsSection), + ]; + if (usesClaude(cwd)) { + const root = join(cwd, ".claude/skills", skill.name); + writeSkillFiles(root, skill); + for (const relPath of Object.keys(skill.files)) { + written.push(join(".claude/skills", skill.name, relPath)); + } + } + return written; +} + +/** + * Install or update a skill globally for the current user. + * + * Writes the skill files under ~/.claude/skills// so Claude Code picks it + * up in every project. Returns the absolute paths written. + */ +export function installGlobalSkill( + skill: ProjectSkill, + home = homedir(), +): string[] { + return writeSkillFiles(join(home, ".claude/skills", skill.name), skill); +} diff --git a/packages/cli/src/markdown.d.ts b/packages/cli/src/markdown.d.ts new file mode 100644 index 00000000..c94d67b1 --- /dev/null +++ b/packages/cli/src/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string; + export default content; +} diff --git a/skills/bunny-cli/SKILL.md b/skills/bunny-cli/SKILL.md index 5c54a177..c90b91eb 100644 --- a/skills/bunny-cli/SKILL.md +++ b/skills/bunny-cli/SKILL.md @@ -26,6 +26,9 @@ Config is stored in (first match wins): # authenticate bunny login +# install this skill for AI coding tools (AGENTS.md + .claude/skills; --global for ~/.claude/skills) +bunny skills install + # make a raw API request bunny api GET /pullzone bunny api GET /user From 05e2e942f9983bc3cda0b4ad910fa79e65c4db7b Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 18 Aug 2026 10:17:13 +0100 Subject: [PATCH 2/5] Harden marked-block upsert and align skill section with shipped docs --- packages/cli/src/commands/skills/content.ts | 4 +- packages/cli/src/core/agent-skill.test.ts | 41 +++++++++++ packages/cli/src/core/agent-skill.ts | 77 ++++++++++++--------- 3 files changed, 89 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/commands/skills/content.ts b/packages/cli/src/commands/skills/content.ts index d2c27774..463aa2b5 100644 --- a/packages/cli/src/commands/skills/content.ts +++ b/packages/cli/src/commands/skills/content.ts @@ -29,12 +29,12 @@ import type { ProjectSkill } from "../../core/agent-skill.ts"; const AGENTS_SECTION = `## bunny.net CLI -This project uses bunny.net. Manage its resources with the \`bunny\` CLI: databases, DNS, storage, Edge Scripts, static sites, Magic Containers, and cloud sandboxes. +This project uses bunny.net. Manage its resources with the \`bunny\` CLI: databases, DNS, storage, Edge Scripts, static sites, and cloud sandboxes. - Authenticate once with \`bunny login\` (or set \`BUNNYNET_API_KEY\`); verify with \`bunny api GET /user\`. - Discover commands with \`bunny --help\` and \`bunny --help\`; every command supports \`--output json\` for machine-readable output. - Interactive prompts are skipped automatically in non-TTY runs; pass \`--force\` explicitly on destructive commands in scripts. -- Key namespaces: \`bunny db\` (Bunny Database: create, shell, studio, tokens), \`bunny dns\` (zones, records, presets), \`bunny sites\` (static hosting and deploys), \`bunny scripts\` (Edge Scripts), \`bunny storage\` (zones and files), \`bunny apps\` (Magic Containers), \`bunny sandbox\` (cloud sandboxes). +- Key namespaces: \`bunny db\` (Bunny Database: create, shell, studio, tokens), \`bunny dns\` (zones, records, presets), \`bunny sites\` (static hosting and deploys), \`bunny scripts\` (Edge Scripts), \`bunny storage\` (zones and files), \`bunny sandbox\` (cloud sandboxes). - When the CLI has no command for something, fall back to \`bunny api \` against api.bunny.net.`; /** The shipped bunny-cli skill, embedded at bundle time from skills/bunny-cli/. */ diff --git a/packages/cli/src/core/agent-skill.test.ts b/packages/cli/src/core/agent-skill.test.ts index e0502a0a..a7032654 100644 --- a/packages/cli/src/core/agent-skill.test.ts +++ b/packages/cli/src/core/agent-skill.test.ts @@ -17,6 +17,7 @@ import { installProjectSkill, isProjectSkillInstalled, type ProjectSkill, + upsertMarkedBlock, usesClaude, } from "./agent-skill.ts"; @@ -39,6 +40,46 @@ afterEach(() => { rmSync(cwd, { recursive: true, force: true }); }); +describe("upsertMarkedBlock", () => { + const { start, end } = agentsMarkers("bunny-test"); + + test("null starts a fresh file with a heading", () => { + const result = upsertMarkedBlock(null, "bunny-test", "body"); + expect(result.startsWith("# Agent instructions\n")).toBe(true); + expect(result).toContain(`${start}\n\nbody\n\n${end}`); + }); + + test("appends when no markers exist, preserving content", () => { + const result = upsertMarkedBlock("# Mine\n", "bunny-test", "body"); + expect(result.startsWith("# Mine\n")).toBe(true); + expect(result.endsWith(`${end}\n`)).toBe(true); + }); + + test("replaces a well-formed block in place", () => { + const before = `intro\n\n${start}\n\nold\n\n${end}\n\noutro\n`; + const result = upsertMarkedBlock(before, "bunny-test", "new"); + expect(result).toBe(`intro\n\n${start}\n\nnew\n\n${end}\n\noutro\n`); + }); + + test("throws on a missing end marker instead of corrupting the file", () => { + expect(() => + upsertMarkedBlock(`${start}\nno end`, "bunny-test", "body"), + ).toThrow("malformed bunny-test block"); + }); + + test("throws on a missing start marker", () => { + expect(() => + upsertMarkedBlock(`no start\n${end}`, "bunny-test", "body"), + ).toThrow("malformed bunny-test block"); + }); + + test("throws when end precedes start", () => { + expect(() => + upsertMarkedBlock(`${end}\nmiddle\n${start}`, "bunny-test", "body"), + ).toThrow("malformed bunny-test block"); + }); +}); + describe("installProjectSkill", () => { test("creates AGENTS.md with a marked block when missing", () => { const files = installProjectSkill(cwd, SKILL); diff --git a/packages/cli/src/core/agent-skill.ts b/packages/cli/src/core/agent-skill.ts index 7d9290d5..0eef3125 100644 --- a/packages/cli/src/core/agent-skill.ts +++ b/packages/cli/src/core/agent-skill.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import { UserError } from "./errors.ts"; /** AGENTS.md is the cross-agent instruction file; managed blocks are created or replaced there. */ export const AGENTS_FILE = "AGENTS.md"; @@ -29,42 +30,55 @@ export function usesClaude(cwd: string): boolean { return existsSync(join(cwd, ".claude")) || existsSync(join(cwd, "CLAUDE.md")); } -function wrapSection(name: string, body: string): string { +/** + * Return `current` with the named marked block created or replaced. + * + * Pure so every marker state is unit-testable: `null` starts a fresh file, no + * markers appends, a well-formed pair is replaced in place, and a malformed + * pair (one marker missing, or end before start) throws instead of guessing, + * since slicing across reversed markers would corrupt the user's file. + */ +export function upsertMarkedBlock( + current: string | null, + name: string, + body: string, +): string { const { start, end } = agentsMarkers(name); - return `${start}\n\n${body.trim()}\n\n${end}`; -} + const section = `${start}\n\n${body.trim()}\n\n${end}`; + if (current === null) return `# Agent instructions\n\n${section}\n`; -function upsertAgentsBlock(cwd: string, name: string, body: string): string { - const path = join(cwd, AGENTS_FILE); - const section = wrapSection(name, body); - if (!existsSync(path)) { - writeFileSync(path, `# Agent instructions\n\n${section}\n`); - return AGENTS_FILE; - } - const current = readFileSync(path, "utf8"); - const { start, end } = agentsMarkers(name); const startAt = current.indexOf(start); const endAt = current.indexOf(end); - if (startAt !== -1 && endAt !== -1) { - const before = current.slice(0, startAt); - const after = current.slice(endAt + end.length); - writeFileSync(path, `${before}${section}${after}`); - return AGENTS_FILE; + if (startAt === -1 && endAt === -1) { + const separator = current.endsWith("\n") ? "\n" : "\n\n"; + return `${current}${separator}${section}\n`; + } + if (startAt === -1 || endAt === -1 || endAt < startAt) { + throw new UserError( + `${AGENTS_FILE} has a malformed ${name} block: expected "${start}" followed by "${end}". Fix or remove the markers, then rerun.`, + ); } - const separator = current.endsWith("\n") ? "\n" : "\n\n"; - writeFileSync(path, `${current}${separator}${section}\n`); + const before = current.slice(0, startAt); + const after = current.slice(endAt + end.length); + return `${before}${section}${after}`; +} + +function upsertAgentsFile(cwd: string, name: string, body: string): string { + const path = join(cwd, AGENTS_FILE); + const current = existsSync(path) ? readFileSync(path, "utf8") : null; + writeFileSync(path, upsertMarkedBlock(current, name, body)); return AGENTS_FILE; } +/** Write the skill's files under `root`, returning their slash-separated relative paths. */ function writeSkillFiles(root: string, skill: ProjectSkill): string[] { - const written: string[] = []; - for (const [relPath, content] of Object.entries(skill.files)) { + const relPaths = Object.keys(skill.files); + for (const relPath of relPaths) { const target = join(root, relPath); mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, content); - written.push(target); + writeFileSync(target, skill.files[relPath] as string); } - return written; + return relPaths; } /** @@ -72,20 +86,20 @@ function writeSkillFiles(root: string, skill: ProjectSkill): string[] { * * Always maintains a marked block in AGENTS.md; additionally writes the skill * files under .claude/skills// when the project already uses Claude Code. - * Returns the relative paths written. Idempotent: reruns refresh the same files. + * Returns the written paths relative to `cwd`, slash-separated for display. + * Idempotent: reruns refresh the same files. */ export function installProjectSkill( cwd: string, skill: ProjectSkill, ): string[] { const written: string[] = [ - upsertAgentsBlock(cwd, skill.name, skill.agentsSection), + upsertAgentsFile(cwd, skill.name, skill.agentsSection), ]; if (usesClaude(cwd)) { - const root = join(cwd, ".claude/skills", skill.name); - writeSkillFiles(root, skill); - for (const relPath of Object.keys(skill.files)) { - written.push(join(".claude/skills", skill.name, relPath)); + const skillRoot = `.claude/skills/${skill.name}`; + for (const relPath of writeSkillFiles(join(cwd, skillRoot), skill)) { + written.push(`${skillRoot}/${relPath}`); } } return written; @@ -101,5 +115,6 @@ export function installGlobalSkill( skill: ProjectSkill, home = homedir(), ): string[] { - return writeSkillFiles(join(home, ".claude/skills", skill.name), skill); + const root = join(home, ".claude/skills", skill.name); + return writeSkillFiles(root, skill).map((relPath) => join(root, relPath)); } From 9e885fd9b40580736a05f0f49ce31a472eacd127 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 18 Aug 2026 12:14:52 +0100 Subject: [PATCH 3/5] fix(skills): address PR review feedback on installer safety and installed guidance - refuse installer writes that a symlink would redirect outside the project (project scope only; in-project symlinks like AGENTS.md -> CLAUDE.md still work) - treat duplicated managed markers as malformed instead of slicing across them - installed AGENTS.md section no longer claims every command emits JSON or that prompts auto-skip without a TTY; it now tells agents to pass value flags and --force --- AGENTS.md | 4 +- packages/cli/src/commands/skills/content.ts | 4 +- packages/cli/src/core/agent-skill.test.ts | 58 ++++++++++++++++++ packages/cli/src/core/agent-skill.ts | 66 ++++++++++++++++++--- 4 files changed, 119 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 15eec2de..470cd527 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,7 +174,7 @@ bunny-cli/ │ ├── cli.ts # Root yargs instance, global flags, command registration │ │ │ ├── core/ -│ │ ├── agent-skill.ts # Generic project skill installer: marked AGENTS.md block upsert + .claude/skills writes (Claude-gated for projects, ~/.claude/skills for --global) +│ │ ├── agent-skill.ts # Generic project skill installer: marked AGENTS.md block upsert + .claude/skills writes (Claude-gated for projects, ~/.claude/skills for --global); project writes refuse symlink escapes │ │ ├── agent-skill.test.ts # Tests for install/upsert idempotency, marker scoping, Claude gating │ │ ├── client-options.ts # clientOptions() helper — builds ClientOptions from ResolvedConfig │ │ ├── define-command.ts # Command factory (see "Command Pattern" below) @@ -1338,7 +1338,7 @@ handler: async ({ output, profile, apiKey }) => { So coding agents discover the CLI at all, `bunny skills install` writes the shipped `skills/bunny-cli/` skill into the user's environment. The generic machinery lives in `packages/cli/src/core/agent-skill.ts` so future per-resource skills can reuse it: -- **Project install (default)**: upserts a marked block (``) into the project's `AGENTS.md` (created if missing, replaced in place on reinstall; markers are per-skill so multiple blocks coexist). When the project uses Claude Code (`.claude/` or `CLAUDE.md` exists) it also writes the full skill with all references to `.claude/skills/bunny-cli/`. +- **Project install (default)**: upserts a marked block (``) into the project's `AGENTS.md` (created if missing, replaced in place on reinstall; markers are per-skill so multiple blocks coexist; a malformed block, meaning a missing, reversed, or duplicated marker, errors instead of guessing). When the project uses Claude Code (`.claude/` or `CLAUDE.md` exists) it also writes the full skill with all references to `.claude/skills/bunny-cli/`. Writes that a symlink would redirect outside the project are refused, so a checkout can't plant links that make the installer overwrite unrelated files (symlinks resolving inside the project, e.g. `AGENTS.md -> CLAUDE.md`, are followed). - **Global install (`--global`)**: writes the skill to `~/.claude/skills/bunny-cli/` so Claude Code picks it up in every project; nothing project-local is touched. - **Single source of truth**: `packages/cli/src/commands/skills/content.ts` embeds `skills/bunny-cli/**` at bundle time via Bun text imports (`with { type: "text" }`), so the installed skill is always the shipped one; only the compact AGENTS.md section is authored separately. `content.test.ts` fails if SKILL.md routes to a reference that isn't embedded. - Commands that create project resources can offer this install (via `isProjectSkillInstalled()` + `confirm()`) at natural first-use moments. diff --git a/packages/cli/src/commands/skills/content.ts b/packages/cli/src/commands/skills/content.ts index 463aa2b5..321c9145 100644 --- a/packages/cli/src/commands/skills/content.ts +++ b/packages/cli/src/commands/skills/content.ts @@ -32,8 +32,8 @@ const AGENTS_SECTION = `## bunny.net CLI This project uses bunny.net. Manage its resources with the \`bunny\` CLI: databases, DNS, storage, Edge Scripts, static sites, and cloud sandboxes. - Authenticate once with \`bunny login\` (or set \`BUNNYNET_API_KEY\`); verify with \`bunny api GET /user\`. -- Discover commands with \`bunny --help\` and \`bunny --help\`; every command supports \`--output json\` for machine-readable output. -- Interactive prompts are skipped automatically in non-TTY runs; pass \`--force\` explicitly on destructive commands in scripts. +- Discover commands with \`bunny --help\` and \`bunny --help\`; resource commands support \`--output json\` for machine-readable output (a few browser-opening helpers like \`bunny docs\` do not). +- In unattended runs, pass a flag for every value a command would prompt for, and \`--force\` on destructive commands; prompts otherwise block or cancel without a TTY. - Key namespaces: \`bunny db\` (Bunny Database: create, shell, studio, tokens), \`bunny dns\` (zones, records, presets), \`bunny sites\` (static hosting and deploys), \`bunny scripts\` (Edge Scripts), \`bunny storage\` (zones and files), \`bunny sandbox\` (cloud sandboxes). - When the CLI has no command for something, fall back to \`bunny api \` against api.bunny.net.`; diff --git a/packages/cli/src/core/agent-skill.test.ts b/packages/cli/src/core/agent-skill.test.ts index a7032654..f504ef12 100644 --- a/packages/cli/src/core/agent-skill.test.ts +++ b/packages/cli/src/core/agent-skill.test.ts @@ -6,6 +6,7 @@ import { readFileSync, realpathSync, rmSync, + symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -78,6 +79,26 @@ describe("upsertMarkedBlock", () => { upsertMarkedBlock(`${end}\nmiddle\n${start}`, "bunny-test", "body"), ).toThrow("malformed bunny-test block"); }); + + test("throws on a duplicate start marker instead of eating content between them", () => { + expect(() => + upsertMarkedBlock( + `${start}\nold\n${start}\nkeep me\n${end}`, + "bunny-test", + "body", + ), + ).toThrow("malformed bunny-test block"); + }); + + test("throws on a duplicate end marker", () => { + expect(() => + upsertMarkedBlock( + `${start}\nold\n${end}\nkeep me\n${end}`, + "bunny-test", + "body", + ), + ).toThrow("malformed bunny-test block"); + }); }); describe("installProjectSkill", () => { @@ -146,6 +167,43 @@ describe("installProjectSkill", () => { const files = installProjectSkill(cwd, SKILL); expect(files).toContain(".claude/skills/bunny-test/SKILL.md"); }); + + test("refuses an AGENTS.md symlink that points outside the project", () => { + const outside = join(cwd, "..", `bunny-agent-skill-outside-${Date.now()}`); + writeFileSync(outside, "precious\n"); + try { + symlinkSync(outside, join(cwd, AGENTS_FILE)); + expect(() => installProjectSkill(cwd, SKILL)).toThrow( + "resolves outside the project", + ); + expect(readFileSync(outside, "utf8")).toBe("precious\n"); + } finally { + rmSync(outside, { force: true }); + } + }); + + test("follows an AGENTS.md symlink that stays inside the project", () => { + writeFileSync(join(cwd, "CLAUDE.md"), "# Claude\n"); + symlinkSync(join(cwd, "CLAUDE.md"), join(cwd, AGENTS_FILE)); + installProjectSkill(cwd, SKILL); + expect(readFileSync(join(cwd, "CLAUDE.md"), "utf8")).toContain( + agentsMarkers("bunny-test").start, + ); + }); + + test("refuses skill writes when .claude/skills escapes via a symlink", () => { + const outside = mkdtempSync(join(tmpdir(), "bunny-agent-skill-escape-")); + try { + mkdirSync(join(cwd, ".claude")); + symlinkSync(outside, join(cwd, ".claude/skills")); + expect(() => installProjectSkill(cwd, SKILL)).toThrow( + "resolves outside the project", + ); + expect(existsSync(join(outside, "bunny-test/SKILL.md"))).toBe(false); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); }); describe("installGlobalSkill", () => { diff --git a/packages/cli/src/core/agent-skill.ts b/packages/cli/src/core/agent-skill.ts index 0eef3125..bfa2848e 100644 --- a/packages/cli/src/core/agent-skill.ts +++ b/packages/cli/src/core/agent-skill.ts @@ -1,6 +1,13 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + realpathSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, sep } from "node:path"; import { UserError } from "./errors.ts"; /** AGENTS.md is the cross-agent instruction file; managed blocks are created or replaced there. */ @@ -35,8 +42,9 @@ export function usesClaude(cwd: string): boolean { * * Pure so every marker state is unit-testable: `null` starts a fresh file, no * markers appends, a well-formed pair is replaced in place, and a malformed - * pair (one marker missing, or end before start) throws instead of guessing, - * since slicing across reversed markers would corrupt the user's file. + * pair (one marker missing, end before start, or duplicated markers) throws + * instead of guessing, since slicing across misplaced markers would corrupt + * the user's file. */ export function upsertMarkedBlock( current: string | null, @@ -53,9 +61,12 @@ export function upsertMarkedBlock( const separator = current.endsWith("\n") ? "\n" : "\n\n"; return `${current}${separator}${section}\n`; } - if (startAt === -1 || endAt === -1 || endAt < startAt) { + const duplicated = + current.indexOf(start, startAt + start.length) !== -1 || + current.indexOf(end, endAt + end.length) !== -1; + if (startAt === -1 || endAt === -1 || endAt < startAt || duplicated) { throw new UserError( - `${AGENTS_FILE} has a malformed ${name} block: expected "${start}" followed by "${end}". Fix or remove the markers, then rerun.`, + `${AGENTS_FILE} has a malformed ${name} block: expected a single "${start}" followed by a single "${end}". Fix or remove the markers, then rerun.`, ); } const before = current.slice(0, startAt); @@ -63,18 +74,53 @@ export function upsertMarkedBlock( return `${before}${section}${after}`; } +/** Throw when writing `target` would follow a symlink to land outside `boundary`. */ +function assertWriteWithin( + boundary: string, + target: string, + label: string, +): void { + const boundaryReal = realpathSync(boundary); + const inside = (p: string) => + p === boundaryReal || p.startsWith(boundaryReal + sep); + let existing = dirname(target); + while (!existsSync(existing)) existing = dirname(existing); + let escapes = !inside(realpathSync(existing)); + if ( + !escapes && + lstatSync(target, { throwIfNoEntry: false })?.isSymbolicLink() + ) { + try { + escapes = !inside(realpathSync(target)); + } catch { + escapes = true; + } + } + if (escapes) { + throw new UserError( + `Refusing to write ${label}: it resolves outside the project through a symlink. Remove the symlink, then rerun.`, + ); + } +} + function upsertAgentsFile(cwd: string, name: string, body: string): string { const path = join(cwd, AGENTS_FILE); + assertWriteWithin(cwd, path, AGENTS_FILE); const current = existsSync(path) ? readFileSync(path, "utf8") : null; writeFileSync(path, upsertMarkedBlock(current, name, body)); return AGENTS_FILE; } /** Write the skill's files under `root`, returning their slash-separated relative paths. */ -function writeSkillFiles(root: string, skill: ProjectSkill): string[] { +function writeSkillFiles( + root: string, + skill: ProjectSkill, + boundary?: string, +): string[] { const relPaths = Object.keys(skill.files); for (const relPath of relPaths) { const target = join(root, relPath); + if (boundary) assertWriteWithin(boundary, target, relPath); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, skill.files[relPath] as string); } @@ -87,7 +133,9 @@ function writeSkillFiles(root: string, skill: ProjectSkill): string[] { * Always maintains a marked block in AGENTS.md; additionally writes the skill * files under .claude/skills// when the project already uses Claude Code. * Returns the written paths relative to `cwd`, slash-separated for display. - * Idempotent: reruns refresh the same files. + * Idempotent: reruns refresh the same files. Refuses any write that a symlink + * would redirect outside the project, so a checkout can't plant links that + * make the installer overwrite unrelated files. */ export function installProjectSkill( cwd: string, @@ -98,7 +146,7 @@ export function installProjectSkill( ]; if (usesClaude(cwd)) { const skillRoot = `.claude/skills/${skill.name}`; - for (const relPath of writeSkillFiles(join(cwd, skillRoot), skill)) { + for (const relPath of writeSkillFiles(join(cwd, skillRoot), skill, cwd)) { written.push(`${skillRoot}/${relPath}`); } } From cfdfce54b65c98e8eb370306e4c7e741b88fbe05 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 18 Aug 2026 12:37:26 +0100 Subject: [PATCH 4/5] feat(skills): global installs cover ~/.agents/skills, add update alias and skills remove - --global now writes to both ~/.agents/skills/bunny-cli (the cross-tool Agent Skills directory read by Cursor, Codex, OpenCode, Copilot, and others) and ~/.claude/skills/bunny-cli - install gains an update alias since reinstalling refreshes in place - new skills remove (rm, uninstall) strips the AGENTS.md block (deleting the file only when the installer's own scaffold heading is all that remains), deletes the skill dirs for either scope, confirms unless --force, and reuses the symlink-escape guard --- .changeset/agent-skills.md | 2 +- AGENTS.md | 11 +- README.md | 5 +- packages/cli/src/commands/skills/index.ts | 3 +- packages/cli/src/commands/skills/install.ts | 14 ++- packages/cli/src/commands/skills/remove.ts | 111 ++++++++++++++++ packages/cli/src/core/agent-skill.test.ts | 87 ++++++++++++- packages/cli/src/core/agent-skill.ts | 132 +++++++++++++++++--- 8 files changed, 331 insertions(+), 34 deletions(-) create mode 100644 packages/cli/src/commands/skills/remove.ts diff --git a/.changeset/agent-skills.md b/.changeset/agent-skills.md index 57cdbb1d..6bfd18cf 100644 --- a/.changeset/agent-skills.md +++ b/.changeset/agent-skills.md @@ -2,4 +2,4 @@ "@bunny.net/cli": minor --- -feat(skills): `bunny skills install` installs the bunny agent skill so AI coding tools know how to use the CLI; a project install upserts a marked block into AGENTS.md and, when the project uses Claude Code, writes the full skill with references to `.claude/skills/bunny-cli/`, while `--global` writes it to `~/.claude/skills/bunny-cli/` for every project; the installed content is the shipped `skills/bunny-cli/` skill embedded at build time +feat(skills): `bunny skills install` (aliases: `add`, `update`) installs the bunny agent skill so AI coding tools know how to use the CLI; a project install upserts a marked block into AGENTS.md and, when the project uses Claude Code, writes the full skill with references to `.claude/skills/bunny-cli/`, while `--global` writes it to `~/.agents/skills/bunny-cli/` (the cross-tool directory) and `~/.claude/skills/bunny-cli/` for every project; `bunny skills remove` (aliases: `rm`, `uninstall`) undoes either scope; the installed content is the shipped `skills/bunny-cli/` skill embedded at build time diff --git a/AGENTS.md b/AGENTS.md index 470cd527..d6cb0c6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,7 +174,7 @@ bunny-cli/ │ ├── cli.ts # Root yargs instance, global flags, command registration │ │ │ ├── core/ -│ │ ├── agent-skill.ts # Generic project skill installer: marked AGENTS.md block upsert + .claude/skills writes (Claude-gated for projects, ~/.claude/skills for --global); project writes refuse symlink escapes +│ │ ├── agent-skill.ts # Generic project skill installer/remover: marked AGENTS.md block upsert/remove + skill file writes (Claude-gated for projects; ~/.agents/skills + ~/.claude/skills for --global); project writes refuse symlink escapes │ │ ├── agent-skill.test.ts # Tests for install/upsert idempotency, marker scoping, Claude gating │ │ ├── client-options.ts # clientOptions() helper — builds ClientOptions from ResolvedConfig │ │ ├── define-command.ts # Command factory (see "Command Pattern" below) @@ -422,7 +422,8 @@ bunny-cli/ │ │ │ ├── index.ts # defineNamespace("skills", ...) registers skills commands │ │ │ ├── content.ts # BUNNY_CLI_SKILL: embeds skills/bunny-cli/** at bundle time via Bun text imports (single source of truth) + compact AGENTS.md section │ │ │ ├── content.test.ts # Guards: every reference SKILL.md routes to is embedded; section stays compact -│ │ │ └── install.ts # bunny skills install [--global]: project (AGENTS.md + Claude-gated .claude/skills) or global (~/.claude/skills) +│ │ │ ├── install.ts # bunny skills install [--global]: project (AGENTS.md + Claude-gated .claude/skills) or global (~/.agents/skills + ~/.claude/skills) +│ │ │ └── remove.ts # bunny skills remove [--global] [--force]: strips the AGENTS.md block and deletes the skill dirs for either scope │ │ └── scripts/ │ │ ├── index.ts # defineNamespace("scripts", ...) — registers all script commands │ │ ├── constants.ts # SCRIPT_MANIFEST, SCRIPT_TYPE_LABELS @@ -1139,7 +1140,8 @@ bunny │ ├── upgrade-router [site] [--link] Republish the site's router script with the CLI's current source │ └── delete [site] [--force] [--keep-storage] Delete pull zone → router → storage zone (typed-name confirmation, so unattended runs need --force; best-effort so re-runs finish a partial delete) ├── skills -│ └── install (alias: add) [--global] Install the bunny agent skill: marked AGENTS.md block + .claude/skills/bunny-cli/ when the project uses Claude Code; --global writes ~/.claude/skills/bunny-cli/ for every project +│ ├── install (aliases: add, update) [--global] Install the bunny agent skill: marked AGENTS.md block + .claude/skills/bunny-cli/ when the project uses Claude Code; --global writes ~/.agents/skills/bunny-cli/ and ~/.claude/skills/bunny-cli/ for every project +│ └── remove (aliases: rm, uninstall) [--global] [--force] Remove the skill: strips the AGENTS.md block (deleting the file when only the installer's scaffold heading remains) and deletes the skill dirs; confirmed unless --force ├── docs Open bunny.net documentation in browser ├── open [--print] Open bunny.net dashboard in browser (or print URL) ├── --profile, -p Profile to use (default: "default") @@ -1339,7 +1341,8 @@ handler: async ({ output, profile, apiKey }) => { So coding agents discover the CLI at all, `bunny skills install` writes the shipped `skills/bunny-cli/` skill into the user's environment. The generic machinery lives in `packages/cli/src/core/agent-skill.ts` so future per-resource skills can reuse it: - **Project install (default)**: upserts a marked block (``) into the project's `AGENTS.md` (created if missing, replaced in place on reinstall; markers are per-skill so multiple blocks coexist; a malformed block, meaning a missing, reversed, or duplicated marker, errors instead of guessing). When the project uses Claude Code (`.claude/` or `CLAUDE.md` exists) it also writes the full skill with all references to `.claude/skills/bunny-cli/`. Writes that a symlink would redirect outside the project are refused, so a checkout can't plant links that make the installer overwrite unrelated files (symlinks resolving inside the project, e.g. `AGENTS.md -> CLAUDE.md`, are followed). -- **Global install (`--global`)**: writes the skill to `~/.claude/skills/bunny-cli/` so Claude Code picks it up in every project; nothing project-local is touched. +- **Global install (`--global`)**: writes the skill to `~/.agents/skills/bunny-cli/` (the cross-tool Agent Skills directory read by Cursor, Codex, OpenCode, Copilot, and others) and `~/.claude/skills/bunny-cli/` so AI coding tools pick it up in every project; nothing project-local is touched. +- **Removal**: `bunny skills remove [--global]` undoes either scope; it strips the marked block (deleting AGENTS.md only when the installer's own scaffold heading is all that remains) and deletes the skill directories. Everything removed is regenerable with `bunny skills install`, and `update` is an install alias since reinstalling refreshes in place. - **Single source of truth**: `packages/cli/src/commands/skills/content.ts` embeds `skills/bunny-cli/**` at bundle time via Bun text imports (`with { type: "text" }`), so the installed skill is always the shipped one; only the compact AGENTS.md section is authored separately. `content.test.ts` fails if SKILL.md routes to a reference that isn't embedded. - Commands that create project resources can offer this install (via `isProjectSkillInstalled()` + `confirm()`) at natural first-use moments. diff --git a/README.md b/README.md index af60ca0b..63a89ecb 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,9 @@ bun ny # Examples bun ny login bun ny db list -bun ny skills install # install the bunny agent skill into this project (AGENTS.md block + .claude/skills when Claude Code is used) so AI coding tools know how to use the CLI -bun ny skills install --global # install to ~/.claude/skills for every project +bun ny skills install # install the bunny agent skill into this project (AGENTS.md block + .claude/skills when Claude Code is used) so AI coding tools know how to use the CLI; alias: skills update +bun ny skills install --global # install to ~/.agents/skills and ~/.claude/skills for every project +bun ny skills remove # remove the skill from this project (or --global); everything is regenerable with skills install bun ny apps deploy ghcr.io/me/api:v1.2 # deploy a pre-built image bun ny apps deploy --dockerfile # build ./Dockerfile and deploy bun ny apps deploy # first run? Imports docker-compose.yml if present; otherwise auto-detects Dockerfile(s) (including monorepo subdirs) so you can pick one or many, or falls back to a pre-built image. diff --git a/packages/cli/src/commands/skills/index.ts b/packages/cli/src/commands/skills/index.ts index 86a2c83f..6c1dbb1b 100644 --- a/packages/cli/src/commands/skills/index.ts +++ b/packages/cli/src/commands/skills/index.ts @@ -1,8 +1,9 @@ import { defineNamespace } from "../../core/define-namespace.ts"; import { skillsInstallCommand } from "./install.ts"; +import { skillsRemoveCommand } from "./remove.ts"; export const skillsNamespace = defineNamespace( "skills", "Install agent skills for AI coding tools.", - [skillsInstallCommand], + [skillsInstallCommand, skillsRemoveCommand], ); diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts index 96cb32ea..e9ad968e 100644 --- a/packages/cli/src/commands/skills/install.ts +++ b/packages/cli/src/commands/skills/install.ts @@ -7,7 +7,7 @@ import { logger } from "../../core/logger.ts"; import { BUNNY_CLI_SKILL } from "./content.ts"; const COMMAND = "install"; -const ALIASES = ["add"] as const; +const ALIASES = ["add", "update"] as const; const DESCRIPTION = "Install the bunny agent skill so AI coding tools know how to use the CLI."; @@ -21,8 +21,9 @@ interface InstallArgs { * Project install (default) maintains a marked block in AGENTS.md, which most * coding agents read, and writes the full skill with references under * .claude/skills/bunny-cli/ when the project uses Claude Code. A global install - * writes the skill to ~/.claude/skills/bunny-cli/ so Claude Code picks it up in - * every project. + * writes the skill to ~/.agents/skills/bunny-cli/ (the cross-tool directory) + * and ~/.claude/skills/bunny-cli/ so AI coding tools pick it up in every + * project. Reinstalling refreshes the same files, so `update` is an alias. * * @example * ```bash @@ -41,7 +42,7 @@ export const skillsInstallCommand = defineCommand({ ], [ "$0 skills install --global", - "Install to ~/.claude/skills for every project", + "Install to ~/.agents/skills and ~/.claude/skills for every project", ], ], @@ -49,7 +50,8 @@ export const skillsInstallCommand = defineCommand({ yargs.option("global", { type: "boolean", default: false, - describe: "Install to ~/.claude/skills instead of the current project", + describe: + "Install to ~/.agents/skills and ~/.claude/skills instead of the current project", }), handler: async ({ global: isGlobal, output }) => { @@ -71,7 +73,7 @@ export const skillsInstallCommand = defineCommand({ for (const file of files) logger.success(`Wrote ${file}`); logger.dim( isGlobal - ? "Claude Code now knows how to use the bunny CLI in every project." + ? "AI coding tools now know how to use the bunny CLI in every project." : "AI coding tools working in this project now know how to use the bunny CLI.", ); }, diff --git a/packages/cli/src/commands/skills/remove.ts b/packages/cli/src/commands/skills/remove.ts new file mode 100644 index 00000000..01f1a501 --- /dev/null +++ b/packages/cli/src/commands/skills/remove.ts @@ -0,0 +1,111 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + AGENTS_FILE, + removeGlobalSkill, + removeProjectSkill, +} from "../../core/agent-skill.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { logger } from "../../core/logger.ts"; +import { confirm, requireConfirmable } from "../../core/ui.ts"; +import { BUNNY_CLI_SKILL } from "./content.ts"; + +const COMMAND = "remove"; +const ALIASES = ["rm", "uninstall"] as const; +const DESCRIPTION = "Remove the bunny agent skill."; + +interface RemoveArgs { + global?: boolean; + force: boolean; +} + +/** + * Remove the bunny-cli agent skill. + * + * Project removal (default) strips the marked block from AGENTS.md and deletes + * .claude/skills/bunny-cli/. A global removal deletes the skill from + * ~/.agents/skills/ and ~/.claude/skills/. Everything removed is regenerable + * with `bunny skills install`. + * + * @example + * ```bash + * bunny skills remove + * bunny skills remove --global --force + * ``` + */ +export const skillsRemoveCommand = defineCommand({ + command: COMMAND, + aliases: ALIASES, + describe: DESCRIPTION, + examples: [ + [ + "$0 skills remove", + "Remove from this project (AGENTS.md and .claude/skills)", + ], + [ + "$0 skills remove --global", + "Remove from ~/.agents/skills and ~/.claude/skills", + ], + ], + + builder: (yargs) => + yargs + .option("global", { + type: "boolean", + default: false, + describe: + "Remove from the global skills directories instead of the current project", + }) + .option("force", { + type: "boolean", + default: false, + describe: "Skip confirmation", + }), + + handler: async ({ global: isGlobal, force, output }) => { + requireConfirmable(output, { + force, + message: "Removing the skill requires confirmation.", + hint: "Pass --force to remove without a prompt.", + }); + const target = isGlobal + ? "~/.agents/skills and ~/.claude/skills" + : "this project"; + const ok = await confirm(`Remove the bunny agent skill from ${target}?`, { + force, + }); + if (!ok) { + logger.log("Removal cancelled."); + process.exit(1); + } + + const removed = isGlobal + ? removeGlobalSkill(BUNNY_CLI_SKILL.name) + : removeProjectSkill(process.cwd(), BUNNY_CLI_SKILL.name); + + if (output === "json") { + logger.log( + JSON.stringify( + { scope: isGlobal ? "global" : "project", removed }, + null, + 2, + ), + ); + return; + } + + if (removed.length === 0) { + logger.log("Nothing to remove: the bunny agent skill is not installed."); + return; + } + for (const path of removed) { + const sectionOnly = + path === AGENTS_FILE && existsSync(join(process.cwd(), AGENTS_FILE)); + logger.success( + sectionOnly + ? `Removed the skill section from ${AGENTS_FILE}` + : `Removed ${path}`, + ); + } + }, +}); diff --git a/packages/cli/src/core/agent-skill.test.ts b/packages/cli/src/core/agent-skill.test.ts index f504ef12..6e7c05e1 100644 --- a/packages/cli/src/core/agent-skill.test.ts +++ b/packages/cli/src/core/agent-skill.test.ts @@ -18,6 +18,9 @@ import { installProjectSkill, isProjectSkillInstalled, type ProjectSkill, + removeGlobalSkill, + removeMarkedBlock, + removeProjectSkill, upsertMarkedBlock, usesClaude, } from "./agent-skill.ts"; @@ -207,17 +210,97 @@ describe("installProjectSkill", () => { }); describe("installGlobalSkill", () => { - test("writes skill files under the home .claude/skills dir", () => { + test("writes skill files under the home .agents/skills and .claude/skills dirs", () => { const files = installGlobalSkill(SKILL, cwd); expect(files).toEqual([ + join(cwd, ".agents/skills/bunny-test/SKILL.md"), + join(cwd, ".agents/skills/bunny-test/references/extra.md"), join(cwd, ".claude/skills/bunny-test/SKILL.md"), join(cwd, ".claude/skills/bunny-test/references/extra.md"), ]); - expect(existsSync(files[0] as string)).toBe(true); + for (const file of files) expect(existsSync(file)).toBe(true); expect(existsSync(join(cwd, AGENTS_FILE))).toBe(false); }); }); +describe("removeMarkedBlock", () => { + const { start, end } = agentsMarkers("bunny-test"); + + test("null when no block is present", () => { + expect(removeMarkedBlock("# Mine\n", "bunny-test")).toBeNull(); + }); + + test("removes the block and collapses the surrounding whitespace", () => { + const content = `intro\n\n${start}\n\nbody\n\n${end}\n\noutro\n`; + expect(removeMarkedBlock(content, "bunny-test")).toBe("intro\n\noutro\n"); + }); + + test("empty string when the block was the whole file", () => { + expect( + removeMarkedBlock(`${start}\n\nbody\n\n${end}\n`, "bunny-test"), + ).toBe(""); + }); + + test("throws on malformed markers like upsert does", () => { + expect(() => removeMarkedBlock(`${start}\nno end`, "bunny-test")).toThrow( + "malformed bunny-test block", + ); + }); +}); + +describe("removeProjectSkill", () => { + test("returns nothing when the skill was never installed", () => { + expect(removeProjectSkill(cwd, "bunny-test")).toEqual([]); + }); + + test("strips the block, keeps the user's content, and deletes skill files", () => { + writeFileSync(join(cwd, AGENTS_FILE), "# My project\n\nUse tabs.\n"); + mkdirSync(join(cwd, ".claude")); + installProjectSkill(cwd, SKILL); + const removed = removeProjectSkill(cwd, "bunny-test"); + expect(removed).toEqual([AGENTS_FILE, ".claude/skills/bunny-test"]); + const content = readFileSync(join(cwd, AGENTS_FILE), "utf8"); + expect(content).toContain("Use tabs."); + expect(content).not.toContain(agentsMarkers("bunny-test").start); + expect(existsSync(join(cwd, ".claude/skills/bunny-test"))).toBe(false); + }); + + test("deletes an AGENTS.md the installer created from scratch", () => { + installProjectSkill(cwd, SKILL); + removeProjectSkill(cwd, "bunny-test"); + expect(existsSync(join(cwd, AGENTS_FILE))).toBe(false); + }); + + test("leaves other skills' blocks in place", () => { + installProjectSkill(cwd, SKILL); + installProjectSkill(cwd, { + name: "bunny-other", + agentsSection: "## Other\n\nOther guidance.", + files: {}, + }); + removeProjectSkill(cwd, "bunny-test"); + const content = readFileSync(join(cwd, AGENTS_FILE), "utf8"); + expect(content).not.toContain(agentsMarkers("bunny-test").start); + expect(content).toContain(agentsMarkers("bunny-other").start); + }); +}); + +describe("removeGlobalSkill", () => { + test("removes the skill from every global root it exists in", () => { + installGlobalSkill(SKILL, cwd); + const removed = removeGlobalSkill("bunny-test", cwd); + expect(removed).toEqual([ + join(cwd, ".agents/skills/bunny-test"), + join(cwd, ".claude/skills/bunny-test"), + ]); + for (const dir of removed) expect(existsSync(dir)).toBe(false); + }); + + test("returns nothing when not installed", () => { + expect(removeGlobalSkill("bunny-test", cwd)).toEqual([]); + }); +}); + describe("isProjectSkillInstalled", () => { test("false without AGENTS.md", () => { expect(isProjectSkillInstalled(cwd, "bunny-test")).toBe(false); diff --git a/packages/cli/src/core/agent-skill.ts b/packages/cli/src/core/agent-skill.ts index bfa2848e..ad6e5b45 100644 --- a/packages/cli/src/core/agent-skill.ts +++ b/packages/cli/src/core/agent-skill.ts @@ -4,6 +4,7 @@ import { mkdirSync, readFileSync, realpathSync, + rmSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; @@ -37,6 +38,29 @@ export function usesClaude(cwd: string): boolean { return existsSync(join(cwd, ".claude")) || existsSync(join(cwd, "CLAUDE.md")); } +/** Heading written when the installer creates AGENTS.md from scratch. */ +const DEFAULT_AGENTS_HEADING = "# Agent instructions"; + +/** Locate the named block's markers, throwing on any malformed arrangement; null when absent. */ +function locateMarkedBlock( + current: string, + name: string, +): { startAt: number; endAt: number } | null { + const { start, end } = agentsMarkers(name); + const startAt = current.indexOf(start); + const endAt = current.indexOf(end); + if (startAt === -1 && endAt === -1) return null; + const duplicated = + current.indexOf(start, startAt + start.length) !== -1 || + current.indexOf(end, endAt + end.length) !== -1; + if (startAt === -1 || endAt === -1 || endAt < startAt || duplicated) { + throw new UserError( + `${AGENTS_FILE} has a malformed ${name} block: expected a single "${start}" followed by a single "${end}". Fix or remove the markers, then rerun.`, + ); + } + return { startAt, endAt }; +} + /** * Return `current` with the named marked block created or replaced. * @@ -53,27 +77,36 @@ export function upsertMarkedBlock( ): string { const { start, end } = agentsMarkers(name); const section = `${start}\n\n${body.trim()}\n\n${end}`; - if (current === null) return `# Agent instructions\n\n${section}\n`; + if (current === null) return `${DEFAULT_AGENTS_HEADING}\n\n${section}\n`; - const startAt = current.indexOf(start); - const endAt = current.indexOf(end); - if (startAt === -1 && endAt === -1) { + const at = locateMarkedBlock(current, name); + if (at === null) { const separator = current.endsWith("\n") ? "\n" : "\n\n"; return `${current}${separator}${section}\n`; } - const duplicated = - current.indexOf(start, startAt + start.length) !== -1 || - current.indexOf(end, endAt + end.length) !== -1; - if (startAt === -1 || endAt === -1 || endAt < startAt || duplicated) { - throw new UserError( - `${AGENTS_FILE} has a malformed ${name} block: expected a single "${start}" followed by a single "${end}". Fix or remove the markers, then rerun.`, - ); - } - const before = current.slice(0, startAt); - const after = current.slice(endAt + end.length); + const before = current.slice(0, at.startAt); + const after = current.slice(at.endAt + end.length); return `${before}${section}${after}`; } +/** + * Return `current` without the named marked block, collapsing the whitespace + * the block occupied. Returns null when no block is present (nothing to do) + * and throws on malformed markers, mirroring upsertMarkedBlock. + */ +export function removeMarkedBlock( + current: string, + name: string, +): string | null { + const at = locateMarkedBlock(current, name); + if (at === null) return null; + const { end } = agentsMarkers(name); + const before = current.slice(0, at.startAt).trimEnd(); + const after = current.slice(at.endAt + end.length).trim(); + const merged = [before, after].filter(Boolean).join("\n\n"); + return merged === "" ? "" : `${merged}\n`; +} + /** Throw when writing `target` would follow a symlink to land outside `boundary`. */ function assertWriteWithin( boundary: string, @@ -153,16 +186,79 @@ export function installProjectSkill( return written; } +/** + * Remove a skill from the current project. + * + * Strips the marked block from AGENTS.md (deleting the file when only the + * installer's own scaffold heading would remain) and deletes + * .claude/skills//. Returns the paths it changed relative to `cwd`, + * empty when nothing was installed. Symlink-guarded like the install path. + */ +export function removeProjectSkill(cwd: string, name: string): string[] { + const removed: string[] = []; + const path = join(cwd, AGENTS_FILE); + if (existsSync(path)) { + assertWriteWithin(cwd, path, AGENTS_FILE); + const updated = removeMarkedBlock(readFileSync(path, "utf8"), name); + if (updated !== null) { + if (updated === "" || updated.trim() === DEFAULT_AGENTS_HEADING) { + rmSync(path); + } else { + writeFileSync(path, updated); + } + removed.push(AGENTS_FILE); + } + } + const skillRoot = `.claude/skills/${name}`; + const dir = join(cwd, skillRoot); + if (existsSync(dir)) { + assertWriteWithin(cwd, dir, skillRoot); + rmSync(dir, { recursive: true, force: true }); + removed.push(skillRoot); + } + return removed; +} + +/** Global skill roots: the cross-tool .agents standard plus Claude Code's own dir. */ +function globalSkillRoots(home: string, name: string): string[] { + return [ + join(home, ".agents/skills", name), + join(home, ".claude/skills", name), + ]; +} + /** * Install or update a skill globally for the current user. * - * Writes the skill files under ~/.claude/skills// so Claude Code picks it - * up in every project. Returns the absolute paths written. + * Writes the skill files under ~/.agents/skills// (the cross-tool + * directory read by Cursor, Codex, OpenCode, Copilot, and others) and + * ~/.claude/skills// so every project picks it up. Returns the + * absolute paths written. */ export function installGlobalSkill( skill: ProjectSkill, home = homedir(), ): string[] { - const root = join(home, ".claude/skills", skill.name); - return writeSkillFiles(root, skill).map((relPath) => join(root, relPath)); + const written: string[] = []; + for (const root of globalSkillRoots(home, skill.name)) { + for (const relPath of writeSkillFiles(root, skill)) { + written.push(join(root, relPath)); + } + } + return written; +} + +/** + * Remove a globally installed skill from every global root. + * + * Returns the absolute directories deleted, empty when none existed. + */ +export function removeGlobalSkill(name: string, home = homedir()): string[] { + const removed: string[] = []; + for (const root of globalSkillRoots(home, name)) { + if (!existsSync(root)) continue; + rmSync(root, { recursive: true, force: true }); + removed.push(root); + } + return removed; } From ef0857f84497fba5f266cb34cb53078fa029ec5e Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 18 Aug 2026 12:41:47 +0100 Subject: [PATCH 5/5] refactor(skills): tighten comments and consolidate micro-tests - collapse multi-line docstrings in agent-skill.ts to one-liners - writeSkillFiles iterates Object.entries, dropping the string cast - remove.ts drops the filesystem re-check for its AGENTS.md message - merge single-assertion marker/predicate tests; assertion count unchanged --- packages/cli/src/commands/skills/remove.ts | 8 +-- packages/cli/src/core/agent-skill.test.ts | 77 +++++----------------- packages/cli/src/core/agent-skill.ts | 58 +++------------- 3 files changed, 29 insertions(+), 114 deletions(-) diff --git a/packages/cli/src/commands/skills/remove.ts b/packages/cli/src/commands/skills/remove.ts index 01f1a501..057cac2d 100644 --- a/packages/cli/src/commands/skills/remove.ts +++ b/packages/cli/src/commands/skills/remove.ts @@ -1,5 +1,3 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; import { AGENTS_FILE, removeGlobalSkill, @@ -99,11 +97,9 @@ export const skillsRemoveCommand = defineCommand({ return; } for (const path of removed) { - const sectionOnly = - path === AGENTS_FILE && existsSync(join(process.cwd(), AGENTS_FILE)); logger.success( - sectionOnly - ? `Removed the skill section from ${AGENTS_FILE}` + path === AGENTS_FILE + ? `Removed the bunny skill from ${AGENTS_FILE}` : `Removed ${path}`, ); } diff --git a/packages/cli/src/core/agent-skill.test.ts b/packages/cli/src/core/agent-skill.test.ts index 6e7c05e1..81075fae 100644 --- a/packages/cli/src/core/agent-skill.test.ts +++ b/packages/cli/src/core/agent-skill.test.ts @@ -65,42 +65,19 @@ describe("upsertMarkedBlock", () => { expect(result).toBe(`intro\n\n${start}\n\nnew\n\n${end}\n\noutro\n`); }); - test("throws on a missing end marker instead of corrupting the file", () => { - expect(() => - upsertMarkedBlock(`${start}\nno end`, "bunny-test", "body"), - ).toThrow("malformed bunny-test block"); - }); - - test("throws on a missing start marker", () => { - expect(() => - upsertMarkedBlock(`no start\n${end}`, "bunny-test", "body"), - ).toThrow("malformed bunny-test block"); - }); - - test("throws when end precedes start", () => { - expect(() => - upsertMarkedBlock(`${end}\nmiddle\n${start}`, "bunny-test", "body"), - ).toThrow("malformed bunny-test block"); - }); - - test("throws on a duplicate start marker instead of eating content between them", () => { - expect(() => - upsertMarkedBlock( - `${start}\nold\n${start}\nkeep me\n${end}`, - "bunny-test", - "body", - ), - ).toThrow("malformed bunny-test block"); - }); - - test("throws on a duplicate end marker", () => { - expect(() => - upsertMarkedBlock( - `${start}\nold\n${end}\nkeep me\n${end}`, - "bunny-test", - "body", - ), - ).toThrow("malformed bunny-test block"); + test("throws on malformed markers instead of corrupting the file", () => { + const malformed = [ + `${start}\nno end`, + `no start\n${end}`, + `${end}\nmiddle\n${start}`, + `${start}\nold\n${start}\nkeep me\n${end}`, + `${start}\nold\n${end}\nkeep me\n${end}`, + ]; + for (const content of malformed) { + expect(() => upsertMarkedBlock(content, "bunny-test", "body")).toThrow( + "malformed bunny-test block", + ); + } }); }); @@ -226,16 +203,10 @@ describe("installGlobalSkill", () => { describe("removeMarkedBlock", () => { const { start, end } = agentsMarkers("bunny-test"); - test("null when no block is present", () => { - expect(removeMarkedBlock("# Mine\n", "bunny-test")).toBeNull(); - }); - - test("removes the block and collapses the surrounding whitespace", () => { + test("removes the block, collapsing whitespace; null when absent, empty when the block was the whole file", () => { const content = `intro\n\n${start}\n\nbody\n\n${end}\n\noutro\n`; expect(removeMarkedBlock(content, "bunny-test")).toBe("intro\n\noutro\n"); - }); - - test("empty string when the block was the whole file", () => { + expect(removeMarkedBlock("# Mine\n", "bunny-test")).toBeNull(); expect( removeMarkedBlock(`${start}\n\nbody\n\n${end}\n`, "bunny-test"), ).toBe(""); @@ -286,7 +257,7 @@ describe("removeProjectSkill", () => { }); describe("removeGlobalSkill", () => { - test("removes the skill from every global root it exists in", () => { + test("removes the skill from every global root; nothing left for a second call", () => { installGlobalSkill(SKILL, cwd); const removed = removeGlobalSkill("bunny-test", cwd); expect(removed).toEqual([ @@ -294,24 +265,15 @@ describe("removeGlobalSkill", () => { join(cwd, ".claude/skills/bunny-test"), ]); for (const dir of removed) expect(existsSync(dir)).toBe(false); - }); - - test("returns nothing when not installed", () => { expect(removeGlobalSkill("bunny-test", cwd)).toEqual([]); }); }); describe("isProjectSkillInstalled", () => { - test("false without AGENTS.md", () => { + test("false without AGENTS.md or marker, true after install, scoped by name", () => { expect(isProjectSkillInstalled(cwd, "bunny-test")).toBe(false); - }); - - test("false when AGENTS.md lacks the marker", () => { writeFileSync(join(cwd, AGENTS_FILE), "# My project\n"); expect(isProjectSkillInstalled(cwd, "bunny-test")).toBe(false); - }); - - test("true after install, scoped by name", () => { installProjectSkill(cwd, SKILL); expect(isProjectSkillInstalled(cwd, "bunny-test")).toBe(true); expect(isProjectSkillInstalled(cwd, "bunny-other")).toBe(false); @@ -319,11 +281,8 @@ describe("isProjectSkillInstalled", () => { }); describe("usesClaude", () => { - test("false in a bare project", () => { + test("false in a bare project, true with .claude/", () => { expect(usesClaude(cwd)).toBe(false); - }); - - test("true with .claude/ or CLAUDE.md", () => { mkdirSync(join(cwd, ".claude")); expect(usesClaude(cwd)).toBe(true); }); diff --git a/packages/cli/src/core/agent-skill.ts b/packages/cli/src/core/agent-skill.ts index ad6e5b45..3c4979ab 100644 --- a/packages/cli/src/core/agent-skill.ts +++ b/packages/cli/src/core/agent-skill.ts @@ -61,15 +61,7 @@ function locateMarkedBlock( return { startAt, endAt }; } -/** - * Return `current` with the named marked block created or replaced. - * - * Pure so every marker state is unit-testable: `null` starts a fresh file, no - * markers appends, a well-formed pair is replaced in place, and a malformed - * pair (one marker missing, end before start, or duplicated markers) throws - * instead of guessing, since slicing across misplaced markers would corrupt - * the user's file. - */ +/** Return `current` with the named block created, appended, or replaced in place; malformed markers throw instead of guessing. */ export function upsertMarkedBlock( current: string | null, name: string, @@ -89,11 +81,7 @@ export function upsertMarkedBlock( return `${before}${section}${after}`; } -/** - * Return `current` without the named marked block, collapsing the whitespace - * the block occupied. Returns null when no block is present (nothing to do) - * and throws on malformed markers, mirroring upsertMarkedBlock. - */ +/** Return `current` without the named block and the whitespace it occupied, or null when no block is present. */ export function removeMarkedBlock( current: string, name: string, @@ -150,26 +138,16 @@ function writeSkillFiles( skill: ProjectSkill, boundary?: string, ): string[] { - const relPaths = Object.keys(skill.files); - for (const relPath of relPaths) { + for (const [relPath, contents] of Object.entries(skill.files)) { const target = join(root, relPath); if (boundary) assertWriteWithin(boundary, target, relPath); mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, skill.files[relPath] as string); + writeFileSync(target, contents); } - return relPaths; + return Object.keys(skill.files); } -/** - * Install or update a skill in the current project. - * - * Always maintains a marked block in AGENTS.md; additionally writes the skill - * files under .claude/skills// when the project already uses Claude Code. - * Returns the written paths relative to `cwd`, slash-separated for display. - * Idempotent: reruns refresh the same files. Refuses any write that a symlink - * would redirect outside the project, so a checkout can't plant links that - * make the installer overwrite unrelated files. - */ +/** Install or update a skill in the project (AGENTS.md block always, .claude/skills// when the project uses Claude Code), returning the cwd-relative paths written. */ export function installProjectSkill( cwd: string, skill: ProjectSkill, @@ -186,14 +164,7 @@ export function installProjectSkill( return written; } -/** - * Remove a skill from the current project. - * - * Strips the marked block from AGENTS.md (deleting the file when only the - * installer's own scaffold heading would remain) and deletes - * .claude/skills//. Returns the paths it changed relative to `cwd`, - * empty when nothing was installed. Symlink-guarded like the install path. - */ +/** Undo installProjectSkill: strip the AGENTS.md block (deleting the file when only the scaffold heading remains) and delete .claude/skills//, returning the changed paths. */ export function removeProjectSkill(cwd: string, name: string): string[] { const removed: string[] = []; const path = join(cwd, AGENTS_FILE); @@ -227,14 +198,7 @@ function globalSkillRoots(home: string, name: string): string[] { ]; } -/** - * Install or update a skill globally for the current user. - * - * Writes the skill files under ~/.agents/skills// (the cross-tool - * directory read by Cursor, Codex, OpenCode, Copilot, and others) and - * ~/.claude/skills// so every project picks it up. Returns the - * absolute paths written. - */ +/** Install or update a skill under every global root so AI coding tools pick it up in every project, returning the absolute paths written. */ export function installGlobalSkill( skill: ProjectSkill, home = homedir(), @@ -248,11 +212,7 @@ export function installGlobalSkill( return written; } -/** - * Remove a globally installed skill from every global root. - * - * Returns the absolute directories deleted, empty when none existed. - */ +/** Delete a skill from every global root, returning the directories removed. */ export function removeGlobalSkill(name: string, home = homedir()): string[] { const removed: string[] = []; for (const root of globalSkillRoots(home, name)) {