@vibemastery/zurf 0.1.0 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import * as fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { HUMAN_BODY_PREVIEW_CHARS, humanBrowseMetaLines, truncateNote, } from '../../lib/browse-output.js';
5
+ import { withBrowserbaseSession } from '../../lib/browserbase-session.js';
6
+ import { cliError, errorCode } from '../../lib/cli-errors.js';
7
+ import { resolveFormat, resolveProjectId } from '../../lib/config.js';
8
+ import { zurfBaseFlags } from '../../lib/flags.js';
9
+ import { htmlToMarkdown } from '../../lib/html-to-markdown.js';
10
+ import { printJson } from '../../lib/json-output.js';
11
+ import { ZurfBrowserbaseCommand } from '../../lib/zurf-browserbase-command.js';
12
+ export default class Browse extends ZurfBrowserbaseCommand {
13
+ static args = {
14
+ url: Args.string({
15
+ description: 'URL to browse',
16
+ required: true,
17
+ }),
18
+ };
19
+ static description = `Browse a URL in a cloud browser and return the rendered content as markdown (default) or raw HTML.
20
+ Uses a real Chromium browser via Browserbase, so JavaScript-heavy pages are fully rendered.
21
+ Requires authentication and a Project ID. Run \`zurf init --global\` before first use.`;
22
+ static examples = [
23
+ '<%= config.bin %> <%= command.id %> https://example.com',
24
+ '<%= config.bin %> <%= command.id %> https://example.com --html',
25
+ '<%= config.bin %> <%= command.id %> https://example.com --json',
26
+ '<%= config.bin %> <%= command.id %> https://example.com -o page.md',
27
+ ];
28
+ static flags = {
29
+ ...zurfBaseFlags,
30
+ output: Flags.string({
31
+ char: 'o',
32
+ description: 'Write rendered content to this file (full content); otherwise human mode prints a truncated preview to stdout',
33
+ }),
34
+ };
35
+ static summary = 'Browse a URL in a cloud browser and return rendered content as markdown';
36
+ async run() {
37
+ const { args, flags } = await this.parse(Browse);
38
+ const url = args.url.trim();
39
+ let parsed;
40
+ try {
41
+ parsed = new URL(url);
42
+ }
43
+ catch {
44
+ cliError({ command: this, exitCode: 2, json: flags.json, message: `Invalid URL: ${url}` });
45
+ }
46
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
47
+ cliError({
48
+ command: this,
49
+ exitCode: 2,
50
+ json: flags.json,
51
+ message: `Only http and https URLs are supported: ${url}`,
52
+ });
53
+ }
54
+ if (!parsed.hostname) {
55
+ cliError({ command: this, exitCode: 2, json: flags.json, message: `Invalid URL: ${url}` });
56
+ }
57
+ await this.runWithBrowserbase(flags, `Browsing ${url}`, async (client) => {
58
+ const resolution = resolveProjectId({ globalConfigDir: this.config.configDir });
59
+ if (resolution.source === 'none') {
60
+ throw new Error('No Browserbase Project ID found. Set BROWSERBASE_PROJECT_ID, run `zurf init --global` with --project-id, or add projectId to your .zurf/config.json.');
61
+ }
62
+ const { projectId } = resolution;
63
+ const result = await withBrowserbaseSession({
64
+ client,
65
+ projectId,
66
+ async work(page) {
67
+ const response = await page.goto(url, { timeout: 30_000, waitUntil: 'networkidle' });
68
+ const statusCode = response?.status() ?? null;
69
+ const content = await page.content();
70
+ return { content, statusCode };
71
+ },
72
+ });
73
+ const format = resolveFormat({ flagHtml: flags.html, globalConfigDir: this.config.configDir });
74
+ const content = format === 'markdown' ? await htmlToMarkdown(result.content) : result.content;
75
+ if (flags.json) {
76
+ const payload = { content, format, statusCode: result.statusCode, url };
77
+ printJson(payload);
78
+ return;
79
+ }
80
+ this.logToStderr(humanBrowseMetaLines({ statusCode: result.statusCode, url }).join('\n'));
81
+ this.logToStderr('');
82
+ if (flags.output) {
83
+ try {
84
+ await fs.writeFile(flags.output, content, 'utf8');
85
+ }
86
+ catch (error) {
87
+ if (errorCode(error) === 'ENOENT') {
88
+ cliError({
89
+ command: this,
90
+ exitCode: 1,
91
+ json: flags.json,
92
+ message: `Directory does not exist for output file: ${path.dirname(path.resolve(flags.output))}`,
93
+ });
94
+ }
95
+ throw error;
96
+ }
97
+ this.logToStderr(`Wrote rendered content to ${flags.output}`);
98
+ return;
99
+ }
100
+ if (content.length <= HUMAN_BODY_PREVIEW_CHARS) {
101
+ this.log(content);
102
+ return;
103
+ }
104
+ this.log(content.slice(0, HUMAN_BODY_PREVIEW_CHARS));
105
+ this.log('');
106
+ this.logToStderr(truncateNote(content.length));
107
+ });
108
+ }
109
+ }
@@ -3,6 +3,7 @@ export default class ConfigWhich extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
+ html: import("@oclif/core/interfaces").BooleanFlag<boolean>;
6
7
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
8
  };
8
9
  static summary: string;
@@ -10,6 +10,7 @@ export default class Fetch extends ZurfBrowserbaseCommand {
10
10
  'allow-redirects': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
11
  output: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
12
  proxies: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ html: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
14
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
15
  };
15
16
  static summary: string;
@@ -1,9 +1,11 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import * as fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { cliError } from '../../lib/cli-errors.js';
4
+ import { cliError, errorCode } from '../../lib/cli-errors.js';
5
+ import { resolveFormat } from '../../lib/config.js';
5
6
  import { buildFetchJsonPayload, HUMAN_BODY_PREVIEW_CHARS, humanFetchMetaLines, truncateNote, } from '../../lib/fetch-output.js';
6
7
  import { zurfBaseFlags } from '../../lib/flags.js';
8
+ import { htmlToMarkdown } from '../../lib/html-to-markdown.js';
7
9
  import { printJson } from '../../lib/json-output.js';
8
10
  import { ZurfBrowserbaseCommand } from '../../lib/zurf-browserbase-command.js';
9
11
  export default class Fetch extends ZurfBrowserbaseCommand {
@@ -13,12 +15,13 @@ export default class Fetch extends ZurfBrowserbaseCommand {
13
15
  required: true,
14
16
  }),
15
17
  };
16
- static description = `Fetch a URL via Browserbase (no browser session; static HTML, 1 MB max).
18
+ static description = `Fetch a URL via Browserbase and return content as markdown (default) or raw HTML (no browser session; static HTML, 1 MB max).
17
19
  Requires authentication. Run \`zurf init --global\` or use a project key before first use.`;
18
20
  static examples = [
19
21
  '<%= config.bin %> <%= command.id %> https://example.com',
22
+ '<%= config.bin %> <%= command.id %> https://example.com --html',
20
23
  '<%= config.bin %> <%= command.id %> https://example.com --json',
21
- '<%= config.bin %> <%= command.id %> https://example.com -o page.html --proxies',
24
+ '<%= config.bin %> <%= command.id %> https://example.com -o page.md --proxies',
22
25
  ];
23
26
  static flags = {
24
27
  ...zurfBaseFlags,
@@ -39,7 +42,7 @@ Requires authentication. Run \`zurf init --global\` or use a project key before
39
42
  description: 'Route through Browserbase proxies (helps with some blocked sites)',
40
43
  }),
41
44
  };
42
- static summary = 'Fetch a URL via Browserbase';
45
+ static summary = 'Fetch a URL via Browserbase and return content as markdown';
43
46
  async run() {
44
47
  const { args, flags } = await this.parse(Fetch);
45
48
  const url = args.url.trim();
@@ -68,24 +71,21 @@ Requires authentication. Run \`zurf init --global\` or use a project key before
68
71
  proxies: flags.proxies,
69
72
  url,
70
73
  });
74
+ const format = resolveFormat({ flagHtml: flags.html, globalConfigDir: this.config.configDir });
75
+ const content = format === 'markdown' ? await htmlToMarkdown(response.content) : response.content;
76
+ const converted = { ...response, content };
71
77
  if (flags.json) {
72
- printJson(buildFetchJsonPayload(response));
78
+ printJson(buildFetchJsonPayload(converted, format));
73
79
  return;
74
80
  }
75
81
  this.logToStderr(humanFetchMetaLines(response).join('\n'));
76
82
  this.logToStderr('');
77
83
  if (flags.output) {
78
84
  try {
79
- await fs.writeFile(flags.output, response.content, 'utf8');
85
+ await fs.writeFile(flags.output, content, 'utf8');
80
86
  }
81
87
  catch (error) {
82
- const code = error !== null &&
83
- typeof error === 'object' &&
84
- 'code' in error &&
85
- typeof error.code === 'string'
86
- ? error.code
87
- : undefined;
88
- if (code === 'ENOENT') {
88
+ if (errorCode(error) === 'ENOENT') {
89
89
  cliError({
90
90
  command: this,
91
91
  exitCode: 1,
@@ -95,10 +95,9 @@ Requires authentication. Run \`zurf init --global\` or use a project key before
95
95
  }
96
96
  throw error;
97
97
  }
98
- this.logToStderr(`Wrote body to ${flags.output}`);
98
+ this.logToStderr(`Wrote content to ${flags.output}`);
99
99
  return;
100
100
  }
101
- const { content } = response;
102
101
  if (content.length <= HUMAN_BODY_PREVIEW_CHARS) {
103
102
  this.log(content);
104
103
  return;
@@ -7,9 +7,12 @@ export default class Init extends Command {
7
7
  gitignore: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
8
  global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
9
  local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ 'project-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ html: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
12
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
13
  };
12
14
  static summary: string;
13
15
  run(): Promise<void>;
14
16
  private readApiKeyForInit;
17
+ private readProjectIdForInit;
15
18
  }
@@ -1,17 +1,18 @@
1
1
  import { Command, Flags } from '@oclif/core';
2
2
  import * as fs from 'node:fs/promises';
3
3
  import { cliError, errorMessage } from '../../lib/cli-errors.js';
4
- import { globalConfigFilePath, localConfigPathForCwd, writeApiKeyConfig } from '../../lib/config.js';
4
+ import { globalConfigFilePath, localConfigPathForCwd, writeConfig } from '../../lib/config.js';
5
5
  import { zurfBaseFlags } from '../../lib/flags.js';
6
6
  import { dotGitignoreMentionsZurf, ensureZurfGitignoreEntry, gitignorePathForCwd, } from '../../lib/gitignore-zurf.js';
7
7
  import { promptLine, readStdinIfPiped } from '../../lib/init-input.js';
8
8
  import { printJson } from '../../lib/json-output.js';
9
9
  export default class Init extends Command {
10
- static description = `Save your Browserbase API key to global or project config.
10
+ static description = `Save your Browserbase API key and optional Project ID to global or project config.
11
11
  Global path follows oclif config (same as \`zurf config which\`).`;
12
12
  static examples = [
13
13
  '<%= config.bin %> <%= command.id %> --global',
14
14
  '<%= config.bin %> <%= command.id %> --local',
15
+ '<%= config.bin %> <%= command.id %> --global --api-key KEY --project-id PROJ_ID',
15
16
  'printenv BROWSERBASE_API_KEY | <%= config.bin %> <%= command.id %> --global',
16
17
  ];
17
18
  static flags = {
@@ -30,16 +31,23 @@ Global path follows oclif config (same as \`zurf config which\`).`;
30
31
  description: 'Store API key in ./.zurf/config.json for this directory',
31
32
  exactlyOne: ['global', 'local'],
32
33
  }),
34
+ 'project-id': Flags.string({
35
+ description: 'Browserbase Project ID (optional; needed for browse, screenshot, pdf commands)',
36
+ }),
33
37
  };
34
- static summary = 'Configure Browserbase API key storage';
38
+ static summary = 'Configure Browserbase API key and Project ID storage';
35
39
  async run() {
36
40
  const { flags } = await this.parse(Init);
37
41
  const apiKey = await this.readApiKeyForInit(flags);
38
42
  const targetPath = flags.global
39
43
  ? globalConfigFilePath(this.config.configDir)
40
44
  : localConfigPathForCwd();
45
+ const projectId = await this.readProjectIdForInit(flags);
46
+ const configUpdate = { apiKey: apiKey.trim() };
47
+ if (projectId)
48
+ configUpdate.projectId = projectId;
41
49
  try {
42
- await writeApiKeyConfig(targetPath, apiKey);
50
+ await writeConfig(targetPath, configUpdate);
43
51
  }
44
52
  catch (error) {
45
53
  cliError({ command: this, exitCode: 1, json: flags.json, message: errorMessage(error) });
@@ -53,10 +61,16 @@ Global path follows oclif config (same as \`zurf config which\`).`;
53
61
  }
54
62
  }
55
63
  if (flags.json) {
56
- printJson({ ok: true, path: targetPath, scope: flags.global ? 'global' : 'local' });
64
+ const payload = { ok: true, path: targetPath, scope: flags.global ? 'global' : 'local' };
65
+ if (projectId)
66
+ payload.projectId = true;
67
+ printJson(payload);
57
68
  }
58
69
  else {
59
70
  this.log(`Saved API key to ${targetPath}`);
71
+ if (projectId) {
72
+ this.log(`Saved Project ID to ${targetPath}`);
73
+ }
60
74
  if (flags.local) {
61
75
  let showTip = true;
62
76
  try {
@@ -92,4 +106,11 @@ Global path follows oclif config (same as \`zurf config which\`).`;
92
106
  }
93
107
  return apiKey;
94
108
  }
109
+ async readProjectIdForInit(flags) {
110
+ let projectId = flags['project-id']?.trim();
111
+ if (!projectId && !flags.json && process.stdin.isTTY) {
112
+ projectId = await promptLine('Browserbase Project ID (optional, press Enter to skip): ');
113
+ }
114
+ return projectId || undefined;
115
+ }
95
116
  }
@@ -7,6 +7,7 @@ export default class Search extends ZurfBrowserbaseCommand {
7
7
  static examples: string[];
8
8
  static flags: {
9
9
  'num-results': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
10
+ html: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
12
  };
12
13
  static summary: string;
@@ -0,0 +1,12 @@
1
+ export interface BrowseJsonPayload {
2
+ content: string;
3
+ format: 'html' | 'markdown';
4
+ statusCode: null | number;
5
+ url: string;
6
+ }
7
+ export declare function humanBrowseMetaLines(options: {
8
+ statusCode: null | number;
9
+ url: string;
10
+ }): string[];
11
+ export declare const HUMAN_BODY_PREVIEW_CHARS = 8000;
12
+ export declare function truncateNote(totalChars: number): string;
@@ -0,0 +1,10 @@
1
+ export function humanBrowseMetaLines(options) {
2
+ return [
3
+ `url: ${options.url}`,
4
+ `statusCode: ${options.statusCode ?? 'unknown'}`,
5
+ ];
6
+ }
7
+ export const HUMAN_BODY_PREVIEW_CHARS = 8000;
8
+ export function truncateNote(totalChars) {
9
+ return `… truncated (${totalChars} chars). Use --output FILE to save the full content.`;
10
+ }
@@ -0,0 +1,7 @@
1
+ import type { Browserbase } from '@browserbasehq/sdk';
2
+ import type { Page } from 'playwright-core';
3
+ export declare function withBrowserbaseSession<T>(options: {
4
+ client: Browserbase;
5
+ projectId: string;
6
+ work: (page: Page) => Promise<T>;
7
+ }): Promise<T>;
@@ -0,0 +1,39 @@
1
+ export async function withBrowserbaseSession(options) {
2
+ const { chromium } = await import('playwright-core');
3
+ const { client, projectId, work } = options;
4
+ const session = await client.sessions.create({ projectId });
5
+ const browser = await chromium.connectOverCDP(session.connectUrl);
6
+ let cleanedUp = false;
7
+ const cleanup = async () => {
8
+ if (cleanedUp)
9
+ return;
10
+ cleanedUp = true;
11
+ try {
12
+ const pages = browser.contexts()[0]?.pages() ?? [];
13
+ await Promise.all(pages.map((p) => p.close().catch(() => { })));
14
+ await browser.close().catch(() => { });
15
+ }
16
+ finally {
17
+ await client.sessions.update(session.id, { status: 'REQUEST_RELEASE' }).catch(() => { });
18
+ }
19
+ };
20
+ const onSignal = () => {
21
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
22
+ cleanup().then(() => process.exit(1), () => process.exit(1));
23
+ };
24
+ process.on('SIGINT', onSignal);
25
+ process.on('SIGTERM', onSignal);
26
+ try {
27
+ const defaultContext = browser.contexts()[0];
28
+ if (!defaultContext) {
29
+ throw new Error('No browser context available from Browserbase session');
30
+ }
31
+ const page = defaultContext.pages()[0] ?? (await defaultContext.newPage());
32
+ return await work(page);
33
+ }
34
+ finally {
35
+ process.removeListener('SIGINT', onSignal);
36
+ process.removeListener('SIGTERM', onSignal);
37
+ await cleanup();
38
+ }
39
+ }
@@ -14,6 +14,7 @@ export declare class CliJsonExitContractError extends Error {
14
14
  constructor();
15
15
  }
16
16
  export declare function errorMessage(err: unknown): string;
17
+ export declare function errorCode(err: unknown): string | undefined;
17
18
  export declare function errorStatus(err: unknown): number | undefined;
18
19
  /** Print JSON or human error and exit; does not return. */
19
20
  export declare function cliError(options: CliErrorOptions): never;
@@ -15,6 +15,15 @@ export function errorMessage(err) {
15
15
  }
16
16
  return String(err);
17
17
  }
18
+ export function errorCode(err) {
19
+ if (err !== null &&
20
+ typeof err === 'object' &&
21
+ 'code' in err &&
22
+ typeof err.code === 'string') {
23
+ return err.code;
24
+ }
25
+ return undefined;
26
+ }
18
27
  export function errorStatus(err) {
19
28
  if (err !== null &&
20
29
  typeof err === 'object' &&
@@ -20,15 +20,41 @@ export type ActiveApiKey = Extract<ResolvedApiKey, {
20
20
  }>;
21
21
  export interface ConfigFileShape {
22
22
  apiKey?: string;
23
+ format?: 'html' | 'markdown';
24
+ projectId?: string;
23
25
  }
26
+ export type ResolvedProjectId = {
27
+ path: string;
28
+ projectId: string;
29
+ source: 'global';
30
+ } | {
31
+ path: string;
32
+ projectId: string;
33
+ source: 'local';
34
+ } | {
35
+ projectId: string;
36
+ source: 'env';
37
+ } | {
38
+ source: 'none';
39
+ };
24
40
  /**
25
41
  * Path to global `config.json` under oclif's `this.config.configDir` (same rules as @oclif/core `Config.dir('config')` for `dirname` zurf).
26
42
  */
27
43
  export declare function globalConfigFilePath(oclifConfigDir: string): string;
28
44
  export declare function localConfigPathForCwd(cwd?: string): string;
29
45
  export declare function findLocalConfigPath(startDir?: string): string | undefined;
46
+ export declare function resolveFormat(options: {
47
+ cwd?: string;
48
+ flagHtml: boolean;
49
+ globalConfigDir: string;
50
+ }): 'html' | 'markdown';
30
51
  export declare function resolveApiKey(options: {
31
52
  cwd?: string;
32
53
  globalConfigDir: string;
33
54
  }): ResolvedApiKey;
55
+ export declare function resolveProjectId(options: {
56
+ cwd?: string;
57
+ globalConfigDir: string;
58
+ }): ResolvedProjectId;
34
59
  export declare function writeApiKeyConfig(targetPath: string, apiKey: string): Promise<void>;
60
+ export declare function writeConfig(targetPath: string, fields: Partial<ConfigFileShape>): Promise<void>;
@@ -26,17 +26,57 @@ export function findLocalConfigPath(startDir = process.cwd()) {
26
26
  }
27
27
  return undefined;
28
28
  }
29
- function readApiKeyFromFile(filePath) {
29
+ function readConfigFile(filePath) {
30
30
  try {
31
31
  const raw = fs.readFileSync(filePath, 'utf8');
32
- const parsed = JSON.parse(raw);
33
- const key = typeof parsed.apiKey === 'string' ? parsed.apiKey.trim() : '';
34
- return key.length > 0 ? key : undefined;
32
+ return JSON.parse(raw);
35
33
  }
36
34
  catch {
37
35
  return undefined;
38
36
  }
39
37
  }
38
+ function readApiKeyFromFile(filePath) {
39
+ const parsed = readConfigFile(filePath);
40
+ if (!parsed)
41
+ return undefined;
42
+ const key = typeof parsed.apiKey === 'string' ? parsed.apiKey.trim() : '';
43
+ return key.length > 0 ? key : undefined;
44
+ }
45
+ function readProjectIdFromFile(filePath) {
46
+ const parsed = readConfigFile(filePath);
47
+ if (!parsed)
48
+ return undefined;
49
+ const id = typeof parsed.projectId === 'string' ? parsed.projectId.trim() : '';
50
+ return id.length > 0 ? id : undefined;
51
+ }
52
+ function readFormatFromFile(filePath) {
53
+ const parsed = readConfigFile(filePath);
54
+ if (!parsed)
55
+ return undefined;
56
+ const fmt = parsed.format;
57
+ if (fmt === 'html' || fmt === 'markdown')
58
+ return fmt;
59
+ return undefined;
60
+ }
61
+ export function resolveFormat(options) {
62
+ if (options.flagHtml)
63
+ return 'html';
64
+ const envVal = process.env.ZURF_HTML?.trim().toLowerCase();
65
+ if (envVal === 'true' || envVal === '1')
66
+ return 'html';
67
+ const cwd = options.cwd ?? process.cwd();
68
+ const localPath = findLocalConfigPath(cwd);
69
+ if (localPath) {
70
+ const fmt = readFormatFromFile(localPath);
71
+ if (fmt)
72
+ return fmt;
73
+ }
74
+ const gPath = globalConfigFilePath(options.globalConfigDir);
75
+ const globalFmt = readFormatFromFile(gPath);
76
+ if (globalFmt)
77
+ return globalFmt;
78
+ return 'markdown';
79
+ }
40
80
  export function resolveApiKey(options) {
41
81
  const cwd = options.cwd ?? process.cwd();
42
82
  const envKey = process.env.BROWSERBASE_API_KEY?.trim();
@@ -57,10 +97,41 @@ export function resolveApiKey(options) {
57
97
  }
58
98
  return { source: 'none' };
59
99
  }
100
+ export function resolveProjectId(options) {
101
+ const cwd = options.cwd ?? process.cwd();
102
+ const envId = process.env.BROWSERBASE_PROJECT_ID?.trim();
103
+ if (envId) {
104
+ return { projectId: envId, source: 'env' };
105
+ }
106
+ const localPath = findLocalConfigPath(cwd);
107
+ if (localPath) {
108
+ const id = readProjectIdFromFile(localPath);
109
+ if (id) {
110
+ return { path: localPath, projectId: id, source: 'local' };
111
+ }
112
+ }
113
+ const gPath = globalConfigFilePath(options.globalConfigDir);
114
+ const globalId = readProjectIdFromFile(gPath);
115
+ if (globalId) {
116
+ return { path: gPath, projectId: globalId, source: 'global' };
117
+ }
118
+ return { source: 'none' };
119
+ }
60
120
  export async function writeApiKeyConfig(targetPath, apiKey) {
121
+ await writeConfig(targetPath, { apiKey: apiKey.trim() });
122
+ }
123
+ export async function writeConfig(targetPath, fields) {
61
124
  const dir = path.dirname(targetPath);
62
125
  await fs.promises.mkdir(dir, { recursive: true });
63
- const payload = { apiKey: apiKey.trim() };
64
- const body = `${JSON.stringify(payload, null, 2)}\n`;
126
+ let existing = {};
127
+ try {
128
+ const raw = await fs.promises.readFile(targetPath, 'utf8');
129
+ existing = JSON.parse(raw);
130
+ }
131
+ catch {
132
+ // file doesn't exist yet — start fresh
133
+ }
134
+ const merged = { ...existing, ...fields };
135
+ const body = `${JSON.stringify(merged, null, 2)}\n`;
65
136
  await fs.promises.writeFile(targetPath, body, { encoding: 'utf8', mode: 0o600 });
66
137
  }
@@ -6,10 +6,11 @@ export type FetchResponseForDisplay = {
6
6
  id: string;
7
7
  statusCode: number;
8
8
  };
9
- export declare function buildFetchJsonPayload(response: FetchResponseForDisplay): {
9
+ export declare function buildFetchJsonPayload(response: FetchResponseForDisplay, format: 'html' | 'markdown'): {
10
10
  content: string;
11
11
  contentType: string;
12
12
  encoding: string;
13
+ format: 'html' | 'markdown';
13
14
  headers: Record<string, string>;
14
15
  id: string;
15
16
  statusCode: number;
@@ -1,8 +1,9 @@
1
- export function buildFetchJsonPayload(response) {
1
+ export function buildFetchJsonPayload(response, format) {
2
2
  return {
3
3
  content: response.content,
4
4
  contentType: response.contentType,
5
5
  encoding: response.encoding,
6
+ format,
6
7
  headers: response.headers,
7
8
  id: response.id,
8
9
  statusCode: response.statusCode,
@@ -19,5 +20,5 @@ export function humanFetchMetaLines(response) {
19
20
  /** ~8k chars keeps terminal scrollback usable while showing most HTML pages in preview. */
20
21
  export const HUMAN_BODY_PREVIEW_CHARS = 8000;
21
22
  export function truncateNote(totalChars) {
22
- return `… truncated (${totalChars} chars). Use --output FILE to save the full body (within the 1 MB Fetch limit).`;
23
+ return `… truncated (${totalChars} chars). Use --output FILE to save the full content (within the 1 MB Fetch limit).`;
23
24
  }
@@ -1,6 +1,9 @@
1
1
  /** Machine-readable output; kept separate from oclif `enableJsonFlag` so error payloads match zurf's stdout JSON contract. */
2
2
  export declare const zurfJsonFlag: import("@oclif/core/interfaces").BooleanFlag<boolean>;
3
+ /** Output raw HTML instead of the default markdown conversion. */
4
+ export declare const zurfHtmlFlag: import("@oclif/core/interfaces").BooleanFlag<boolean>;
3
5
  /** Shared flags for commands that support JSON output (no `--api-key` — use BROWSERBASE_API_KEY or config files). */
4
6
  export declare const zurfBaseFlags: {
7
+ readonly html: import("@oclif/core/interfaces").BooleanFlag<boolean>;
5
8
  readonly json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
6
9
  };
package/dist/lib/flags.js CHANGED
@@ -4,7 +4,14 @@ export const zurfJsonFlag = Flags.boolean({
4
4
  description: 'Print machine-readable JSON to stdout',
5
5
  env: 'ZURF_JSON',
6
6
  });
7
+ /** Output raw HTML instead of the default markdown conversion. */
8
+ export const zurfHtmlFlag = Flags.boolean({
9
+ default: false,
10
+ description: 'Output raw HTML instead of markdown',
11
+ env: 'ZURF_HTML',
12
+ });
7
13
  /** Shared flags for commands that support JSON output (no `--api-key` — use BROWSERBASE_API_KEY or config files). */
8
14
  export const zurfBaseFlags = {
15
+ html: zurfHtmlFlag,
9
16
  json: zurfJsonFlag,
10
17
  };
@@ -0,0 +1 @@
1
+ export declare function htmlToMarkdown(html: string): Promise<string>;
@@ -0,0 +1,21 @@
1
+ let cached;
2
+ async function getService() {
3
+ if (cached)
4
+ return cached;
5
+ const { default: Turndown } = await import('turndown');
6
+ const service = new Turndown({
7
+ codeBlockStyle: 'fenced',
8
+ headingStyle: 'atx',
9
+ });
10
+ service.remove(['script', 'style', 'iframe', 'noscript']);
11
+ service.addRule('remove-svg', {
12
+ filter: (node) => node.nodeName.toLowerCase() === 'svg',
13
+ replacement: () => '',
14
+ });
15
+ cached = service;
16
+ return service;
17
+ }
18
+ export async function htmlToMarkdown(html) {
19
+ const service = await getService();
20
+ return service.turndown(html);
21
+ }