diff --git a/.changeset/cloud-open-nudge.md b/.changeset/cloud-open-nudge.md new file mode 100644 index 000000000..a57889dff --- /dev/null +++ b/.changeset/cloud-open-nudge.md @@ -0,0 +1,5 @@ +--- +"browse": patch +--- + +Nudge cloud search/fetch users toward `browse open`. After a successful `browse cloud search` or `browse cloud fetch`, the CLI prints a one-line, once-per-install tip to stderr pointing at `browse open ` — stdout stays machine-clean, the tip never fires on failures, and it can be disabled with `BROWSE_DISABLE_OPEN_NUDGE=1` (also skipped in CI and tests). The once-per-install marker lives in the CLI cache dir (`open-nudge.json`), mirroring the existing update-check/skill-nudge cache-file pattern. diff --git a/packages/cli/src/commands/cloud/fetch.ts b/packages/cli/src/commands/cloud/fetch.ts index ff3f211c8..b0070a1ff 100644 --- a/packages/cli/src/commands/cloud/fetch.ts +++ b/packages/cli/src/commands/cloud/fetch.ts @@ -10,6 +10,7 @@ import { import { apiCommonFlags, toApiOptions } from "../../lib/cloud/flags.js"; import { BrowseCommand } from "../../base.js"; import { fail } from "../../lib/errors.js"; +import { writeOpenNudge } from "../../lib/open-nudge.js"; const fetchFormats = ["raw", "markdown", "json"] as const; type FetchFormat = (typeof fetchFormats)[number]; @@ -86,10 +87,11 @@ export default class Fetch extends BrowseCommand { statusCode: result.statusCode, sizeBytes: Buffer.byteLength(contents, "utf8"), }); - return; + } else { + outputJson(result); } - outputJson(result); + await writeOpenNudge(this.config.cacheDir); } } diff --git a/packages/cli/src/commands/cloud/search.ts b/packages/cli/src/commands/cloud/search.ts index 36b26e7a5..e998c6a26 100644 --- a/packages/cli/src/commands/cloud/search.ts +++ b/packages/cli/src/commands/cloud/search.ts @@ -7,6 +7,7 @@ import { } from "../../lib/cloud/api.js"; import { apiCommonFlags, toApiOptions } from "../../lib/cloud/flags.js"; import { BrowseCommand } from "../../base.js"; +import { writeOpenNudge } from "../../lib/open-nudge.js"; import { outputFormatFlags, outputTable, @@ -80,25 +81,22 @@ export default class Search extends BrowseCommand { console.log( `Wrote ${result.results.length} results for "${result.query}" to ${flags.output}.`, ); - return; + } else { + outputJson({ + ok: true, + outputPath: flags.output, + requestId: result.requestId, + query: result.query, + resultCount: result.results.length, + }); } - - outputJson({ - ok: true, - outputPath: flags.output, - requestId: result.requestId, - query: result.query, - resultCount: result.results.length, - }); - return; - } - - if (outputFormat === "table") { + } else if (outputFormat === "table") { outputSearchTable(result.results, { wide: flags.wide }); - return; + } else { + outputJson(result); } - outputJson(result); + await writeOpenNudge(this.config.cacheDir); } } diff --git a/packages/cli/src/lib/open-nudge.ts b/packages/cli/src/lib/open-nudge.ts new file mode 100644 index 000000000..d9072bf8e --- /dev/null +++ b/packages/cli/src/lib/open-nudge.ts @@ -0,0 +1,114 @@ +import { constants } from "node:fs"; +import { access, mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +export const OPEN_NUDGE_HINT = + "Tip: open any of these in a live browser — browse open (no API key needed locally)."; + +const OPEN_NUDGE_MARKER_FILE = "open-nudge.json"; + +interface OpenNudgeOptions { + cacheFile?: string; +} + +/** + * Once-per-install nudge from a successful `cloud search`/`cloud fetch` + * toward `browse open`. Returns the hint the first time it fires — the caller + * prints it to stderr so machine-readable stdout stays clean — and null once + * the install marker exists. Best-effort: any failure yields null so it can + * never affect CLI behavior. + */ +export async function maybeNudgeOpen( + options: OpenNudgeOptions = {}, + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (isNudgeDisabled(env)) { + return null; + } + + const cachePath = options.cacheFile; + if (!cachePath) { + return null; + } + + if (await markerExists(cachePath)) { + return null; + } + + // Only nudge when the marker actually lands, so an unwritable cache dir + // can't cause the "once-per-install" tip to fire on every run. + return (await writeNudgeMarker(cachePath)) ? OPEN_NUDGE_HINT : null; +} + +/** + * Print the once-per-install `browse open` hint to stderr, keyed on a marker + * file in the CLI cache dir. Called by cloud commands after successful output. + */ +export async function writeOpenNudge( + cacheDir: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + try { + const hint = await maybeNudgeOpen( + { cacheFile: join(cacheDir, OPEN_NUDGE_MARKER_FILE) }, + env, + ); + if (hint) { + process.stderr.write(`\n${hint}\n`); + } + } catch { + // Best-effort nudges should never affect command output. + } +} + +function isNudgeDisabled(env: NodeJS.ProcessEnv): boolean { + if ( + env.BROWSE_DISABLE_OPEN_NUDGE === "1" || + env.BB_DISABLE_OPEN_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 { + 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/open-nudge.test.ts b/packages/cli/tests/open-nudge.test.ts new file mode 100644 index 000000000..e1adc2574 --- /dev/null +++ b/packages/cli/tests/open-nudge.test.ts @@ -0,0 +1,77 @@ +import { access, 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 { maybeNudgeOpen, OPEN_NUDGE_HINT } from "../src/lib/open-nudge.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 freshCacheFile(): Promise { + const dir = await mkdtemp(join(tmpdir(), "browse-open-nudge-")); + cleanupPaths.push(dir); + return join(dir, "open-nudge.json"); +} + +const enabledEnv: NodeJS.ProcessEnv = { NODE_ENV: "development", CI: "" }; + +describe("maybeNudgeOpen", () => { + it("returns the hint once, then honors the install marker", async () => { + const cacheFile = await freshCacheFile(); + expect(await maybeNudgeOpen({ cacheFile }, enabledEnv)).toBe( + OPEN_NUDGE_HINT, + ); + await expect(access(cacheFile)).resolves.toBeUndefined(); + expect(await maybeNudgeOpen({ cacheFile }, enabledEnv)).toBeNull(); + }); + + it("respects BROWSE_DISABLE_OPEN_NUDGE and BB_DISABLE_OPEN_NUDGE", async () => { + const cacheFile = await freshCacheFile(); + expect( + await maybeNudgeOpen( + { cacheFile }, + { ...enabledEnv, BROWSE_DISABLE_OPEN_NUDGE: "1" }, + ), + ).toBeNull(); + expect( + await maybeNudgeOpen( + { cacheFile }, + { ...enabledEnv, BB_DISABLE_OPEN_NUDGE: "1" }, + ), + ).toBeNull(); + }); + + it("does not nudge in CI or test environments", async () => { + const cacheFile = await freshCacheFile(); + expect( + await maybeNudgeOpen( + { cacheFile }, + { NODE_ENV: "development", CI: "true" }, + ), + ).toBeNull(); + expect( + await maybeNudgeOpen({ cacheFile }, { NODE_ENV: "test", CI: "" }), + ).toBeNull(); + }); + + it("returns null when no cache file is configured", async () => { + expect(await maybeNudgeOpen({}, enabledEnv)).toBeNull(); + }); + + it("returns null when the marker cannot be written", async () => { + // Parent "directory" is a regular file, so mkdir/writeFile must fail. + const blocker = await freshCacheFile(); + await writeFile(blocker, "not a directory\n", "utf8"); + const cacheFile = join(blocker, "open-nudge.json"); + expect(await maybeNudgeOpen({ cacheFile }, enabledEnv)).toBeNull(); + }); +});