@apmantza/greedysearch-pi 1.8.0 → 1.8.2

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,141 +1,145 @@
1
- #!/usr/bin/env node
2
-
3
- // extractors/perplexity.mjs
4
- // Navigate Perplexity, wait for streaming to complete, return clean answer + sources.
5
- //
6
- // Usage:
7
- // node extractors/perplexity.mjs "<query>" [--tab <prefix>]
8
- //
9
- // Output (stdout): JSON { answer, sources, query, url }
10
- // Errors go to stderr only — stdout is always clean JSON for piping.
11
- //
12
- // TODO: Refactor - this file has 42 lines duplicated with google-ai.mjs (line 28)
13
-
14
- import {
15
- cdp,
16
- formatAnswer,
17
- getOrOpenTab,
18
- handleError,
19
- injectClipboardInterceptor,
20
- outputJson,
21
- parseArgs,
22
- parseSourcesFromMarkdown,
23
- validateQuery,
24
- waitForStreamComplete,
25
- } from "./common.mjs";
26
- import { dismissConsent } from "./consent.mjs";
27
- import { SELECTORS } from "./selectors.mjs";
28
-
29
- const S = SELECTORS.perplexity;
30
- const GLOBAL_VAR = "__pplxClipboard";
31
-
32
- // ============================================================================
33
- // Extraction
34
- // ============================================================================
35
-
36
- async function extractAnswer(tab) {
37
- await cdp([
38
- "eval",
39
- tab,
40
- `document.querySelector('${S.copyButton}')?.click()`,
41
- ]);
42
- await new Promise((r) => setTimeout(r, 400));
43
-
44
- let answer = await cdp(["eval", tab, `window.${GLOBAL_VAR} || ''`]);
45
-
46
- // Retry once if clipboard is empty (Perplexity might be slow to write)
47
- if (!answer) {
48
- console.error("[perplexity] Clipboard empty, retrying in 2s...");
49
- await cdp([
50
- "eval",
51
- tab,
52
- `document.querySelector('${S.copyButton}')?.click()`,
53
- ]);
54
- await new Promise((r) => setTimeout(r, 2000));
55
- answer = await cdp(["eval", tab, `window.${GLOBAL_VAR} || ''`]);
56
- }
57
-
58
- if (!answer) throw new Error("Clipboard interceptor returned empty text");
59
-
60
- const sources = parseSourcesFromMarkdown(answer);
61
- return { answer: answer.trim(), sources };
62
- }
63
-
64
- // ============================================================================
65
- // Main
66
- // ============================================================================
67
-
68
- const USAGE =
69
- 'Usage: node extractors/perplexity.mjs "<query>" [--tab <prefix>]\n';
70
-
71
- async function main() {
72
- const args = process.argv.slice(2);
73
- validateQuery(args, USAGE);
74
-
75
- const { query, tabPrefix, short } = parseArgs(args);
76
-
77
- try {
78
- // Refresh page list so cache is current
79
- await cdp(["list"]);
80
-
81
- const tab = await getOrOpenTab(tabPrefix);
82
-
83
- // Navigate to homepage and use the search box (direct ?q= URLs trigger bot redirect)
84
- await cdp(["nav", tab, "https://www.perplexity.ai/"], 35000);
85
- await dismissConsent(tab, cdp);
86
-
87
- // Wait for React app to mount input (up to 8s)
88
- const deadline = Date.now() + 8000;
89
- while (Date.now() < deadline) {
90
- const found = await cdp([
91
- "eval",
92
- tab,
93
- `!!document.querySelector('${S.input}')`,
94
- ]).catch(() => "false");
95
- if (found === "true") break;
96
- await new Promise((r) => setTimeout(r, 400));
97
- }
98
- await new Promise((r) => setTimeout(r, 300));
99
-
100
- await injectClipboardInterceptor(tab, GLOBAL_VAR);
101
- await cdp(["click", tab, S.input]);
102
- await new Promise((r) => setTimeout(r, 400));
103
- await cdp(["type", tab, query]);
104
- await new Promise((r) => setTimeout(r, 400));
105
-
106
- // Submit with Enter (most reliable across Chrome instances)
107
- await cdp([
108
- "eval",
109
- tab,
110
- `document.querySelector('${S.input}')?.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,keyCode:13})), 'ok'`,
111
- ]);
112
-
113
- await waitForStreamComplete(tab, {
114
- timeout: 30000,
115
- interval: 600,
116
- stableRounds: 3,
117
- selector: "document.body",
118
- });
119
-
120
- const { answer, sources } = await extractAnswer(tab);
121
-
122
- if (!answer)
123
- throw new Error(
124
- "No answer extracted Perplexity may not have responded",
125
- );
126
-
127
- const finalUrl = await cdp(["eval", tab, "document.location.href"]).catch(
128
- () => "",
129
- );
130
- outputJson({
131
- query,
132
- url: finalUrl,
133
- answer: formatAnswer(answer, short),
134
- sources,
135
- });
136
- } catch (e) {
137
- handleError(e);
138
- }
139
- }
140
-
141
- main();
1
+ #!/usr/bin/env node
2
+
3
+ // extractors/perplexity.mjs
4
+ // Navigate Perplexity, wait for streaming to complete, return clean answer + sources.
5
+ //
6
+ // Usage:
7
+ // node extractors/perplexity.mjs "<query>" [--tab <prefix>]
8
+ //
9
+ // Output (stdout): JSON { answer, sources, query, url }
10
+ // Errors go to stderr only — stdout is always clean JSON for piping.
11
+ //
12
+ // TODO: Refactor - this file has 42 lines duplicated with google-ai.mjs (line 28)
13
+
14
+ import {
15
+ cdp,
16
+ formatAnswer,
17
+ getOrOpenTab,
18
+ handleError,
19
+ injectClipboardInterceptor,
20
+ outputJson,
21
+ parseArgs,
22
+ parseSourcesFromMarkdown,
23
+ validateQuery,
24
+ waitForStreamComplete,
25
+ } from "./common.mjs";
26
+ import { dismissConsent } from "./consent.mjs";
27
+ import { SELECTORS } from "./selectors.mjs";
28
+
29
+ const S = SELECTORS.perplexity;
30
+ const GLOBAL_VAR = "__pplxClipboard";
31
+
32
+ // ============================================================================
33
+ // Language-agnostic copy button finder
34
+ // ============================================================================
35
+
36
+ function findCopyButtonJsExpression() {
37
+ // Perplexity uses SVG icons via <use xlink:href="#pplx-icon-copy">
38
+ // This works across all locales since it doesn't depend on aria-label text
39
+ return `Array.from(document.querySelectorAll('button')).find(b => b.innerHTML.includes('#pplx-icon-copy'))`;
40
+ }
41
+
42
+ // ============================================================================
43
+ // Extraction
44
+ // ============================================================================
45
+
46
+ async function extractAnswer(tab) {
47
+ const copyBtnExpr = findCopyButtonJsExpression();
48
+
49
+ await cdp(["eval", tab, `${copyBtnExpr}?.click()`]);
50
+ await new Promise((r) => setTimeout(r, 400));
51
+
52
+ let answer = await cdp(["eval", tab, `window.${GLOBAL_VAR} || ''`]);
53
+
54
+ // Retry once if clipboard is empty (Perplexity might be slow to write)
55
+ if (!answer) {
56
+ console.error("[perplexity] Clipboard empty, retrying in 2s...");
57
+ await cdp(["eval", tab, `${copyBtnExpr}?.click()`]);
58
+ await new Promise((r) => setTimeout(r, 2000));
59
+ answer = await cdp(["eval", tab, `window.${GLOBAL_VAR} || ''`]);
60
+ }
61
+
62
+ if (!answer) throw new Error("Clipboard interceptor returned empty text");
63
+
64
+ const sources = parseSourcesFromMarkdown(answer);
65
+ return { answer: answer.trim(), sources };
66
+ }
67
+
68
+ // ============================================================================
69
+ // Main
70
+ // ============================================================================
71
+
72
+ const USAGE =
73
+ 'Usage: node extractors/perplexity.mjs "<query>" [--tab <prefix>]\n';
74
+
75
+ async function main() {
76
+ const args = process.argv.slice(2);
77
+ validateQuery(args, USAGE);
78
+
79
+ const { query, tabPrefix, short } = parseArgs(args);
80
+
81
+ try {
82
+ // Refresh page list so cache is current
83
+ await cdp(["list"]);
84
+
85
+ const tab = await getOrOpenTab(tabPrefix);
86
+
87
+ // Navigate to homepage and use the search box (direct ?q= URLs trigger bot redirect)
88
+ await cdp(["nav", tab, "https://www.perplexity.ai/"], 35000);
89
+ await dismissConsent(tab, cdp);
90
+
91
+ // Wait for React app to mount input (up to 8s)
92
+ const deadline = Date.now() + 8000;
93
+ while (Date.now() < deadline) {
94
+ const found = await cdp([
95
+ "eval",
96
+ tab,
97
+ `!!document.querySelector('${S.input}')`,
98
+ ]).catch(() => "false");
99
+ if (found === "true") break;
100
+ await new Promise((r) => setTimeout(r, 400));
101
+ }
102
+ await new Promise((r) => setTimeout(r, 300));
103
+
104
+ await injectClipboardInterceptor(tab, GLOBAL_VAR);
105
+ await cdp(["click", tab, S.input]);
106
+ await new Promise((r) => setTimeout(r, 400));
107
+ await cdp(["type", tab, query]);
108
+ await new Promise((r) => setTimeout(r, 400));
109
+
110
+ // Submit with Enter (most reliable across Chrome instances)
111
+ await cdp([
112
+ "eval",
113
+ tab,
114
+ `document.querySelector('${S.input}')?.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,keyCode:13})), 'ok'`,
115
+ ]);
116
+
117
+ await waitForStreamComplete(tab, {
118
+ timeout: 30000,
119
+ interval: 600,
120
+ stableRounds: 3,
121
+ selector: "document.body",
122
+ });
123
+
124
+ const { answer, sources } = await extractAnswer(tab);
125
+
126
+ if (!answer)
127
+ throw new Error(
128
+ "No answer extracted — Perplexity may not have responded",
129
+ );
130
+
131
+ const finalUrl = await cdp(["eval", tab, "document.location.href"]).catch(
132
+ () => "",
133
+ );
134
+ outputJson({
135
+ query,
136
+ url: finalUrl,
137
+ answer: formatAnswer(answer, short),
138
+ sources,
139
+ });
140
+ } catch (e) {
141
+ handleError(e);
142
+ }
143
+ }
144
+
145
+ main();
@@ -1,52 +1,54 @@
1
- // extractors/selectors.mjs
2
- // Centralized CSS selectors for all engines.
3
- // Update selectors here when a site changes its UI.
4
-
5
- export const SELECTORS = {
6
- // ──────────────────────────────────────────────
7
- // Perplexity (perplexity.ai)
8
- // ──────────────────────────────────────────────
9
- perplexity: {
10
- input: "#ask-input",
11
- copyButton: 'button[aria-label="Copy"]',
12
- sourceItem: "[data-pplx-citation-url]",
13
- sourceLink: "a",
14
- consent: "#onetrust-accept-btn-handler",
15
- },
16
-
17
- // ──────────────────────────────────────────────
18
- // Bing Copilot (copilot.microsoft.com)
19
- // ──────────────────────────────────────────────
20
- bing: {
21
- input: "#userInput",
22
- copyButton: 'button[data-testid="copy-ai-message-button"]',
23
- sourceLink: 'a[href^="http"][target="_blank"]',
24
- sourceExclude: "copilot.microsoft.com",
25
- consent: "#onetrust-accept-btn-handler",
26
- },
27
-
28
- // ──────────────────────────────────────────────
29
- // Google AI Mode (google.com/search?udm=50)
30
- // ──────────────────────────────────────────────
31
- google: {
32
- answerContainer: ".pWvJNd",
33
- sourceLink: 'a[href^="http"]',
34
- sourceExclude: ["google.", "gstatic", "googleapis"],
35
- sourceHeadingParent: "[data-snhf]",
36
- consent: '#L2AGLb, button[jsname="b3VHJd"], .tHlp8d',
37
- },
38
-
39
- // ──────────────────────────────────────────────
40
- // Gemini (gemini.google.com/app)
41
- // ──────────────────────────────────────────────
42
- gemini: {
43
- input: "rich-textarea .ql-editor",
44
- copyButton: 'button[aria-label="Copy"]',
45
- sendButton: 'button[aria-label*="Send"]',
46
- sourcesSidebarButton: "button.legacy-sources-sidebar-button",
47
- sourcesExclude: ["gemini.google", "gstatic", "google.com/search"],
48
- citationButtonPattern: 'button[aria-label*="citation from"]',
49
- // For parsing citation aria-labels: "View source details for citation from {name}. Opens side panel."
50
- citationNameRegex: /from\s+(.+?)\.\s/,
51
- },
52
- };
1
+ // extractors/selectors.mjs
2
+ // Centralized CSS selectors for all engines.
3
+ // Update selectors here when a site changes its UI.
4
+
5
+ export const SELECTORS = {
6
+ // ──────────────────────────────────────────────
7
+ // Perplexity (perplexity.ai)
8
+ // ──────────────────────────────────────────────
9
+ perplexity: {
10
+ input: "#ask-input",
11
+ // Note: copy button found via JS in extractor (language-agnostic)
12
+ copyButton: null,
13
+ sourceItem: "[data-pplx-citation-url]",
14
+ sourceLink: "a",
15
+ consent: "#onetrust-accept-btn-handler",
16
+ },
17
+
18
+ // ──────────────────────────────────────────────
19
+ // Bing Copilot (copilot.microsoft.com)
20
+ // ──────────────────────────────────────────────
21
+ bing: {
22
+ input: "#userInput",
23
+ copyButton: 'button[data-testid="copy-ai-message-button"]',
24
+ sourceLink: 'a[href^="http"][target="_blank"]',
25
+ sourceExclude: "copilot.microsoft.com",
26
+ consent: "#onetrust-accept-btn-handler",
27
+ },
28
+
29
+ // ──────────────────────────────────────────────
30
+ // Google AI Mode (google.com/search?udm=50)
31
+ // ──────────────────────────────────────────────
32
+ google: {
33
+ answerContainer: ".pWvJNd",
34
+ sourceLink: 'a[href^="http"]',
35
+ sourceExclude: ["google.", "gstatic", "googleapis"],
36
+ sourceHeadingParent: "[data-snhf]",
37
+ consent: '#L2AGLb, button[jsname="b3VHJd"], .tHlp8d',
38
+ },
39
+
40
+ // ──────────────────────────────────────────────
41
+ // Gemini (gemini.google.com/app)
42
+ // ──────────────────────────────────────────────
43
+ gemini: {
44
+ input: "rich-textarea .ql-editor",
45
+ // Language-agnostic: use Material icon data attributes (work across locales)
46
+ copyButton: 'button:has(mat-icon[data-mat-icon-name="content_copy"])',
47
+ sendButton: 'button:has(mat-icon[data-mat-icon-name="send"]), .send-button',
48
+ sourcesSidebarButton: "button.legacy-sources-sidebar-button",
49
+ sourcesExclude: ["gemini.google", "gstatic", "google.com/search"],
50
+ citationButtonPattern: 'button[aria-label*="citation from"]',
51
+ // For parsing citation aria-labels: "View source details for citation from {name}. Opens side panel."
52
+ citationNameRegex: /from\s+(.+?)\.\s/,
53
+ },
54
+ };
package/index.ts CHANGED
@@ -15,10 +15,10 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
15
15
  import { Type } from "@sinclair/typebox";
16
16
 
17
17
  import { formatCodingTask } from "./src/formatters/coding.js";
18
- import { registerGreedySearchTool } from "./src/tools/greedy-search-handler.js";
18
+ import { DEFAULTS } from "./src/search/defaults.mjs";
19
19
  import { registerDeepResearchTool } from "./src/tools/deep-research-handler.js";
20
+ import { registerGreedySearchTool } from "./src/tools/greedy-search-handler.js";
20
21
  import { cdpAvailable, type ProgressUpdate } from "./src/tools/shared.js";
21
- import { DEFAULTS } from "./src/search/defaults.js";
22
22
 
23
23
  const __dir = dirname(fileURLToPath(import.meta.url));
24
24
 
@@ -62,22 +62,43 @@ export default function greedySearchExtension(pi: ExtensionAPI) {
62
62
  },
63
63
  ),
64
64
  mode: Type.Union(
65
- [Type.Literal("code"), Type.Literal("review"), Type.Literal("plan"), Type.Literal("test"), Type.Literal("debug")],
65
+ [
66
+ Type.Literal("code"),
67
+ Type.Literal("review"),
68
+ Type.Literal("plan"),
69
+ Type.Literal("test"),
70
+ Type.Literal("debug"),
71
+ ],
66
72
  {
67
- description: "Task mode: code (default), review (code review), plan (architect review), test (write tests), debug (root cause analysis)",
73
+ description:
74
+ "Task mode: code (default), review (code review), plan (architect review), test (write tests), debug (root cause analysis)",
68
75
  default: "code",
69
76
  },
70
77
  ),
71
- context: Type.Optional(Type.String({ description: "Optional code context/snippet to include with the task" })),
78
+ context: Type.Optional(
79
+ Type.String({
80
+ description: "Optional code context/snippet to include with the task",
81
+ }),
82
+ ),
72
83
  }),
73
84
  execute: async (_toolCallId, params, signal, onUpdate) => {
74
- const { task, engine = "gemini", mode = "code", context } = params as {
75
- task: string; engine: string; mode: string; context?: string;
85
+ const {
86
+ task,
87
+ engine = "gemini",
88
+ mode = "code",
89
+ context,
90
+ } = params as {
91
+ task: string;
92
+ engine: string;
93
+ mode: string;
94
+ context?: string;
76
95
  };
77
96
 
78
97
  if (!cdpAvailable(__dir)) {
79
98
  return {
80
- content: [{ type: "text", text: "cdp.mjs missing — try reinstalling." }],
99
+ content: [
100
+ { type: "text", text: "cdp.mjs missing — try reinstalling." },
101
+ ],
81
102
  details: {} as { raw?: Record<string, unknown> },
82
103
  };
83
104
  }
@@ -87,35 +108,67 @@ export default function greedySearchExtension(pi: ExtensionAPI) {
87
108
 
88
109
  try {
89
110
  onUpdate?.({
90
- content: [{ type: "text", text: `**Coding task...** 🔄 ${engine === "all" ? "Gemini + Copilot" : engine} (${mode} mode)` }],
111
+ content: [
112
+ {
113
+ type: "text",
114
+ text: `**Coding task...** 🔄 ${engine === "all" ? "Gemini + Copilot" : engine} (${mode} mode)`,
115
+ },
116
+ ],
91
117
  details: { _progress: true },
92
118
  } satisfies ProgressUpdate);
93
119
 
94
- const data = await new Promise<Record<string, unknown>>((resolve, reject) => {
95
- const proc = spawn("node", [join(__dir, "bin", "coding-task.mjs"), task, ...flags], {
96
- stdio: ["ignore", "pipe", "pipe"],
97
- });
98
- let out = "";
99
- let err = "";
100
-
101
- const onAbort = () => { proc.kill("SIGTERM"); reject(new Error("Aborted")); };
102
- signal?.addEventListener("abort", onAbort, { once: true });
103
-
104
- proc.stdout.on("data", (d: Buffer) => (out += d));
105
- proc.stderr.on("data", (d: Buffer) => (err += d));
106
- proc.on("close", (code: number) => {
107
- signal?.removeEventListener("abort", onAbort);
108
- if (code !== 0) {
109
- reject(new Error(err.trim() || `coding-task.mjs exited with code ${code}`));
110
- } else {
111
- try { resolve(JSON.parse(out.trim())); }
112
- catch { reject(new Error(`Invalid JSON from coding-task.mjs: ${out.slice(0, 200)}`)); }
113
- }
114
- });
115
-
116
- // Timeout after 3 minutes
117
- setTimeout(() => { proc.kill("SIGTERM"); reject(new Error(`Coding task timed out after ${DEFAULTS.CODING_TASK_TIMEOUT / 1000}s`)); }, DEFAULTS.CODING_TASK_TIMEOUT);
118
- });
120
+ const data = await new Promise<Record<string, unknown>>(
121
+ (resolve, reject) => {
122
+ const proc = spawn(
123
+ "node",
124
+ [join(__dir, "bin", "coding-task.mjs"), task, ...flags],
125
+ {
126
+ stdio: ["ignore", "pipe", "pipe"],
127
+ },
128
+ );
129
+ let out = "";
130
+ let err = "";
131
+
132
+ const onAbort = () => {
133
+ proc.kill("SIGTERM");
134
+ reject(new Error("Aborted"));
135
+ };
136
+ signal?.addEventListener("abort", onAbort, { once: true });
137
+
138
+ proc.stdout.on("data", (d: Buffer) => (out += d));
139
+ proc.stderr.on("data", (d: Buffer) => (err += d));
140
+ proc.on("close", (code: number) => {
141
+ signal?.removeEventListener("abort", onAbort);
142
+ if (code !== 0) {
143
+ reject(
144
+ new Error(
145
+ err.trim() || `coding-task.mjs exited with code ${code}`,
146
+ ),
147
+ );
148
+ } else {
149
+ try {
150
+ resolve(JSON.parse(out.trim()));
151
+ } catch {
152
+ reject(
153
+ new Error(
154
+ `Invalid JSON from coding-task.mjs: ${out.slice(0, 200)}`,
155
+ ),
156
+ );
157
+ }
158
+ }
159
+ });
160
+
161
+ // Timeout after 3 minutes
162
+ setTimeout(() => {
163
+ proc.kill("SIGTERM");
164
+ reject(
165
+ new Error(
166
+ `Coding task timed out after ${DEFAULTS.CODING_TASK_TIMEOUT / 1000}s`,
167
+ ),
168
+ );
169
+ }, DEFAULTS.CODING_TASK_TIMEOUT);
170
+ },
171
+ );
119
172
 
120
173
  const text = formatCodingTask(data);
121
174
  return {
@@ -131,4 +184,95 @@ export default function greedySearchExtension(pi: ExtensionAPI) {
131
184
  }
132
185
  },
133
186
  });
134
- }
187
+
188
+ // ─── /set-greedy-locale command ───────────────────────────────────────────
189
+ pi.registerCommand("set-greedy-locale", {
190
+ description:
191
+ "Set default locale for GreedySearch results (e.g., /set-greedy-locale de, /set-greedy-locale --clear, /set-greedy-locale --show)",
192
+ handler: async (args, ctx) => {
193
+ const arg = args.trim() || "--show";
194
+
195
+ if (arg === "--show") {
196
+ const config = loadUserConfig();
197
+ if (config.locale) {
198
+ ctx.ui.notify(`Default locale: ${config.locale}`, "info");
199
+ } else {
200
+ ctx.ui.notify("No default locale (uses: en)", "info");
201
+ }
202
+ return;
203
+ }
204
+
205
+ if (arg === "--clear") {
206
+ const config = loadUserConfig();
207
+ delete config.locale;
208
+ saveUserConfig(config);
209
+ ctx.ui.notify("Default locale cleared (now uses: en).", "info");
210
+ return;
211
+ }
212
+
213
+ // Set locale
214
+ const locale = arg.toLowerCase();
215
+ const VALID_LOCALES = [
216
+ "en",
217
+ "de",
218
+ "fr",
219
+ "es",
220
+ "it",
221
+ "pt",
222
+ "nl",
223
+ "pl",
224
+ "ru",
225
+ "ja",
226
+ "ko",
227
+ "zh",
228
+ "ar",
229
+ "hi",
230
+ "tr",
231
+ "sv",
232
+ "da",
233
+ "no",
234
+ "fi",
235
+ "cs",
236
+ "hu",
237
+ "ro",
238
+ "el",
239
+ ];
240
+
241
+ if (!VALID_LOCALES.includes(locale)) {
242
+ ctx.ui.notify(
243
+ `Invalid locale "${locale}". Valid: ${VALID_LOCALES.join(", ")}`,
244
+ "error",
245
+ );
246
+ return;
247
+ }
248
+
249
+ const config = loadUserConfig();
250
+ config.locale = locale;
251
+ saveUserConfig(config);
252
+ ctx.ui.notify(`Default locale set to: ${locale}`, "info");
253
+ },
254
+ });
255
+ }
256
+
257
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
258
+ // Config helpers for /set-greedy-locale command
259
+ import { homedir } from "node:os";
260
+
261
+ const USER_CONFIG_DIR = join(homedir(), ".config", "greedysearch");
262
+ const USER_CONFIG_FILE = join(USER_CONFIG_DIR, "config.json");
263
+
264
+ function loadUserConfig(): Record<string, string> {
265
+ try {
266
+ if (existsSync(USER_CONFIG_FILE)) {
267
+ return JSON.parse(readFileSync(USER_CONFIG_FILE, "utf8"));
268
+ }
269
+ } catch {
270
+ // Ignore parse errors
271
+ }
272
+ return {};
273
+ }
274
+
275
+ function saveUserConfig(config: Record<string, string>): void {
276
+ mkdirSync(USER_CONFIG_DIR, { recursive: true });
277
+ writeFileSync(USER_CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
278
+ }