diff --git a/.changeset/browse-common-list-contract.md b/.changeset/browse-common-list-contract.md new file mode 100644 index 0000000000..b09265719f --- /dev/null +++ b/.changeset/browse-common-list-contract.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +Add opt-in `--list-version 2` to resource and catalog lists, with a shared total `--limit`, `--all`, table/JSON formatting, and `{ data, hasMore, nextCursor }` JSON output. Secrets lists can follow cursors automatically without skipping records. Existing commands retain their default output and pagination behavior. diff --git a/packages/cli/README.md b/packages/cli/README.md index 58e8b5b142..ccc0077218 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -267,6 +267,47 @@ browse templates clone google-trends-keywords browse templates clone amazon-product-scraping --language python ./my-scraper ``` +## Common list behavior (opt-in) + +Existing commands keep their current output shapes and limits. Use `--list-version 2` +to opt into shared collection behavior on `cloud projects list`, `cloud sessions list`, +`cloud contexts list`, `cloud secrets list`, `functions secrets list`, and +`skills` / `templates` `list` and `find`. + +```bash +browse cloud sessions list --list-version 2 --limit 10 --json +browse cloud secrets list --list-version 2 --all --format table +browse functions secrets list --list-version 2 --limit 50 --json +browse templates find scraping --list-version 2 --limit 5 --wide +``` + +- `--limit N` is a total record limit in every output format; the default is 20. +- `--all` removes that limit and follows available API cursors. It cannot be combined + with `--limit`. +- `--format table|json`, `--json`, and `--wide` work across these commands. The default + is table output in a terminal and JSON when piped. Exact `find` matches also use + collection output in version 2. +- JSON always has `{ "data": [...], "hasMore": true|false|null, "nextCursor": string|null }`. + Table output uses common empty and continuation messages with resource-specific columns. +- `hasMore: true` means more records are known to exist; `false` means the source was + exhausted; `null` means the API does not expose completeness. Sessions use `null` + after all returned records are shown: `--all` cannot promise the entire session history. +- Only the two secrets lists support `--cursor`. Resume from `nextCursor` with the same + filters. The token always follows the last emitted record; a total limit can span + multiple API pages. Array sources have no resume token even when output is truncated. +- A pagination failure exits unsuccessfully without printing a partial collection. + Context lists cover locally saved aliases only. + +Version 1 remains the default, including existing JSON envelopes, JSON output that +ignores table-only limits, and single-page secrets results. New collection-only flags +require version 2 where the command did not already support them. No existing command +or flag is removed. This opt-in is intended for a minor release; changing the default +requires a separate compatibility decision. + +This initial collection adapter does not change ranked `cloud search` results (the +API exposes a capped result set without pagination), browser-driver `tab list`, or +non-collection commands such as logs and downloads. + ## Configuration Set your Browserbase API key to enable remote sessions and cloud commands: diff --git a/packages/cli/src/commands/cloud/contexts/list.ts b/packages/cli/src/commands/cloud/contexts/list.ts index d2a9ec73be..4b56ef834f 100644 --- a/packages/cli/src/commands/cloud/contexts/list.ts +++ b/packages/cli/src/commands/cloud/contexts/list.ts @@ -1,3 +1,11 @@ +import { + collectionVersionFlag, + collectionLimitFlags, + usesCollectionContract, + outputCollection, + validateCollectionFlags, + requireCollectionVersion, +} from "../../../lib/collections.js"; import { outputJson } from "../../../lib/cloud/api.js"; import { type ContextAliasEntry, @@ -22,12 +30,25 @@ export default class ContextsList extends BrowseCommand { static override flags = { ...outputFormatFlags, + ...collectionVersionFlag, + ...collectionLimitFlags, }; async run(): Promise { const { flags } = await this.parse(ContextsList); + if (usesCollectionContract(flags)) validateCollectionFlags(flags); + else requireCollectionVersion(flags, ["limit", "all"]); const contexts = await listContextAliases(); + if (usesCollectionContract(flags)) { + await outputCollection({ + flags, + source: { kind: "array", complete: true, load: async () => contexts }, + table: (items) => outputContextsTable(items, { wide: flags.wide }), + }); + return; + } + if (resolveOutputFormat(flags) === "json") { // Wrap in a named key to match `templates list` / `skills list` so the // JSON shape is consistent and machine-readable across list commands. diff --git a/packages/cli/src/commands/cloud/projects/list.ts b/packages/cli/src/commands/cloud/projects/list.ts index ee1437d64e..d6a49d4cf4 100644 --- a/packages/cli/src/commands/cloud/projects/list.ts +++ b/packages/cli/src/commands/cloud/projects/list.ts @@ -1,3 +1,11 @@ +import { + collectionVersionFlag, + collectionLimitFlags, + usesCollectionContract, + outputCollection, + validateCollectionFlags, + requireCollectionVersion, +} from "../../../lib/collections.js"; import { createBrowserbaseClient, outputJson, @@ -27,13 +35,29 @@ export default class ProjectsList extends BrowseCommand { "browse cloud projects list", "browse cloud projects list --json", ]; - static override flags = { ...apiCommonFlags, ...outputFormatFlags }; + static override flags = { + ...apiCommonFlags, + ...outputFormatFlags, + ...collectionVersionFlag, + ...collectionLimitFlags, + }; async run(): Promise { const { flags } = await this.parse(ProjectsList); + if (usesCollectionContract(flags)) validateCollectionFlags(flags); + else requireCollectionVersion(flags, ["limit", "all"]); await withBrowserbaseApi("projects", async () => { const client = createBrowserbaseClient(toApiOptions(flags)); const projects = (await client.projects.list()) as BrowserbaseProject[]; + if (usesCollectionContract(flags)) { + await outputCollection({ + flags, + source: { kind: "array", complete: true, load: async () => projects }, + table: (items) => outputProjectsTable(items, { wide: flags.wide }), + }); + return; + } + if (resolveOutputFormat(flags) === "json") { outputJson(projects); return; diff --git a/packages/cli/src/commands/cloud/secrets/list.ts b/packages/cli/src/commands/cloud/secrets/list.ts index 771a4486ee..d46dcfa608 100644 --- a/packages/cli/src/commands/cloud/secrets/list.ts +++ b/packages/cli/src/commands/cloud/secrets/list.ts @@ -2,10 +2,18 @@ import { BrowseCommand } from "../../../base.js"; import { apiCommonFlags, toApiOptions } from "../../../lib/cloud/flags.js"; import { listSecrets } from "../../../lib/secrets/api.js"; import { - listSecretsFlags, + collectionSecretsFlags, toListSecretsOptions, } from "../../../lib/secrets/flags.js"; import { outputJson } from "../../../lib/output.js"; +import { + usesCollectionContract, + outputCollection, +} from "../../../lib/collections.js"; +import { + outputSecretsTable, + validateSecretsCollectionFlags, +} from "../../../lib/secrets/output.js"; export default class SecretsList extends BrowseCommand { static override description = @@ -16,10 +24,24 @@ export default class SecretsList extends BrowseCommand { "browse cloud secrets list --start-at 2026-01-01T00:00:00Z --end-at 2026-02-01T00:00:00Z", "browse cloud secrets list --limit 10", ]; - static override flags = { ...apiCommonFlags, ...listSecretsFlags }; + static override flags = { ...apiCommonFlags, ...collectionSecretsFlags }; async run(): Promise { const { flags } = await this.parse(SecretsList); + validateSecretsCollectionFlags(flags); const options = toApiOptions(flags); + if (usesCollectionContract(flags)) { + const query = toListSecretsOptions(flags); + await outputCollection({ + flags, + source: { + kind: "cursor", + pageSize: 1000, + loadPage: (page) => listSecrets(options, { ...query, ...page }), + }, + table: (items) => outputSecretsTable(items, flags), + }); + return; + } outputJson(await listSecrets(options, toListSecretsOptions(flags))); } } diff --git a/packages/cli/src/commands/cloud/sessions/list.ts b/packages/cli/src/commands/cloud/sessions/list.ts index 0f422fb742..bddb17a70e 100644 --- a/packages/cli/src/commands/cloud/sessions/list.ts +++ b/packages/cli/src/commands/cloud/sessions/list.ts @@ -1,5 +1,11 @@ import { Flags } from "@oclif/core"; +import { + collectionVersionFlag, + usesCollectionContract, + outputCollection, + validateCollectionFlags, +} from "../../../lib/collections.js"; import { createBrowserbaseClient, outputJson, @@ -42,12 +48,14 @@ export default class SessionsList extends BrowseCommand { static override flags = { ...apiCommonFlags, ...outputFormatFlags, + ...collectionVersionFlag, all: Flags.boolean({ - description: "Show all returned sessions in table output.", + description: + "Show all returned sessions (all output formats in version 2).", }), limit: Flags.integer({ - default: 20, - description: "Maximum sessions to show in table output.", + description: + "Maximum sessions: table rows in version 1 (default 20), records in version 2 (default 20).", helpValue: "", min: 1, }), @@ -64,6 +72,7 @@ export default class SessionsList extends BrowseCommand { async run(): Promise { const { flags } = await this.parse(SessionsList); + if (usesCollectionContract(flags)) validateCollectionFlags(flags); await withBrowserbaseApi("sessions", async () => { const client = createBrowserbaseClient(toApiOptions(flags)); const query: { q?: string; status?: SessionStatus } = {}; @@ -77,13 +86,30 @@ export default class SessionsList extends BrowseCommand { const sessions = (await client.sessions.list( query, )) as BrowserbaseSession[]; + if (usesCollectionContract(flags)) { + await outputCollection({ + flags, + source: { + kind: "array", + complete: false, + load: async () => sessions, + }, + table: (items) => + outputSessionsTable(items, { + limit: items.length, + wide: flags.wide, + }), + }); + return; + } + if (resolveOutputFormat(flags) === "json") { outputJson(sessions); return; } outputSessionsTable(sessions, { - limit: flags.all ? sessions.length : flags.limit, + limit: flags.all ? sessions.length : (flags.limit ?? 20), wide: flags.wide, }); }); diff --git a/packages/cli/src/commands/functions/secrets/list.ts b/packages/cli/src/commands/functions/secrets/list.ts index 375ba7935e..3f73afb807 100644 --- a/packages/cli/src/commands/functions/secrets/list.ts +++ b/packages/cli/src/commands/functions/secrets/list.ts @@ -3,10 +3,18 @@ import { BrowseCommand } from "../../../base.js"; import { apiCommonFlags, toApiOptions } from "../../../lib/cloud/flags.js"; import { listFunctionSecrets } from "../../../lib/secrets/api.js"; import { - listSecretsFlags, + collectionSecretsFlags, toListSecretsOptions, } from "../../../lib/secrets/flags.js"; import { outputJson } from "../../../lib/output.js"; +import { + usesCollectionContract, + outputCollection, +} from "../../../lib/collections.js"; +import { + outputSecretsTable, + validateSecretsCollectionFlags, +} from "../../../lib/secrets/output.js"; export default class FunctionSecretsList extends BrowseCommand { static override description = @@ -24,10 +32,28 @@ export default class FunctionSecretsList extends BrowseCommand { required: true, }), }; - static override flags = { ...apiCommonFlags, ...listSecretsFlags }; + static override flags = { ...apiCommonFlags, ...collectionSecretsFlags }; async run(): Promise { const { args, flags } = await this.parse(FunctionSecretsList); + validateSecretsCollectionFlags(flags); const options = toApiOptions(flags); + if (usesCollectionContract(flags)) { + const query = toListSecretsOptions(flags); + await outputCollection({ + flags, + source: { + kind: "cursor", + pageSize: 1000, + loadPage: (page) => + listFunctionSecrets(options, args.functionId, { + ...query, + ...page, + }), + }, + table: (items) => outputSecretsTable(items, flags), + }); + return; + } outputJson( await listFunctionSecrets( options, diff --git a/packages/cli/src/commands/skills/find.ts b/packages/cli/src/commands/skills/find.ts index a9b1e6286e..4ad4bbaba0 100644 --- a/packages/cli/src/commands/skills/find.ts +++ b/packages/cli/src/commands/skills/find.ts @@ -1,5 +1,11 @@ import { Args, Flags } from "@oclif/core"; +import { + collectionVersionFlag, + usesCollectionContract, + outputCollection, + validateCollectionFlags, +} from "../../lib/collections.js"; import { BrowseCommand } from "../../base.js"; import { outputFormatFlags, @@ -35,12 +41,14 @@ export default class SkillsFind extends BrowseCommand { static override flags = { ...outputFormatFlags, + ...collectionVersionFlag, all: Flags.boolean({ - description: "Show all matching skills in table output.", + description: + "Show all matching skills (all output formats in version 2).", }), limit: Flags.integer({ - default: 25, - description: "Maximum matching skills to show in table output.", + description: + "Maximum matches: table rows in version 1 (default 25), records in version 2 (default 20).", helpValue: "", min: 1, }), @@ -48,11 +56,26 @@ export default class SkillsFind extends BrowseCommand { async run(): Promise { const { args, flags } = await this.parse(SkillsFind); + if (usesCollectionContract(flags)) validateCollectionFlags(flags); const skills = prioritizeExactSkillMatch( await listCatalogSkills({ query: args.query }), args.query, ); + if (usesCollectionContract(flags)) { + await outputCollection({ + flags, + source: { kind: "array", complete: true, load: async () => skills }, + table: (items) => + outputSkillTable(items, { + limit: items.length, + wide: flags.wide, + footer: false, + }), + }); + return; + } + const outputFormat = resolveOutputFormat(flags); if (outputFormat === "json") { outputJson({ query: args.query, skills }); @@ -67,7 +90,7 @@ export default class SkillsFind extends BrowseCommand { outputSkillTable(skills, { heading: `Skills matching "${args.query}"`, - limit: flags.all ? skills.length : flags.limit, + limit: flags.all ? skills.length : (flags.limit ?? 25), wide: flags.wide, }); } diff --git a/packages/cli/src/commands/skills/list.ts b/packages/cli/src/commands/skills/list.ts index 5e438ca396..0c69c3684c 100644 --- a/packages/cli/src/commands/skills/list.ts +++ b/packages/cli/src/commands/skills/list.ts @@ -1,5 +1,11 @@ import { Flags } from "@oclif/core"; +import { + collectionVersionFlag, + usesCollectionContract, + outputCollection, + validateCollectionFlags, +} from "../../lib/collections.js"; import { BrowseCommand } from "../../base.js"; import { outputFormatFlags, @@ -23,12 +29,14 @@ export default class SkillsList extends BrowseCommand { static override flags = { ...outputFormatFlags, + ...collectionVersionFlag, all: Flags.boolean({ - description: "Show all returned skills in table output.", + description: + "Show all returned skills (all output formats in version 2).", }), limit: Flags.integer({ - default: 25, - description: "Maximum skills to show in table output.", + description: + "Maximum skills: table rows in version 1 (default 25), records in version 2 (default 20).", helpValue: "", min: 1, }), @@ -36,15 +44,30 @@ export default class SkillsList extends BrowseCommand { async run(): Promise { const { flags } = await this.parse(SkillsList); + if (usesCollectionContract(flags)) validateCollectionFlags(flags); const skills = await listCatalogSkills(); + if (usesCollectionContract(flags)) { + await outputCollection({ + flags, + source: { kind: "array", complete: true, load: async () => skills }, + table: (items) => + outputSkillTable(items, { + limit: items.length, + wide: flags.wide, + footer: false, + }), + }); + return; + } + if (resolveOutputFormat(flags) === "json") { outputJson({ skills }); return; } outputSkillTable(skills, { - limit: flags.all ? skills.length : flags.limit, + limit: flags.all ? skills.length : (flags.limit ?? 25), wide: flags.wide, }); } diff --git a/packages/cli/src/commands/templates/find.ts b/packages/cli/src/commands/templates/find.ts index 623127f024..729b71dbe7 100644 --- a/packages/cli/src/commands/templates/find.ts +++ b/packages/cli/src/commands/templates/find.ts @@ -1,5 +1,13 @@ import { Args } from "@oclif/core"; +import { + collectionVersionFlag, + collectionLimitFlags, + usesCollectionContract, + outputCollection, + validateCollectionFlags, + requireCollectionVersion, +} from "../../lib/collections.js"; import { BrowseCommand } from "../../base.js"; import { outputFormatFlags, @@ -33,13 +41,36 @@ export default class TemplatesFind extends BrowseCommand { static override flags = { ...outputFormatFlags, + ...collectionVersionFlag, + ...collectionLimitFlags, }; async run(): Promise { const { args, flags } = await this.parse(TemplatesFind); + if (usesCollectionContract(flags)) validateCollectionFlags(flags); + else requireCollectionVersion(flags, ["limit", "all"]); const exactTemplate = await getTemplateIfExists(args.query); const outputFormat = resolveOutputFormat(flags); + if (usesCollectionContract(flags)) { + await outputCollection({ + flags, + source: { + kind: "array", + complete: true, + load: async () => + exactTemplate + ? [exactTemplate] + : (await listTemplates()).filter((template) => + templateMatchesQuery(template, args.query), + ), + }, + table: (items) => + outputTemplateTable(items, { wide: flags.wide, footer: false }), + }); + return; + } + if (exactTemplate) { if (outputFormat === "json") { outputJson({ query: args.query, templates: [exactTemplate] }); diff --git a/packages/cli/src/commands/templates/list.ts b/packages/cli/src/commands/templates/list.ts index 426a607e06..22b5806630 100644 --- a/packages/cli/src/commands/templates/list.ts +++ b/packages/cli/src/commands/templates/list.ts @@ -1,5 +1,13 @@ import { Flags } from "@oclif/core"; +import { + collectionVersionFlag, + collectionLimitFlags, + usesCollectionContract, + outputCollection, + validateCollectionFlags, + requireCollectionVersion, +} from "../../lib/collections.js"; import { BrowseCommand } from "../../base.js"; import { outputFormatFlags, @@ -22,6 +30,8 @@ export default class TemplatesList extends BrowseCommand { static override flags = { ...outputFormatFlags, + ...collectionVersionFlag, + ...collectionLimitFlags, category: Flags.string({ description: "Filter templates by category.", helpValue: "", @@ -38,12 +48,24 @@ export default class TemplatesList extends BrowseCommand { async run(): Promise { const { flags } = await this.parse(TemplatesList); + if (usesCollectionContract(flags)) validateCollectionFlags(flags); + else requireCollectionVersion(flags, ["limit", "all"]); const templates = await listTemplates({ category: flags.category, source: flags.source, tag: flags.tag, }); + if (usesCollectionContract(flags)) { + await outputCollection({ + flags, + source: { kind: "array", complete: true, load: async () => templates }, + table: (items) => + outputTemplateTable(items, { wide: flags.wide, footer: false }), + }); + return; + } + if (resolveOutputFormat(flags) === "json") { outputJson({ templates }); return; diff --git a/packages/cli/src/lib/collections.ts b/packages/cli/src/lib/collections.ts new file mode 100644 index 0000000000..126dc78147 --- /dev/null +++ b/packages/cli/src/lib/collections.ts @@ -0,0 +1,146 @@ +import { Flags } from "@oclif/core"; +import { fail } from "./errors.js"; +import { outputJson, resolveOutputFormat } from "./output.js"; + +/** Opt-in while existing scripts retain the version 1 output and limit semantics. */ +export const collectionVersionFlag = { + "list-version": Flags.string({ + options: ["1", "2"], + default: "1", + description: + "List contract: 1 preserves existing behavior; 2 uses common limits and output.", + }), +}; + +export const collectionLimitFlags = { + limit: Flags.integer({ + min: 1, + description: "Maximum records to return (list version 2 default: 20).", + }), + all: Flags.boolean({ + description: "Return every available record (list version 2).", + }), +}; + +export interface CollectionFlags { + "list-version"?: string; + limit?: number; + all?: boolean; + cursor?: string; + format?: string; + json?: boolean; + wide?: boolean; +} + +export interface CollectionResult { + data: T[]; + /** null means the source does not expose whether its response is complete. */ + hasMore: boolean | null; + /** A backend token, available only when it resumes after the last emitted row. */ + nextCursor: string | null; +} + +export type CollectionSource = + | { kind: "array"; load: () => Promise; complete: boolean } + | { + kind: "cursor"; + pageSize: number; + loadPage: (options: { limit: number; cursor?: string }) => Promise<{ + data: T[]; + nextCursor: string | null; + }>; + }; + +export function usesCollectionContract(flags: CollectionFlags): boolean { + return flags["list-version"] === "2"; +} + +export function validateCollectionFlags(flags: CollectionFlags): void { + if (flags.all && flags.limit !== undefined) + fail("--all and --limit cannot be used together with --list-version 2."); + if ( + flags.limit !== undefined && + (!Number.isSafeInteger(flags.limit) || flags.limit < 1) + ) + fail("--limit must be a positive safe integer."); + if (flags.cursor !== undefined && !flags.cursor) + fail("--cursor must be a non-empty continuation token."); +} + +export async function collect( + source: CollectionSource, + flags: CollectionFlags, +): Promise> { + validateCollectionFlags(flags); + const limit = flags.all ? Infinity : (flags.limit ?? 20); + if (source.kind === "array") { + if (flags.cursor !== undefined) + fail("This list source does not support --cursor."); + const items = await source.load(); + return { + data: items.slice(0, limit), + hasMore: items.length > limit ? true : source.complete ? false : null, + nextCursor: null, + }; + } + + const data: T[] = []; + let cursor = flags.cursor; + const seen = new Set(cursor === undefined ? [] : [cursor]); + while (data.length < limit) { + const requested = Math.min(source.pageSize, limit - data.length); + const page = await source.loadPage({ limit: requested, cursor }); + // Silently slicing an oversized API page would skip records on continuation. + if (page.data.length > requested) + fail( + "List API returned more records than requested; cannot safely continue.", + ); + data.push(...page.data); + if (page.nextCursor === null) + return { data, hasMore: false, nextCursor: null }; + if ( + typeof page.nextCursor !== "string" || + !page.nextCursor || + seen.has(page.nextCursor) + ) + fail("List API returned a repeated or empty pagination cursor."); + seen.add(page.nextCursor); + cursor = page.nextCursor; + } + return { data, hasMore: true, nextCursor: cursor ?? null }; +} + +export async function outputCollection(options: { + flags: CollectionFlags; + source: CollectionSource; + table: (items: T[]) => void; +}): Promise { + // Collect before writing stdout so a later-page failure never looks like success. + const result = await collect(options.source, options.flags); + if (resolveOutputFormat(options.flags) === "json") { + outputJson(result); + return; + } + if (result.data.length === 0) console.log("No results."); + else options.table(result.data); + if (result.hasMore === true) + console.log( + `Showing ${result.data.length} results. More results are available; use --all or increase --limit.`, + ); + else if (result.hasMore === null) + console.log( + `Showing ${result.data.length} results returned by the API; completeness is unknown.`, + ); + if (result.nextCursor !== null) + console.log(`Next cursor: ${result.nextCursor}`); +} + +export function requireCollectionVersion( + flags: CollectionFlags, + names: Array, +): void { + for (const name of names) { + if (flags[name] !== undefined && flags[name] !== false) + fail(`--${name} requires --list-version 2 for this command.`); + } +} diff --git a/packages/cli/src/lib/secrets/flags.ts b/packages/cli/src/lib/secrets/flags.ts index 505030b594..b2519e4030 100644 --- a/packages/cli/src/lib/secrets/flags.ts +++ b/packages/cli/src/lib/secrets/flags.ts @@ -1,11 +1,16 @@ import { Flags } from "@oclif/core"; +import { collectionVersionFlag, collectionLimitFlags } from "../collections.js"; +import { outputFormatFlags } from "../output.js"; import type { ListSecretsOptions } from "./api.js"; -export const listSecretsFlags = { +export const collectionSecretsFlags = { + ...collectionVersionFlag, + ...collectionLimitFlags, + ...outputFormatFlags, limit: Flags.integer({ min: 1, - max: 1000, - description: "Maximum results per page (API default: 20).", + description: + "Maximum results: per page in version 1 (max 1000), total in version 2 (default 20).", }), cursor: Flags.string({ description: "nextCursor from the previous page. Keep the same filters.", diff --git a/packages/cli/src/lib/secrets/output.ts b/packages/cli/src/lib/secrets/output.ts new file mode 100644 index 0000000000..0ec4cb082b --- /dev/null +++ b/packages/cli/src/lib/secrets/output.ts @@ -0,0 +1,38 @@ +import { + type CollectionFlags, + requireCollectionVersion, + usesCollectionContract, + validateCollectionFlags, +} from "../collections.js"; +import { fail } from "../errors.js"; +import { formatId, outputTable } from "../output.js"; +import type { Secret } from "./api.js"; + +export function validateSecretsCollectionFlags(flags: CollectionFlags): void { + if (usesCollectionContract(flags)) { + validateCollectionFlags(flags); + return; + } + requireCollectionVersion(flags, ["all", "format", "json", "wide"]); + if (flags.limit !== undefined && flags.limit > 1000) { + fail("--limit must be at most 1000 with --list-version 1."); + } +} + +export function outputSecretsTable( + secrets: Secret[], + flags: CollectionFlags, +): void { + outputTable( + secrets, + [ + { + header: "ID", + maxWidth: 12, + value: (secret) => formatId(secret.id, flags.wide), + }, + { header: "Key", maxWidth: 48, value: (secret) => secret.secretKey }, + ], + { wide: flags.wide }, + ); +} diff --git a/packages/cli/src/lib/skills/catalog.ts b/packages/cli/src/lib/skills/catalog.ts index b895da67e9..b603dcea3e 100644 --- a/packages/cli/src/lib/skills/catalog.ts +++ b/packages/cli/src/lib/skills/catalog.ts @@ -48,6 +48,7 @@ export function prioritizeExactSkillMatch( } interface SkillTableOptions { + footer?: boolean; heading?: string; limit?: number; wide?: boolean; @@ -101,6 +102,8 @@ export function outputSkillTable( { wide: options.wide }, ); + if (options.footer === false) return; + console.log("Install with: browse skills add "); if (visibleSkills.length < skills.length) { console.log( diff --git a/packages/cli/src/lib/templates/output.ts b/packages/cli/src/lib/templates/output.ts index cb5b2e37c7..443cf3c727 100644 --- a/packages/cli/src/lib/templates/output.ts +++ b/packages/cli/src/lib/templates/output.ts @@ -3,7 +3,7 @@ import { outputTable } from "../output.js"; export function outputTemplateTable( templates: Template[], - options: { heading?: string; wide?: boolean } = {}, + options: { heading?: string; wide?: boolean; footer?: boolean } = {}, ): void { if (templates.length === 0) { console.log("No templates found."); @@ -46,7 +46,8 @@ export function outputTemplateTable( { wide: options.wide }, ); - console.log("Use --wide for full values or --json for full descriptions."); + if (options.footer !== false) + console.log("Use --wide for full values or --json for full descriptions."); } export function printTemplateDetail(template: Template): void { diff --git a/packages/cli/tests/cli-collections-contract.test.ts b/packages/cli/tests/cli-collections-contract.test.ts new file mode 100644 index 0000000000..1c0e825046 --- /dev/null +++ b/packages/cli/tests/cli-collections-contract.test.ts @@ -0,0 +1,305 @@ +import { 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 { + jsonResponse, + startFakeBrowserbaseServer, + type FakeBrowserbaseServer, +} from "./helpers/fake-browserbase-server.js"; +import { runCli } from "./helpers/run-cli.js"; + +const env = { + BROWSERBASE_API_KEY: "test-key", + BROWSE_LOAD_DOTENV: "0", + BROWSERBASE_TELEMETRY_DISABLED: "1", +}; +let server: FakeBrowserbaseServer | undefined; +const dirs: string[] = []; +afterEach(async () => { + await server?.close(); + server = undefined; + for (const dir of dirs.splice(0)) + await rm(dir, { recursive: true, force: true }); +}); +const records = Array.from({ length: 21 }, (_, i) => ({ + id: `record-${i}`, + name: `Record ${i}`, + slug: `example.com/task-${i}`, + hostname: "example.com", + task: `task-${i}`, + title: `Record ${i}`, + description: "", + category: "", + aliases: [], + tags: [], + source: "", + updated: "", + recommendedMethod: "", + verified: false, + proxies: false, + sourceUrl: "", + partner: false, + screenshotUrls: [], + installCount: 0, +})); + +const arrayCommands = [ + { args: ["cloud", "projects", "list"], key: undefined, complete: true }, + { args: ["cloud", "sessions", "list"], key: undefined, complete: false }, + { args: ["skills", "list"], key: "skills", complete: true }, + { args: ["skills", "find", "Record"], key: "skills", complete: true }, + { args: ["templates", "list"], key: "templates", complete: true }, + { args: ["templates", "find", "Record"], key: "templates", complete: true }, +]; + +describe("shared collection CLI contract", () => { + it.each(arrayCommands)( + "preserves legacy output and aligns $args", + async ({ args, key, complete }) => { + const data = + key === "templates" + ? records.map((r) => ({ ...r, category: [] })) + : records; + server = await startFakeBrowserbaseServer((request, response) => { + if (request.path === "/Record") { + jsonResponse(response, 404, {}); + return; + } + jsonResponse(response, 200, key ? { [key]: data } : data); + }); + const options = { + env: { + ...env, + BROWSERBASE_BASE_URL: server.baseUrl, + BROWSE_SKILLS_API_BASE_URL: server.baseUrl, + BROWSERBASE_TEMPLATES_API: server.baseUrl, + }, + }; + const legacy = await runCli([...args, "--json"], options); + expect(legacy.exitCode, legacy.stderr).toBe(0); + const legacyPayload = JSON.parse(legacy.stdout); + const legacyData = key ? legacyPayload[key] : legacyPayload; + expect(legacyData).toHaveLength(21); + const defaultList = await runCli( + [...args, "--list-version", "2"], + options, + ); + expect(defaultList.exitCode, defaultList.stderr).toBe(0); + expect(JSON.parse(defaultList.stdout)).toEqual({ + data: legacyData.slice(0, 20), + hasMore: true, + nextCursor: null, + }); + const limited = await runCli( + [...args, "--list-version", "2", "--limit", "1", "--json"], + options, + ); + expect(limited.exitCode, limited.stderr).toBe(0); + expect(JSON.parse(limited.stdout)).toEqual({ + data: legacyData.slice(0, 1), + hasMore: true, + nextCursor: null, + }); + const table = await runCli( + [...args, "--list-version", "2", "--limit", "1", "--format", "table"], + options, + ); + expect(table.exitCode, table.stderr).toBe(0); + const label = args.includes("sessions") ? "record-" : "Record "; + expect(table.stdout).toContain(`${label}0`); + expect(table.stdout).not.toContain(`${label}1`); + expect(table.stdout).toContain( + "Showing 1 results. More results are available", + ); + const all = await runCli( + [...args, "--list-version", "2", "--all", "--json"], + options, + ); + expect(all.exitCode, all.stderr).toBe(0); + expect(JSON.parse(all.stdout)).toEqual({ + data: legacyData, + hasMore: complete ? false : null, + nextCursor: null, + }); + const requestCount = server.requests.length; + const conflict = await runCli( + [...args, "--list-version", "2", "--all", "--limit", "1"], + options, + ); + expect(conflict.exitCode).not.toBe(0); + expect(conflict.stderr).toContain("--all and --limit"); + expect(server.requests).toHaveLength(requestCount); + }, + ); + + it("applies the same contract to local context aliases", async () => { + const dir = await mkdtemp(join(tmpdir(), "browse-collection-contexts-")); + dirs.push(dir); + await writeFile( + join(dir, "contexts.json"), + JSON.stringify({ + version: 1, + contexts: { + a: { id: "ctx-a", createdAt: "2026-01-01T00:00:00Z" }, + b: { id: "ctx-b", createdAt: "2026-01-02T00:00:00Z" }, + }, + }), + ); + const result = await runCli( + [ + "cloud", + "contexts", + "list", + "--list-version", + "2", + "--limit", + "1", + "--json", + ], + { env: { ...env, BROWSERBASE_CONFIG_DIR: dir } }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + data: [{ name: "a", id: "ctx-a", createdAt: "2026-01-01T00:00:00Z" }], + hasMore: true, + nextCursor: null, + }); + }); + + it.each([ + ["cloud", "secrets", "list"], + ["functions", "secrets", "list", "function-id"], + ])( + "paginates %j without losing records and preserves filters", + async (...args) => { + const secrets = Array.from({ length: 5 }, (_, i) => ({ + id: `secret-${i}`, + secretKey: `KEY_${i}`, + })); + server = await startFakeBrowserbaseServer((request, response) => { + const query = new URL(request.path, "http://localhost").searchParams; + const offset = Number(query.get("cursor") ?? 0); + const size = Math.min(2, Number(query.get("limit") ?? 20)); + const data = secrets.slice(offset, offset + size); + const end = offset + data.length; + jsonResponse(response, 200, { + data, + limit: size, + nextCursor: end < secrets.length ? String(end) : null, + }); + }); + const baseArgs = [ + ...args, + "--base-url", + server.baseUrl, + "--list-version", + "2", + "--start-at", + "2026-01-01T00:00:00Z", + "--end-at", + "2026-02-01T00:00:00Z", + ]; + const first = await runCli([...baseArgs, "--limit", "3", "--json"], { + env, + }); + expect(first.exitCode, first.stderr).toBe(0); + expect(JSON.parse(first.stdout)).toEqual({ + data: secrets.slice(0, 3), + hasMore: true, + nextCursor: "3", + }); + expect( + server.requests.map((r) => + new URL(r.path, server!.baseUrl).searchParams.get("limit"), + ), + ).toEqual(["3", "1"]); + const rest = await runCli( + [...baseArgs, "--cursor", "3", "--all", "--json"], + { env }, + ); + expect(rest.exitCode, rest.stderr).toBe(0); + expect(JSON.parse(rest.stdout)).toEqual({ + data: secrets.slice(3), + hasMore: false, + nextCursor: null, + }); + const all = await runCli( + [...baseArgs, "--all", "--format", "table", "--wide"], + { env }, + ); + expect(all.exitCode, all.stderr).toBe(0); + for (const secret of secrets) + expect(all.stdout).toContain(secret.secretKey); + for (const request of server.requests) { + const query = new URL(request.path, server.baseUrl).searchParams; + expect(query.get("startAt")).toBe("2026-01-01T00:00:00Z"); + expect(query.get("endAt")).toBe("2026-02-01T00:00:00Z"); + } + }, + ); + + it.each(["error", "cycle", "oversized"])( + "fails cleanly on a later page: %s", + async (mode) => { + let calls = 0; + server = await startFakeBrowserbaseServer((_request, response) => { + calls++; + if (calls === 1) { + jsonResponse(response, 200, { + data: [{ id: "one", secretKey: "ONE" }], + nextCursor: "next", + }); + return; + } + if (mode === "error") + jsonResponse(response, 403, { message: "Forbidden" }); + else + jsonResponse(response, 200, { + data: mode === "oversized" ? [{}, {}, {}] : [], + nextCursor: "next", + }); + }); + const result = await runCli( + [ + "cloud", + "secrets", + "list", + "--base-url", + server.baseUrl, + "--list-version", + "2", + "--limit", + "3", + "--json", + ], + { env }, + ); + expect(result.exitCode).not.toBe(0); + expect(result.stdout).toBe(""); + expect(server.requests).toHaveLength(2); + }, + ); + + it("uses the same empty table message", async () => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, { data: [], nextCursor: null }), + ); + const result = await runCli( + [ + "cloud", + "secrets", + "list", + "--base-url", + server.baseUrl, + "--list-version", + "2", + "--format", + "table", + ], + { env }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toBe("No results.\n"); + }); +}); diff --git a/packages/cli/tests/collections.test.ts b/packages/cli/tests/collections.test.ts new file mode 100644 index 0000000000..3aea25c658 --- /dev/null +++ b/packages/cli/tests/collections.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { collect, type CollectionSource } from "../src/lib/collections.js"; + +describe("cursor collection boundaries", () => { + it("accepts advancing empty pages and stops exactly at the requested record", async () => { + const calls: Array<{ limit: number; cursor?: string }> = []; + const source: CollectionSource = { + kind: "cursor", + pageSize: 2, + loadPage: async (options) => { + calls.push(options); + if (!options.cursor) return { data: [], nextCursor: "a+b/==" }; + if (options.cursor === "a+b/==") + return { data: [1, 2], nextCursor: "after-2" }; + return { data: [3], nextCursor: "after-3" }; + }, + }; + expect(await collect(source, { limit: 3 })).toEqual({ + data: [1, 2, 3], + hasMore: true, + nextCursor: "after-3", + }); + expect(calls).toEqual([ + { limit: 2, cursor: undefined }, + { limit: 2, cursor: "a+b/==" }, + { limit: 1, cursor: "after-2" }, + ]); + }); + + it.each(["", "initial"])( + "rejects invalid continuation %j", + async (nextCursor) => { + await expect( + collect( + { + kind: "cursor", + pageSize: 1000, + loadPage: async () => ({ data: [], nextCursor }), + }, + { cursor: "initial" }, + ), + ).rejects.toThrow("pagination cursor"); + }, + ); + + it("allows total limits greater than the API page size", async () => { + const calls: number[] = []; + const result = await collect( + { + kind: "cursor", + pageSize: 1000, + loadPage: async ({ limit, cursor }) => { + calls.push(limit); + return { + data: Array.from( + { length: limit }, + (_, i) => Number(cursor ?? 0) + i, + ), + nextCursor: cursor ? null : "1000", + }; + }, + }, + { limit: 1001 }, + ); + expect(result.data).toHaveLength(1001); + expect(result.data[1000]).toBe(1000); + expect(result.hasMore).toBe(false); + expect(calls).toEqual([1000, 1]); + }); +});