@vibemastery/zurf 0.2.2 → 0.3.0

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.
@@ -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).
17
- Requires authentication. Run \`zurf init --global\` or use a project key before first use.`;
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).
19
+ Requires authentication. Run \`zurf setup\` 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,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;
@@ -12,7 +12,7 @@ export default class Search extends ZurfBrowserbaseCommand {
12
12
  }),
13
13
  };
14
14
  static description = `Search the web via Browserbase (Exa-powered).
15
- Requires authentication. Run \`zurf init --global\` or use a project key before first use.`;
15
+ Requires authentication. Run \`zurf setup\` or use a project key before first use.`;
16
16
  static examples = [
17
17
  '<%= config.bin %> <%= command.id %> "browserbase documentation"',
18
18
  '<%= config.bin %> <%= command.id %> "laravel inertia" --num-results 5 --json',
@@ -1,15 +1,14 @@
1
1
  import { Command } from '@oclif/core';
2
- export default class Init extends Command {
2
+ export default class Setup extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
- 'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
- gitignore: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
6
  global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
- local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
7
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
9
  };
12
10
  static summary: string;
13
11
  run(): Promise<void>;
14
- private readApiKeyForInit;
12
+ private collectProviderKeys;
13
+ private suggestGitignore;
15
14
  }
@@ -0,0 +1,123 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import * as fs from 'node:fs/promises';
3
+ import { cliError, errorMessage } from '../../lib/cli-errors.js';
4
+ import { globalConfigFilePath, localConfigPathForCwd, readConfigFile, writeConfig, } from '../../lib/config.js';
5
+ import { zurfJsonFlag } from '../../lib/flags.js';
6
+ import { dotGitignoreMentionsZurf, ensureZurfGitignoreEntry, gitignorePathForCwd, } from '../../lib/gitignore-zurf.js';
7
+ import { printJson } from '../../lib/json-output.js';
8
+ import { promptApiKey, promptProjectId, selectProviders, selectScope, } from '../../lib/setup-prompts.js';
9
+ async function promptBrowserbase() {
10
+ const apiKey = await promptApiKey('Browserbase');
11
+ const projectId = await promptProjectId();
12
+ const browserbase = { apiKey: apiKey.trim() };
13
+ if (projectId.trim()) {
14
+ browserbase.projectId = projectId.trim();
15
+ }
16
+ return { configured: 'Browserbase', providers: { browserbase } };
17
+ }
18
+ async function promptPerplexity() {
19
+ const apiKey = await promptApiKey('Perplexity');
20
+ return { configured: 'Perplexity', providers: { perplexity: { apiKey: apiKey.trim() } } };
21
+ }
22
+ function resolveScope(flags) {
23
+ if (flags.global)
24
+ return 'global';
25
+ if (flags.local)
26
+ return 'local';
27
+ return undefined;
28
+ }
29
+ export default class Setup extends Command {
30
+ static description = `Interactive setup wizard for configuring API keys for all providers (Browserbase, Perplexity).
31
+ Stores keys in global or local config. Re-run to update or add providers.`;
32
+ static examples = [
33
+ '<%= config.bin %> <%= command.id %>',
34
+ '<%= config.bin %> <%= command.id %> --global',
35
+ '<%= config.bin %> <%= command.id %> --local',
36
+ ];
37
+ static flags = {
38
+ global: Flags.boolean({
39
+ description: 'Store config in user config directory (skip scope prompt)',
40
+ }),
41
+ json: zurfJsonFlag,
42
+ local: Flags.boolean({
43
+ description: 'Store config in .zurf/config.json in the current directory (skip scope prompt)',
44
+ }),
45
+ };
46
+ static summary = 'Configure API keys for Browserbase and Perplexity';
47
+ async run() {
48
+ const { flags } = await this.parse(Setup);
49
+ if (flags.global && flags.local) {
50
+ cliError({ command: this, exitCode: 2, json: flags.json, message: 'Cannot use both --global and --local.' });
51
+ }
52
+ const scope = resolveScope(flags) ?? (process.stdin.isTTY
53
+ ? await selectScope()
54
+ : cliError({
55
+ command: this,
56
+ exitCode: 1,
57
+ json: flags.json,
58
+ message: 'Non-interactive environment detected. Use --global or --local flag, or set API keys via environment variables (BROWSERBASE_API_KEY, PERPLEXITY_API_KEY).',
59
+ }));
60
+ const targetPath = scope === 'global'
61
+ ? globalConfigFilePath(this.config.configDir)
62
+ : localConfigPathForCwd();
63
+ const existing = readConfigFile(targetPath);
64
+ const bbConfigured = Boolean(existing?.providers?.browserbase?.apiKey);
65
+ const pplxConfigured = Boolean(existing?.providers?.perplexity?.apiKey);
66
+ const selectedProviders = process.stdin.isTTY
67
+ ? await selectProviders([
68
+ { configured: bbConfigured, name: 'Browserbase', value: 'browserbase' },
69
+ { configured: pplxConfigured, name: 'Perplexity', value: 'perplexity' },
70
+ ])
71
+ : ['browserbase', 'perplexity'];
72
+ const { configUpdate, configured } = await this.collectProviderKeys(selectedProviders);
73
+ try {
74
+ await writeConfig(targetPath, configUpdate);
75
+ }
76
+ catch (error) {
77
+ cliError({ command: this, exitCode: 1, json: flags.json, message: errorMessage(error) });
78
+ }
79
+ if (flags.json) {
80
+ printJson({ configured, ok: true, path: targetPath, scope });
81
+ return;
82
+ }
83
+ for (const name of configured) {
84
+ this.log(`Configured ${name} in ${targetPath}`);
85
+ }
86
+ if (scope === 'local') {
87
+ await this.suggestGitignore();
88
+ }
89
+ }
90
+ async collectProviderKeys(selectedProviders) {
91
+ const configUpdate = { providers: {} };
92
+ const configured = [];
93
+ for (const provider of selectedProviders) {
94
+ // eslint-disable-next-line no-await-in-loop -- sequential prompts, must run in order
95
+ const result = provider === 'browserbase' ? await promptBrowserbase() : await promptPerplexity();
96
+ Object.assign(configUpdate.providers, result.providers);
97
+ configured.push(result.configured);
98
+ }
99
+ return { configUpdate, configured };
100
+ }
101
+ async suggestGitignore() {
102
+ let showTip = true;
103
+ try {
104
+ const gi = await fs.readFile(gitignorePathForCwd(), 'utf8');
105
+ if (dotGitignoreMentionsZurf(gi)) {
106
+ showTip = false;
107
+ }
108
+ }
109
+ catch {
110
+ // no .gitignore yet
111
+ }
112
+ if (showTip) {
113
+ this.log('Tip: add .zurf/ to .gitignore so keys are not committed.');
114
+ try {
115
+ await ensureZurfGitignoreEntry(gitignorePathForCwd());
116
+ this.log('Added .zurf/ to .gitignore.');
117
+ }
118
+ catch {
119
+ // best effort
120
+ }
121
+ }
122
+ }
123
+ }
@@ -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
+ }
@@ -10,7 +10,7 @@ export async function createBrowserbaseClient(options) {
10
10
  }
11
11
  export class MissingApiKeyError extends Error {
12
12
  constructor() {
13
- super('No Browserbase API key found. Set BROWSERBASE_API_KEY, run `zurf init --global` or `zurf init --local`, or add a project `.zurf/config.json`.');
13
+ super('No Browserbase API key found. Set BROWSERBASE_API_KEY, run `zurf setup`, or add a project `.zurf/config.json`.');
14
14
  this.name = 'MissingApiKeyError';
15
15
  }
16
16
  }
@@ -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' &&
@@ -18,17 +18,64 @@ export type ResolvedApiKey = {
18
18
  export type ActiveApiKey = Extract<ResolvedApiKey, {
19
19
  apiKey: string;
20
20
  }>;
21
- export interface ConfigFileShape {
21
+ /** Old flat config shape (v0.2.x and earlier). */
22
+ export interface LegacyConfigFileShape {
22
23
  apiKey?: string;
24
+ format?: 'html' | 'markdown';
25
+ projectId?: string;
26
+ }
27
+ /** Current config shape (v0.3.0+). */
28
+ export interface ConfigFileShape {
29
+ format?: 'html' | 'markdown';
30
+ providers?: {
31
+ browserbase?: {
32
+ apiKey?: string;
33
+ projectId?: string;
34
+ };
35
+ perplexity?: {
36
+ apiKey?: string;
37
+ };
38
+ };
23
39
  }
40
+ export type ResolvedProjectId = {
41
+ path: string;
42
+ projectId: string;
43
+ source: 'global';
44
+ } | {
45
+ path: string;
46
+ projectId: string;
47
+ source: 'local';
48
+ } | {
49
+ projectId: string;
50
+ source: 'env';
51
+ } | {
52
+ source: 'none';
53
+ };
54
+ /** Alias — structurally identical to ResolvedApiKey; kept for semantic clarity at call sites. */
55
+ export type ResolvedPerplexityApiKey = ResolvedApiKey;
24
56
  /**
25
57
  * Path to global `config.json` under oclif's `this.config.configDir` (same rules as @oclif/core `Config.dir('config')` for `dirname` zurf).
26
58
  */
27
59
  export declare function globalConfigFilePath(oclifConfigDir: string): string;
28
60
  export declare function localConfigPathForCwd(cwd?: string): string;
29
61
  export declare function findLocalConfigPath(startDir?: string): string | undefined;
62
+ export declare function readConfigFile(filePath: string): ConfigFileShape | undefined;
63
+ export declare function resolveFormat(options: {
64
+ cwd?: string;
65
+ flagHtml: boolean;
66
+ globalConfigDir: string;
67
+ }): 'html' | 'markdown';
30
68
  export declare function resolveApiKey(options: {
31
69
  cwd?: string;
32
70
  globalConfigDir: string;
33
71
  }): ResolvedApiKey;
72
+ export declare function resolveProjectId(options: {
73
+ cwd?: string;
74
+ globalConfigDir: string;
75
+ }): ResolvedProjectId;
76
+ export declare function resolvePerplexityApiKey(options: {
77
+ cwd?: string;
78
+ globalConfigDir: string;
79
+ }): ResolvedPerplexityApiKey;
34
80
  export declare function writeApiKeyConfig(targetPath: string, apiKey: string): Promise<void>;
81
+ export declare function writeConfig(targetPath: string, fields: Partial<ConfigFileShape>): Promise<void>;
@@ -26,17 +26,84 @@ export function findLocalConfigPath(startDir = process.cwd()) {
26
26
  }
27
27
  return undefined;
28
28
  }
29
- function readApiKeyFromFile(filePath) {
29
+ /**
30
+ * Detect whether a parsed config object uses the old flat shape (has `apiKey` at root, no `providers` key).
31
+ */
32
+ function isLegacyConfig(raw) {
33
+ if (raw === null || typeof raw !== 'object')
34
+ return false;
35
+ const obj = raw;
36
+ return ('apiKey' in obj || 'projectId' in obj) && !('providers' in obj);
37
+ }
38
+ /**
39
+ * Migrate a legacy flat config to the new nested shape.
40
+ */
41
+ function migrateLegacyConfig(legacy) {
42
+ const config = {};
43
+ if (legacy.apiKey || legacy.projectId) {
44
+ config.providers = {
45
+ browserbase: {},
46
+ };
47
+ if (legacy.apiKey)
48
+ config.providers.browserbase.apiKey = legacy.apiKey;
49
+ if (legacy.projectId)
50
+ config.providers.browserbase.projectId = legacy.projectId;
51
+ }
52
+ if (legacy.format)
53
+ config.format = legacy.format;
54
+ return config;
55
+ }
56
+ export function readConfigFile(filePath) {
30
57
  try {
31
58
  const raw = fs.readFileSync(filePath, 'utf8');
32
59
  const parsed = JSON.parse(raw);
33
- const key = typeof parsed.apiKey === 'string' ? parsed.apiKey.trim() : '';
34
- return key.length > 0 ? key : undefined;
60
+ if (isLegacyConfig(parsed)) {
61
+ return migrateLegacyConfig(parsed);
62
+ }
63
+ return parsed;
35
64
  }
36
65
  catch {
37
66
  return undefined;
38
67
  }
39
68
  }
69
+ function readStringField(filePath, getter) {
70
+ const parsed = readConfigFile(filePath);
71
+ if (!parsed)
72
+ return undefined;
73
+ const val = getter(parsed)?.trim() ?? '';
74
+ return val.length > 0 ? val : undefined;
75
+ }
76
+ const readBrowserbaseApiKeyFromFile = (f) => readStringField(f, (c) => c.providers?.browserbase?.apiKey);
77
+ const readBrowserbaseProjectIdFromFile = (f) => readStringField(f, (c) => c.providers?.browserbase?.projectId);
78
+ const readPerplexityApiKeyFromFile = (f) => readStringField(f, (c) => c.providers?.perplexity?.apiKey);
79
+ function readFormatFromFile(filePath) {
80
+ const parsed = readConfigFile(filePath);
81
+ if (!parsed)
82
+ return undefined;
83
+ const fmt = parsed.format;
84
+ if (fmt === 'html' || fmt === 'markdown')
85
+ return fmt;
86
+ return undefined;
87
+ }
88
+ export function resolveFormat(options) {
89
+ if (options.flagHtml)
90
+ return 'html';
91
+ const envVal = process.env.ZURF_HTML?.trim().toLowerCase();
92
+ if (envVal === 'true' || envVal === '1')
93
+ return 'html';
94
+ const cwd = options.cwd ?? process.cwd();
95
+ const localPath = findLocalConfigPath(cwd);
96
+ if (localPath) {
97
+ const fmt = readFormatFromFile(localPath);
98
+ if (fmt)
99
+ return fmt;
100
+ }
101
+ const gPath = globalConfigFilePath(options.globalConfigDir);
102
+ const globalFmt = readFormatFromFile(gPath);
103
+ if (globalFmt)
104
+ return globalFmt;
105
+ return 'markdown';
106
+ }
40
107
  export function resolveApiKey(options) {
41
108
  const cwd = options.cwd ?? process.cwd();
42
109
  const envKey = process.env.BROWSERBASE_API_KEY?.trim();
@@ -45,22 +112,98 @@ export function resolveApiKey(options) {
45
112
  }
46
113
  const localPath = findLocalConfigPath(cwd);
47
114
  if (localPath) {
48
- const key = readApiKeyFromFile(localPath);
115
+ const key = readBrowserbaseApiKeyFromFile(localPath);
116
+ if (key) {
117
+ return { apiKey: key, path: localPath, source: 'local' };
118
+ }
119
+ }
120
+ const gPath = globalConfigFilePath(options.globalConfigDir);
121
+ const globalKey = readBrowserbaseApiKeyFromFile(gPath);
122
+ if (globalKey) {
123
+ return { apiKey: globalKey, path: gPath, source: 'global' };
124
+ }
125
+ return { source: 'none' };
126
+ }
127
+ export function resolveProjectId(options) {
128
+ const cwd = options.cwd ?? process.cwd();
129
+ const envId = process.env.BROWSERBASE_PROJECT_ID?.trim();
130
+ if (envId) {
131
+ return { projectId: envId, source: 'env' };
132
+ }
133
+ const localPath = findLocalConfigPath(cwd);
134
+ if (localPath) {
135
+ const id = readBrowserbaseProjectIdFromFile(localPath);
136
+ if (id) {
137
+ return { path: localPath, projectId: id, source: 'local' };
138
+ }
139
+ }
140
+ const gPath = globalConfigFilePath(options.globalConfigDir);
141
+ const globalId = readBrowserbaseProjectIdFromFile(gPath);
142
+ if (globalId) {
143
+ return { path: gPath, projectId: globalId, source: 'global' };
144
+ }
145
+ return { source: 'none' };
146
+ }
147
+ export function resolvePerplexityApiKey(options) {
148
+ const cwd = options.cwd ?? process.cwd();
149
+ const envKey = process.env.PERPLEXITY_API_KEY?.trim();
150
+ if (envKey) {
151
+ return { apiKey: envKey, source: 'env' };
152
+ }
153
+ const localPath = findLocalConfigPath(cwd);
154
+ if (localPath) {
155
+ const key = readPerplexityApiKeyFromFile(localPath);
49
156
  if (key) {
50
157
  return { apiKey: key, path: localPath, source: 'local' };
51
158
  }
52
159
  }
53
160
  const gPath = globalConfigFilePath(options.globalConfigDir);
54
- const globalKey = readApiKeyFromFile(gPath);
161
+ const globalKey = readPerplexityApiKeyFromFile(gPath);
55
162
  if (globalKey) {
56
163
  return { apiKey: globalKey, path: gPath, source: 'global' };
57
164
  }
58
165
  return { source: 'none' };
59
166
  }
60
167
  export async function writeApiKeyConfig(targetPath, apiKey) {
168
+ await writeConfig(targetPath, { providers: { browserbase: { apiKey: apiKey.trim() } } });
169
+ }
170
+ export async function writeConfig(targetPath, fields) {
61
171
  const dir = path.dirname(targetPath);
62
172
  await fs.promises.mkdir(dir, { recursive: true });
63
- const payload = { apiKey: apiKey.trim() };
64
- const body = `${JSON.stringify(payload, null, 2)}\n`;
173
+ let existing = {};
174
+ try {
175
+ const raw = await fs.promises.readFile(targetPath, 'utf8');
176
+ const parsed = JSON.parse(raw);
177
+ existing = isLegacyConfig(parsed) ? migrateLegacyConfig(parsed) : parsed;
178
+ }
179
+ catch {
180
+ // file doesn't exist yet — start fresh
181
+ }
182
+ const merged = {
183
+ ...existing,
184
+ ...fields,
185
+ providers: {
186
+ ...existing.providers,
187
+ ...fields.providers,
188
+ browserbase: {
189
+ ...existing.providers?.browserbase,
190
+ ...fields.providers?.browserbase,
191
+ },
192
+ perplexity: {
193
+ ...existing.providers?.perplexity,
194
+ ...fields.providers?.perplexity,
195
+ },
196
+ },
197
+ };
198
+ if (merged.providers?.browserbase && Object.keys(merged.providers.browserbase).length === 0) {
199
+ delete merged.providers.browserbase;
200
+ }
201
+ if (merged.providers?.perplexity && Object.keys(merged.providers.perplexity).length === 0) {
202
+ delete merged.providers.perplexity;
203
+ }
204
+ if (merged.providers && Object.keys(merged.providers).length === 0) {
205
+ delete merged.providers;
206
+ }
207
+ const body = `${JSON.stringify(merged, null, 2)}\n`;
65
208
  await fs.promises.writeFile(targetPath, body, { encoding: 'utf8', mode: 0o600 });
66
209
  }
@@ -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
  };