pi-agent-browser-native 0.2.39 → 0.2.40

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,352 @@
1
+ /**
2
+ * Purpose: Provide the optional Brave-backed `agent_browser_web_search` companion tool.
3
+ * Responsibilities: Define strict search input schema, resolve the configured Brave credential lazily, call Brave Search with cancellation/timeout, normalize compact results, and keep secrets out of content/details.
4
+ * Scope: Live web search only; browser automation remains in the `agent_browser` tool.
5
+ */
6
+
7
+ import { StringEnum } from "@earendil-works/pi-ai";
8
+ import { defineTool } from "@earendil-works/pi-coding-agent";
9
+ import { Type } from "typebox";
10
+ import { resolveBraveApiKey, type AgentBrowserConfigState } from "./config.js";
11
+
12
+ export const AGENT_BROWSER_WEB_SEARCH_TOOL_NAME = "agent_browser_web_search";
13
+ export const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
14
+ export const DEFAULT_SEARCH_RESULT_COUNT = 5;
15
+ export const MAX_SEARCH_RESULT_COUNT = 10;
16
+ export const SEARCH_REQUEST_TIMEOUT_MS = 15_000;
17
+
18
+ export type BraveWebSearchResult = {
19
+ title?: unknown;
20
+ url?: unknown;
21
+ description?: unknown;
22
+ age?: unknown;
23
+ language?: unknown;
24
+ profile?: {
25
+ name?: unknown;
26
+ url?: unknown;
27
+ };
28
+ meta_url?: {
29
+ hostname?: unknown;
30
+ };
31
+ };
32
+
33
+ export type BraveWebSearchResponse = {
34
+ query?: {
35
+ original?: unknown;
36
+ altered?: unknown;
37
+ };
38
+ web?: {
39
+ results?: BraveWebSearchResult[];
40
+ };
41
+ };
42
+
43
+ export type NormalizedSearchResult = {
44
+ title: string;
45
+ url: string;
46
+ description?: string;
47
+ source?: string;
48
+ age?: string;
49
+ language?: string;
50
+ };
51
+
52
+ export const AgentBrowserWebSearchParams = Type.Object(
53
+ {
54
+ query: Type.String({
55
+ minLength: 1,
56
+ description: "Search query to run with Brave Search.",
57
+ }),
58
+ count: Type.Optional(
59
+ Type.Integer({
60
+ minimum: 1,
61
+ maximum: MAX_SEARCH_RESULT_COUNT,
62
+ description: `Number of web results to return. Defaults to ${DEFAULT_SEARCH_RESULT_COUNT}; max ${MAX_SEARCH_RESULT_COUNT}.`,
63
+ }),
64
+ ),
65
+ offset: Type.Optional(
66
+ Type.Integer({
67
+ minimum: 0,
68
+ maximum: 9,
69
+ description: "Zero-based result offset for pagination. Defaults to 0.",
70
+ }),
71
+ ),
72
+ country: Type.Optional(
73
+ Type.String({
74
+ pattern: "^[A-Za-z]{2}$",
75
+ description: "Optional 2-letter country code, such as US or GB.",
76
+ }),
77
+ ),
78
+ searchLang: Type.Optional(
79
+ Type.String({
80
+ minLength: 2,
81
+ maxLength: 8,
82
+ description: "Optional search language code, such as en or en-US.",
83
+ }),
84
+ ),
85
+ safesearch: Type.Optional(
86
+ StringEnum(["off", "moderate", "strict"] as const, {
87
+ description: "Optional Brave safe-search setting. Defaults to Brave's API default.",
88
+ }),
89
+ ),
90
+ freshness: Type.Optional(
91
+ StringEnum(["pd", "pw", "pm", "py"] as const, {
92
+ description: "Optional freshness window: pd=past day, pw=past week, pm=past month, py=past year.",
93
+ }),
94
+ ),
95
+ },
96
+ { additionalProperties: false },
97
+ );
98
+
99
+ const HTML_ENTITY_REPLACEMENTS: Readonly<Record<string, string>> = {
100
+ amp: "&",
101
+ apos: "'",
102
+ gt: ">",
103
+ lt: "<",
104
+ nbsp: " ",
105
+ quot: '"',
106
+ };
107
+
108
+ const HTML_TAG_NAMES_TO_STRIP = new Set([
109
+ "a",
110
+ "abbr",
111
+ "address",
112
+ "article",
113
+ "aside",
114
+ "audio",
115
+ "b",
116
+ "base",
117
+ "blockquote",
118
+ "body",
119
+ "br",
120
+ "button",
121
+ "canvas",
122
+ "code",
123
+ "div",
124
+ "em",
125
+ "embed",
126
+ "footer",
127
+ "form",
128
+ "h1",
129
+ "h2",
130
+ "h3",
131
+ "h4",
132
+ "h5",
133
+ "h6",
134
+ "head",
135
+ "header",
136
+ "html",
137
+ "i",
138
+ "iframe",
139
+ "img",
140
+ "input",
141
+ "li",
142
+ "link",
143
+ "main",
144
+ "mark",
145
+ "math",
146
+ "meta",
147
+ "nav",
148
+ "object",
149
+ "ol",
150
+ "option",
151
+ "p",
152
+ "pre",
153
+ "script",
154
+ "section",
155
+ "select",
156
+ "source",
157
+ "span",
158
+ "strong",
159
+ "style",
160
+ "svg",
161
+ "table",
162
+ "tbody",
163
+ "td",
164
+ "textarea",
165
+ "tfoot",
166
+ "th",
167
+ "thead",
168
+ "tr",
169
+ "u",
170
+ "ul",
171
+ "video",
172
+ ]);
173
+
174
+ function decodeHtmlEntity(entity: string): string {
175
+ const named = HTML_ENTITY_REPLACEMENTS[entity.toLowerCase()];
176
+ if (named !== undefined) return named;
177
+ const decimalMatch = /^#(\d+)$/.exec(entity);
178
+ const hexMatch = /^#x([0-9a-f]+)$/i.exec(entity);
179
+ const codePoint = decimalMatch ? Number.parseInt(decimalMatch[1] ?? "", 10) : hexMatch ? Number.parseInt(hexMatch[1] ?? "", 16) : undefined;
180
+ if (codePoint === undefined || !Number.isFinite(codePoint)) return `&${entity};`;
181
+ try {
182
+ return String.fromCodePoint(codePoint);
183
+ } catch {
184
+ return `&${entity};`;
185
+ }
186
+ }
187
+
188
+ export function decodeHtmlEntities(value: string): string {
189
+ return value.replace(/&([a-z][a-z0-9]+|#\d+|#x[0-9a-f]+);/gi, (_match, entity: string) => decodeHtmlEntity(entity));
190
+ }
191
+
192
+ function stripDecodedHtmlTags(value: string): string {
193
+ return value.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, " ").replace(/<\/?([a-z][a-z0-9-]*)(\s[^>]*)?>/gi, (match, tagName: string, attributes: string | undefined) => {
194
+ if (attributes || match.startsWith("</") || HTML_TAG_NAMES_TO_STRIP.has(tagName.toLowerCase())) return " ";
195
+ return match;
196
+ });
197
+ }
198
+
199
+ export function cleanSearchText(value: unknown, maxLength = 500): string | undefined {
200
+ if (typeof value !== "string") return undefined;
201
+ const cleaned = stripDecodedHtmlTags(decodeHtmlEntities(value.replace(/<[^>]*>/g, " ")))
202
+ .replace(/\s+/g, " ")
203
+ .trim();
204
+ if (!cleaned) return undefined;
205
+ if (cleaned.length <= maxLength) return cleaned;
206
+ return `${cleaned.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
207
+ }
208
+
209
+ export function normalizeSearchUrl(value: unknown): string | undefined {
210
+ if (typeof value !== "string") return undefined;
211
+ try {
212
+ const url = new URL(value);
213
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
214
+ return url.toString();
215
+ } catch {
216
+ return undefined;
217
+ }
218
+ }
219
+
220
+ export function normalizeSearchResult(result: BraveWebSearchResult): NormalizedSearchResult | undefined {
221
+ const title = cleanSearchText(result.title, 180);
222
+ const url = normalizeSearchUrl(result.url);
223
+ if (!title || !url) return undefined;
224
+ return {
225
+ title,
226
+ url,
227
+ description: cleanSearchText(result.description, 320),
228
+ source: cleanSearchText(result.profile?.name, 120) ?? cleanSearchText(result.meta_url?.hostname, 120),
229
+ age: cleanSearchText(result.age, 80),
230
+ language: cleanSearchText(result.language, 40),
231
+ };
232
+ }
233
+
234
+ export function formatSearchResults(query: string, results: NormalizedSearchResult[]): string {
235
+ if (results.length === 0) {
236
+ return `No Brave web results found for: ${query}`;
237
+ }
238
+ const lines = [`Brave web search results for: ${query}`, ""];
239
+ results.forEach((result, index) => {
240
+ lines.push(`${index + 1}. ${result.title}`);
241
+ lines.push(` URL: ${result.url}`);
242
+ if (result.source) lines.push(` Source: ${result.source}`);
243
+ if (result.age) lines.push(` Age: ${result.age}`);
244
+ if (result.description) lines.push(` Summary: ${result.description}`);
245
+ lines.push("");
246
+ });
247
+ return lines.join("\n").trimEnd();
248
+ }
249
+
250
+ export function buildBraveSearchUrl(params: {
251
+ query: string;
252
+ count: number;
253
+ offset: number;
254
+ country?: string;
255
+ searchLang?: string;
256
+ safesearch?: "off" | "moderate" | "strict";
257
+ freshness?: "pd" | "pw" | "pm" | "py";
258
+ }): URL {
259
+ const url = new URL(BRAVE_SEARCH_ENDPOINT);
260
+ url.searchParams.set("q", params.query);
261
+ url.searchParams.set("count", String(params.count));
262
+ url.searchParams.set("offset", String(params.offset));
263
+ if (params.country) url.searchParams.set("country", params.country.toUpperCase());
264
+ if (params.searchLang) url.searchParams.set("search_lang", params.searchLang);
265
+ if (params.safesearch) url.searchParams.set("safesearch", params.safesearch);
266
+ if (params.freshness) url.searchParams.set("freshness", params.freshness);
267
+ return url;
268
+ }
269
+
270
+ function redactSearchSecret(text: string, apiKey: string): string {
271
+ return apiKey ? text.split(apiKey).join("[REDACTED]") : text;
272
+ }
273
+
274
+ export async function fetchBraveSearchJson(url: URL, apiKey: string, signal?: AbortSignal): Promise<BraveWebSearchResponse> {
275
+ const controller = new AbortController();
276
+ const timeout = setTimeout(() => controller.abort(new Error("Brave search timed out")), SEARCH_REQUEST_TIMEOUT_MS);
277
+ const abort = () => controller.abort(signal?.reason ?? new Error("Brave search cancelled"));
278
+ signal?.addEventListener("abort", abort, { once: true });
279
+ try {
280
+ const response = await fetch(url, {
281
+ headers: {
282
+ Accept: "application/json",
283
+ "X-Subscription-Token": apiKey,
284
+ },
285
+ signal: controller.signal,
286
+ });
287
+ const text = await response.text();
288
+ if (!response.ok) {
289
+ const errorPreview = cleanSearchText(redactSearchSecret(text, apiKey), 300);
290
+ throw new Error(`Brave search failed with HTTP ${response.status}: ${errorPreview ? redactSearchSecret(errorPreview, apiKey) : response.statusText}`);
291
+ }
292
+ try {
293
+ return JSON.parse(text) as BraveWebSearchResponse;
294
+ } catch (error) {
295
+ throw new Error(`Brave search returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
296
+ }
297
+ } finally {
298
+ clearTimeout(timeout);
299
+ signal?.removeEventListener("abort", abort);
300
+ }
301
+ }
302
+
303
+ export function createAgentBrowserWebSearchTool(configState: AgentBrowserConfigState) {
304
+ return defineTool({
305
+ name: AGENT_BROWSER_WEB_SEARCH_TOOL_NAME,
306
+ label: "Agent Browser Web Search",
307
+ description: `Search the web with Brave Search when configured. Returns up to ${MAX_SEARCH_RESULT_COUNT} concise web results.`,
308
+ promptSnippet: "Search the live web with Brave Search for current or external web information.",
309
+ promptGuidelines: [
310
+ "Use agent_browser_web_search when live web search would help answer the task, find current external information, or discover candidate URLs for agent_browser.",
311
+ "Prefer agent_browser_web_search over opening a search engine results page with agent_browser when a quick result list is enough.",
312
+ "After using agent_browser_web_search, cite result URLs in the final answer when web evidence informed the answer.",
313
+ ],
314
+ parameters: AgentBrowserWebSearchParams,
315
+ async execute(_toolCallId, params, signal) {
316
+ const resolvedCredential = await resolveBraveApiKey(configState, { signal });
317
+ if (!resolvedCredential) {
318
+ throw new Error("Brave Search credential source is configured but did not resolve. Run pi-agent-browser-config web-search status for setup details.");
319
+ }
320
+ const query = params.query.trim();
321
+ if (!query) throw new Error("query must not be blank");
322
+ const count = Math.min(Math.max(params.count ?? DEFAULT_SEARCH_RESULT_COUNT, 1), MAX_SEARCH_RESULT_COUNT);
323
+ const offset = Math.max(params.offset ?? 0, 0);
324
+ const url = buildBraveSearchUrl({
325
+ query,
326
+ count,
327
+ offset,
328
+ country: params.country,
329
+ searchLang: params.searchLang,
330
+ safesearch: params.safesearch,
331
+ freshness: params.freshness,
332
+ });
333
+ const data = await fetchBraveSearchJson(url, resolvedCredential.value, signal);
334
+ const results = (data.web?.results ?? [])
335
+ .map(normalizeSearchResult)
336
+ .filter((result): result is NormalizedSearchResult => Boolean(result));
337
+ const returnedQuery = cleanSearchText(data.query?.altered, 300) ?? cleanSearchText(data.query?.original, 300) ?? query;
338
+ return {
339
+ content: [{ type: "text", text: formatSearchResults(returnedQuery, results) }],
340
+ details: {
341
+ provider: "brave",
342
+ query,
343
+ returnedQuery,
344
+ count,
345
+ offset,
346
+ fetchedAt: new Date().toISOString(),
347
+ results,
348
+ },
349
+ };
350
+ },
351
+ });
352
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-browser-native",
3
- "version": "0.2.39",
3
+ "version": "0.2.40",
4
4
  "description": "pi extension that exposes agent-browser as a native tool for browser automation",
5
5
  "type": "module",
6
6
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
@@ -27,11 +27,13 @@
27
27
  "node": ">=22.19.0"
28
28
  },
29
29
  "bin": {
30
+ "pi-agent-browser-config": "scripts/config.mjs",
30
31
  "pi-agent-browser-doctor": "scripts/doctor.mjs"
31
32
  },
32
33
  "files": [
33
34
  "extensions",
34
35
  "platform-smoke.config.mjs",
36
+ "scripts/config.mjs",
35
37
  "scripts/doctor.mjs",
36
38
  "scripts/agent-browser-capability-baseline.mjs",
37
39
  "scripts/platform-smoke.mjs",
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Purpose: Manage pi-agent-browser-native package config under Pi-scoped config paths.
4
+ * Responsibilities: Print config paths/status, write redacted web-search and browser profile settings, preserve safe permissions, and avoid echoing secrets.
5
+ * Scope: Maintainer/user setup CLI only; extension runtime validation and tool execution live under extensions/agent-browser/lib/.
6
+ */
7
+
8
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname, resolve } from "node:path";
10
+ import process from "node:process";
11
+
12
+ const CONFIG_ENV = "PI_AGENT_BROWSER_CONFIG";
13
+ const BRAVE_API_KEY_ENV = "BRAVE_API_KEY";
14
+ const RELATIVE_CONFIG = [".pi", "config", "pi-agent-browser-native", "config.json"];
15
+ const DEFAULT_CONFIG = { version: 1 };
16
+
17
+ class UsageError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = "UsageError";
21
+ }
22
+ }
23
+
24
+ function usage() {
25
+ return `pi-agent-browser-config
26
+
27
+ Usage:
28
+ pi-agent-browser-config paths
29
+ pi-agent-browser-config show
30
+ pi-agent-browser-config web-search status
31
+ pi-agent-browser-config web-search set-key --stdin [--global]
32
+ pi-agent-browser-config web-search set-env <ENV_VAR> [--global|--project]
33
+ pi-agent-browser-config web-search set-command <command> [--global]
34
+ pi-agent-browser-config web-search clear [--global|--project]
35
+ pi-agent-browser-config browser profile status
36
+ pi-agent-browser-config browser profile set <name> [--policy explicit-only|authenticated-only|always] [--global|--project]
37
+ pi-agent-browser-config browser profile clear [--global|--project]
38
+
39
+ Notes:
40
+ Global config: ~/.pi/config/pi-agent-browser-native/config.json
41
+ Project config: .pi/config/pi-agent-browser-native/config.json
42
+ Override: PI_AGENT_BROWSER_CONFIG=/path/to/config.json
43
+ Project-local plaintext, interpolation-literal, malformed, and command-backed web-search keys are refused; use exact set-env references there.
44
+ `;
45
+ }
46
+
47
+ function getHome(env = process.env) {
48
+ return env.HOME?.trim() || env.USERPROFILE?.trim();
49
+ }
50
+
51
+ function getGlobalConfigPath(env = process.env) {
52
+ const home = getHome(env);
53
+ if (!home) throw new Error("Could not resolve home directory for global config.");
54
+ return resolve(home, ...RELATIVE_CONFIG);
55
+ }
56
+
57
+ function getProjectConfigPath(cwd = process.cwd()) {
58
+ return resolve(cwd, ...RELATIVE_CONFIG);
59
+ }
60
+
61
+ function getPaths(env = process.env, cwd = process.cwd()) {
62
+ const override = env[CONFIG_ENV]?.trim();
63
+ return {
64
+ global: getGlobalConfigPath(env),
65
+ project: getProjectConfigPath(cwd),
66
+ override: override ? resolve(override) : undefined,
67
+ };
68
+ }
69
+
70
+ function parseArgs(argv) {
71
+ const positional = [];
72
+ const flags = new Map();
73
+ for (let index = 0; index < argv.length; index += 1) {
74
+ const arg = argv[index];
75
+ if (!arg.startsWith("--")) {
76
+ positional.push(arg);
77
+ continue;
78
+ }
79
+ if (arg === "--global" || arg === "--project" || arg === "--stdin" || arg === "--help") {
80
+ flags.set(arg, true);
81
+ continue;
82
+ }
83
+ if (arg === "--policy") {
84
+ const value = argv[index + 1];
85
+ if (!value || value.startsWith("--")) throw new UsageError("--policy requires a value.");
86
+ flags.set(arg, value);
87
+ index += 1;
88
+ continue;
89
+ }
90
+ throw new UsageError(`Unknown option: ${arg}`);
91
+ }
92
+ if (flags.get("--global") && flags.get("--project")) throw new UsageError("Use only one of --global or --project.");
93
+ return { flags, positional };
94
+ }
95
+
96
+ function readConfig(path) {
97
+ if (!existsSync(path)) return { ...DEFAULT_CONFIG };
98
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
99
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${path} must contain a JSON object.`);
100
+ return { ...DEFAULT_CONFIG, ...parsed };
101
+ }
102
+
103
+ function writeConfig(path, config) {
104
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
105
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
106
+ try {
107
+ chmodSync(dirname(path), 0o700);
108
+ chmodSync(path, 0o600);
109
+ } catch {
110
+ // Best effort on platforms/filesystems that do not support POSIX modes.
111
+ }
112
+ }
113
+
114
+ function selectWritePath(flags) {
115
+ const paths = getPaths();
116
+ if (flags.get("--project")) return { path: paths.project, scope: "project" };
117
+ return { path: paths.global, scope: "global" };
118
+ }
119
+
120
+ function classifyCredential(rawValue) {
121
+ const trimmed = String(rawValue ?? "").trim();
122
+ if (!trimmed) return "not configured";
123
+ if (trimmed.startsWith("!")) return "configured via command";
124
+ if (trimmed.includes("$")) return "configured via environment interpolation";
125
+ return "configured as plaintext [redacted]";
126
+ }
127
+
128
+ function mergeConfig() {
129
+ const paths = getPaths();
130
+ const layers = [];
131
+ for (const [scope, path] of [["global", paths.global], ["project", paths.project], ...(paths.override ? [["override", paths.override]] : [])]) {
132
+ if (!existsSync(path)) continue;
133
+ layers.push({ scope, path, config: readConfig(path) });
134
+ }
135
+ const merged = layers.reduce((current, layer) => ({
136
+ ...current,
137
+ ...layer.config,
138
+ browser: { ...(current.browser ?? {}), ...(layer.config.browser ?? {}) },
139
+ webSearch: { ...(current.webSearch ?? {}), ...(layer.config.webSearch ?? {}) },
140
+ }), { ...DEFAULT_CONFIG });
141
+ return { layers, merged, paths };
142
+ }
143
+
144
+ function printPaths() {
145
+ const paths = getPaths();
146
+ console.log(`Global: ${paths.global}`);
147
+ console.log(`Project: ${paths.project}`);
148
+ console.log(`Override: ${paths.override ?? `${CONFIG_ENV} not set`}`);
149
+ }
150
+
151
+ function printStatus() {
152
+ const { layers, merged, paths } = mergeConfig();
153
+ printPaths();
154
+ console.log("");
155
+ console.log("Config files:");
156
+ for (const [scope, path] of [["global", paths.global], ["project", paths.project], ...(paths.override ? [["override", paths.override]] : [])]) {
157
+ console.log(` ${scope}: ${path} ${existsSync(path) ? "[exists]" : "[missing]"}`);
158
+ }
159
+ console.log("");
160
+ console.log("Effective config:");
161
+ const source = merged.webSearch?.braveApiKey;
162
+ console.log(` webSearch.braveApiKey: ${source ? classifyCredential(source) : process.env[BRAVE_API_KEY_ENV]?.trim() ? `configured via ${BRAVE_API_KEY_ENV} environment fallback` : "not configured"}`);
163
+ const profile = merged.browser?.defaultProfile;
164
+ console.log(` browser.defaultProfile: ${profile?.name ? `${profile.name} (policy: ${profile.policy ?? "authenticated-only"})` : "not configured"}`);
165
+ if (layers.length === 0) console.log(" layers: none");
166
+ }
167
+
168
+ async function readSecretFromStdin(useStdin) {
169
+ if (!useStdin) throw new UsageError("set-key requires --stdin so the key is not passed through argv or an echoed prompt.");
170
+ let input = "";
171
+ for await (const chunk of process.stdin) input += chunk;
172
+ const value = input.trim();
173
+ if (!value) throw new UsageError("No key was provided on stdin.");
174
+ return value;
175
+ }
176
+
177
+ function mutateConfig(path, mutate) {
178
+ const config = readConfig(path);
179
+ mutate(config);
180
+ writeConfig(path, config);
181
+ }
182
+
183
+ async function handleWebSearch(args, flags) {
184
+ const action = args[0];
185
+ if (action === "status") {
186
+ printStatus();
187
+ return;
188
+ }
189
+ if (action === "set-key") {
190
+ if (flags.get("--project")) throw new UsageError("Plaintext Brave keys cannot be written to project-local config. Use set-env or set-command.");
191
+ const key = await readSecretFromStdin(Boolean(flags.get("--stdin")));
192
+ const { path } = selectWritePath(flags);
193
+ mutateConfig(path, (config) => {
194
+ config.webSearch = { ...(config.webSearch ?? {}), braveApiKey: key };
195
+ });
196
+ console.log(`Saved Brave Search key to global config: ${path}`);
197
+ return;
198
+ }
199
+ if (action === "set-env") {
200
+ const envName = args[1];
201
+ if (!envName || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName)) throw new UsageError("set-env requires a valid environment variable name.");
202
+ const { path, scope } = selectWritePath(flags);
203
+ mutateConfig(path, (config) => {
204
+ config.webSearch = { ...(config.webSearch ?? {}), braveApiKey: `$${envName}` };
205
+ });
206
+ console.log(`Saved Brave Search ${scope} env reference to: ${path}`);
207
+ return;
208
+ }
209
+ if (action === "set-command") {
210
+ if (flags.get("--project")) throw new UsageError("Command-backed Brave keys cannot be written to project-local config. Use set-env there.");
211
+ const command = args.slice(1).join(" ").trim();
212
+ if (!command) throw new UsageError("set-command requires a command string.");
213
+ const { path, scope } = selectWritePath(flags);
214
+ mutateConfig(path, (config) => {
215
+ config.webSearch = { ...(config.webSearch ?? {}), braveApiKey: `!${command}` };
216
+ });
217
+ console.log(`Saved Brave Search ${scope} command source to: ${path}`);
218
+ return;
219
+ }
220
+ if (action === "clear") {
221
+ const { path, scope } = selectWritePath(flags);
222
+ mutateConfig(path, (config) => {
223
+ if (config.webSearch) delete config.webSearch.braveApiKey;
224
+ });
225
+ console.log(`Cleared Brave Search credential source in ${scope} config: ${path}`);
226
+ return;
227
+ }
228
+ throw new UsageError(`Unsupported web-search action: ${action ?? ""}`);
229
+ }
230
+
231
+ function handleBrowser(args, flags) {
232
+ if (args[0] !== "profile") throw new UsageError(`Unsupported browser action: ${args[0] ?? ""}`);
233
+ const action = args[1];
234
+ if (action === "status") {
235
+ printStatus();
236
+ return;
237
+ }
238
+ if (action === "set") {
239
+ const name = args[2]?.trim();
240
+ if (!name) throw new UsageError("browser profile set requires a profile name.");
241
+ const policy = flags.get("--policy") || "authenticated-only";
242
+ if (!["explicit-only", "authenticated-only", "always"].includes(policy)) throw new UsageError("Invalid --policy value.");
243
+ const { path, scope } = selectWritePath(flags);
244
+ mutateConfig(path, (config) => {
245
+ config.browser = { ...(config.browser ?? {}), defaultProfile: { name, policy } };
246
+ });
247
+ console.log(`Saved browser default profile in ${scope} config: ${path}`);
248
+ return;
249
+ }
250
+ if (action === "clear") {
251
+ const { path, scope } = selectWritePath(flags);
252
+ mutateConfig(path, (config) => {
253
+ if (config.browser) delete config.browser.defaultProfile;
254
+ });
255
+ console.log(`Cleared browser default profile in ${scope} config: ${path}`);
256
+ return;
257
+ }
258
+ throw new UsageError(`Unsupported browser profile action: ${action ?? ""}`);
259
+ }
260
+
261
+ export async function main(argv = process.argv.slice(2)) {
262
+ const { flags, positional } = parseArgs(argv);
263
+ if (flags.get("--help") || positional.length === 0) {
264
+ console.log(usage());
265
+ return 0;
266
+ }
267
+ const command = positional[0];
268
+ if (command === "paths") {
269
+ printPaths();
270
+ return 0;
271
+ }
272
+ if (command === "show") {
273
+ printStatus();
274
+ return 0;
275
+ }
276
+ if (command === "web-search") {
277
+ await handleWebSearch(positional.slice(1), flags);
278
+ return 0;
279
+ }
280
+ if (command === "browser") {
281
+ handleBrowser(positional.slice(1), flags);
282
+ return 0;
283
+ }
284
+ throw new UsageError(`Unknown command: ${command}`);
285
+ }
286
+
287
+ if (import.meta.url === `file://${process.argv[1]}`) {
288
+ main().catch((error) => {
289
+ if (error instanceof UsageError) {
290
+ console.error(error.message);
291
+ console.error(usage());
292
+ process.exit(2);
293
+ }
294
+ console.error(error instanceof Error ? error.message : String(error));
295
+ process.exit(1);
296
+ });
297
+ }