Skip to content
5 changes: 5 additions & 0 deletions .changeset/skills-onboarding-offer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": minor
---

feat(skills): `bunny login` offers a one-time global agent-skill install after authenticating (interactive runs only, skipped when already installed), and install.sh now points at `bunny skills install --global`
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ 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.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; SKILL.md is a completion sentinel (removed first, written last per root) and the installed check requires every global root, so partial installs and failed refreshes re-offer
│ │ ├── 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)
Expand Down Expand Up @@ -423,6 +423,7 @@ bunny-cli/
│ │ │ ├── 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)
│ │ │ ├── offer.ts # One-time global-install nudge: interactive offer after bunny login + passive post-command stderr hint for users who never log in (shared marker in the XDG cache dir)
│ │ │ └── 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
Expand Down Expand Up @@ -958,7 +959,7 @@ Tests and type-checking run on every pull request via `.github/workflows/ci.yml`

```
bunny
├── login [--force] Authenticate via browser
├── login [--force] [--install-skill] Authenticate via browser; --install-skill/--no-install-skill decides the agent-skill offer without prompting
├── logout [--force] Remove stored authentication profile
├── whoami Show authenticated account (name, email, account id, profile)
├── config
Expand Down Expand Up @@ -1346,6 +1347,7 @@ So coding agents discover the CLI at all, `bunny skills install` writes the ship
- **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.
- **Experimental namespaces stay out**: commands hidden from help while experimental (`apps`, `registries`, `storage`) are not referenced by the skill or the AGENTS.md section; add their references back when they graduate to the visible command list in `cli.ts`.
- Commands that create project resources can offer this install (via `isProjectSkillInstalled()` + `confirm()`) at natural first-use moments.
- **Onboarding**: `bunny login` makes a one-time offer to install the skill globally after authenticating (`commands/skills/offer.ts`; interactive runs only, skipped only when every global root has a completed install). The marker is written on a decline or a successful install only, so a Ctrl-C'd prompt or a failed install (reported as a warning with the manual command) re-offers on the next login. `bunny login --install-skill` installs without prompting and `--no-install-skill` skips the offer, keeping scripted TTY logins unattended. Users who authenticate without `bunny login` (env var, pre-existing profile) get a one-time passive stderr hint after their next interactive command instead (`hintGlobalSkillInstall()` in `index.ts`; requires credentials, skips `skills` commands and projects with the skill installed, never prompts). Both nudges share one marker file in the XDG cache dir, so users see at most one. `install.sh` also mentions `bunny skills install --global` in its outro.

---

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ bun install
bun ny <command>

# Examples
bun ny login
bun ny login # offers to install the agent skill after authenticating; --install-skill/--no-install-skill decides without prompting
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
Expand Down
1 change: 1 addition & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,4 @@ esac

echo ""
echo "Run 'bunny --help' to get started."
echo "Using AI coding tools? Run 'bunny skills install --global' so they know how to use the CLI."
83 changes: 51 additions & 32 deletions packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { clientOptions } from "../../core/client-options.ts";
import { defineCommand } from "../../core/define-command.ts";
import { logger } from "../../core/logger.ts";
import { confirm, openBrowser, spinner } from "../../core/ui.ts";
import { offerGlobalSkillInstall } from "../skills/offer.ts";

const DASHBOARD_URL =
process.env.BUNNYNET_DASHBOARD_URL ?? "https://dash.bunny.net";
Expand Down Expand Up @@ -47,18 +48,27 @@ const SUCCESS_HTML = `<!doctype html>
</body>
</html>`;

export const authLoginCommand = defineCommand<{ force: boolean }>({
export const authLoginCommand = defineCommand<{
force: boolean;
installSkill?: boolean;
}>({
command: "login",
describe: "Authenticate with bunny.net via the browser.",

builder: (yargs) =>
yargs.option("force", {
type: "boolean",
default: false,
describe: "Overwrite existing profile without confirmation",
}),

handler: async ({ profile, force, verbose }) => {
yargs
.option("force", {
type: "boolean",
default: false,
describe: "Overwrite existing profile without confirmation",
})
.option("install-skill", {
type: "boolean",
describe:
"Install the agent skill after login without prompting (--no-install-skill skips the offer)",
}),

handler: async ({ profile, force, verbose, output, installSkill }) => {
if (profileExists(profile)) {
logger.warn(
`Profile "${profile}" already exists and will be overwritten.`,
Expand Down Expand Up @@ -145,39 +155,48 @@ export const authLoginCommand = defineCommand<{ force: boolean }>({
);
});

let apiKey: string;
try {
const apiKey = await Promise.race([apiKeyPromise, timeout]);
setProfile(profile, apiKey);
apiKey = await Promise.race([apiKeyPromise, timeout]);
} catch (err: any) {
logger.error(`Authentication failed: ${err.message}`);
process.exit(1);
} finally {
clearTimeout(timeoutId);
// Graceful stop: the success page is still flushing to the browser; force-closed at the end.
server.stop();
}

setProfile(profile, apiKey);

// Fetch user details for a personalised greeting
// The greeting fetch is best-effort: the profile is already saved.
let name: string | null = null;
const spin = spinner("Verifying credentials...");
try {
const config = resolveConfig(profile, undefined, verbose);
const client = createCoreClient(clientOptions(config, verbose));

const spin = spinner("Verifying credentials...");
spin.start();
const { data } = await client.GET("/user");
spin.stop();

const name = data
name = data
? [data.FirstName, data.LastName].filter(Boolean).join(" ")
: null;

logger.log();
logger.success(
name
? `Welcome, ${name}! 🐰`
: `Authenticated! Profile "${profile}" saved. 🐇`,
);
logger.log();
logger.dim(
"You can now use the CLI to manage edge scripts, databases, apps, and storage.",
);
} catch (err: any) {
logger.error(`Authentication failed: ${err.message}`);
process.exit(1);
} catch {
} finally {
clearTimeout(timeoutId);
server.stop(true);
spin.stop();
}

logger.log();
logger.success(
name
? `Welcome, ${name}! 🐰`
: `Authenticated! Profile "${profile}" saved. 🐇`,
);
logger.log();
logger.dim(
"You can now use the CLI to manage edge scripts, databases, apps, and storage.",
);

await offerGlobalSkillInstall(output, installSkill);
server.stop(true);
},
});
102 changes: 102 additions & 0 deletions packages/cli/src/commands/skills/offer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { loadConfigFile } from "../../config/index.ts";
import {
installGlobalSkill,
isGlobalSkillInstalled,
isProjectSkillInstalled,
} from "../../core/agent-skill.ts";
import { logger } from "../../core/logger.ts";
import { confirmOrCancel, isInteractive } from "../../core/ui.ts";
import { CACHE_DIR } from "../../core/update-check.ts";
import { BUNNY_CLI_SKILL } from "./content.ts";

// Losing the cache dir only means one repeat offer.
const OFFER_MARKER = join(CACHE_DIR, "skills-offered");

const INSTALL_COMMAND = "bunny skills install --global";

// Set once login has handled the offer (prompt or flag), so the post-command hint stays quiet in the same process.
let promptedThisRun = false;

function shouldOffer(output?: string): boolean {
return (
isInteractive(output) &&
!existsSync(OFFER_MARKER) &&
!isGlobalSkillInstalled(BUNNY_CLI_SKILL.name)
);
}

function markOffered(): void {
mkdirSync(dirname(OFFER_MARKER), { recursive: true });
writeFileSync(OFFER_MARKER, `${new Date().toISOString()}\n`);
}

// Marks offered only on success, so a failed install is offered again on the next login.
function installAndReport(): void {
try {
installGlobalSkill(BUNNY_CLI_SKILL);
markOffered();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Partial installs suppress retries

If writing a later skill file or the second global root fails after SKILL.md has been written, installGlobalSkill leaves that partial output while skipping markOffered. The any-root installed check then suppresses subsequent offers, leaving the global skill incomplete instead of retrying the failed installation.

Fix in Claude Code

logger.success(
"Agent skill installed to ~/.agents/skills and ~/.claude/skills.",
);
} catch (err) {
logger.warn(
`Couldn't install the agent skill (${err instanceof Error ? err.message : err}); run: ${INSTALL_COMMAND}`,
);
}
}

/** One-time interactive offer to install the agent skill globally; never throws or blocks unattended runs. An explicit `installSkill` flag decides without prompting. */
export async function offerGlobalSkillInstall(
output?: string,
installSkill?: boolean,
): Promise<void> {
try {
if (installSkill === false) return;
if (installSkill === true) {
promptedThisRun = true;
installAndReport();
return;
}
if (!shouldOffer(output)) return;
promptedThisRun = true;
logger.log();
const answer = await confirmOrCancel(
"Install the bunny agent skill so AI coding tools (Claude Code, Cursor, Codex, ...) know how to use this CLI?",
{ initial: true },
);
// An interrupted prompt is not an answer; the next login offers again.
if (answer === "cancel") return;
if (answer === "no") {
markOffered();
logger.dim(`You can install it any time with: ${INSTALL_COMMAND}`);
return;
}
installAndReport();
} catch {}
}

function hasCredentials(): boolean {
if (process.env.BUNNYNET_API_KEY) return true;
const file = loadConfigFile();
return Object.keys(file?.profiles ?? {}).length > 0;
}

/** One-time passive stderr hint for users who authenticated without `bunny login`; never throws or prompts. */
export function hintGlobalSkillInstall(): void {
try {
if (
promptedThisRun ||
!shouldOffer() ||
!hasCredentials() ||
isProjectSkillInstalled(process.cwd(), BUNNY_CLI_SKILL.name)
) {
return;
}
markOffered();
logger.dim(
`\nTip: run \`${INSTALL_COMMAND}\` so AI coding tools (Claude Code, Cursor, Codex, ...) know how to use this CLI.`,
);
} catch {}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow retrying after a failed skill installation

If the marker is written successfully but installGlobalSkill() then fails—for example because one of the home-directory skill roots is not writable—this empty catch suppresses the error while leaving skills-offered in place. Every later login returns at the marker check even though the skill is not installed, and the user receives neither an error nor another offer. Clear or defer the marker on installation failure and report a non-fatal warning.

Useful? React with 👍 / 👎.

}
25 changes: 22 additions & 3 deletions packages/cli/src/core/agent-skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
agentsMarkers,
installGlobalSkill,
installProjectSkill,
isGlobalSkillInstalled,
isProjectSkillInstalled,
type ProjectSkill,
removeGlobalSkill,
Expand Down Expand Up @@ -132,8 +133,8 @@ describe("installProjectSkill", () => {
const files = installProjectSkill(cwd, SKILL);
expect(files).toEqual([
AGENTS_FILE,
".claude/skills/bunny-test/SKILL.md",
".claude/skills/bunny-test/references/extra.md",
".claude/skills/bunny-test/SKILL.md",
]);
const skill = readFileSync(
join(cwd, ".claude/skills/bunny-test/SKILL.md"),
Expand Down Expand Up @@ -188,15 +189,33 @@ describe("installProjectSkill", () => {

describe("installGlobalSkill", () => {
test("writes skill files under the home .agents/skills and .claude/skills dirs", () => {
expect(isGlobalSkillInstalled("bunny-test", cwd)).toBe(false);
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, ".agents/skills/bunny-test/SKILL.md"),
join(cwd, ".claude/skills/bunny-test/references/extra.md"),
join(cwd, ".claude/skills/bunny-test/SKILL.md"),
]);
for (const file of files) expect(existsSync(file)).toBe(true);
expect(existsSync(join(cwd, AGENTS_FILE))).toBe(false);
expect(isGlobalSkillInstalled("bunny-test", cwd)).toBe(true);
// A missing root means a partial install, which must count as not installed so it re-offers.
rmSync(join(cwd, ".agents"), { recursive: true, force: true });
expect(isGlobalSkillInstalled("bunny-test", cwd)).toBe(false);
});

test("a failed refresh clears the completion sentinel so it counts as not installed", () => {
installGlobalSkill(SKILL, cwd);
const conflict = join(cwd, ".claude/skills/bunny-test/references/extra.md");
rmSync(conflict);
// A directory where a file belongs makes the refresh fail mid-write.
mkdirSync(conflict);
expect(() => installGlobalSkill(SKILL, cwd)).toThrow();
expect(existsSync(join(cwd, ".claude/skills/bunny-test/SKILL.md"))).toBe(
false,
);
expect(isGlobalSkillInstalled("bunny-test", cwd)).toBe(false);
});
});

Expand Down
21 changes: 18 additions & 3 deletions packages/cli/src/core/agent-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,19 +132,24 @@ function upsertAgentsFile(cwd: string, name: string, body: string): string {
return AGENTS_FILE;
}

/** Write the skill's files under `root`, returning their slash-separated relative paths. */
/** Write the skill's files under `root`, returning their slash-separated relative paths in write order. */
function writeSkillFiles(
root: string,
skill: ProjectSkill,
boundary?: string,
): string[] {
for (const [relPath, contents] of Object.entries(skill.files)) {
// SKILL.md is the completion sentinel: removed first and written last, so an interrupted install or refresh reads as incomplete.
rmSync(join(root, "SKILL.md"), { force: true });
const entries = Object.entries(skill.files).sort(
([a], [b]) => Number(a === "SKILL.md") - Number(b === "SKILL.md"),
);
for (const [relPath, contents] of entries) {
const target = join(root, relPath);
if (boundary) assertWriteWithin(boundary, target, relPath);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, contents);
}
return Object.keys(skill.files);
return entries.map(([relPath]) => relPath);
}

/** Install or update a skill in the project (AGENTS.md block always, .claude/skills/<name>/ when the project uses Claude Code), returning the cwd-relative paths written. */
Expand Down Expand Up @@ -212,6 +217,16 @@ export function installGlobalSkill(
return written;
}

/** True when every global root has a completed install (SKILL.md is written last), so a partial or failed install re-offers. */
export function isGlobalSkillInstalled(
name: string,
home = homedir(),
): boolean {
return globalSkillRoots(home, name).every((root) =>
existsSync(join(root, "SKILL.md")),
);
}

/** Delete a skill from every global root, returning the directories removed. */
export function removeGlobalSkill(name: string, home = homedir()): string[] {
const removed: string[] = [];
Expand Down
Loading