Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 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.
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.
}
}
}
18 changes: 15 additions & 3 deletions packages/cli/src/hooks/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,35 @@ 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 {
// 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.
// 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;
54 changes: 54 additions & 0 deletions packages/cli/src/lib/help.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (await this.skillBannerNeeded()) {
this.log(AGENT_START_HERE);
}
await super.showRootHelp();
await this.writeUpdateNotice();
}

private async skillBannerNeeded(): Promise<boolean> {
try {
const agent = await detectAgent();
return !(await isBrowseSkillInstalled(agent ?? "", process.env));
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
} catch {
// Best-effort: if detection fails, show the discovery banner.
return true;
}
}

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.
}
}
}
181 changes: 181 additions & 0 deletions packages/cli/src/lib/skill-nudge.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
}

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<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;
}

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<SkillNudgeStore> {
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<string, number> = {};
for (const [key, value] of Object.entries(
parsed.shown as Record<string, unknown>,
)) {
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<void> {
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];
}
}
}
71 changes: 71 additions & 0 deletions packages/cli/src/lib/skill-presence.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

// 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<boolean> {
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;
}
Loading
Loading