Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/browse-skill-nudge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
"browse": patch
---

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.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { join } from "node:path";

import { Flags } from "@oclif/core";

import { BrowseCommand } from "../base.js";
Expand All @@ -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 =
Expand Down Expand Up @@ -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<void> {
Comment thread
shrey150 marked this conversation as resolved.
try {
const notice = await getUpdateNotice(this.config.version, process.env, {
cacheFile: join(this.config.cacheDir, "update-check.json"),
Comment thread
shrey150 marked this conversation as resolved.
});
if (notice) {
process.stderr.write(`\n${notice}`);
}
} catch {
// Best-effort update notice should never affect doctor output.
}
}
}
35 changes: 32 additions & 3 deletions packages/cli/src/hooks/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,52 @@ 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,
takeFirstUpdateNotice,
} from "../lib/update.js";

const hook: Hook.Init = async function ({ config }) {
const hook: Hook.Init = async function ({ config, id }) {
try {
startTelemetryInvocation();
} catch {
// Best-effort telemetry should never affect CLI behavior.
}

try {
await maybeAutoUpdateCli(config.version, process.env, {
// Silent: refresh the cached latest version when stale, but never print.
await scheduleBackgroundUpdateCheck(process.env, {
cacheFile: join(config.cacheDir, "update-check.json"),
});
} catch {
// 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"),
commandId: id,
});
} catch {
// Best-effort skill nudges should never affect CLI behavior.
}
};

export default hook;
39 changes: 39 additions & 0 deletions packages/cli/src/lib/help.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { join } from "node:path";

import { Help } from "@oclif/core";

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 — 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<void> {
this.log(AGENT_START_HERE);
await super.showRootHelp();
await this.writeUpdateNotice();
}

private async writeUpdateNotice(): Promise<void> {
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.
}
}
}
112 changes: 112 additions & 0 deletions packages/cli/src/lib/skill-nudge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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.
*/
export async function maybeNudgeInstallSkill(
env: NodeJS.ProcessEnv = process.env,
options: SkillNudgeOptions = {},
): Promise<void> {
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);
}
}

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 markerExists(cachePath: string): Promise<boolean> {
try {
await access(cachePath, constants.F_OK);
return true;
} catch {
return false;
}
}

async function writeNudgeMarker(cachePath: string): Promise<boolean> {
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;
}
}
21 changes: 21 additions & 0 deletions packages/cli/src/lib/skill-presence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { access } from "node:fs/promises";
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.
*/
export async function isBrowseSkillInstalled(
home: string = homedir(),
): Promise<boolean> {
try {
await access(join(home, ".agents", "skills", "browse"));
return true;
} catch {
return false;
}
}
8 changes: 7 additions & 1 deletion packages/cli/src/lib/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -141,6 +142,9 @@ function createCliTelemetry(options: CreateCliTelemetryOptions): CliTelemetry {
? resolveAnonymousInstallId(env, options.sessionId)
: Promise.resolve("");
const agentPromise = telemetryEnabled ? detectAgent() : Promise.resolve(null);
const skillPresentPromise: Promise<boolean | null> = telemetryEnabled
? isBrowseSkillInstalled().catch(() => null)
: Promise.resolve(null);

const baseProperties: TelemetryProperties = {
source: browserbaseTelemetrySource,
Expand All @@ -157,9 +161,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, {
Expand All @@ -170,6 +175,7 @@ function createCliTelemetry(options: CreateCliTelemetryOptions): CliTelemetry {
properties: {
...baseProperties,
agent,
skill_present: skillPresent,
...properties,
},
});
Expand Down
Loading
Loading