diff --git a/.changeset/agent-skills.md b/.changeset/agent-skills.md new file mode 100644 index 00000000..6bfd18cf --- /dev/null +++ b/.changeset/agent-skills.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": minor +--- + +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 99aae59f..d6cb0c6b 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/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) │ │ ├── define-namespace.ts # Namespace/group factory for subcommand trees @@ -416,6 +418,12 @@ 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 (~/.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 @@ -1131,6 +1139,9 @@ 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 (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") @@ -1325,6 +1336,16 @@ 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; 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 `~/.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. + --- ## Local Context (`.bunny/` Manifest) diff --git a/README.md b/README.md index 31a567f6..63a89ecb 100644 --- a/README.md +++ b/README.md @@ -43,6 +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; 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/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..321c9145 --- /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, 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\`; 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.`; + +/** 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..6c1dbb1b --- /dev/null +++ b/packages/cli/src/commands/skills/index.ts @@ -0,0 +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, skillsRemoveCommand], +); diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts new file mode 100644 index 00000000..e9ad968e --- /dev/null +++ b/packages/cli/src/commands/skills/install.ts @@ -0,0 +1,80 @@ +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", "update"] 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 ~/.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 + * 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 ~/.agents/skills and ~/.claude/skills for every project", + ], + ], + + builder: (yargs) => + yargs.option("global", { + type: "boolean", + default: false, + describe: + "Install to ~/.agents/skills and ~/.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 + ? "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..057cac2d --- /dev/null +++ b/packages/cli/src/commands/skills/remove.ts @@ -0,0 +1,107 @@ +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) { + logger.success( + 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 new file mode 100644 index 00000000..81075fae --- /dev/null +++ b/packages/cli/src/core/agent-skill.test.ts @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AGENTS_FILE, + agentsMarkers, + installGlobalSkill, + installProjectSkill, + isProjectSkillInstalled, + type ProjectSkill, + removeGlobalSkill, + removeMarkedBlock, + removeProjectSkill, + upsertMarkedBlock, + 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("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 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", + ); + } + }); +}); + +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"); + }); + + 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", () => { + 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"), + ]); + 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("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"); + expect(removeMarkedBlock("# Mine\n", "bunny-test")).toBeNull(); + 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; nothing left for a second call", () => { + 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); + expect(removeGlobalSkill("bunny-test", cwd)).toEqual([]); + }); +}); + +describe("isProjectSkillInstalled", () => { + test("false without AGENTS.md or marker, true after install, scoped by name", () => { + expect(isProjectSkillInstalled(cwd, "bunny-test")).toBe(false); + writeFileSync(join(cwd, AGENTS_FILE), "# My project\n"); + expect(isProjectSkillInstalled(cwd, "bunny-test")).toBe(false); + 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, true with .claude/", () => { + expect(usesClaude(cwd)).toBe(false); + 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..3c4979ab --- /dev/null +++ b/packages/cli/src/core/agent-skill.ts @@ -0,0 +1,224 @@ +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +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. */ +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")); +} + +/** 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 block created, appended, or replaced in place; malformed markers throw instead of guessing. */ +export function upsertMarkedBlock( + current: string | null, + name: string, + body: string, +): string { + const { start, end } = agentsMarkers(name); + const section = `${start}\n\n${body.trim()}\n\n${end}`; + if (current === null) return `${DEFAULT_AGENTS_HEADING}\n\n${section}\n`; + + const at = locateMarkedBlock(current, name); + if (at === null) { + const separator = current.endsWith("\n") ? "\n" : "\n\n"; + return `${current}${separator}${section}\n`; + } + const before = current.slice(0, at.startAt); + const after = current.slice(at.endAt + end.length); + return `${before}${section}${after}`; +} + +/** 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, +): 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, + 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, + boundary?: string, +): string[] { + 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, contents); + } + return Object.keys(skill.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, +): string[] { + const written: string[] = [ + upsertAgentsFile(cwd, skill.name, skill.agentsSection), + ]; + if (usesClaude(cwd)) { + const skillRoot = `.claude/skills/${skill.name}`; + for (const relPath of writeSkillFiles(join(cwd, skillRoot), skill, cwd)) { + written.push(`${skillRoot}/${relPath}`); + } + } + return written; +} + +/** 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); + 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 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(), +): string[] { + 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; +} + +/** 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)) { + if (!existsSync(root)) continue; + rmSync(root, { recursive: true, force: true }); + removed.push(root); + } + return removed; +} 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