From 970dd4de375d92cddd80cd530d6c5a1d542811e1 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 18 Aug 2026 14:18:26 +0100 Subject: [PATCH 1/7] feat(skills): offer the global skill install during onboarding - bunny login makes a one-time offer after authenticating: interactive runs only, marker in the XDG cache dir, skipped when any global root already has the skill; failures never break login - install.sh outro points AI-tool users at bunny skills install --global - new isGlobalSkillInstalled() in core/agent-skill.ts backs the already-installed check --- .changeset/skills-onboarding-offer.md | 5 +++ AGENTS.md | 2 + install.sh | 1 + packages/cli/src/commands/auth/login.ts | 5 ++- packages/cli/src/commands/skills/offer.ts | 45 +++++++++++++++++++++++ packages/cli/src/core/agent-skill.test.ts | 11 ++++++ packages/cli/src/core/agent-skill.ts | 10 +++++ 7 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 .changeset/skills-onboarding-offer.md create mode 100644 packages/cli/src/commands/skills/offer.ts diff --git a/.changeset/skills-onboarding-offer.md b/.changeset/skills-onboarding-offer.md new file mode 100644 index 00000000..19941898 --- /dev/null +++ b/.changeset/skills-onboarding-offer.md @@ -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` diff --git a/AGENTS.md b/AGENTS.md index 30c847a3..71d30761 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 interactive offer to install the skill globally (used by bunny login; 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 @@ -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, marker file in the XDG cache dir, skipped when any global root already has the skill), and `install.sh` mentions `bunny skills install --global` in its outro. --- diff --git a/install.sh b/install.sh index 033d64df..d6a557f5 100755 --- a/install.sh +++ b/install.sh @@ -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." diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index bdc98096..4fcfde55 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -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"; @@ -58,7 +59,7 @@ export const authLoginCommand = defineCommand<{ force: boolean }>({ describe: "Overwrite existing profile without confirmation", }), - handler: async ({ profile, force, verbose }) => { + handler: async ({ profile, force, verbose, output }) => { if (profileExists(profile)) { logger.warn( `Profile "${profile}" already exists and will be overwritten.`, @@ -179,5 +180,7 @@ export const authLoginCommand = defineCommand<{ force: boolean }>({ clearTimeout(timeoutId); server.stop(true); } + + await offerGlobalSkillInstall(output); }, }); diff --git a/packages/cli/src/commands/skills/offer.ts b/packages/cli/src/commands/skills/offer.ts new file mode 100644 index 00000000..4e1df6d8 --- /dev/null +++ b/packages/cli/src/commands/skills/offer.ts @@ -0,0 +1,45 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { + installGlobalSkill, + isGlobalSkillInstalled, +} from "../../core/agent-skill.ts"; +import { logger } from "../../core/logger.ts"; +import { confirm, isInteractive } from "../../core/ui.ts"; +import { BUNNY_CLI_SKILL } from "./content.ts"; + +// Same state dir as the update check; losing the marker only means one repeat offer. +const OFFER_MARKER = join( + process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), + "bunnynet", + "skills-offered", +); + +/** One-time interactive offer to install the agent skill globally; never throws or blocks unattended runs. */ +export async function offerGlobalSkillInstall(output?: string): Promise { + try { + if (!isInteractive(output)) return; + if (existsSync(OFFER_MARKER)) return; + if (isGlobalSkillInstalled(BUNNY_CLI_SKILL.name)) return; + // Marked before prompting so an interrupted prompt still counts as offered. + mkdirSync(dirname(OFFER_MARKER), { recursive: true }); + writeFileSync(OFFER_MARKER, `${new Date().toISOString()}\n`); + + logger.log(); + const ok = await confirm( + "Install the bunny agent skill so AI coding tools (Claude Code, Cursor, Codex, ...) know how to use this CLI?", + { initial: true }, + ); + if (!ok) { + logger.dim( + "You can install it any time with: bunny skills install --global", + ); + return; + } + installGlobalSkill(BUNNY_CLI_SKILL); + logger.success( + "Agent skill installed to ~/.agents/skills and ~/.claude/skills.", + ); + } catch {} +} diff --git a/packages/cli/src/core/agent-skill.test.ts b/packages/cli/src/core/agent-skill.test.ts index 81075fae..5864a156 100644 --- a/packages/cli/src/core/agent-skill.test.ts +++ b/packages/cli/src/core/agent-skill.test.ts @@ -16,6 +16,7 @@ import { agentsMarkers, installGlobalSkill, installProjectSkill, + isGlobalSkillInstalled, isProjectSkillInstalled, type ProjectSkill, removeGlobalSkill, @@ -269,6 +270,16 @@ describe("removeGlobalSkill", () => { }); }); +describe("isGlobalSkillInstalled", () => { + test("true while any global root still has the skill", () => { + expect(isGlobalSkillInstalled("bunny-test", cwd)).toBe(false); + installGlobalSkill(SKILL, cwd); + expect(isGlobalSkillInstalled("bunny-test", cwd)).toBe(true); + rmSync(join(cwd, ".agents"), { recursive: true, force: true }); + expect(isGlobalSkillInstalled("bunny-test", cwd)).toBe(true); + }); +}); + describe("isProjectSkillInstalled", () => { test("false without AGENTS.md or marker, true after install, scoped by name", () => { 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 3c4979ab..85dea1ff 100644 --- a/packages/cli/src/core/agent-skill.ts +++ b/packages/cli/src/core/agent-skill.ts @@ -212,6 +212,16 @@ export function installGlobalSkill( return written; } +/** True when the named skill is already installed in any global root. */ +export function isGlobalSkillInstalled( + name: string, + home = homedir(), +): boolean { + return globalSkillRoots(home, name).some((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[] = []; From 1a568be9298fc15898f167152a9edce9cf741497 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 18 Aug 2026 14:36:25 +0100 Subject: [PATCH 2/7] refactor(skills): reuse the update-check cache dir and fold the installed-check test - offer.ts reuses CACHE_DIR from core/update-check.ts instead of rebuilding the XDG path - the three offer guards collapse into one condition - isGlobalSkillInstalled assertions fold into the existing global-install test --- packages/cli/src/commands/skills/offer.ts | 20 ++++++++++---------- packages/cli/src/core/agent-skill.test.ts | 14 ++++---------- packages/cli/src/core/update-check.ts | 3 ++- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/commands/skills/offer.ts b/packages/cli/src/commands/skills/offer.ts index 4e1df6d8..b3cdd57c 100644 --- a/packages/cli/src/commands/skills/offer.ts +++ b/packages/cli/src/commands/skills/offer.ts @@ -1,5 +1,4 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { installGlobalSkill, @@ -7,21 +6,22 @@ import { } from "../../core/agent-skill.ts"; import { logger } from "../../core/logger.ts"; import { confirm, isInteractive } from "../../core/ui.ts"; +import { CACHE_DIR } from "../../core/update-check.ts"; import { BUNNY_CLI_SKILL } from "./content.ts"; -// Same state dir as the update check; losing the marker only means one repeat offer. -const OFFER_MARKER = join( - process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), - "bunnynet", - "skills-offered", -); +// Losing the cache dir only means one repeat offer. +const OFFER_MARKER = join(CACHE_DIR, "skills-offered"); /** One-time interactive offer to install the agent skill globally; never throws or blocks unattended runs. */ export async function offerGlobalSkillInstall(output?: string): Promise { try { - if (!isInteractive(output)) return; - if (existsSync(OFFER_MARKER)) return; - if (isGlobalSkillInstalled(BUNNY_CLI_SKILL.name)) return; + if ( + !isInteractive(output) || + existsSync(OFFER_MARKER) || + isGlobalSkillInstalled(BUNNY_CLI_SKILL.name) + ) { + return; + } // Marked before prompting so an interrupted prompt still counts as offered. mkdirSync(dirname(OFFER_MARKER), { recursive: true }); writeFileSync(OFFER_MARKER, `${new Date().toISOString()}\n`); diff --git a/packages/cli/src/core/agent-skill.test.ts b/packages/cli/src/core/agent-skill.test.ts index 5864a156..7ccccd80 100644 --- a/packages/cli/src/core/agent-skill.test.ts +++ b/packages/cli/src/core/agent-skill.test.ts @@ -189,6 +189,7 @@ 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"), @@ -198,6 +199,9 @@ describe("installGlobalSkill", () => { ]); 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); + rmSync(join(cwd, ".agents"), { recursive: true, force: true }); + expect(isGlobalSkillInstalled("bunny-test", cwd)).toBe(true); }); }); @@ -270,16 +274,6 @@ describe("removeGlobalSkill", () => { }); }); -describe("isGlobalSkillInstalled", () => { - test("true while any global root still has the skill", () => { - expect(isGlobalSkillInstalled("bunny-test", cwd)).toBe(false); - installGlobalSkill(SKILL, cwd); - expect(isGlobalSkillInstalled("bunny-test", cwd)).toBe(true); - rmSync(join(cwd, ".agents"), { recursive: true, force: true }); - expect(isGlobalSkillInstalled("bunny-test", cwd)).toBe(true); - }); -}); - describe("isProjectSkillInstalled", () => { test("false without AGENTS.md or marker, true after install, scoped by name", () => { expect(isProjectSkillInstalled(cwd, "bunny-test")).toBe(false); diff --git a/packages/cli/src/core/update-check.ts b/packages/cli/src/core/update-check.ts index 4d3644b7..5940e013 100644 --- a/packages/cli/src/core/update-check.ts +++ b/packages/cli/src/core/update-check.ts @@ -3,7 +3,8 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { VERSION } from "./version.ts"; -const CACHE_DIR = join( +/** CLI state dir for throttle markers and caches (also used by the skills offer). */ +export const CACHE_DIR = join( process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "bunnynet", ); From 6e5ce2e754522bac9d0798c8221b52bc26f13e98 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 18 Aug 2026 17:22:01 +0100 Subject: [PATCH 3/7] feat(skills): hint the global install on command use and harden the login offer --- AGENTS.md | 4 +- packages/cli/src/commands/auth/login.ts | 53 ++++++++------- packages/cli/src/commands/skills/offer.ts | 78 +++++++++++++++++------ packages/cli/src/core/ui.ts | 22 +++++++ packages/cli/src/index.ts | 3 + 5 files changed, 116 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 71d30761..987b373f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -423,7 +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 interactive offer to install the skill globally (used by bunny login; marker in the XDG cache dir) +│ │ │ ├── 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 @@ -1347,7 +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, marker file in the XDG cache dir, skipped when any global root already has the skill), and `install.sh` mentions `bunny skills install --global` in its outro. +- **Onboarding**: `bunny login` makes a one-time offer to install the skill globally after authenticating (`commands/skills/offer.ts`; interactive runs only, skipped when any global root already has the skill; the marker is written after the answer so a Ctrl-C'd prompt re-offers, and an install failure prints the manual command instead of failing silently). 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. --- diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 4fcfde55..aeea36a3 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -146,41 +146,46 @@ 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); + server.stop(true); + } + + 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); }, }); diff --git a/packages/cli/src/commands/skills/offer.ts b/packages/cli/src/commands/skills/offer.ts index b3cdd57c..fe30ef27 100644 --- a/packages/cli/src/commands/skills/offer.ts +++ b/packages/cli/src/commands/skills/offer.ts @@ -1,45 +1,87 @@ 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 { confirm, isInteractive } from "../../core/ui.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 the login prompt has run, 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`); +} + /** One-time interactive offer to install the agent skill globally; never throws or blocks unattended runs. */ export async function offerGlobalSkillInstall(output?: string): Promise { try { - if ( - !isInteractive(output) || - existsSync(OFFER_MARKER) || - isGlobalSkillInstalled(BUNNY_CLI_SKILL.name) - ) { - return; - } - // Marked before prompting so an interrupted prompt still counts as offered. - mkdirSync(dirname(OFFER_MARKER), { recursive: true }); - writeFileSync(OFFER_MARKER, `${new Date().toISOString()}\n`); - + if (!shouldOffer(output)) return; + promptedThisRun = true; logger.log(); - const ok = await confirm( + 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 }, ); - if (!ok) { + // An interrupted prompt is not an answer; the next login offers again. + if (answer === "cancel") return; + markOffered(); + if (answer === "no") { + logger.dim(`You can install it any time with: ${INSTALL_COMMAND}`); + return; + } + try { + installGlobalSkill(BUNNY_CLI_SKILL); + logger.success( + "Agent skill installed to ~/.agents/skills and ~/.claude/skills.", + ); + } catch { logger.dim( - "You can install it any time with: bunny skills install --global", + `Couldn't install the skill automatically; run: ${INSTALL_COMMAND}`, ); + } + } 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; } - installGlobalSkill(BUNNY_CLI_SKILL); - logger.success( - "Agent skill installed to ~/.agents/skills and ~/.claude/skills.", + markOffered(); + logger.dim( + `\nTip: run \`${INSTALL_COMMAND}\` so AI coding tools (Claude Code, Cursor, Codex, ...) know how to use this CLI.`, ); } catch {} } diff --git a/packages/cli/src/core/ui.ts b/packages/cli/src/core/ui.ts index cabd7461..20f79271 100644 --- a/packages/cli/src/core/ui.ts +++ b/packages/cli/src/core/ui.ts @@ -38,6 +38,28 @@ export async function confirm( return confirmed ?? false; } +/** Like confirm, but reports Ctrl-C as "cancel" instead of folding it into "no". */ +export async function confirmOrCancel( + message: string, + opts?: { initial?: boolean }, +): Promise<"yes" | "no" | "cancel"> { + let cancelled = false; + const { confirmed } = await prompts( + { + type: "confirm", + name: "confirmed", + message, + initial: opts?.initial ?? false, + }, + { + onCancel: () => { + cancelled = true; + }, + }, + ); + return cancelled ? "cancel" : confirmed ? "yes" : "no"; +} + export async function confirmTyped( expected: string, opts?: { force?: boolean }, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c4ebbf62..5d521efc 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { cli } from "./cli.ts"; +import { hintGlobalSkillInstall } from "./commands/skills/offer.ts"; import { checkForUpdate, getLatestVersion } from "./core/update-check.ts"; import { VERSION } from "./core/version.ts"; @@ -19,3 +20,5 @@ if (args.includes("--version") || args.includes("-V")) { await cli.parse(); await checkForUpdate(); +// Skills commands manage the skill explicitly, so the nudge would be noise there. +if (args[0] !== "skills") hintGlobalSkillInstall(); From 64aa390abd34d2e774c8a7708b1ef369ac4b3f65 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 18 Aug 2026 17:30:25 +0100 Subject: [PATCH 4/7] fix(auth): stop the login callback server gracefully so the success page reaches the browser --- packages/cli/src/commands/auth/login.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index aeea36a3..3103b9ff 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -154,7 +154,8 @@ export const authLoginCommand = defineCommand<{ force: boolean }>({ process.exit(1); } finally { clearTimeout(timeoutId); - server.stop(true); + // Graceful stop: the success page is still flushing to the browser; force-closed at the end. + server.stop(); } setProfile(profile, apiKey); @@ -187,5 +188,6 @@ export const authLoginCommand = defineCommand<{ force: boolean }>({ ); await offerGlobalSkillInstall(output); + server.stop(true); }, }); From 773416eee13fa428e0a03299730a0bde97a47cfa Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 18 Aug 2026 17:34:33 +0100 Subject: [PATCH 5/7] fix(skills): keep the offer retryable on failed installs and add login --install-skill --- AGENTS.md | 4 +-- README.md | 2 +- packages/cli/src/commands/auth/login.ts | 27 +++++++++----- packages/cli/src/commands/skills/offer.ts | 43 +++++++++++++++-------- 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 987b373f..429b3d0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -959,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 @@ -1347,7 +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 when any global root already has the skill; the marker is written after the answer so a Ctrl-C'd prompt re-offers, and an install failure prints the manual command instead of failing silently). 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. +- **Onboarding**: `bunny login` makes a one-time offer to install the skill globally after authenticating (`commands/skills/offer.ts`; interactive runs only, skipped when any global root already has the skill). 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. --- diff --git a/README.md b/README.md index 63a89ecb..72e97674 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ bun install bun ny # 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 diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 3103b9ff..ba0345e6 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -48,18 +48,27 @@ const SUCCESS_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, output }) => { + 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.`, @@ -187,7 +196,7 @@ export const authLoginCommand = defineCommand<{ force: boolean }>({ "You can now use the CLI to manage edge scripts, databases, apps, and storage.", ); - await offerGlobalSkillInstall(output); + await offerGlobalSkillInstall(output, installSkill); server.stop(true); }, }); diff --git a/packages/cli/src/commands/skills/offer.ts b/packages/cli/src/commands/skills/offer.ts index fe30ef27..23028d1f 100644 --- a/packages/cli/src/commands/skills/offer.ts +++ b/packages/cli/src/commands/skills/offer.ts @@ -16,7 +16,7 @@ const OFFER_MARKER = join(CACHE_DIR, "skills-offered"); const INSTALL_COMMAND = "bunny skills install --global"; -// Set once the login prompt has run, so the post-command hint stays quiet in the same process. +// 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 { @@ -32,9 +32,33 @@ function markOffered(): void { writeFileSync(OFFER_MARKER, `${new Date().toISOString()}\n`); } -/** One-time interactive offer to install the agent skill globally; never throws or blocks unattended runs. */ -export async function offerGlobalSkillInstall(output?: string): Promise { +// 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(); + 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 { + try { + if (installSkill === false) return; + if (installSkill === true) { + promptedThisRun = true; + installAndReport(); + return; + } if (!shouldOffer(output)) return; promptedThisRun = true; logger.log(); @@ -44,21 +68,12 @@ export async function offerGlobalSkillInstall(output?: string): Promise { ); // An interrupted prompt is not an answer; the next login offers again. if (answer === "cancel") return; - markOffered(); if (answer === "no") { + markOffered(); logger.dim(`You can install it any time with: ${INSTALL_COMMAND}`); return; } - try { - installGlobalSkill(BUNNY_CLI_SKILL); - logger.success( - "Agent skill installed to ~/.agents/skills and ~/.claude/skills.", - ); - } catch { - logger.dim( - `Couldn't install the skill automatically; run: ${INSTALL_COMMAND}`, - ); - } + installAndReport(); } catch {} } From ec69f2367e4373a21a9308a9843d62706f4302bd Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 18 Aug 2026 17:43:25 +0100 Subject: [PATCH 6/7] fix(skills): treat partial global installs as not installed so the offer retries --- AGENTS.md | 4 ++-- packages/cli/src/core/agent-skill.test.ts | 9 +++++---- packages/cli/src/core/agent-skill.ts | 14 +++++++++----- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 429b3d0a..fbd54a99 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/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 written last per root and the installed check requires every global root, so partial installs 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) @@ -1347,7 +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 when any global root already has the skill). 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. +- **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. --- diff --git a/packages/cli/src/core/agent-skill.test.ts b/packages/cli/src/core/agent-skill.test.ts index 7ccccd80..73f64d0f 100644 --- a/packages/cli/src/core/agent-skill.test.ts +++ b/packages/cli/src/core/agent-skill.test.ts @@ -133,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"), @@ -192,16 +192,17 @@ describe("installGlobalSkill", () => { 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(true); + expect(isGlobalSkillInstalled("bunny-test", cwd)).toBe(false); }); }); diff --git a/packages/cli/src/core/agent-skill.ts b/packages/cli/src/core/agent-skill.ts index 85dea1ff..15ba523b 100644 --- a/packages/cli/src/core/agent-skill.ts +++ b/packages/cli/src/core/agent-skill.ts @@ -132,19 +132,23 @@ 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 written last so its presence marks a completed root. + 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// when the project uses Claude Code), returning the cwd-relative paths written. */ @@ -212,12 +216,12 @@ export function installGlobalSkill( return written; } -/** True when the named skill is already installed in any global root. */ +/** 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).some((root) => + return globalSkillRoots(home, name).every((root) => existsSync(join(root, "SKILL.md")), ); } From 9bc86356996c00f27559f6d82c17767c1d6494d3 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 18 Aug 2026 17:51:07 +0100 Subject: [PATCH 7/7] fix(skills): clear the SKILL.md sentinel before refreshing so failed updates read as incomplete --- AGENTS.md | 2 +- packages/cli/src/core/agent-skill.test.ts | 13 +++++++++++++ packages/cli/src/core/agent-skill.ts | 3 ++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fbd54a99..adb8fee2 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/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 written last per root and the installed check requires every global root, so partial installs re-offer +│ │ ├── 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) diff --git a/packages/cli/src/core/agent-skill.test.ts b/packages/cli/src/core/agent-skill.test.ts index 73f64d0f..cafb1f28 100644 --- a/packages/cli/src/core/agent-skill.test.ts +++ b/packages/cli/src/core/agent-skill.test.ts @@ -204,6 +204,19 @@ describe("installGlobalSkill", () => { 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); + }); }); describe("removeMarkedBlock", () => { diff --git a/packages/cli/src/core/agent-skill.ts b/packages/cli/src/core/agent-skill.ts index 15ba523b..5cb42879 100644 --- a/packages/cli/src/core/agent-skill.ts +++ b/packages/cli/src/core/agent-skill.ts @@ -138,7 +138,8 @@ function writeSkillFiles( skill: ProjectSkill, boundary?: string, ): string[] { - // SKILL.md is written last so its presence marks a completed root. + // 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"), );