-
Notifications
You must be signed in to change notification settings - Fork 1.6k
[STG-2191] feat(cli): static browse skill nudge + de-spam update notice #2200
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
shrey150
wants to merge
10
commits into
main
Choose a base branch
from
shrey/browse-skill-nudge
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
f681dc1
feat(cli): nudge agents to install the browse skill + de-spam update …
shrey150 598d2c6
chore(cli): drop no-op changeset (browse is changeset-ignored)
shrey150 3362936
chore(cli): restore browse patch changeset
shrey150 19921e1
refactor(cli): de-scope skill nudge to static model (always-on help b…
shrey150 47aeb84
feat(cli): push the update notice once per discovered release on regu…
shrey150 e13a4eb
feat(cli): remind about updates until upgraded, once per 20h (codex p…
shrey150 9c38b32
fix(cli): detect project-scoped skill installs too
shrey150 7fa623c
chore(cli): drop reviewer-facing aside from skill-presence comment
shrey150 4b5f305
feat(cli): nudge skill install at browser-session start instead of on…
shrey150 cae6087
style(cli): prettier on init hook and nudge tests
shrey150 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)); | ||
|
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. | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]; | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.