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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/agent-skills.md
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <string> Profile to use (default: "default")
Expand Down Expand Up @@ -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 (`<!-- bunny-cli:start/end -->`) 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)
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ bun ny <command>
# 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.
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -32,6 +33,7 @@ const commands: CommandModule[] = [
sandboxNamespace,
sitesNamespace,
configNamespace,
skillsNamespace,
docsCommand,
openCommand,
apiCommand,
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/commands/skills/content.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
55 changes: 55 additions & 0 deletions packages/cli/src/commands/skills/content.ts
Original file line number Diff line number Diff line change
@@ -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 <namespace> --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 <METHOD> <path>\` 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,
},
};
9 changes: 9 additions & 0 deletions packages/cli/src/commands/skills/index.ts
Original file line number Diff line number Diff line change
@@ -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],
);
80 changes: 80 additions & 0 deletions packages/cli/src/commands/skills/install.ts
Original file line number Diff line number Diff line change
@@ -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<InstallArgs>({
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.",
);
},
});
107 changes: 107 additions & 0 deletions packages/cli/src/commands/skills/remove.ts
Original file line number Diff line number Diff line change
@@ -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<RemoveArgs>({
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}`,
);
}
},
});
Loading
Loading