From f681dc10f77afe417e35d26c1fa44ae258e98d8f Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 5 Jun 2026 20:01:38 -0700 Subject: [PATCH 01/10] feat(cli): nudge agents to install the browse skill + de-spam update notice Co-Authored-By: Claude Opus 4.8 --- .changeset/browse-skill-nudge.md | 7 + packages/cli/package.json | 1 + packages/cli/src/commands/doctor.ts | 17 ++ packages/cli/src/hooks/init.ts | 18 ++- packages/cli/src/lib/help.ts | 54 +++++++ packages/cli/src/lib/skill-nudge.ts | 181 ++++++++++++++++++++++ packages/cli/src/lib/skill-presence.ts | 71 +++++++++ packages/cli/src/lib/telemetry.ts | 10 +- packages/cli/src/lib/update.ts | 66 +++++--- packages/cli/tests/cli-surface.test.ts | 47 +++++- packages/cli/tests/cli-update.test.ts | 65 ++++---- packages/cli/tests/skill-nudge.test.ts | 124 +++++++++++++++ packages/cli/tests/skill-presence.test.ts | 76 +++++++++ 13 files changed, 684 insertions(+), 53 deletions(-) create mode 100644 .changeset/browse-skill-nudge.md create mode 100644 packages/cli/src/lib/help.ts create mode 100644 packages/cli/src/lib/skill-nudge.ts create mode 100644 packages/cli/src/lib/skill-presence.ts create mode 100644 packages/cli/tests/skill-nudge.test.ts create mode 100644 packages/cli/tests/skill-presence.test.ts diff --git a/.changeset/browse-skill-nudge.md b/.changeset/browse-skill-nudge.md new file mode 100644 index 000000000..f6fc6b0f1 --- /dev/null +++ b/.changeset/browse-skill-nudge.md @@ -0,0 +1,7 @@ +--- +"browse": patch +--- + +Surface the browse skill to coding agents. Root help (`browse` / `browse --help`) leads with an agent-targeted "Start here" banner pointing to `browse skills install` — shown only when the skill is not already installed, so it never nags users who have it. A once-per-session, agent-only nudge (stderr, never stdout) prompts detected agents that don't yet have the browse skill installed to run `browse skills install`. The nudge is throttled per agent session, skipped for humans, CI, and `skills` commands, and can be disabled with `BROWSE_DISABLE_SKILL_NUDGE=1`. Command telemetry now includes a `skill_present` property so skill adoption among agents is measurable. + +Also stop the "Update available" notice from printing on every command. The update check still refreshes its cache silently in the background, but the notice is now shown only on the human-facing surfaces — `browse` / `browse --help` and `browse doctor` — so it no longer spams scripts and agent command loops. diff --git a/packages/cli/package.json b/packages/cli/package.json index 2db6085ba..5f8e3d6aa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -24,6 +24,7 @@ "bin": "browse", "dirname": "browse", "commands": "./dist/commands", + "helpClass": "./dist/lib/help", "hooks": { "init": "./dist/hooks/init.js", "prerun": "./dist/hooks/prerun.js", diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 497096aed..7b7b8a296 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,3 +1,5 @@ +import { join } from "node:path"; + import { Flags } from "@oclif/core"; import { BrowseCommand } from "../base.js"; @@ -8,6 +10,7 @@ import { import { buildDoctorReport, renderDoctorReport } from "../lib/driver/doctor.js"; import { sessionName } from "../lib/driver/flags.js"; import { outputJson } from "../lib/output.js"; +import { getUpdateNotice } from "../lib/update.js"; export default class Doctor extends BrowseCommand { static override description = @@ -42,6 +45,20 @@ export default class Doctor extends BrowseCommand { } this.log(renderDoctorReport(report)); + await this.writeUpdateNotice(); if (report.verdict === "fail") this.exit(1); } + + private async writeUpdateNotice(): Promise { + try { + const notice = await getUpdateNotice(this.config.version, process.env, { + cacheFile: join(this.config.cacheDir, "update-check.json"), + }); + if (notice) { + process.stderr.write(`\n${notice}`); + } + } catch { + // Best-effort update notice should never affect doctor output. + } + } } diff --git a/packages/cli/src/hooks/init.ts b/packages/cli/src/hooks/init.ts index 3010eb7d3..2301dad56 100644 --- a/packages/cli/src/hooks/init.ts +++ b/packages/cli/src/hooks/init.ts @@ -2,10 +2,11 @@ import { join } from "node:path"; import type { Hook } from "@oclif/core"; +import { maybeNudgeInstallSkill } from "../lib/skill-nudge.js"; import { startTelemetryInvocation } from "../lib/telemetry.js"; -import { maybeAutoUpdateCli } from "../lib/update.js"; +import { scheduleBackgroundUpdateCheck } from "../lib/update.js"; -const hook: Hook.Init = async function ({ config }) { +const hook: Hook.Init = async function ({ config, id }) { try { startTelemetryInvocation(); } catch { @@ -13,12 +14,23 @@ const hook: Hook.Init = async function ({ config }) { } try { - await maybeAutoUpdateCli(config.version, process.env, { + // Silent: refresh the cached latest version when stale, but never print. + // The notice itself is shown only on `browse`/`--help` and `doctor`. + await scheduleBackgroundUpdateCheck(process.env, { cacheFile: join(config.cacheDir, "update-check.json"), }); } catch { // Best-effort update checks should never affect CLI behavior. } + + try { + await maybeNudgeInstallSkill(process.env, { + cacheFile: join(config.cacheDir, "skill-nudge.json"), + commandId: id, + }); + } catch { + // Best-effort skill nudges should never affect CLI behavior. + } }; export default hook; diff --git a/packages/cli/src/lib/help.ts b/packages/cli/src/lib/help.ts new file mode 100644 index 000000000..efe9d8b68 --- /dev/null +++ b/packages/cli/src/lib/help.ts @@ -0,0 +1,54 @@ +import { join } from "node:path"; + +import { Help } from "@oclif/core"; + +import { detectAgent } from "./agent.js"; +import { isBrowseSkillInstalled } from "./skill-presence.js"; +import { getUpdateNotice } from "./update.js"; + +const AGENT_START_HERE = `Start here (for AI agents): + Run \`browse skills install\` to load the browse skill into your coding agent + (Claude Code, Codex, Cursor, Gemini, …), then prefer \`browse\` for browser + automation. +`; + +/** + * Root-help override that leads with an agent-targeted "Start here" pointer to + * the browse skill — but only when the skill is NOT already installed, so it + * never nags users who have it. Shown on bare `browse` and `browse --help` + * (both route through showRootHelp). Also surfaces the update notice here and in + * `doctor` — the only human-facing surfaces that show it, so it never spams + * commands. + */ +export default class BrowseHelp extends Help { + public override async showRootHelp(): Promise { + if (await this.skillBannerNeeded()) { + this.log(AGENT_START_HERE); + } + await super.showRootHelp(); + await this.writeUpdateNotice(); + } + + private async skillBannerNeeded(): Promise { + try { + const agent = await detectAgent(); + return !(await isBrowseSkillInstalled(agent ?? "", process.env)); + } catch { + // Best-effort: if detection fails, show the discovery banner. + return true; + } + } + + private async writeUpdateNotice(): Promise { + try { + const notice = await getUpdateNotice(this.config.version, process.env, { + cacheFile: join(this.config.cacheDir, "update-check.json"), + }); + if (notice) { + process.stderr.write(`\n${notice}`); + } + } catch { + // Best-effort update notice should never affect help output. + } + } +} diff --git a/packages/cli/src/lib/skill-nudge.ts b/packages/cli/src/lib/skill-nudge.ts new file mode 100644 index 000000000..ff13b7c5d --- /dev/null +++ b/packages/cli/src/lib/skill-nudge.ts @@ -0,0 +1,181 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { detectAgent } from "./agent.js"; +import { isBrowseSkillInstalled } from "./skill-presence.js"; + +// When the calling agent exposes no per-session id, fall back to a time window +// so a tight command loop sees the nudge at most once per window while a fresh +// session some hours later still gets a reminder. +const NUDGE_FALLBACK_TTL_MS = 4 * 60 * 60 * 1000; +const NUDGE_STORE_PRUNE_MS = 7 * 24 * 60 * 60 * 1000; + +interface SkillNudgeOptions { + cacheFile?: string; + commandId?: string; + now?: number; +} + +interface SkillNudgeStore { + shown: Record; +} + +interface NudgeKey { + key: string; + sessionScoped: boolean; +} + +/** + * Once-per-session, agent-only nudge to install the browse skill, printed to + * stderr so it never corrupts machine-readable stdout. Best-effort: any failure + * is swallowed so it can never affect CLI behavior. Humans (no detected agent) + * are pointed to the skill via the root help banner instead. + */ +export async function maybeNudgeInstallSkill( + env: NodeJS.ProcessEnv = process.env, + options: SkillNudgeOptions = {}, +): Promise { + if (isNudgeDisabled(env)) { + return; + } + + // The user is already engaging with skills; don't nudge on those commands or + // on bare/`--help` invocations (the help banner covers discovery there). + const commandId = options.commandId; + if (!commandId || commandId === "help" || commandId.startsWith("skills")) { + return; + } + + const cachePath = options.cacheFile; + if (!cachePath) { + return; + } + + const agent = await detectAgent(); + if (!agent) { + return; + } + + if (await isBrowseSkillInstalled(agent, env)) { + return; + } + + const now = options.now ?? Date.now(); + const { key, sessionScoped } = resolveNudgeKey(agent, env); + const store = await readNudgeStore(cachePath); + + const lastShown = store.shown[key]; + if (lastShown !== undefined) { + if (sessionScoped) { + return; + } + if (now - lastShown < NUDGE_FALLBACK_TTL_MS) { + return; + } + } + + writeNudge(); + + store.shown[key] = now; + pruneNudgeStore(store, now); + await writeNudgeStore(cachePath, store); +} + +function resolveNudgeKey(agent: string, env: NodeJS.ProcessEnv): NudgeKey { + // Real per-session identifiers exposed by some harnesses. Claude Code, Gemini, + // etc. expose only a boolean, so they fall through to the TTL window. + const sessionId = firstNonEmpty(env.CODEX_THREAD_ID, env.CURSOR_TRACE_ID); + if (sessionId) { + return { key: `${agent}:session:${sessionId}`, sessionScoped: true }; + } + return { key: `${agent}:window`, sessionScoped: false }; +} + +function firstNonEmpty(...values: (string | undefined)[]): string | undefined { + for (const value of values) { + if (value && value.length > 0) { + return value; + } + } + return undefined; +} + +function writeNudge(): void { + process.stderr.write( + [ + "Tip: browse works best with its skill loaded into your agent.", + "Run:", + " browse skills install", + "", + ].join("\n"), + ); +} + +function isNudgeDisabled(env: NodeJS.ProcessEnv): boolean { + if ( + env.BROWSE_DISABLE_SKILL_NUDGE === "1" || + env.BB_DISABLE_SKILL_NUDGE === "1" + ) { + return true; + } + if (env.NODE_ENV === "test") { + return true; + } + return isCiEnvironment(env); +} + +function isCiEnvironment(env: NodeJS.ProcessEnv): boolean { + const value = env.CI; + if (!value) { + return false; + } + const normalized = value.trim().toLowerCase(); + return !( + normalized === "" || + normalized === "0" || + normalized === "false" || + normalized === "no" || + normalized === "off" + ); +} + +async function readNudgeStore(cachePath: string): Promise { + try { + const contents = await readFile(cachePath, "utf8"); + const parsed = JSON.parse(contents) as { shown?: unknown }; + if (parsed && typeof parsed.shown === "object" && parsed.shown !== null) { + const shown: Record = {}; + for (const [key, value] of Object.entries( + parsed.shown as Record, + )) { + if (typeof value === "number" && Number.isFinite(value)) { + shown[key] = value; + } + } + return { shown }; + } + } catch { + // Missing or unreadable store; start fresh. + } + return { shown: {} }; +} + +async function writeNudgeStore( + cachePath: string, + store: SkillNudgeStore, +): Promise { + try { + await mkdir(dirname(cachePath), { recursive: true }); + await writeFile(cachePath, `${JSON.stringify(store)}\n`, "utf8"); + } catch { + // Best-effort cache writes should never affect CLI behavior. + } +} + +function pruneNudgeStore(store: SkillNudgeStore, now: number): void { + for (const [key, shownAt] of Object.entries(store.shown)) { + if (now - shownAt >= NUDGE_STORE_PRUNE_MS) { + delete store.shown[key]; + } + } +} diff --git a/packages/cli/src/lib/skill-presence.ts b/packages/cli/src/lib/skill-presence.ts new file mode 100644 index 000000000..4d7c2beca --- /dev/null +++ b/packages/cli/src/lib/skill-presence.ts @@ -0,0 +1,71 @@ +import { constants } from "node:fs"; +import { access } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const BROWSE_SKILL_FOLDER = "browse"; + +/** + * Candidate skills directories where `browse skills install` + * (`npx skills add ... --global --agent *`) may have written the browse skill + * for a given agent. The canonical copy always lands in `~/.agents/skills`; + * agents with bespoke skill dirs also get a symlink there. + */ +export function browseSkillDirsForAgent( + agentName: string, + env: NodeJS.ProcessEnv = process.env, + home: string = homedir(), +): string[] { + const dirs = new Set(); + + // Universal canonical location shared by most agents. + dirs.add(join(home, ".agents", "skills")); + + switch (agentName) { + case "claude": + case "cowork": + dirs.add(join(env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "skills")); + break; + case "codex": + dirs.add(join(env.CODEX_HOME ?? join(home, ".codex"), "skills")); + break; + case "cursor": + case "cursor-cli": + dirs.add(join(home, ".cursor", "skills")); + break; + case "gemini": + dirs.add(join(home, ".gemini", "skills")); + break; + case "github-copilot": + dirs.add(join(home, ".copilot", "skills")); + break; + default: + // Universal-only agents (hermes, openclaw, opencode, …) share + // `~/.agents/skills`, already added above. + break; + } + + return [...dirs]; +} + +/** + * Best-effort check for whether the bundled browse skill is present on disk in + * any of the calling agent's skills directories. This detects an installed + * skill *file*; it cannot know whether the agent has loaded it into context. + */ +export async function isBrowseSkillInstalled( + agentName: string, + env: NodeJS.ProcessEnv = process.env, + home: string = homedir(), +): Promise { + for (const dir of browseSkillDirsForAgent(agentName, env, home)) { + try { + await access(join(dir, BROWSE_SKILL_FOLDER, "SKILL.md"), constants.F_OK); + return true; + } catch { + // Not present in this directory; keep checking. + } + } + + return false; +} diff --git a/packages/cli/src/lib/telemetry.ts b/packages/cli/src/lib/telemetry.ts index 8b0f04024..65977c1f5 100644 --- a/packages/cli/src/lib/telemetry.ts +++ b/packages/cli/src/lib/telemetry.ts @@ -7,6 +7,7 @@ import type { Command } from "@oclif/core"; import { detectAgent } from "./agent.js"; import { getRunTelemetry, resetRunTelemetry } from "./run-telemetry.js"; +import { isBrowseSkillInstalled } from "./skill-presence.js"; import type { CommandFailureTelemetry } from "./errors.js"; const browserbaseTelemetrySource = "cli"; @@ -141,6 +142,11 @@ function createCliTelemetry(options: CreateCliTelemetryOptions): CliTelemetry { ? resolveAnonymousInstallId(env, options.sessionId) : Promise.resolve(""); const agentPromise = telemetryEnabled ? detectAgent() : Promise.resolve(null); + const skillPresentPromise: Promise = telemetryEnabled + ? agentPromise + .then((agent) => (agent ? isBrowseSkillInstalled(agent, env) : null)) + .catch(() => null) + : Promise.resolve(null); const baseProperties: TelemetryProperties = { source: browserbaseTelemetrySource, @@ -157,9 +163,10 @@ function createCliTelemetry(options: CreateCliTelemetryOptions): CliTelemetry { return; } - const [distinctId, agent] = await Promise.all([ + const [distinctId, agent, skillPresent] = await Promise.all([ distinctIdPromise, agentPromise, + skillPresentPromise, ]); await posthogCapture(transport, { @@ -170,6 +177,7 @@ function createCliTelemetry(options: CreateCliTelemetryOptions): CliTelemetry { properties: { ...baseProperties, agent, + skill_present: skillPresent, ...properties, }, }); diff --git a/packages/cli/src/lib/update.ts b/packages/cli/src/lib/update.ts index 84040bcdd..8464e2828 100644 --- a/packages/cli/src/lib/update.ts +++ b/packages/cli/src/lib/update.ts @@ -19,15 +19,43 @@ interface UpdateCheckOptions { cacheFile?: string; } -export async function maybeAutoUpdateCli( +/** + * Read-only check for a newer published version. Returns the formatted notice + * text when a fresh cache shows an update is available, otherwise null. Never + * prints and never hits the network — call from human-facing surfaces (root + * help, `doctor`) rather than on every command so we don't spam automation. + */ +export async function getUpdateNotice( currentVersion: string, env: NodeJS.ProcessEnv = process.env, options: UpdateCheckOptions = {}, +): Promise { + if (isUpdateCheckDisabled(env)) { + return null; + } + + const cachePath = resolveUpdateCheckPath(env, options); + if (!cachePath) { + return null; + } + + const cache = await readFreshUpdateCheckCache(cachePath); + if (!cache || !isVersionNewer(currentVersion, cache.version)) { + return null; + } + + return formatUpdateNotice(currentVersion, cache.version); +} + +/** + * Refresh the cached "latest version" in the background when it is stale, so + * the surfaces that show the notice have fresh data. Silent: never prints. + */ +export async function scheduleBackgroundUpdateCheck( + env: NodeJS.ProcessEnv = process.env, + options: UpdateCheckOptions = {}, ): Promise { - if ( - env.BROWSE_DISABLE_UPDATE_CHECK === "1" || - env.BB_DISABLE_UPDATE_CHECK === "1" - ) { + if (isUpdateCheckDisabled(env)) { return; } @@ -38,15 +66,19 @@ export async function maybeAutoUpdateCli( const cache = await readFreshUpdateCheckCache(cachePath); if (cache) { - if (isVersionNewer(currentVersion, cache.version)) { - writeUpdateNotice(currentVersion, cache.version); - } return; } spawnBackgroundUpdateCheck(env, cachePath); } +function isUpdateCheckDisabled(env: NodeJS.ProcessEnv): boolean { + return ( + env.BROWSE_DISABLE_UPDATE_CHECK === "1" || + env.BB_DISABLE_UPDATE_CHECK === "1" + ); +} + export async function refreshUpdateCheckCache( env: NodeJS.ProcessEnv = process.env, options: UpdateCheckOptions = {}, @@ -166,18 +198,16 @@ function resolveUpdateCheckWorkerPath(): string { ); } -function writeUpdateNotice( +function formatUpdateNotice( currentVersion: string, latestVersion: string, -): void { - process.stderr.write( - [ - `Update available: ${currentVersion} -> ${latestVersion}.`, - "Run:", - ` npm i -g ${CLI_PACKAGE_NAME}@latest`, - "", - ].join("\n"), - ); +): string { + return [ + `Update available: ${currentVersion} -> ${latestVersion}.`, + "Run:", + ` npm i -g ${CLI_PACKAGE_NAME}@latest`, + "", + ].join("\n"); } async function fetchLatestCliVersion(): Promise { diff --git a/packages/cli/tests/cli-surface.test.ts b/packages/cli/tests/cli-surface.test.ts index 41878e0b4..595146535 100644 --- a/packages/cli/tests/cli-surface.test.ts +++ b/packages/cli/tests/cli-surface.test.ts @@ -1,7 +1,28 @@ -import { describe, expect, it } from "vitest"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; import { runCli } from "./helpers/run-cli.js"; +const surfaceCleanup: string[] = []; + +async function homeWithoutSkill(): Promise { + const home = await mkdtemp(join(tmpdir(), "browse-banner-noskill-")); + surfaceCleanup.push(home); + return home; +} + +async function homeWithSkill(): Promise { + const home = await mkdtemp(join(tmpdir(), "browse-banner-skill-")); + surfaceCleanup.push(home); + const dir = join(home, ".agents", "skills", "browse"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "SKILL.md"), "---\nname: browse\n---\n", "utf8"); + return home; +} + const cloudCommandsWithExamples = [ ["cloud", "projects", "list"], ["cloud", "projects", "get"], @@ -46,6 +67,13 @@ const skillsCommandsWithExamples = [ ]; describe("CLI surface", () => { + afterEach(async () => { + while (surfaceCleanup.length > 0) { + const path = surfaceCleanup.pop(); + if (path) await rm(path, { recursive: true, force: true }); + } + }); + it("prints browse root help", async () => { const result = await runCli(["--help"]); expect(result.exitCode).toBe(0); @@ -57,6 +85,23 @@ describe("CLI surface", () => { expect(result.stdout).toContain("skills"); }); + it("shows the skill banner on root help when the skill is not installed", async () => { + const result = await runCli(["--help"], { + env: { HOME: await homeWithoutSkill() }, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Start here (for AI agents)"); + }); + + it("hides the skill banner on root help when the skill is installed", async () => { + const result = await runCli(["--help"], { + env: { HOME: await homeWithSkill() }, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain("Start here (for AI agents)"); + expect(result.stdout).toContain("Unified Browserbase CLI"); + }); + it("prints cloud topic help", async () => { const result = await runCli(["cloud", "--help"]); expect(result.exitCode).toBe(0); diff --git a/packages/cli/tests/cli-update.test.ts b/packages/cli/tests/cli-update.test.ts index af5169de2..4060b5bad 100644 --- a/packages/cli/tests/cli-update.test.ts +++ b/packages/cli/tests/cli-update.test.ts @@ -6,10 +6,7 @@ import { tmpdir } from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; import { runCli } from "./helpers/run-cli.js"; -import { - maybeAutoUpdateCli, - refreshUpdateCheckCache, -} from "../src/lib/update.js"; +import { getUpdateNotice, refreshUpdateCheckCache } from "../src/lib/update.js"; const require = createRequire(import.meta.url); const { version: cliVersion } = require("../package.json") as { @@ -30,7 +27,7 @@ afterEach(async () => { }); describe("CLI auto-update", () => { - it("uses a fresh cache to print an update notice without hitting the network", async () => { + it("shows the update notice on root help from a fresh cache without hitting the network", async () => { const cacheDir = await createTempDir("browse-update-cache-"); const cachePath = join(cacheDir, "update-check.json"); await writeUpdateCache(cachePath, { @@ -38,6 +35,29 @@ describe("CLI auto-update", () => { version: "99.0.0", }); + const result = await runCli(["--help"], { + env: { + BROWSE_CACHE_DIR: cacheDir, + BROWSE_DISABLE_UPDATE_CHECK: "0", + BROWSE_DAEMON_DIR: cacheDir, + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + `Update available: ${cliVersion} -> 99.0.0.`, + ); + expect(result.stderr).toContain("Run:\n npm i -g browse@latest"); + }); + + it("does not show the update notice on regular commands (no spam)", async () => { + const cacheDir = await createTempDir("browse-update-nospam-"); + const cachePath = join(cacheDir, "update-check.json"); + await writeUpdateCache(cachePath, { + checkedAt: new Date().toISOString(), + version: "99.0.0", + }); + const result = await runCli(["status"], { env: { BROWSE_CACHE_DIR: cacheDir, @@ -51,13 +71,10 @@ describe("CLI auto-update", () => { browserConnected: false, session: "default", }); - expect(result.stderr).toContain( - `Update available: ${cliVersion} -> 99.0.0.`, - ); - expect(result.stderr).toContain("Run:\n npm i -g browse@latest"); + expect(result.stderr).not.toContain("Update available:"); }); - it("compares prerelease identifiers with ASCII ordering", async () => { + it("returns no notice for prerelease identifiers with ASCII ordering", async () => { const cacheDir = await createTempDir("browse-update-prerelease-"); const cachePath = join(cacheDir, "update-check.json"); await writeUpdateCache(cachePath, { @@ -65,21 +82,13 @@ describe("CLI auto-update", () => { version: "1.0.0-beta.B", }); - const stderrSpy = vi - .spyOn(process.stderr, "write") - .mockImplementation(() => true); - - try { - await maybeAutoUpdateCli("1.0.0-beta.b", { - ...process.env, - BROWSE_DISABLE_UPDATE_CHECK: "0", - BROWSE_UPDATE_CHECK_FILE: cachePath, - }); + const notice = await getUpdateNotice("1.0.0-beta.b", { + ...process.env, + BROWSE_DISABLE_UPDATE_CHECK: "0", + BROWSE_UPDATE_CHECK_FILE: cachePath, + }); - expect(stderrSpy).not.toHaveBeenCalled(); - } finally { - stderrSpy.mockRestore(); - } + expect(notice).toBeNull(); }); it("refreshes the update cache from the npm registry", async () => { @@ -115,7 +124,7 @@ describe("CLI auto-update", () => { }); }); - it("treats stale cache entries as refreshes instead of notifying immediately", async () => { + it("does not notify from a stale cache even on root help", async () => { const cacheDir = await createTempDir("browse-update-stale-"); const cachePath = join(cacheDir, "update-check.json"); await writeUpdateCache(cachePath, { @@ -123,7 +132,7 @@ describe("CLI auto-update", () => { version: "98.0.0", }); - const result = await runCli(["status"], { + const result = await runCli(["--help"], { env: { BROWSE_CACHE_DIR: cacheDir, BROWSE_DISABLE_UPDATE_CHECK: "0", @@ -132,10 +141,6 @@ describe("CLI auto-update", () => { }); expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ - browserConnected: false, - session: "default", - }); expect(result.stderr).not.toContain("Update available:"); }); }); diff --git a/packages/cli/tests/skill-nudge.test.ts b/packages/cli/tests/skill-nudge.test.ts new file mode 100644 index 000000000..17757aa93 --- /dev/null +++ b/packages/cli/tests/skill-nudge.test.ts @@ -0,0 +1,124 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { maybeNudgeInstallSkill } from "../src/lib/skill-nudge.js"; + +const cleanupPaths: string[] = []; + +afterEach(async () => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + while (cleanupPaths.length > 0) { + const path = cleanupPaths.pop(); + if (!path) continue; + await rm(path, { recursive: true, force: true }); + } +}); + +async function setup(options: { skillInstalled?: boolean } = {}): Promise<{ + home: string; + cacheFile: string; +}> { + const home = await mkdtemp(join(tmpdir(), "browse-nudge-home-")); + cleanupPaths.push(home); + if (options.skillInstalled) { + const skillDir = join(home, ".agents", "skills", "browse"); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, "SKILL.md"), "---\nname: browse\n---\n"); + } + + // Detected agent = codex (checked before CLAUDECODE in @vercel/detect-agent), + // so the test is deterministic even when run inside another agent harness. + vi.stubEnv("HOME", home); + vi.stubEnv("CODEX_THREAD_ID", "sess-1"); + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("CI", ""); + vi.stubEnv("BROWSE_DISABLE_SKILL_NUDGE", ""); + + return { home, cacheFile: join(home, "skill-nudge.json") }; +} + +function stderrSpy() { + return vi.spyOn(process.stderr, "write").mockImplementation(() => true); +} + +function nudged(spy: ReturnType): boolean { + return spy.mock.calls.some((call) => + String(call[0]).includes("browse skills install"), + ); +} + +describe("maybeNudgeInstallSkill", () => { + it("nudges a detected agent that is missing the skill", async () => { + const { cacheFile } = await setup(); + const spy = stderrSpy(); + await maybeNudgeInstallSkill(process.env, { + cacheFile, + commandId: "status", + }); + expect(nudged(spy)).toBe(true); + }); + + it("does not nudge twice in the same session", async () => { + const { cacheFile } = await setup(); + await maybeNudgeInstallSkill(process.env, { + cacheFile, + commandId: "status", + }); + const spy = stderrSpy(); + await maybeNudgeInstallSkill(process.env, { + cacheFile, + commandId: "open", + }); + expect(nudged(spy)).toBe(false); + }); + + it("nudges again for a new session id", async () => { + const { cacheFile } = await setup(); + await maybeNudgeInstallSkill(process.env, { + cacheFile, + commandId: "status", + }); + vi.stubEnv("CODEX_THREAD_ID", "sess-2"); + const spy = stderrSpy(); + await maybeNudgeInstallSkill(process.env, { + cacheFile, + commandId: "status", + }); + expect(nudged(spy)).toBe(true); + }); + + it("does not nudge when the skill is already installed", async () => { + const { cacheFile } = await setup({ skillInstalled: true }); + const spy = stderrSpy(); + await maybeNudgeInstallSkill(process.env, { + cacheFile, + commandId: "status", + }); + expect(nudged(spy)).toBe(false); + }); + + it("does not nudge on skills subcommands", async () => { + const { cacheFile } = await setup(); + const spy = stderrSpy(); + await maybeNudgeInstallSkill(process.env, { + cacheFile, + commandId: "skills:install", + }); + expect(nudged(spy)).toBe(false); + }); + + it("respects BROWSE_DISABLE_SKILL_NUDGE", async () => { + const { cacheFile } = await setup(); + vi.stubEnv("BROWSE_DISABLE_SKILL_NUDGE", "1"); + const spy = stderrSpy(); + await maybeNudgeInstallSkill(process.env, { + cacheFile, + commandId: "status", + }); + expect(nudged(spy)).toBe(false); + }); +}); diff --git a/packages/cli/tests/skill-presence.test.ts b/packages/cli/tests/skill-presence.test.ts new file mode 100644 index 000000000..3dbe17b7f --- /dev/null +++ b/packages/cli/tests/skill-presence.test.ts @@ -0,0 +1,76 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + browseSkillDirsForAgent, + isBrowseSkillInstalled, +} from "../src/lib/skill-presence.js"; + +const cleanupPaths: string[] = []; + +afterEach(async () => { + while (cleanupPaths.length > 0) { + const path = cleanupPaths.pop(); + if (!path) continue; + await rm(path, { recursive: true, force: true }); + } +}); + +async function createTempHome(): Promise { + const home = await mkdtemp(join(tmpdir(), "browse-skill-home-")); + cleanupPaths.push(home); + return home; +} + +async function writeSkill(dir: string): Promise { + const skillDir = join(dir, "browse"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + join(skillDir, "SKILL.md"), + "---\nname: browse\n---\n", + "utf8", + ); +} + +describe("browseSkillDirsForAgent", () => { + it("always includes the universal canonical dir", () => { + const dirs = browseSkillDirsForAgent("hermes", {}, "/home/u"); + expect(dirs).toContain(join("/home/u", ".agents", "skills")); + }); + + it("adds the claude config dir for claude", () => { + const dirs = browseSkillDirsForAgent("claude", {}, "/home/u"); + expect(dirs).toContain(join("/home/u", ".claude", "skills")); + }); + + it("honors CODEX_HOME for codex", () => { + const dirs = browseSkillDirsForAgent( + "codex", + { CODEX_HOME: "/custom/codex" }, + "/home/u", + ); + expect(dirs).toContain(join("/custom/codex", "skills")); + }); +}); + +describe("isBrowseSkillInstalled", () => { + it("returns false when the skill is absent", async () => { + const home = await createTempHome(); + expect(await isBrowseSkillInstalled("codex", {}, home)).toBe(false); + }); + + it("detects the skill in the universal canonical dir", async () => { + const home = await createTempHome(); + await writeSkill(join(home, ".agents", "skills")); + expect(await isBrowseSkillInstalled("codex", {}, home)).toBe(true); + }); + + it("detects the skill in the agent-specific dir", async () => { + const home = await createTempHome(); + await writeSkill(join(home, ".claude", "skills")); + expect(await isBrowseSkillInstalled("claude", {}, home)).toBe(true); + }); +}); From 598d2c60239395a6079f1faed80b73b25526be24 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 6 Jun 2026 18:20:53 -0700 Subject: [PATCH 02/10] chore(cli): drop no-op changeset (browse is changeset-ignored) Co-Authored-By: Claude Opus 4.8 --- .changeset/browse-skill-nudge.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .changeset/browse-skill-nudge.md diff --git a/.changeset/browse-skill-nudge.md b/.changeset/browse-skill-nudge.md deleted file mode 100644 index f6fc6b0f1..000000000 --- a/.changeset/browse-skill-nudge.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"browse": patch ---- - -Surface the browse skill to coding agents. Root help (`browse` / `browse --help`) leads with an agent-targeted "Start here" banner pointing to `browse skills install` — shown only when the skill is not already installed, so it never nags users who have it. A once-per-session, agent-only nudge (stderr, never stdout) prompts detected agents that don't yet have the browse skill installed to run `browse skills install`. The nudge is throttled per agent session, skipped for humans, CI, and `skills` commands, and can be disabled with `BROWSE_DISABLE_SKILL_NUDGE=1`. Command telemetry now includes a `skill_present` property so skill adoption among agents is measurable. - -Also stop the "Update available" notice from printing on every command. The update check still refreshes its cache silently in the background, but the notice is now shown only on the human-facing surfaces — `browse` / `browse --help` and `browse doctor` — so it no longer spams scripts and agent command loops. From 336293616b6c3fc15575fe546e3b01ad6a079354 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 6 Jun 2026 18:47:24 -0700 Subject: [PATCH 03/10] chore(cli): restore browse patch changeset browse is released via the decoupled release-cli.yml workflow, which consumes "browse" changesets; the core ignore list only keeps the main stagehand release from bumping it. Co-Authored-By: Claude Opus 4.8 --- .changeset/browse-skill-nudge.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/browse-skill-nudge.md diff --git a/.changeset/browse-skill-nudge.md b/.changeset/browse-skill-nudge.md new file mode 100644 index 000000000..f6fc6b0f1 --- /dev/null +++ b/.changeset/browse-skill-nudge.md @@ -0,0 +1,7 @@ +--- +"browse": patch +--- + +Surface the browse skill to coding agents. Root help (`browse` / `browse --help`) leads with an agent-targeted "Start here" banner pointing to `browse skills install` — shown only when the skill is not already installed, so it never nags users who have it. A once-per-session, agent-only nudge (stderr, never stdout) prompts detected agents that don't yet have the browse skill installed to run `browse skills install`. The nudge is throttled per agent session, skipped for humans, CI, and `skills` commands, and can be disabled with `BROWSE_DISABLE_SKILL_NUDGE=1`. Command telemetry now includes a `skill_present` property so skill adoption among agents is measurable. + +Also stop the "Update available" notice from printing on every command. The update check still refreshes its cache silently in the background, but the notice is now shown only on the human-facing surfaces — `browse` / `browse --help` and `browse doctor` — so it no longer spams scripts and agent command loops. From 19921e174b85c604fa1063d0645a3a09d70d31b0 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 12 Jun 2026 12:58:56 -0700 Subject: [PATCH 04/10] refactor(cli): de-scope skill nudge to static model (always-on help banner + once-per-install hint) Removes agent detection, session keys, and TTL windowing per team review feedback; detection reduced to one canonical-path check (~/.agents/skills/browse) plus a once-per-install marker file in the CLI cache dir (same mechanism as update-check.json / open-nudge.json). Co-Authored-By: Claude Fable 5 --- .changeset/browse-skill-nudge.md | 2 +- packages/cli/src/lib/help.ts | 25 +--- packages/cli/src/lib/skill-nudge.ts | 135 ++++++---------------- packages/cli/src/lib/skill-presence.ts | 70 ++--------- packages/cli/src/lib/telemetry.ts | 4 +- packages/cli/tests/cli-surface.test.ts | 4 +- packages/cli/tests/skill-nudge.test.ts | 74 ++++++------ packages/cli/tests/skill-presence.test.ts | 56 ++------- 8 files changed, 102 insertions(+), 268 deletions(-) diff --git a/.changeset/browse-skill-nudge.md b/.changeset/browse-skill-nudge.md index f6fc6b0f1..30ef072cb 100644 --- a/.changeset/browse-skill-nudge.md +++ b/.changeset/browse-skill-nudge.md @@ -2,6 +2,6 @@ "browse": patch --- -Surface the browse skill to coding agents. Root help (`browse` / `browse --help`) leads with an agent-targeted "Start here" banner pointing to `browse skills install` — shown only when the skill is not already installed, so it never nags users who have it. A once-per-session, agent-only nudge (stderr, never stdout) prompts detected agents that don't yet have the browse skill installed to run `browse skills install`. The nudge is throttled per agent session, skipped for humans, CI, and `skills` commands, and can be disabled with `BROWSE_DISABLE_SKILL_NUDGE=1`. Command telemetry now includes a `skill_present` property so skill adoption among agents is measurable. +Surface the browse skill at runtime with two static touchpoints. Root help (`browse` / `browse --help`) now always leads with a "Start here (for AI agents)" banner pointing to `browse skills install`. Separately, the first regular command on a fresh install prints a one-time stderr hint (never stdout) when the canonical skill dir (`~/.agents/skills/browse`) is absent — gated by a once-per-install marker file in the CLI cache dir, skipped on `help`/`skills` commands and in CI/tests, and disabled with `BROWSE_DISABLE_SKILL_NUDGE=1`. No agent detection, session keys, or time windows are involved; the only check is one canonical-path lookup. Command telemetry includes a `skill_present` property driven by the same check so skill adoption is measurable. Also stop the "Update available" notice from printing on every command. The update check still refreshes its cache silently in the background, but the notice is now shown only on the human-facing surfaces — `browse` / `browse --help` and `browse doctor` — so it no longer spams scripts and agent command loops. diff --git a/packages/cli/src/lib/help.ts b/packages/cli/src/lib/help.ts index efe9d8b68..129b0c19f 100644 --- a/packages/cli/src/lib/help.ts +++ b/packages/cli/src/lib/help.ts @@ -2,8 +2,6 @@ import { join } from "node:path"; import { Help } from "@oclif/core"; -import { detectAgent } from "./agent.js"; -import { isBrowseSkillInstalled } from "./skill-presence.js"; import { getUpdateNotice } from "./update.js"; const AGENT_START_HERE = `Start here (for AI agents): @@ -14,31 +12,18 @@ const AGENT_START_HERE = `Start here (for AI agents): /** * Root-help override that leads with an agent-targeted "Start here" pointer to - * the browse skill — but only when the skill is NOT already installed, so it - * never nags users who have it. Shown on bare `browse` and `browse --help` - * (both route through showRootHelp). Also surfaces the update notice here and in - * `doctor` — the only human-facing surfaces that show it, so it never spams - * commands. + * the browse skill — static help text, always shown on bare `browse` and + * `browse --help` (both route through showRootHelp). Also surfaces the update + * notice here and in `doctor` — the only human-facing surfaces that show it, + * so it never spams commands. */ export default class BrowseHelp extends Help { public override async showRootHelp(): Promise { - if (await this.skillBannerNeeded()) { - this.log(AGENT_START_HERE); - } + this.log(AGENT_START_HERE); await super.showRootHelp(); await this.writeUpdateNotice(); } - private async skillBannerNeeded(): Promise { - try { - const agent = await detectAgent(); - return !(await isBrowseSkillInstalled(agent ?? "", process.env)); - } catch { - // Best-effort: if detection fails, show the discovery banner. - return true; - } - } - private async writeUpdateNotice(): Promise { try { const notice = await getUpdateNotice(this.config.version, process.env, { diff --git a/packages/cli/src/lib/skill-nudge.ts b/packages/cli/src/lib/skill-nudge.ts index ff13b7c5d..33874eacd 100644 --- a/packages/cli/src/lib/skill-nudge.ts +++ b/packages/cli/src/lib/skill-nudge.ts @@ -1,35 +1,27 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { access, mkdir, writeFile } from "node:fs/promises"; +import { constants } from "node:fs"; import { dirname } from "node:path"; -import { detectAgent } from "./agent.js"; import { isBrowseSkillInstalled } from "./skill-presence.js"; -// When the calling agent exposes no per-session id, fall back to a time window -// so a tight command loop sees the nudge at most once per window while a fresh -// session some hours later still gets a reminder. -const NUDGE_FALLBACK_TTL_MS = 4 * 60 * 60 * 1000; -const NUDGE_STORE_PRUNE_MS = 7 * 24 * 60 * 60 * 1000; - interface SkillNudgeOptions { cacheFile?: string; commandId?: string; - now?: number; -} - -interface SkillNudgeStore { - shown: Record; } -interface NudgeKey { - key: string; - sessionScoped: boolean; -} +const SKILL_NUDGE_TIP = [ + "Tip: browse works best with its skill loaded into your agent.", + "Run:", + " browse skills install", + "", +].join("\n"); /** - * Once-per-session, agent-only nudge to install the browse skill, printed to - * stderr so it never corrupts machine-readable stdout. Best-effort: any failure - * is swallowed so it can never affect CLI behavior. Humans (no detected agent) - * are pointed to the skill via the root help banner instead. + * Once-per-install hint to install the browse skill, printed to stderr so it + * never corrupts machine-readable stdout. Fires on the first regular command + * when the canonical skill dir is absent; a marker file in the CLI cache dir + * (same mechanism as update-check.json) keeps it silent afterwards. + * Best-effort: any failure is swallowed so it can never affect CLI behavior. */ export async function maybeNudgeInstallSkill( env: NodeJS.ProcessEnv = process.env, @@ -51,64 +43,20 @@ export async function maybeNudgeInstallSkill( return; } - const agent = await detectAgent(); - if (!agent) { + if (await markerExists(cachePath)) { return; } - if (await isBrowseSkillInstalled(agent, env)) { + if (await isBrowseSkillInstalled()) { return; } - const now = options.now ?? Date.now(); - const { key, sessionScoped } = resolveNudgeKey(agent, env); - const store = await readNudgeStore(cachePath); - - const lastShown = store.shown[key]; - if (lastShown !== undefined) { - if (sessionScoped) { - return; - } - if (now - lastShown < NUDGE_FALLBACK_TTL_MS) { - return; - } - } - - writeNudge(); - - store.shown[key] = now; - pruneNudgeStore(store, now); - await writeNudgeStore(cachePath, store); -} - -function resolveNudgeKey(agent: string, env: NodeJS.ProcessEnv): NudgeKey { - // Real per-session identifiers exposed by some harnesses. Claude Code, Gemini, - // etc. expose only a boolean, so they fall through to the TTL window. - const sessionId = firstNonEmpty(env.CODEX_THREAD_ID, env.CURSOR_TRACE_ID); - if (sessionId) { - return { key: `${agent}:session:${sessionId}`, sessionScoped: true }; - } - return { key: `${agent}:window`, sessionScoped: false }; -} - -function firstNonEmpty(...values: (string | undefined)[]): string | undefined { - for (const value of values) { - if (value && value.length > 0) { - return value; - } + // Write the marker first and only nudge when it actually lands, so an + // unwritable cache dir can't cause the once-per-install tip to fire on + // every run. + if (await writeNudgeMarker(cachePath)) { + process.stderr.write(SKILL_NUDGE_TIP); } - return undefined; -} - -function writeNudge(): void { - process.stderr.write( - [ - "Tip: browse works best with its skill loaded into your agent.", - "Run:", - " browse skills install", - "", - ].join("\n"), - ); } function isNudgeDisabled(env: NodeJS.ProcessEnv): boolean { @@ -139,43 +87,26 @@ function isCiEnvironment(env: NodeJS.ProcessEnv): boolean { ); } -async function readNudgeStore(cachePath: string): Promise { +async function markerExists(cachePath: string): Promise { try { - const contents = await readFile(cachePath, "utf8"); - const parsed = JSON.parse(contents) as { shown?: unknown }; - if (parsed && typeof parsed.shown === "object" && parsed.shown !== null) { - const shown: Record = {}; - for (const [key, value] of Object.entries( - parsed.shown as Record, - )) { - if (typeof value === "number" && Number.isFinite(value)) { - shown[key] = value; - } - } - return { shown }; - } + await access(cachePath, constants.F_OK); + return true; } catch { - // Missing or unreadable store; start fresh. + return false; } - return { shown: {} }; } -async function writeNudgeStore( - cachePath: string, - store: SkillNudgeStore, -): Promise { +async function writeNudgeMarker(cachePath: string): Promise { try { await mkdir(dirname(cachePath), { recursive: true }); - await writeFile(cachePath, `${JSON.stringify(store)}\n`, "utf8"); + await writeFile( + cachePath, + `${JSON.stringify({ shownAt: new Date().toISOString() })}\n`, + "utf8", + ); + return true; } catch { - // Best-effort cache writes should never affect CLI behavior. - } -} - -function pruneNudgeStore(store: SkillNudgeStore, now: number): void { - for (const [key, shownAt] of Object.entries(store.shown)) { - if (now - shownAt >= NUDGE_STORE_PRUNE_MS) { - delete store.shown[key]; - } + // Best-effort marker writes should never affect CLI behavior. + return false; } } diff --git a/packages/cli/src/lib/skill-presence.ts b/packages/cli/src/lib/skill-presence.ts index 4d7c2beca..70bb76a13 100644 --- a/packages/cli/src/lib/skill-presence.ts +++ b/packages/cli/src/lib/skill-presence.ts @@ -1,71 +1,21 @@ -import { constants } from "node:fs"; import { access } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -const BROWSE_SKILL_FOLDER = "browse"; - /** - * Candidate skills directories where `browse skills install` - * (`npx skills add ... --global --agent *`) may have written the browse skill - * for a given agent. The canonical copy always lands in `~/.agents/skills`; - * agents with bespoke skill dirs also get a symlink there. - */ -export function browseSkillDirsForAgent( - agentName: string, - env: NodeJS.ProcessEnv = process.env, - home: string = homedir(), -): string[] { - const dirs = new Set(); - - // Universal canonical location shared by most agents. - dirs.add(join(home, ".agents", "skills")); - - switch (agentName) { - case "claude": - case "cowork": - dirs.add(join(env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "skills")); - break; - case "codex": - dirs.add(join(env.CODEX_HOME ?? join(home, ".codex"), "skills")); - break; - case "cursor": - case "cursor-cli": - dirs.add(join(home, ".cursor", "skills")); - break; - case "gemini": - dirs.add(join(home, ".gemini", "skills")); - break; - case "github-copilot": - dirs.add(join(home, ".copilot", "skills")); - break; - default: - // Universal-only agents (hermes, openclaw, opencode, …) share - // `~/.agents/skills`, already added above. - break; - } - - return [...dirs]; -} - -/** - * Best-effort check for whether the bundled browse skill is present on disk in - * any of the calling agent's skills directories. This detects an installed - * skill *file*; it cannot know whether the agent has loaded it into context. + * Check the single canonical install location for the browse skill: + * `~/.agents/skills/browse`. This is the directory `browse skills install` + * itself writes (via `npx skills add --global --agent '*'`) and the path + * `skills ls` prints — agent-specific dirs are just symlinks into it, so one + * filesystem check covers every agent. */ export async function isBrowseSkillInstalled( - agentName: string, - env: NodeJS.ProcessEnv = process.env, home: string = homedir(), ): Promise { - for (const dir of browseSkillDirsForAgent(agentName, env, home)) { - try { - await access(join(dir, BROWSE_SKILL_FOLDER, "SKILL.md"), constants.F_OK); - return true; - } catch { - // Not present in this directory; keep checking. - } + try { + await access(join(home, ".agents", "skills", "browse")); + return true; + } catch { + return false; } - - return false; } diff --git a/packages/cli/src/lib/telemetry.ts b/packages/cli/src/lib/telemetry.ts index 65977c1f5..0e836599f 100644 --- a/packages/cli/src/lib/telemetry.ts +++ b/packages/cli/src/lib/telemetry.ts @@ -143,9 +143,7 @@ function createCliTelemetry(options: CreateCliTelemetryOptions): CliTelemetry { : Promise.resolve(""); const agentPromise = telemetryEnabled ? detectAgent() : Promise.resolve(null); const skillPresentPromise: Promise = telemetryEnabled - ? agentPromise - .then((agent) => (agent ? isBrowseSkillInstalled(agent, env) : null)) - .catch(() => null) + ? isBrowseSkillInstalled().catch(() => null) : Promise.resolve(null); const baseProperties: TelemetryProperties = { diff --git a/packages/cli/tests/cli-surface.test.ts b/packages/cli/tests/cli-surface.test.ts index 595146535..118880151 100644 --- a/packages/cli/tests/cli-surface.test.ts +++ b/packages/cli/tests/cli-surface.test.ts @@ -93,12 +93,12 @@ describe("CLI surface", () => { expect(result.stdout).toContain("Start here (for AI agents)"); }); - it("hides the skill banner on root help when the skill is installed", async () => { + it("shows the skill banner on root help even when the skill is installed", async () => { const result = await runCli(["--help"], { env: { HOME: await homeWithSkill() }, }); expect(result.exitCode).toBe(0); - expect(result.stdout).not.toContain("Start here (for AI agents)"); + expect(result.stdout).toContain("Start here (for AI agents)"); expect(result.stdout).toContain("Unified Browserbase CLI"); }); diff --git a/packages/cli/tests/skill-nudge.test.ts b/packages/cli/tests/skill-nudge.test.ts index 17757aa93..221811d8c 100644 --- a/packages/cli/tests/skill-nudge.test.ts +++ b/packages/cli/tests/skill-nudge.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -25,20 +25,19 @@ async function setup(options: { skillInstalled?: boolean } = {}): Promise<{ const home = await mkdtemp(join(tmpdir(), "browse-nudge-home-")); cleanupPaths.push(home); if (options.skillInstalled) { - const skillDir = join(home, ".agents", "skills", "browse"); - await mkdir(skillDir, { recursive: true }); - await writeFile(join(skillDir, "SKILL.md"), "---\nname: browse\n---\n"); + await mkdir(join(home, ".agents", "skills", "browse"), { + recursive: true, + }); } - // Detected agent = codex (checked before CLAUDECODE in @vercel/detect-agent), - // so the test is deterministic even when run inside another agent harness. + // os.homedir() honors $HOME, so the canonical-path check stays inside the + // temp dir even when the suite runs on a machine with the skill installed. vi.stubEnv("HOME", home); - vi.stubEnv("CODEX_THREAD_ID", "sess-1"); vi.stubEnv("NODE_ENV", "development"); vi.stubEnv("CI", ""); vi.stubEnv("BROWSE_DISABLE_SKILL_NUDGE", ""); - return { home, cacheFile: join(home, "skill-nudge.json") }; + return { home, cacheFile: join(home, "cache", "skill-nudge.json") }; } function stderrSpy() { @@ -52,7 +51,7 @@ function nudged(spy: ReturnType): boolean { } describe("maybeNudgeInstallSkill", () => { - it("nudges a detected agent that is missing the skill", async () => { + it("nudges once when the canonical skill dir is absent and writes the marker", async () => { const { cacheFile } = await setup(); const spy = stderrSpy(); await maybeNudgeInstallSkill(process.env, { @@ -60,15 +59,18 @@ describe("maybeNudgeInstallSkill", () => { commandId: "status", }); expect(nudged(spy)).toBe(true); + await expect(access(cacheFile)).resolves.toBeUndefined(); }); - it("does not nudge twice in the same session", async () => { + it("stays silent on the next run once the marker exists", async () => { const { cacheFile } = await setup(); + const spy = stderrSpy(); await maybeNudgeInstallSkill(process.env, { cacheFile, commandId: "status", }); - const spy = stderrSpy(); + expect(nudged(spy)).toBe(true); + spy.mockClear(); await maybeNudgeInstallSkill(process.env, { cacheFile, commandId: "open", @@ -76,21 +78,6 @@ describe("maybeNudgeInstallSkill", () => { expect(nudged(spy)).toBe(false); }); - it("nudges again for a new session id", async () => { - const { cacheFile } = await setup(); - await maybeNudgeInstallSkill(process.env, { - cacheFile, - commandId: "status", - }); - vi.stubEnv("CODEX_THREAD_ID", "sess-2"); - const spy = stderrSpy(); - await maybeNudgeInstallSkill(process.env, { - cacheFile, - commandId: "status", - }); - expect(nudged(spy)).toBe(true); - }); - it("does not nudge when the skill is already installed", async () => { const { cacheFile } = await setup({ skillInstalled: true }); const spy = stderrSpy(); @@ -101,19 +88,40 @@ describe("maybeNudgeInstallSkill", () => { expect(nudged(spy)).toBe(false); }); - it("does not nudge on skills subcommands", async () => { + it("does not nudge on skills subcommands, help, or a missing commandId", async () => { const { cacheFile } = await setup(); const spy = stderrSpy(); - await maybeNudgeInstallSkill(process.env, { - cacheFile, - commandId: "skills:install", - }); + for (const commandId of ["skills:install", "skills", "help", undefined]) { + await maybeNudgeInstallSkill(process.env, { cacheFile, commandId }); + } expect(nudged(spy)).toBe(false); }); - it("respects BROWSE_DISABLE_SKILL_NUDGE", async () => { + it("respects env opt-outs and CI", async () => { const { cacheFile } = await setup(); - vi.stubEnv("BROWSE_DISABLE_SKILL_NUDGE", "1"); + const spy = stderrSpy(); + for (const overrides of [ + { BROWSE_DISABLE_SKILL_NUDGE: "1" }, + { BB_DISABLE_SKILL_NUDGE: "1" }, + { NODE_ENV: "test" }, + { CI: "true" }, + ]) { + const env: NodeJS.ProcessEnv = { + NODE_ENV: "development", + CI: "", + ...overrides, + }; + await maybeNudgeInstallSkill(env, { cacheFile, commandId: "status" }); + } + expect(nudged(spy)).toBe(false); + }); + + it("does not nudge when the marker cannot be written", async () => { + const { home } = await setup(); + // Parent "directory" is a regular file, so mkdir/writeFile must fail. + const blocker = join(home, "blocked"); + await writeFile(blocker, "not a directory\n", "utf8"); + const cacheFile = join(blocker, "skill-nudge.json"); const spy = stderrSpy(); await maybeNudgeInstallSkill(process.env, { cacheFile, diff --git a/packages/cli/tests/skill-presence.test.ts b/packages/cli/tests/skill-presence.test.ts index 3dbe17b7f..d905e910d 100644 --- a/packages/cli/tests/skill-presence.test.ts +++ b/packages/cli/tests/skill-presence.test.ts @@ -1,13 +1,10 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { - browseSkillDirsForAgent, - isBrowseSkillInstalled, -} from "../src/lib/skill-presence.js"; +import { isBrowseSkillInstalled } from "../src/lib/skill-presence.js"; const cleanupPaths: string[] = []; @@ -25,52 +22,17 @@ async function createTempHome(): Promise { return home; } -async function writeSkill(dir: string): Promise { - const skillDir = join(dir, "browse"); - await mkdir(skillDir, { recursive: true }); - await writeFile( - join(skillDir, "SKILL.md"), - "---\nname: browse\n---\n", - "utf8", - ); -} - -describe("browseSkillDirsForAgent", () => { - it("always includes the universal canonical dir", () => { - const dirs = browseSkillDirsForAgent("hermes", {}, "/home/u"); - expect(dirs).toContain(join("/home/u", ".agents", "skills")); - }); - - it("adds the claude config dir for claude", () => { - const dirs = browseSkillDirsForAgent("claude", {}, "/home/u"); - expect(dirs).toContain(join("/home/u", ".claude", "skills")); - }); - - it("honors CODEX_HOME for codex", () => { - const dirs = browseSkillDirsForAgent( - "codex", - { CODEX_HOME: "/custom/codex" }, - "/home/u", - ); - expect(dirs).toContain(join("/custom/codex", "skills")); - }); -}); - describe("isBrowseSkillInstalled", () => { - it("returns false when the skill is absent", async () => { - const home = await createTempHome(); - expect(await isBrowseSkillInstalled("codex", {}, home)).toBe(false); - }); - - it("detects the skill in the universal canonical dir", async () => { + it("returns false when the canonical skill dir is absent", async () => { const home = await createTempHome(); - await writeSkill(join(home, ".agents", "skills")); - expect(await isBrowseSkillInstalled("codex", {}, home)).toBe(true); + expect(await isBrowseSkillInstalled(home)).toBe(false); }); - it("detects the skill in the agent-specific dir", async () => { + it("returns true when ~/.agents/skills/browse exists", async () => { const home = await createTempHome(); - await writeSkill(join(home, ".claude", "skills")); - expect(await isBrowseSkillInstalled("claude", {}, home)).toBe(true); + await mkdir(join(home, ".agents", "skills", "browse"), { + recursive: true, + }); + expect(await isBrowseSkillInstalled(home)).toBe(true); }); }); From 47aeb840193f40a2f358d43b3c17bb8feeaaab30 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 12 Jun 2026 16:33:52 -0700 Subject: [PATCH 05/10] feat(cli): push the update notice once per discovered release on regular commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any command (browse [X]) prints the notice the first time a new release is seen in the cache, then stays silent until the next release. No time windows or session keys — state is a notifiedVersion field in the existing update-check.json. help/doctor keep rendering it and mark it as seen. Co-Authored-By: Claude Fable 5 --- packages/cli/src/hooks/init.ts | 21 +++++- packages/cli/src/lib/update.ts | 61 +++++++++++++++++- packages/cli/tests/cli-update.test.ts | 93 +++++++++++++++++++++++---- 3 files changed, 158 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/hooks/init.ts b/packages/cli/src/hooks/init.ts index 2301dad56..b5c2480b5 100644 --- a/packages/cli/src/hooks/init.ts +++ b/packages/cli/src/hooks/init.ts @@ -4,7 +4,10 @@ import type { Hook } from "@oclif/core"; import { maybeNudgeInstallSkill } from "../lib/skill-nudge.js"; import { startTelemetryInvocation } from "../lib/telemetry.js"; -import { scheduleBackgroundUpdateCheck } from "../lib/update.js"; +import { + scheduleBackgroundUpdateCheck, + takeFirstUpdateNotice, +} from "../lib/update.js"; const hook: Hook.Init = async function ({ config, id }) { try { @@ -15,7 +18,6 @@ const hook: Hook.Init = async function ({ config, id }) { try { // Silent: refresh the cached latest version when stale, but never print. - // The notice itself is shown only on `browse`/`--help` and `doctor`. await scheduleBackgroundUpdateCheck(process.env, { cacheFile: join(config.cacheDir, "update-check.json"), }); @@ -23,6 +25,21 @@ const hook: Hook.Init = async function ({ config, id }) { // Best-effort update checks should never affect CLI behavior. } + try { + // Push notice exactly once per discovered release; help and doctor render + // it themselves, so skip those surfaces to avoid double-printing. + if (id && id !== "help" && id !== "doctor") { + const notice = await takeFirstUpdateNotice(config.version, process.env, { + cacheFile: join(config.cacheDir, "update-check.json"), + }); + if (notice) { + process.stderr.write(`\n${notice}`); + } + } + } catch { + // Best-effort update notices should never affect CLI behavior. + } + try { await maybeNudgeInstallSkill(process.env, { cacheFile: join(config.cacheDir, "skill-nudge.json"), diff --git a/packages/cli/src/lib/update.ts b/packages/cli/src/lib/update.ts index 8464e2828..abf5f3506 100644 --- a/packages/cli/src/lib/update.ts +++ b/packages/cli/src/lib/update.ts @@ -13,6 +13,8 @@ const UPDATE_TIMEOUT_MS = 1500; interface UpdateCheckCache { checkedAt: string; version: string; + /** Latest version the user has already been told about (once per version). */ + notifiedVersion?: string; } interface UpdateCheckOptions { @@ -44,6 +46,56 @@ export async function getUpdateNotice( return null; } + // Pull surfaces always render; marking is best-effort so the one-time + // push notice does not repeat what the user has already seen. + if (cache.notifiedVersion !== cache.version) { + await writeUpdateCheckCache(cachePath, { + ...cache, + notifiedVersion: cache.version, + }); + } + + return formatUpdateNotice(currentVersion, cache.version); +} + +/** + * One-time push notice: returns the update notice the FIRST time a given + * newer version is seen, then stays silent until the next release. No time + * windows — state is just "which version was already announced" in the same + * update-check cache. The notice is only returned when recording that state + * succeeds, so a read-only cache dir can never cause repeated printing. + */ +export async function takeFirstUpdateNotice( + currentVersion: string, + env: NodeJS.ProcessEnv = process.env, + options: UpdateCheckOptions = {}, +): Promise { + if (isUpdateCheckDisabled(env)) { + return null; + } + + const cachePath = resolveUpdateCheckPath(env, options); + if (!cachePath) { + return null; + } + + const cache = await readFreshUpdateCheckCache(cachePath); + if (!cache || !isVersionNewer(currentVersion, cache.version)) { + return null; + } + + if (cache.notifiedVersion === cache.version) { + return null; + } + + const recorded = await writeUpdateCheckCache(cachePath, { + ...cache, + notifiedVersion: cache.version, + }); + if (!recorded) { + return null; + } + return formatUpdateNotice(currentVersion, cache.version); } @@ -127,6 +179,7 @@ async function readUpdateCheckCache( const parsed = JSON.parse(contents) as { checkedAt?: unknown; version?: unknown; + notifiedVersion?: unknown; }; if (typeof parsed.version !== "string" || parsed.version.length === 0) { @@ -140,6 +193,10 @@ async function readUpdateCheckCache( return { checkedAt: parsed.checkedAt, version: parsed.version, + ...(typeof parsed.notifiedVersion === "string" && + parsed.notifiedVersion.length > 0 + ? { notifiedVersion: parsed.notifiedVersion } + : {}), }; } catch { return null; @@ -149,12 +206,14 @@ async function readUpdateCheckCache( async function writeUpdateCheckCache( cachePath: string, cache: UpdateCheckCache, -): Promise { +): Promise { try { await mkdir(dirname(cachePath), { recursive: true }); await writeFile(cachePath, `${JSON.stringify(cache)}\n`, "utf8"); + return true; } catch { // Best-effort cache writes should never affect CLI behavior. + return false; } } diff --git a/packages/cli/tests/cli-update.test.ts b/packages/cli/tests/cli-update.test.ts index 4060b5bad..f71724eae 100644 --- a/packages/cli/tests/cli-update.test.ts +++ b/packages/cli/tests/cli-update.test.ts @@ -6,7 +6,11 @@ import { tmpdir } from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; import { runCli } from "./helpers/run-cli.js"; -import { getUpdateNotice, refreshUpdateCheckCache } from "../src/lib/update.js"; +import { + getUpdateNotice, + refreshUpdateCheckCache, + takeFirstUpdateNotice, +} from "../src/lib/update.js"; const require = createRequire(import.meta.url); const { version: cliVersion } = require("../package.json") as { @@ -50,28 +54,89 @@ describe("CLI auto-update", () => { expect(result.stderr).toContain("Run:\n npm i -g browse@latest"); }); - it("does not show the update notice on regular commands (no spam)", async () => { - const cacheDir = await createTempDir("browse-update-nospam-"); + it("shows the update notice exactly once per release on regular commands", async () => { + const cacheDir = await createTempDir("browse-update-once-"); const cachePath = join(cacheDir, "update-check.json"); await writeUpdateCache(cachePath, { checkedAt: new Date().toISOString(), version: "99.0.0", }); + const env = { + BROWSE_CACHE_DIR: cacheDir, + BROWSE_DISABLE_UPDATE_CHECK: "0", + BROWSE_DAEMON_DIR: cacheDir, + }; - const result = await runCli(["status"], { - env: { - BROWSE_CACHE_DIR: cacheDir, - BROWSE_DISABLE_UPDATE_CHECK: "0", - BROWSE_DAEMON_DIR: cacheDir, - }, - }); - - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ + const first = await runCli(["status"], { env }); + expect(first.exitCode).toBe(0); + expect(JSON.parse(first.stdout)).toMatchObject({ browserConnected: false, session: "default", }); - expect(result.stderr).not.toContain("Update available:"); + expect(first.stderr).toContain( + `Update available: ${cliVersion} -> 99.0.0.`, + ); + + const second = await runCli(["status"], { env }); + expect(second.exitCode).toBe(0); + expect(second.stderr).not.toContain("Update available:"); + }); + + it("announces again when a newer release supersedes the notified one", async () => { + const cacheDir = await createTempDir("browse-update-renotify-"); + const cachePath = join(cacheDir, "update-check.json"); + const env = { + ...process.env, + BROWSE_DISABLE_UPDATE_CHECK: "0", + BROWSE_UPDATE_CHECK_FILE: cachePath, + }; + + await writeUpdateCache(cachePath, { + checkedAt: new Date().toISOString(), + version: "99.0.0", + }); + expect(await takeFirstUpdateNotice("1.0.0", env)).toContain("99.0.0"); + expect(await takeFirstUpdateNotice("1.0.0", env)).toBeNull(); + + await writeUpdateCache(cachePath, { + checkedAt: new Date().toISOString(), + notifiedVersion: "99.0.0", + version: "100.0.0", + }); + expect(await takeFirstUpdateNotice("1.0.0", env)).toContain("100.0.0"); + expect(await takeFirstUpdateNotice("1.0.0", env)).toBeNull(); + }); + + it("never repeats the push notice when the cache is unwritable", async () => { + const cacheDir = await createTempDir("browse-update-unwritable-"); + const cachePath = join(cacheDir, "update-check.json", "nested.json"); + + const env = { + ...process.env, + BROWSE_DISABLE_UPDATE_CHECK: "0", + BROWSE_UPDATE_CHECK_FILE: cachePath, + }; + + // A file standing in for the parent directory makes the path unwritable. + await writeFile(join(cacheDir, "update-check.json"), "not a directory"); + expect(await takeFirstUpdateNotice("1.0.0", env)).toBeNull(); + }); + + it("does not repeat the push notice after help already showed it", async () => { + const cacheDir = await createTempDir("browse-update-pullmark-"); + const cachePath = join(cacheDir, "update-check.json"); + const env = { + ...process.env, + BROWSE_DISABLE_UPDATE_CHECK: "0", + BROWSE_UPDATE_CHECK_FILE: cachePath, + }; + + await writeUpdateCache(cachePath, { + checkedAt: new Date().toISOString(), + version: "99.0.0", + }); + expect(await getUpdateNotice("1.0.0", env)).toContain("99.0.0"); + expect(await takeFirstUpdateNotice("1.0.0", env)).toBeNull(); }); it("returns no notice for prerelease identifiers with ASCII ordering", async () => { From e13a4eb31df28337810eb31f92ec3692bf5de216 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 12 Jun 2026 16:39:52 -0700 Subject: [PATCH 06/10] feat(cli): remind about updates until upgraded, once per 20h (codex parity) Replaces the once-per-release push with codex's semantics (codex-rs/tui/src/updates.rs: upgrade banner every session until upgraded, 20h check interval): regular commands remind while outdated, deduped to once per 20h via lastNotifiedAt in update-check.json. help/doctor always render and refresh the marker. Co-Authored-By: Claude Fable 5 --- packages/cli/src/hooks/init.ts | 6 ++-- packages/cli/src/lib/update.ts | 49 +++++++++++++++------------ packages/cli/tests/cli-update.test.ts | 31 +++++++++++------ 3 files changed, 51 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/hooks/init.ts b/packages/cli/src/hooks/init.ts index b5c2480b5..06e28defc 100644 --- a/packages/cli/src/hooks/init.ts +++ b/packages/cli/src/hooks/init.ts @@ -6,7 +6,7 @@ import { maybeNudgeInstallSkill } from "../lib/skill-nudge.js"; import { startTelemetryInvocation } from "../lib/telemetry.js"; import { scheduleBackgroundUpdateCheck, - takeFirstUpdateNotice, + takeUpdateNotice, } from "../lib/update.js"; const hook: Hook.Init = async function ({ config, id }) { @@ -26,10 +26,10 @@ const hook: Hook.Init = async function ({ config, id }) { } try { - // Push notice exactly once per discovered release; help and doctor render + // Remind until upgraded, at most once per interval; help and doctor render // it themselves, so skip those surfaces to avoid double-printing. if (id && id !== "help" && id !== "doctor") { - const notice = await takeFirstUpdateNotice(config.version, process.env, { + const notice = await takeUpdateNotice(config.version, process.env, { cacheFile: join(config.cacheDir, "update-check.json"), }); if (notice) { diff --git a/packages/cli/src/lib/update.ts b/packages/cli/src/lib/update.ts index abf5f3506..f07b0941d 100644 --- a/packages/cli/src/lib/update.ts +++ b/packages/cli/src/lib/update.ts @@ -8,13 +8,16 @@ import semver from "semver"; const CLI_PACKAGE_NAME = "browse"; const DEFAULT_NPM_REGISTRY_BASE_URL = "https://registry.npmjs.org/"; const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +// Codex-parity cadence (codex-rs/tui/src/updates.rs uses 20h): remind on +// regular commands until the user upgrades, at most once per interval. +const UPDATE_NOTIFY_INTERVAL_MS = 20 * 60 * 60 * 1000; const UPDATE_TIMEOUT_MS = 1500; interface UpdateCheckCache { checkedAt: string; version: string; - /** Latest version the user has already been told about (once per version). */ - notifiedVersion?: string; + /** When the user was last shown the update notice (any surface). */ + lastNotifiedAt?: string; } interface UpdateCheckOptions { @@ -46,26 +49,24 @@ export async function getUpdateNotice( return null; } - // Pull surfaces always render; marking is best-effort so the one-time - // push notice does not repeat what the user has already seen. - if (cache.notifiedVersion !== cache.version) { - await writeUpdateCheckCache(cachePath, { - ...cache, - notifiedVersion: cache.version, - }); - } + // Pull surfaces always render; marking is best-effort so the push notice + // does not immediately repeat what the user has already seen. + await writeUpdateCheckCache(cachePath, { + ...cache, + lastNotifiedAt: new Date().toISOString(), + }); return formatUpdateNotice(currentVersion, cache.version); } /** - * One-time push notice: returns the update notice the FIRST time a given - * newer version is seen, then stays silent until the next release. No time - * windows — state is just "which version was already announced" in the same - * update-check cache. The notice is only returned when recording that state + * Push notice for regular commands: reminds until the user upgrades, at most + * once per UPDATE_NOTIFY_INTERVAL_MS (Codex shows its upgrade banner every + * session until upgraded; the interval is the one-shot-CLI analog of "once + * per session"). The notice is only returned when recording lastNotifiedAt * succeeds, so a read-only cache dir can never cause repeated printing. */ -export async function takeFirstUpdateNotice( +export async function takeUpdateNotice( currentVersion: string, env: NodeJS.ProcessEnv = process.env, options: UpdateCheckOptions = {}, @@ -84,13 +85,19 @@ export async function takeFirstUpdateNotice( return null; } - if (cache.notifiedVersion === cache.version) { + const lastNotifiedMs = cache.lastNotifiedAt + ? Date.parse(cache.lastNotifiedAt) + : Number.NaN; + if ( + Number.isFinite(lastNotifiedMs) && + Date.now() - lastNotifiedMs < UPDATE_NOTIFY_INTERVAL_MS + ) { return null; } const recorded = await writeUpdateCheckCache(cachePath, { ...cache, - notifiedVersion: cache.version, + lastNotifiedAt: new Date().toISOString(), }); if (!recorded) { return null; @@ -179,7 +186,7 @@ async function readUpdateCheckCache( const parsed = JSON.parse(contents) as { checkedAt?: unknown; version?: unknown; - notifiedVersion?: unknown; + lastNotifiedAt?: unknown; }; if (typeof parsed.version !== "string" || parsed.version.length === 0) { @@ -193,9 +200,9 @@ async function readUpdateCheckCache( return { checkedAt: parsed.checkedAt, version: parsed.version, - ...(typeof parsed.notifiedVersion === "string" && - parsed.notifiedVersion.length > 0 - ? { notifiedVersion: parsed.notifiedVersion } + ...(typeof parsed.lastNotifiedAt === "string" && + parsed.lastNotifiedAt.length > 0 + ? { lastNotifiedAt: parsed.lastNotifiedAt } : {}), }; } catch { diff --git a/packages/cli/tests/cli-update.test.ts b/packages/cli/tests/cli-update.test.ts index f71724eae..37e9ffee1 100644 --- a/packages/cli/tests/cli-update.test.ts +++ b/packages/cli/tests/cli-update.test.ts @@ -9,7 +9,7 @@ import { runCli } from "./helpers/run-cli.js"; import { getUpdateNotice, refreshUpdateCheckCache, - takeFirstUpdateNotice, + takeUpdateNotice, } from "../src/lib/update.js"; const require = createRequire(import.meta.url); @@ -54,7 +54,7 @@ describe("CLI auto-update", () => { expect(result.stderr).toContain("Run:\n npm i -g browse@latest"); }); - it("shows the update notice exactly once per release on regular commands", async () => { + it("shows the update notice on regular commands, deduped within the notify interval", async () => { const cacheDir = await createTempDir("browse-update-once-"); const cachePath = join(cacheDir, "update-check.json"); await writeUpdateCache(cachePath, { @@ -82,7 +82,7 @@ describe("CLI auto-update", () => { expect(second.stderr).not.toContain("Update available:"); }); - it("announces again when a newer release supersedes the notified one", async () => { + it("reminds again after the notify interval until the user upgrades", async () => { const cacheDir = await createTempDir("browse-update-renotify-"); const cachePath = join(cacheDir, "update-check.json"); const env = { @@ -95,16 +95,25 @@ describe("CLI auto-update", () => { checkedAt: new Date().toISOString(), version: "99.0.0", }); - expect(await takeFirstUpdateNotice("1.0.0", env)).toContain("99.0.0"); - expect(await takeFirstUpdateNotice("1.0.0", env)).toBeNull(); + expect(await takeUpdateNotice("1.0.0", env)).toContain("99.0.0"); + expect(await takeUpdateNotice("1.0.0", env)).toBeNull(); + // 21h-old lastNotifiedAt (past the 20h interval) -> reminds again. await writeUpdateCache(cachePath, { checkedAt: new Date().toISOString(), - notifiedVersion: "99.0.0", - version: "100.0.0", + lastNotifiedAt: new Date(Date.now() - 21 * 60 * 60 * 1000).toISOString(), + version: "99.0.0", + }); + expect(await takeUpdateNotice("1.0.0", env)).toContain("99.0.0"); + expect(await takeUpdateNotice("1.0.0", env)).toBeNull(); + + // Upgraded -> silence even past the interval. + await writeUpdateCache(cachePath, { + checkedAt: new Date().toISOString(), + lastNotifiedAt: new Date(Date.now() - 21 * 60 * 60 * 1000).toISOString(), + version: "99.0.0", }); - expect(await takeFirstUpdateNotice("1.0.0", env)).toContain("100.0.0"); - expect(await takeFirstUpdateNotice("1.0.0", env)).toBeNull(); + expect(await takeUpdateNotice("99.0.0", env)).toBeNull(); }); it("never repeats the push notice when the cache is unwritable", async () => { @@ -119,7 +128,7 @@ describe("CLI auto-update", () => { // A file standing in for the parent directory makes the path unwritable. await writeFile(join(cacheDir, "update-check.json"), "not a directory"); - expect(await takeFirstUpdateNotice("1.0.0", env)).toBeNull(); + expect(await takeUpdateNotice("1.0.0", env)).toBeNull(); }); it("does not repeat the push notice after help already showed it", async () => { @@ -136,7 +145,7 @@ describe("CLI auto-update", () => { version: "99.0.0", }); expect(await getUpdateNotice("1.0.0", env)).toContain("99.0.0"); - expect(await takeFirstUpdateNotice("1.0.0", env)).toBeNull(); + expect(await takeUpdateNotice("1.0.0", env)).toBeNull(); }); it("returns no notice for prerelease identifiers with ASCII ordering", async () => { From 9c38b32e9d4dfc0fc36e5310a06746cf4d48d44e Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 12 Jun 2026 16:48:01 -0700 Subject: [PATCH 07/10] fix(cli): detect project-scoped skill installs too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skills CLI resolves join(global ? homedir() : cwd, '.agents', 'skills'), so a project-scoped install lands at /.agents/skills/browse — check both canonical scopes (still just two access() calls, no detection logic). The public 'browser' skill folder is deliberately not checked: it is being deprecated in favor of this bundled, CLI-version-pinned skill. Co-Authored-By: Claude Fable 5 --- packages/cli/src/lib/skill-presence.ts | 37 +++++++++++++++++------ packages/cli/tests/skill-presence.test.ts | 19 +++++++++--- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/lib/skill-presence.ts b/packages/cli/src/lib/skill-presence.ts index 70bb76a13..da1892962 100644 --- a/packages/cli/src/lib/skill-presence.ts +++ b/packages/cli/src/lib/skill-presence.ts @@ -3,19 +3,36 @@ import { homedir } from "node:os"; import { join } from "node:path"; /** - * Check the single canonical install location for the browse skill: - * `~/.agents/skills/browse`. This is the directory `browse skills install` - * itself writes (via `npx skills add --global --agent '*'`) and the path - * `skills ls` prints — agent-specific dirs are just symlinks into it, so one - * filesystem check covers every agent. + * Check the canonical install locations for the bundled browse skill, matching + * the `skills` CLI's own two scopes (its source resolves + * `join(global ? homedir() : cwd, ".agents", "skills")`): + * + * - global: `~/.agents/skills/browse` — what `browse skills install` + * itself writes (`npx skills add --global --agent '*'`) + * - project: `/.agents/skills/browse` — `skills add` without `-g` + * + * Agent-specific dirs are symlinks/copies alongside the canonical dir, so two + * filesystem checks cover every agent at both scopes. The public `browser` + * skill (browserbase/skills) is deliberately not checked: it is being + * deprecated in favor of this bundled, CLI-version-pinned skill. */ export async function isBrowseSkillInstalled( home: string = homedir(), + cwd: string = process.cwd(), ): Promise { - try { - await access(join(home, ".agents", "skills", "browse")); - return true; - } catch { - return false; + const candidates = [ + join(home, ".agents", "skills", "browse"), + join(cwd, ".agents", "skills", "browse"), + ]; + + for (const candidate of candidates) { + try { + await access(candidate); + return true; + } catch { + // Keep checking the remaining scopes. + } } + + return false; } diff --git a/packages/cli/tests/skill-presence.test.ts b/packages/cli/tests/skill-presence.test.ts index d905e910d..322ca9403 100644 --- a/packages/cli/tests/skill-presence.test.ts +++ b/packages/cli/tests/skill-presence.test.ts @@ -23,16 +23,27 @@ async function createTempHome(): Promise { } describe("isBrowseSkillInstalled", () => { - it("returns false when the canonical skill dir is absent", async () => { + it("returns false when both canonical scopes are absent", async () => { const home = await createTempHome(); - expect(await isBrowseSkillInstalled(home)).toBe(false); + const project = await createTempHome(); + expect(await isBrowseSkillInstalled(home, project)).toBe(false); }); - it("returns true when ~/.agents/skills/browse exists", async () => { + it("returns true when the global ~/.agents/skills/browse exists", async () => { const home = await createTempHome(); + const project = await createTempHome(); await mkdir(join(home, ".agents", "skills", "browse"), { recursive: true, }); - expect(await isBrowseSkillInstalled(home)).toBe(true); + expect(await isBrowseSkillInstalled(home, project)).toBe(true); + }); + + it("returns true for a project-scoped /.agents/skills/browse", async () => { + const home = await createTempHome(); + const project = await createTempHome(); + await mkdir(join(project, ".agents", "skills", "browse"), { + recursive: true, + }); + expect(await isBrowseSkillInstalled(home, project)).toBe(true); }); }); From 7fa623c843dbdfbc390db472c4a1eed1df9526ff Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 12 Jun 2026 16:49:30 -0700 Subject: [PATCH 08/10] chore(cli): drop reviewer-facing aside from skill-presence comment Co-Authored-By: Claude Fable 5 --- packages/cli/src/lib/skill-presence.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/src/lib/skill-presence.ts b/packages/cli/src/lib/skill-presence.ts index da1892962..1a7dec7e2 100644 --- a/packages/cli/src/lib/skill-presence.ts +++ b/packages/cli/src/lib/skill-presence.ts @@ -12,9 +12,7 @@ import { join } from "node:path"; * - project: `/.agents/skills/browse` — `skills add` without `-g` * * Agent-specific dirs are symlinks/copies alongside the canonical dir, so two - * filesystem checks cover every agent at both scopes. The public `browser` - * skill (browserbase/skills) is deliberately not checked: it is being - * deprecated in favor of this bundled, CLI-version-pinned skill. + * filesystem checks cover every agent at both scopes. */ export async function isBrowseSkillInstalled( home: string = homedir(), From 4b5f3058b8b59cec5becf655698610cde579adaf Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 12 Jun 2026 17:09:10 -0700 Subject: [PATCH 09/10] feat(cli): nudge skill install at browser-session start instead of once per install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon spawn is the natural session boundary: the tip now shows on the command that starts each browser session while the skill is absent, every session, until it is installed. Deletes the skill-nudge.json marker and all cacheFile plumbing — the nudge is now stateless. Co-Authored-By: Claude Fable 5 --- packages/cli/src/hooks/init.ts | 9 -- packages/cli/src/lib/driver/daemon/client.ts | 7 + packages/cli/src/lib/skill-nudge.ts | 85 +++-------- packages/cli/tests/skill-nudge.test.ts | 146 +++++++------------ 4 files changed, 75 insertions(+), 172 deletions(-) diff --git a/packages/cli/src/hooks/init.ts b/packages/cli/src/hooks/init.ts index 06e28defc..bf7d90419 100644 --- a/packages/cli/src/hooks/init.ts +++ b/packages/cli/src/hooks/init.ts @@ -2,7 +2,6 @@ import { join } from "node:path"; import type { Hook } from "@oclif/core"; -import { maybeNudgeInstallSkill } from "../lib/skill-nudge.js"; import { startTelemetryInvocation } from "../lib/telemetry.js"; import { scheduleBackgroundUpdateCheck, @@ -40,14 +39,6 @@ const hook: Hook.Init = async function ({ config, id }) { // Best-effort update notices should never affect CLI behavior. } - try { - await maybeNudgeInstallSkill(process.env, { - cacheFile: join(config.cacheDir, "skill-nudge.json"), - commandId: id, - }); - } catch { - // Best-effort skill nudges should never affect CLI behavior. - } }; export default hook; diff --git a/packages/cli/src/lib/driver/daemon/client.ts b/packages/cli/src/lib/driver/daemon/client.ts index 353e05bc4..0c11d34f3 100644 --- a/packages/cli/src/lib/driver/daemon/client.ts +++ b/packages/cli/src/lib/driver/daemon/client.ts @@ -3,6 +3,7 @@ import { promises as fs } from "node:fs"; import net from "node:net"; import { fail } from "../../errors.js"; +import { maybeNudgeInstallSkill } from "../../skill-nudge.js"; import type { DriverCommandName } from "../commands/types.js"; import { targetsCompatible } from "../mode.js"; import type { ConnectionTarget, DriverStatus, OpenResult } from "../types.js"; @@ -56,6 +57,12 @@ export async function ensureDriverDaemon({ } spawnDaemon(session, target); await waitForSocketReady(getSocketPath(session), 30_000); + try { + // A fresh daemon marks the start of a browser session. + await maybeNudgeInstallSkill(); + } catch { + // Best-effort nudge must never affect daemon startup. + } } finally { await releaseLock(session); } diff --git a/packages/cli/src/lib/skill-nudge.ts b/packages/cli/src/lib/skill-nudge.ts index 33874eacd..f523d74cd 100644 --- a/packages/cli/src/lib/skill-nudge.ts +++ b/packages/cli/src/lib/skill-nudge.ts @@ -1,62 +1,35 @@ -import { access, mkdir, writeFile } from "node:fs/promises"; -import { constants } from "node:fs"; -import { dirname } from "node:path"; - import { isBrowseSkillInstalled } from "./skill-presence.js"; -interface SkillNudgeOptions { - cacheFile?: string; - commandId?: string; -} - -const SKILL_NUDGE_TIP = [ - "Tip: browse works best with its skill loaded into your agent.", - "Run:", - " browse skills install", - "", -].join("\n"); - /** - * Once-per-install hint to install the browse skill, printed to stderr so it - * never corrupts machine-readable stdout. Fires on the first regular command - * when the canonical skill dir is absent; a marker file in the CLI cache dir - * (same mechanism as update-check.json) keeps it silent afterwards. - * Best-effort: any failure is swallowed so it can never affect CLI behavior. + * Stderr tip suggesting `browse skills install`, shown when a new browser + * session's daemon starts and the bundled skill is not installed. The daemon + * spawn is the session boundary, so the tip appears once per session until + * the skill is installed — no marker files or time windows. Best-effort and + * stderr-only: it can never affect machine-readable stdout. */ export async function maybeNudgeInstallSkill( env: NodeJS.ProcessEnv = process.env, - options: SkillNudgeOptions = {}, ): Promise { if (isNudgeDisabled(env)) { return; } - // The user is already engaging with skills; don't nudge on those commands or - // on bare/`--help` invocations (the help banner covers discovery there). - const commandId = options.commandId; - if (!commandId || commandId === "help" || commandId.startsWith("skills")) { - return; - } - - const cachePath = options.cacheFile; - if (!cachePath) { - return; - } - - if (await markerExists(cachePath)) { - return; - } - if (await isBrowseSkillInstalled()) { return; } - // Write the marker first and only nudge when it actually lands, so an - // unwritable cache dir can't cause the once-per-install tip to fire on - // every run. - if (await writeNudgeMarker(cachePath)) { - process.stderr.write(SKILL_NUDGE_TIP); - } + writeNudge(); +} + +function writeNudge(): void { + process.stderr.write( + [ + "Tip: browse works best with its skill loaded into your agent.", + "Run:", + " browse skills install", + "", + ].join("\n"), + ); } function isNudgeDisabled(env: NodeJS.ProcessEnv): boolean { @@ -86,27 +59,3 @@ function isCiEnvironment(env: NodeJS.ProcessEnv): boolean { normalized === "off" ); } - -async function markerExists(cachePath: string): Promise { - try { - await access(cachePath, constants.F_OK); - return true; - } catch { - return false; - } -} - -async function writeNudgeMarker(cachePath: string): Promise { - try { - await mkdir(dirname(cachePath), { recursive: true }); - await writeFile( - cachePath, - `${JSON.stringify({ shownAt: new Date().toISOString() })}\n`, - "utf8", - ); - return true; - } catch { - // Best-effort marker writes should never affect CLI behavior. - return false; - } -} diff --git a/packages/cli/tests/skill-nudge.test.ts b/packages/cli/tests/skill-nudge.test.ts index 221811d8c..66b7db506 100644 --- a/packages/cli/tests/skill-nudge.test.ts +++ b/packages/cli/tests/skill-nudge.test.ts @@ -1,16 +1,24 @@ -import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { maybeNudgeInstallSkill } from "../src/lib/skill-nudge.js"; const cleanupPaths: string[] = []; +let stderrSpy: ReturnType; + +beforeEach(() => { + stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); +}); afterEach(async () => { + stderrSpy.mockRestore(); vi.unstubAllEnvs(); - vi.restoreAllMocks(); + while (cleanupPaths.length > 0) { const path = cleanupPaths.pop(); if (!path) continue; @@ -18,115 +26,63 @@ afterEach(async () => { } }); -async function setup(options: { skillInstalled?: boolean } = {}): Promise<{ - home: string; - cacheFile: string; -}> { +async function createTempHome(withSkill: boolean): Promise { const home = await mkdtemp(join(tmpdir(), "browse-nudge-home-")); cleanupPaths.push(home); - if (options.skillInstalled) { + if (withSkill) { await mkdir(join(home, ".agents", "skills", "browse"), { recursive: true, }); } - - // os.homedir() honors $HOME, so the canonical-path check stays inside the - // temp dir even when the suite runs on a machine with the skill installed. - vi.stubEnv("HOME", home); - vi.stubEnv("NODE_ENV", "development"); - vi.stubEnv("CI", ""); - vi.stubEnv("BROWSE_DISABLE_SKILL_NUDGE", ""); - - return { home, cacheFile: join(home, "cache", "skill-nudge.json") }; + return home; } -function stderrSpy() { - return vi.spyOn(process.stderr, "write").mockImplementation(() => true); +function nudgeEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + BROWSE_DISABLE_SKILL_NUDGE: "0", + CI: "0", + NODE_ENV: "production", + ...overrides, + }; } -function nudged(spy: ReturnType): boolean { - return spy.mock.calls.some((call) => - String(call[0]).includes("browse skills install"), - ); +function stderrText(): string { + return stderrSpy.mock.calls.map((call) => String(call[0])).join(""); } -describe("maybeNudgeInstallSkill", () => { - it("nudges once when the canonical skill dir is absent and writes the marker", async () => { - const { cacheFile } = await setup(); - const spy = stderrSpy(); - await maybeNudgeInstallSkill(process.env, { - cacheFile, - commandId: "status", - }); - expect(nudged(spy)).toBe(true); - await expect(access(cacheFile)).resolves.toBeUndefined(); - }); - - it("stays silent on the next run once the marker exists", async () => { - const { cacheFile } = await setup(); - const spy = stderrSpy(); - await maybeNudgeInstallSkill(process.env, { - cacheFile, - commandId: "status", - }); - expect(nudged(spy)).toBe(true); - spy.mockClear(); - await maybeNudgeInstallSkill(process.env, { - cacheFile, - commandId: "open", - }); - expect(nudged(spy)).toBe(false); +describe("maybeNudgeInstallSkill (session start)", () => { + it("nudges when the skill is absent", async () => { + const home = await createTempHome(false); + vi.stubEnv("HOME", home); + await maybeNudgeInstallSkill(nudgeEnv()); + expect(stderrText()).toContain("browse skills install"); }); - it("does not nudge when the skill is already installed", async () => { - const { cacheFile } = await setup({ skillInstalled: true }); - const spy = stderrSpy(); - await maybeNudgeInstallSkill(process.env, { - cacheFile, - commandId: "status", - }); - expect(nudged(spy)).toBe(false); + it("nudges on every call while the skill is absent (one per session start)", async () => { + const home = await createTempHome(false); + vi.stubEnv("HOME", home); + await maybeNudgeInstallSkill(nudgeEnv()); + await maybeNudgeInstallSkill(nudgeEnv()); + const matches = stderrText().match(/browse skills install/g) ?? []; + expect(matches).toHaveLength(2); }); - it("does not nudge on skills subcommands, help, or a missing commandId", async () => { - const { cacheFile } = await setup(); - const spy = stderrSpy(); - for (const commandId of ["skills:install", "skills", "help", undefined]) { - await maybeNudgeInstallSkill(process.env, { cacheFile, commandId }); - } - expect(nudged(spy)).toBe(false); + it("stays silent when the skill is installed", async () => { + const home = await createTempHome(true); + vi.stubEnv("HOME", home); + await maybeNudgeInstallSkill(nudgeEnv()); + expect(stderrText()).not.toContain("browse skills install"); }); - it("respects env opt-outs and CI", async () => { - const { cacheFile } = await setup(); - const spy = stderrSpy(); - for (const overrides of [ - { BROWSE_DISABLE_SKILL_NUDGE: "1" }, - { BB_DISABLE_SKILL_NUDGE: "1" }, - { NODE_ENV: "test" }, - { CI: "true" }, - ]) { - const env: NodeJS.ProcessEnv = { - NODE_ENV: "development", - CI: "", - ...overrides, - }; - await maybeNudgeInstallSkill(env, { cacheFile, commandId: "status" }); - } - expect(nudged(spy)).toBe(false); - }); - - it("does not nudge when the marker cannot be written", async () => { - const { home } = await setup(); - // Parent "directory" is a regular file, so mkdir/writeFile must fail. - const blocker = join(home, "blocked"); - await writeFile(blocker, "not a directory\n", "utf8"); - const cacheFile = join(blocker, "skill-nudge.json"); - const spy = stderrSpy(); - await maybeNudgeInstallSkill(process.env, { - cacheFile, - commandId: "status", - }); - expect(nudged(spy)).toBe(false); + it.each([ + ["BROWSE_DISABLE_SKILL_NUDGE", { BROWSE_DISABLE_SKILL_NUDGE: "1" }], + ["BB_DISABLE_SKILL_NUDGE", { BB_DISABLE_SKILL_NUDGE: "1" }], + ["CI", { CI: "true" }], + ["NODE_ENV=test", { NODE_ENV: "test" }], + ])("stays silent under %s", async (_label, overrides) => { + const home = await createTempHome(false); + vi.stubEnv("HOME", home); + await maybeNudgeInstallSkill(nudgeEnv(overrides)); + expect(stderrText()).not.toContain("browse skills install"); }); }); From cae608739212b5592773b86006ba8208315c154b Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 12 Jun 2026 17:09:51 -0700 Subject: [PATCH 10/10] style(cli): prettier on init hook and nudge tests Co-Authored-By: Claude Fable 5 --- packages/cli/src/hooks/init.ts | 1 - packages/cli/tests/skill-nudge.test.ts | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/cli/src/hooks/init.ts b/packages/cli/src/hooks/init.ts index bf7d90419..efb430e6b 100644 --- a/packages/cli/src/hooks/init.ts +++ b/packages/cli/src/hooks/init.ts @@ -38,7 +38,6 @@ const hook: Hook.Init = async function ({ config, id }) { } catch { // Best-effort update notices should never affect CLI behavior. } - }; export default hook; diff --git a/packages/cli/tests/skill-nudge.test.ts b/packages/cli/tests/skill-nudge.test.ts index 66b7db506..e746ba8eb 100644 --- a/packages/cli/tests/skill-nudge.test.ts +++ b/packages/cli/tests/skill-nudge.test.ts @@ -10,9 +10,7 @@ const cleanupPaths: string[] = []; let stderrSpy: ReturnType; beforeEach(() => { - stderrSpy = vi - .spyOn(process.stderr, "write") - .mockImplementation(() => true); + stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); }); afterEach(async () => {