Skip to content
Draft
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
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
111 changes: 111 additions & 0 deletions packages/cli/src/lib/open-nudge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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;
}

await writeNudgeMarker(cachePath);
return OPEN_NUDGE_HINT;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}

/**
* 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<void> {
try {
await mkdir(dirname(cachePath), { recursive: true });
await writeFile(
cachePath,
`${JSON.stringify({ shownAt: new Date().toISOString() })}\n`,
"utf8",
);
} catch {
// Best-effort marker writes should never affect CLI behavior.
}
}
70 changes: 70 additions & 0 deletions packages/cli/tests/open-nudge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { access } from "node:fs/promises";
import { mkdtemp, rm } 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();
});
});
Loading