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/skills-refuse-filesystem-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

Refuse project agent skill installs into the filesystem root
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1367,7 +1367,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 (`<!-- 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). The full skill with all references is always written to `.agents/skills/bunny-cli/`, and additionally to `.claude/skills/bunny-cli/` when the project uses Claude Code (`.claude/` or `CLAUDE.md` exists). Installing into the home directory is refused: per-user config dirs like `~/.claude` would otherwise read as project markers, and files there would apply to every directory you work in. Removal is still allowed there, so a stray `AGENTS.md` from an earlier version can be cleaned up, but it strips only that block: the skill directories under home are the global install, and only `--global` deletes those. 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).
- **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). The full skill with all references is always written to `.agents/skills/bunny-cli/`, and additionally to `.claude/skills/bunny-cli/` when the project uses Claude Code (`.claude/` or `CLAUDE.md` exists). Installing into the home directory is refused: per-user config dirs like `~/.claude` would otherwise read as project markers, and files there would apply to every directory you work in. The filesystem root is refused for the same reason, that it is not a project. Removal is still allowed there, so a stray `AGENTS.md` from an earlier version can be cleaned up, but it strips only that block: the skill directories under home are the global install, and only `--global` deletes those. 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.
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/skills/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ 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
* .agents/skills/bunny-cli/, plus .claude/skills/bunny-cli/ when the project
* uses Claude Code. Installing into the home directory is refused, since files
* there are not project scoped. A global install writes the skill to
* uses Claude Code. Installing into the home directory or the filesystem root
* is refused, since neither is a project. 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.
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/core/agent-skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ describe("installProjectSkill", () => {
]);
});

test("refuses to install into the filesystem root", () => {
expect(() => installProjectSkill("/", SKILL)).toThrow(
"Refusing to install into the filesystem root",
);
});

test("refuses to install into the home directory", () => {
expect(() => installProjectSkill(cwd, SKILL, cwd)).toThrow(
"Refusing to install into your home directory",
Expand Down
29 changes: 22 additions & 7 deletions packages/cli/src/core/agent-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,27 @@ function isSamePath(a: string, b: string): boolean {
}
}

/** Throw when cwd is the user's home directory, where per-user config dirs like ~/.claude would read as project markers. */
function assertNotHome(cwd: string, home: string): void {
if (!isSamePath(cwd, home)) return;
throw new UserError(
"Refusing to install into your home directory: it is not a project, and files here apply to every directory you work in. Run `bunny skills install --global` for a machine-wide install, or rerun this from a project directory.",
);
/** True when the path is its own parent, which only the filesystem root is. */
function isFilesystemRoot(path: string): boolean {
let resolved = path;
try {
resolved = realpathSync(path);
} catch {}
return dirname(resolved) === resolved;
}

/** Throw when cwd is somewhere a project install makes no sense: the user's home, where per-user config dirs like ~/.claude would read as project markers, or the filesystem root. */
function assertProjectDir(cwd: string, home: string): void {
if (isSamePath(cwd, home)) {
throw new UserError(
"Refusing to install into your home directory: it is not a project, and files here apply to every directory you work in. Run `bunny skills install --global` for a machine-wide install, or rerun this from a project directory.",
);
}
if (isFilesystemRoot(cwd)) {
throw new UserError(
"Refusing to install into the filesystem root: it is not a project. Rerun this from a project directory.",
);
}
}

/** Heading written when the installer creates AGENTS.md from scratch. */
Expand Down Expand Up @@ -188,7 +203,7 @@ export function installProjectSkill(
skill: ProjectSkill,
home = homedir(),
): string[] {
assertNotHome(cwd, home);
assertProjectDir(cwd, home);
const written: string[] = [
upsertAgentsFile(cwd, skill.name, skill.agentsSection),
];
Expand Down