Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/cloud-open-nudge.md
Original file line number Diff line number Diff line change
@@ -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 <url>` — 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.
6 changes: 4 additions & 2 deletions packages/cli/src/commands/cloud/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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);
}
}

Expand Down
28 changes: 13 additions & 15 deletions packages/cli/src/commands/cloud/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}

Expand Down
114 changes: 114 additions & 0 deletions packages/cli/src/lib/open-nudge.ts
Original file line number Diff line number Diff line change
@@ -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 <url> (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<string | null> {
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<void> {
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<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;
}
}
77 changes: 77 additions & 0 deletions packages/cli/tests/open-nudge.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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();
});
});
Loading