pi-codex-search 0.1.1 → 0.1.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.
- package/README.md +176 -50
- package/index.ts +353 -104
- package/package.json +3 -2
- package/src/codex.ts +50 -6
- package/src/command.ts +343 -0
- package/src/config.ts +214 -0
package/src/command.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
type ConfigScope,
|
|
5
|
+
DEFAULT_ENABLED,
|
|
6
|
+
DEFAULT_FRESHNESS,
|
|
7
|
+
DEFAULT_SEARCH_CONTEXT_SIZE,
|
|
8
|
+
DEFAULT_TOOL_NAME,
|
|
9
|
+
deleteConfig,
|
|
10
|
+
type Freshness,
|
|
11
|
+
getConfigPath,
|
|
12
|
+
loadConfig,
|
|
13
|
+
type PiCodexSearchConfig,
|
|
14
|
+
type ResolvedConfig,
|
|
15
|
+
saveConfig,
|
|
16
|
+
} from "./config.ts";
|
|
17
|
+
import type { SearchContextSize } from "./codex.ts";
|
|
18
|
+
|
|
19
|
+
const COMMAND_NAME = "codex-search-settings";
|
|
20
|
+
const SUBCOMMANDS = ["status", "reset"] as const;
|
|
21
|
+
|
|
22
|
+
export function registerSettingsCommand(pi: ExtensionAPI): void {
|
|
23
|
+
pi.registerCommand(COMMAND_NAME, {
|
|
24
|
+
description: "Configure pi-codex-search (tool name, model, defaults, freshness).",
|
|
25
|
+
getArgumentCompletions(prefix) {
|
|
26
|
+
const lower = prefix.toLowerCase();
|
|
27
|
+
const matches = SUBCOMMANDS.filter((name) => name.startsWith(lower));
|
|
28
|
+
return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
|
|
29
|
+
},
|
|
30
|
+
handler: async (args, ctx) => {
|
|
31
|
+
const trimmed = args.trim();
|
|
32
|
+
try {
|
|
33
|
+
if (!trimmed) {
|
|
34
|
+
if (ctx.hasUI) {
|
|
35
|
+
await openMainDialog(ctx);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await printStatus(ctx);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (trimmed === "status") {
|
|
42
|
+
await printStatus(ctx);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (trimmed === "reset") {
|
|
46
|
+
if (ctx.hasUI) {
|
|
47
|
+
await openResetMenu(ctx);
|
|
48
|
+
} else {
|
|
49
|
+
notify(
|
|
50
|
+
ctx,
|
|
51
|
+
"`reset` requires interactive mode. Delete the config files manually.",
|
|
52
|
+
"warning",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
notify(
|
|
58
|
+
ctx,
|
|
59
|
+
`Unknown subcommand: ${trimmed}. Expected: ${SUBCOMMANDS.join(", ")}.`,
|
|
60
|
+
"error",
|
|
61
|
+
);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
notify(ctx, (error as Error).message, "error");
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function openMainDialog(ctx: ExtensionCommandContext): Promise<void> {
|
|
70
|
+
let dirty = false;
|
|
71
|
+
|
|
72
|
+
while (true) {
|
|
73
|
+
const resolved = await loadConfig(ctx.cwd);
|
|
74
|
+
const choice = await ctx.ui.select(buildMainTitle(resolved, ctx.cwd), [
|
|
75
|
+
"Show current configuration",
|
|
76
|
+
`Edit project config (${relative(ctx.cwd, getConfigPath("project", ctx.cwd))})`,
|
|
77
|
+
`Edit home config (${homeRelative(getConfigPath("home", ctx.cwd))})`,
|
|
78
|
+
"Reset configuration…",
|
|
79
|
+
"Done",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
if (!choice || choice === "Done") break;
|
|
83
|
+
|
|
84
|
+
if (choice === "Show current configuration") {
|
|
85
|
+
ctx.ui.notify(formatStatus(resolved, ctx.cwd));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (choice.startsWith("Edit project")) {
|
|
89
|
+
if (await editScope(ctx, "project")) dirty = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (choice.startsWith("Edit home")) {
|
|
93
|
+
if (await editScope(ctx, "home")) dirty = true;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (choice.startsWith("Reset")) {
|
|
97
|
+
if (await openResetMenu(ctx)) dirty = true;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (dirty) {
|
|
103
|
+
// ctx is stale after reload — dialog is already closed at this point.
|
|
104
|
+
await ctx.reload();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function editScope(ctx: ExtensionCommandContext, scope: ConfigScope): Promise<boolean> {
|
|
109
|
+
const filePath = getConfigPath(scope, ctx.cwd);
|
|
110
|
+
const displayPath = scope === "home" ? homeRelative(filePath) : relative(ctx.cwd, filePath);
|
|
111
|
+
let saved = false;
|
|
112
|
+
|
|
113
|
+
while (true) {
|
|
114
|
+
const resolved = await loadConfig(ctx.cwd);
|
|
115
|
+
const current: PiCodexSearchConfig = { ...resolved.sources[scope] };
|
|
116
|
+
|
|
117
|
+
const choice = await ctx.ui.select(`Edit ${scope} config (${displayPath})`, [
|
|
118
|
+
`Enabled → ${formatValue(current.enabled?.toString(), String(DEFAULT_ENABLED))}`,
|
|
119
|
+
`Tool name → ${formatValue(current.toolName, DEFAULT_TOOL_NAME)}`,
|
|
120
|
+
`Model → ${formatValue(current.model, "auto")}`,
|
|
121
|
+
`Base URL → ${formatValue(current.baseUrl, "default")}`,
|
|
122
|
+
`Client version → ${formatValue(current.clientVersion, "default")}`,
|
|
123
|
+
`Search context size → ${formatValue(current.searchContextSize, DEFAULT_SEARCH_CONTEXT_SIZE)}`,
|
|
124
|
+
`Freshness → ${formatValue(current.freshness, DEFAULT_FRESHNESS)}`,
|
|
125
|
+
"Back",
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
if (!choice || choice === "Back") return saved;
|
|
129
|
+
|
|
130
|
+
if (choice.startsWith("Enabled")) {
|
|
131
|
+
const value = await ctx.ui.select("Enabled", ["true", "false", "Clear"]);
|
|
132
|
+
if (!value) continue;
|
|
133
|
+
const next = value === "Clear" ? undefined : value === "true";
|
|
134
|
+
if (await applyBooleanField(ctx, scope, current, "enabled", next)) saved = true;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (choice.startsWith("Tool name")) {
|
|
138
|
+
const value = await ctx.ui.input("Tool name (empty to clear)", current.toolName ?? "");
|
|
139
|
+
if (value === undefined) continue;
|
|
140
|
+
if (await applyTextField(ctx, scope, current, "toolName", value)) saved = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (choice.startsWith("Model")) {
|
|
144
|
+
const value = await ctx.ui.input("Codex model id (empty to clear)", current.model ?? "");
|
|
145
|
+
if (value === undefined) continue;
|
|
146
|
+
if (await applyTextField(ctx, scope, current, "model", value)) saved = true;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (choice.startsWith("Base URL")) {
|
|
150
|
+
const value = await ctx.ui.input(
|
|
151
|
+
"Codex backend base URL (empty to clear)",
|
|
152
|
+
current.baseUrl ?? "",
|
|
153
|
+
);
|
|
154
|
+
if (value === undefined) continue;
|
|
155
|
+
if (await applyTextField(ctx, scope, current, "baseUrl", value)) saved = true;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (choice.startsWith("Client version")) {
|
|
159
|
+
const value = await ctx.ui.input(
|
|
160
|
+
"Client version sent to /codex/models (empty to clear)",
|
|
161
|
+
current.clientVersion ?? "",
|
|
162
|
+
);
|
|
163
|
+
if (value === undefined) continue;
|
|
164
|
+
if (await applyTextField(ctx, scope, current, "clientVersion", value)) saved = true;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (choice.startsWith("Search context size")) {
|
|
168
|
+
const value = await ctx.ui.select("Search context size", ["low", "medium", "high", "Clear"]);
|
|
169
|
+
if (!value) continue;
|
|
170
|
+
const next = value === "Clear" ? undefined : (value as SearchContextSize);
|
|
171
|
+
if (await applyEnumField(ctx, scope, current, "searchContextSize", next)) saved = true;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (choice.startsWith("Freshness")) {
|
|
175
|
+
const value = await ctx.ui.select("Freshness", ["live", "cached", "Clear"]);
|
|
176
|
+
if (!value) continue;
|
|
177
|
+
const next = value === "Clear" ? undefined : (value as Freshness);
|
|
178
|
+
if (await applyEnumField(ctx, scope, current, "freshness", next)) saved = true;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function openResetMenu(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
185
|
+
let removed = false;
|
|
186
|
+
|
|
187
|
+
while (true) {
|
|
188
|
+
const choice = await ctx.ui.select("Reset configuration", [
|
|
189
|
+
`Delete project config (${relative(ctx.cwd, getConfigPath("project", ctx.cwd))})`,
|
|
190
|
+
`Delete home config (${homeRelative(getConfigPath("home", ctx.cwd))})`,
|
|
191
|
+
"Back",
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
if (!choice || choice === "Back") return removed;
|
|
195
|
+
|
|
196
|
+
const scope: ConfigScope = choice.startsWith("Delete project") ? "project" : "home";
|
|
197
|
+
const filePath = getConfigPath(scope, ctx.cwd);
|
|
198
|
+
const confirmed = await ctx.ui.confirm("Delete config", `Remove ${filePath}?`);
|
|
199
|
+
if (!confirmed) continue;
|
|
200
|
+
try {
|
|
201
|
+
const deleted = await deleteConfig(scope, ctx.cwd);
|
|
202
|
+
if (deleted) {
|
|
203
|
+
ctx.ui.notify(`Deleted ${filePath}.`);
|
|
204
|
+
removed = true;
|
|
205
|
+
} else {
|
|
206
|
+
ctx.ui.notify(`${filePath} did not exist.`, "warning");
|
|
207
|
+
}
|
|
208
|
+
} catch (error) {
|
|
209
|
+
ctx.ui.notify(`Failed to delete ${filePath}: ${(error as Error).message}`, "error");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function applyTextField(
|
|
215
|
+
ctx: ExtensionCommandContext,
|
|
216
|
+
scope: ConfigScope,
|
|
217
|
+
current: PiCodexSearchConfig,
|
|
218
|
+
key: "toolName" | "model" | "baseUrl" | "clientVersion",
|
|
219
|
+
value: string,
|
|
220
|
+
): Promise<boolean> {
|
|
221
|
+
const next: PiCodexSearchConfig = { ...current };
|
|
222
|
+
const trimmed = value.trim();
|
|
223
|
+
if (trimmed.length === 0) {
|
|
224
|
+
delete next[key];
|
|
225
|
+
} else {
|
|
226
|
+
next[key] = trimmed;
|
|
227
|
+
}
|
|
228
|
+
return await persist(ctx, scope, next, key);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function applyEnumField<K extends "searchContextSize" | "freshness">(
|
|
232
|
+
ctx: ExtensionCommandContext,
|
|
233
|
+
scope: ConfigScope,
|
|
234
|
+
current: PiCodexSearchConfig,
|
|
235
|
+
key: K,
|
|
236
|
+
value: PiCodexSearchConfig[K] | undefined,
|
|
237
|
+
): Promise<boolean> {
|
|
238
|
+
const next: PiCodexSearchConfig = { ...current };
|
|
239
|
+
if (value === undefined) {
|
|
240
|
+
delete next[key];
|
|
241
|
+
} else {
|
|
242
|
+
next[key] = value;
|
|
243
|
+
}
|
|
244
|
+
return await persist(ctx, scope, next, key);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function applyBooleanField(
|
|
248
|
+
ctx: ExtensionCommandContext,
|
|
249
|
+
scope: ConfigScope,
|
|
250
|
+
current: PiCodexSearchConfig,
|
|
251
|
+
key: "enabled",
|
|
252
|
+
value: boolean | undefined,
|
|
253
|
+
): Promise<boolean> {
|
|
254
|
+
const next: PiCodexSearchConfig = { ...current };
|
|
255
|
+
if (value === undefined) {
|
|
256
|
+
delete next[key];
|
|
257
|
+
} else {
|
|
258
|
+
next[key] = value;
|
|
259
|
+
}
|
|
260
|
+
return await persist(ctx, scope, next, key);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function persist(
|
|
264
|
+
ctx: ExtensionCommandContext,
|
|
265
|
+
scope: ConfigScope,
|
|
266
|
+
next: PiCodexSearchConfig,
|
|
267
|
+
field: string,
|
|
268
|
+
): Promise<boolean> {
|
|
269
|
+
try {
|
|
270
|
+
const filePath = await saveConfig(scope, ctx.cwd, next);
|
|
271
|
+
ctx.ui.notify(`Saved ${field} to ${filePath}.`);
|
|
272
|
+
return true;
|
|
273
|
+
} catch (error) {
|
|
274
|
+
ctx.ui.notify((error as Error).message, "error");
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function printStatus(ctx: ExtensionCommandContext): Promise<void> {
|
|
280
|
+
const resolved = await loadConfig(ctx.cwd);
|
|
281
|
+
notify(ctx, formatStatus(resolved, ctx.cwd));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function buildMainTitle(resolved: ResolvedConfig, cwd: string): string {
|
|
285
|
+
return [
|
|
286
|
+
"Codex Search settings",
|
|
287
|
+
`Effective: enabled=${resolved.enabled}, tool=${resolved.toolName}, model=${resolved.model ?? "(auto)"}, freshness=${resolved.defaultFreshness}, contextSize=${resolved.defaultSearchContextSize}`,
|
|
288
|
+
`Project file: ${relative(cwd, getConfigPath("project", cwd))}${resolved.sources.project ? "" : " (absent)"}`,
|
|
289
|
+
`Home file: ${homeRelative(getConfigPath("home", cwd))}${resolved.sources.home ? "" : " (absent)"}`,
|
|
290
|
+
].join("\n");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function formatStatus(resolved: ResolvedConfig, cwd: string): string {
|
|
294
|
+
const lines = ["Codex Search settings:"];
|
|
295
|
+
lines.push(` enabled = ${resolved.enabled}`);
|
|
296
|
+
lines.push(` toolName = ${resolved.toolName}`);
|
|
297
|
+
lines.push(` model = ${resolved.model ?? "(auto from /codex/models)"}`);
|
|
298
|
+
lines.push(` baseUrl = ${resolved.baseUrl ?? "(default)"}`);
|
|
299
|
+
lines.push(` clientVersion = ${resolved.clientVersion ?? "(default)"}`);
|
|
300
|
+
lines.push(` searchContextSize = ${resolved.defaultSearchContextSize}`);
|
|
301
|
+
lines.push(` freshness = ${resolved.defaultFreshness}`);
|
|
302
|
+
lines.push("");
|
|
303
|
+
lines.push("Sources (env > project > home):");
|
|
304
|
+
lines.push(` env = ${describeSource(resolved.sources.env)}`);
|
|
305
|
+
lines.push(
|
|
306
|
+
` project = ${describeSource(resolved.sources.project)} (${relative(cwd, getConfigPath("project", cwd))})`,
|
|
307
|
+
);
|
|
308
|
+
lines.push(
|
|
309
|
+
` home = ${describeSource(resolved.sources.home)} (${homeRelative(getConfigPath("home", cwd))})`,
|
|
310
|
+
);
|
|
311
|
+
return lines.join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function describeSource(config: PiCodexSearchConfig | undefined): string {
|
|
315
|
+
if (!config) return "(none)";
|
|
316
|
+
const keys = Object.keys(config);
|
|
317
|
+
if (keys.length === 0) return "(empty)";
|
|
318
|
+
return keys.sort().join(", ");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function formatValue(value: string | undefined, fallback: string): string {
|
|
322
|
+
return value ?? `(default: ${fallback})`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function homeRelative(filePath: string): string {
|
|
326
|
+
const home = process.env.HOME ?? "";
|
|
327
|
+
return home && filePath.startsWith(`${home}/`)
|
|
328
|
+
? `~/${filePath.slice(home.length + 1)}`
|
|
329
|
+
: filePath;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function notify(
|
|
333
|
+
ctx: Pick<ExtensionCommandContext, "hasUI" | "ui">,
|
|
334
|
+
message: string,
|
|
335
|
+
level: "info" | "warning" | "error" = "info",
|
|
336
|
+
): void {
|
|
337
|
+
if (ctx.hasUI) {
|
|
338
|
+
ctx.ui.notify(message, level);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (level === "error") console.error(message);
|
|
342
|
+
else console.log(message);
|
|
343
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { SearchContextSize } from "./codex.ts";
|
|
5
|
+
|
|
6
|
+
export type Freshness = "live" | "cached";
|
|
7
|
+
|
|
8
|
+
export type ConfigScope = "project" | "home";
|
|
9
|
+
|
|
10
|
+
export interface PiCodexSearchConfig {
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
toolName?: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
clientVersion?: string;
|
|
16
|
+
searchContextSize?: SearchContextSize;
|
|
17
|
+
freshness?: Freshness;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ResolvedConfig {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
toolName: string;
|
|
23
|
+
model?: string;
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
clientVersion?: string;
|
|
26
|
+
defaultSearchContextSize: SearchContextSize;
|
|
27
|
+
defaultFreshness: Freshness;
|
|
28
|
+
sources: {
|
|
29
|
+
project?: PiCodexSearchConfig;
|
|
30
|
+
home?: PiCodexSearchConfig;
|
|
31
|
+
env?: PiCodexSearchConfig;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_ENABLED = true;
|
|
36
|
+
export const DEFAULT_TOOL_NAME = "codex_search";
|
|
37
|
+
export const DEFAULT_SEARCH_CONTEXT_SIZE: SearchContextSize = "medium";
|
|
38
|
+
export const DEFAULT_FRESHNESS: Freshness = "live";
|
|
39
|
+
export const CONFIG_FILE_NAME = "pi-codex-search.json";
|
|
40
|
+
const TOOL_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/;
|
|
41
|
+
const CONTEXT_SIZES: readonly SearchContextSize[] = ["low", "medium", "high"] as const;
|
|
42
|
+
const FRESHNESS_VALUES: readonly Freshness[] = ["live", "cached"] as const;
|
|
43
|
+
|
|
44
|
+
export function getConfigPath(scope: ConfigScope, cwd: string): string {
|
|
45
|
+
if (scope === "project") return join(cwd, ".pi", CONFIG_FILE_NAME);
|
|
46
|
+
return join(homedir(), ".pi", CONFIG_FILE_NAME);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function loadConfig(cwd: string): Promise<ResolvedConfig> {
|
|
50
|
+
const homeConfig = await readConfigFile(getConfigPath("home", cwd));
|
|
51
|
+
const projectConfig = await readConfigFile(getConfigPath("project", cwd));
|
|
52
|
+
const envConfig = readEnvConfig();
|
|
53
|
+
|
|
54
|
+
const merged: PiCodexSearchConfig = {
|
|
55
|
+
...homeConfig,
|
|
56
|
+
...projectConfig,
|
|
57
|
+
...envConfig,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const resolved: ResolvedConfig = {
|
|
61
|
+
enabled: merged.enabled ?? DEFAULT_ENABLED,
|
|
62
|
+
toolName: merged.toolName ?? DEFAULT_TOOL_NAME,
|
|
63
|
+
defaultSearchContextSize: merged.searchContextSize ?? DEFAULT_SEARCH_CONTEXT_SIZE,
|
|
64
|
+
defaultFreshness: merged.freshness ?? DEFAULT_FRESHNESS,
|
|
65
|
+
sources: {},
|
|
66
|
+
};
|
|
67
|
+
if (merged.model !== undefined) resolved.model = merged.model;
|
|
68
|
+
if (merged.baseUrl !== undefined) resolved.baseUrl = merged.baseUrl;
|
|
69
|
+
if (merged.clientVersion !== undefined) resolved.clientVersion = merged.clientVersion;
|
|
70
|
+
if (homeConfig) resolved.sources.home = homeConfig;
|
|
71
|
+
if (projectConfig) resolved.sources.project = projectConfig;
|
|
72
|
+
if (envConfig && Object.keys(envConfig).length > 0) resolved.sources.env = envConfig;
|
|
73
|
+
|
|
74
|
+
return resolved;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function saveConfig(
|
|
78
|
+
scope: ConfigScope,
|
|
79
|
+
cwd: string,
|
|
80
|
+
config: PiCodexSearchConfig,
|
|
81
|
+
): Promise<string> {
|
|
82
|
+
validateConfig(config, `<save:${scope}>`);
|
|
83
|
+
const filePath = getConfigPath(scope, cwd);
|
|
84
|
+
const clean = stripUndefined(config);
|
|
85
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
86
|
+
await writeFile(filePath, `${JSON.stringify(clean, null, 2)}\n`, "utf-8");
|
|
87
|
+
return filePath;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function deleteConfig(scope: ConfigScope, cwd: string): Promise<boolean> {
|
|
91
|
+
const filePath = getConfigPath(scope, cwd);
|
|
92
|
+
try {
|
|
93
|
+
await unlink(filePath);
|
|
94
|
+
return true;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function readConfigFile(filePath: string): Promise<PiCodexSearchConfig | undefined> {
|
|
102
|
+
let raw: string;
|
|
103
|
+
try {
|
|
104
|
+
raw = await readFile(filePath, "utf-8");
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
107
|
+
throw new Error(`Failed to read ${filePath}: ${(error as Error).message}`);
|
|
108
|
+
}
|
|
109
|
+
let parsed: unknown;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(raw);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
throw new Error(`Invalid JSON in ${filePath}: ${(error as Error).message}`);
|
|
114
|
+
}
|
|
115
|
+
if (!isPlainObject(parsed)) {
|
|
116
|
+
throw new Error(`Invalid config in ${filePath}: expected a JSON object`);
|
|
117
|
+
}
|
|
118
|
+
const config = parsed as PiCodexSearchConfig;
|
|
119
|
+
validateConfig(config, filePath);
|
|
120
|
+
return config;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readEnvConfig(): PiCodexSearchConfig | undefined {
|
|
124
|
+
const env: PiCodexSearchConfig = {};
|
|
125
|
+
const enabled = booleanEnv("PI_CODEX_WEB_SEARCH_ENABLED");
|
|
126
|
+
if (enabled !== undefined) env.enabled = enabled;
|
|
127
|
+
const toolName = trimmedEnv("PI_CODEX_WEB_SEARCH_TOOL_NAME");
|
|
128
|
+
if (toolName !== undefined) env.toolName = toolName;
|
|
129
|
+
const model = trimmedEnv("PI_CODEX_WEB_SEARCH_MODEL");
|
|
130
|
+
if (model !== undefined) env.model = model;
|
|
131
|
+
const baseUrl = trimmedEnv("PI_CODEX_WEB_SEARCH_BASE_URL");
|
|
132
|
+
if (baseUrl !== undefined) env.baseUrl = baseUrl;
|
|
133
|
+
const clientVersion = trimmedEnv("PI_CODEX_WEB_SEARCH_CLIENT_VERSION");
|
|
134
|
+
if (clientVersion !== undefined) env.clientVersion = clientVersion;
|
|
135
|
+
const searchContextSize = trimmedEnv("PI_CODEX_WEB_SEARCH_CONTEXT_SIZE");
|
|
136
|
+
if (searchContextSize !== undefined) {
|
|
137
|
+
env.searchContextSize = searchContextSize as SearchContextSize;
|
|
138
|
+
}
|
|
139
|
+
const freshness = trimmedEnv("PI_CODEX_WEB_SEARCH_FRESHNESS");
|
|
140
|
+
if (freshness !== undefined) env.freshness = freshness as Freshness;
|
|
141
|
+
|
|
142
|
+
if (Object.keys(env).length === 0) return undefined;
|
|
143
|
+
validateConfig(env, "<env>");
|
|
144
|
+
return env;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validateConfig(config: PiCodexSearchConfig, sourceLabel: string): void {
|
|
148
|
+
if (config.enabled !== undefined && typeof config.enabled !== "boolean") {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Invalid enabled in ${sourceLabel}: ${JSON.stringify(config.enabled)}. Must be a boolean.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (config.toolName !== undefined && !TOOL_NAME_PATTERN.test(config.toolName)) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Invalid toolName in ${sourceLabel}: ${JSON.stringify(config.toolName)}. ` +
|
|
156
|
+
`Must match ${TOOL_NAME_PATTERN.source}.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (config.model !== undefined && !isNonEmptyString(config.model)) {
|
|
160
|
+
throw new Error(`Invalid model in ${sourceLabel}: must be a non-empty string.`);
|
|
161
|
+
}
|
|
162
|
+
if (config.baseUrl !== undefined && !isNonEmptyString(config.baseUrl)) {
|
|
163
|
+
throw new Error(`Invalid baseUrl in ${sourceLabel}: must be a non-empty string.`);
|
|
164
|
+
}
|
|
165
|
+
if (config.clientVersion !== undefined && !isNonEmptyString(config.clientVersion)) {
|
|
166
|
+
throw new Error(`Invalid clientVersion in ${sourceLabel}: must be a non-empty string.`);
|
|
167
|
+
}
|
|
168
|
+
if (config.searchContextSize !== undefined && !CONTEXT_SIZES.includes(config.searchContextSize)) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Invalid searchContextSize in ${sourceLabel}: ${JSON.stringify(config.searchContextSize)}. ` +
|
|
171
|
+
`Expected one of ${CONTEXT_SIZES.join(", ")}.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (config.freshness !== undefined && !FRESHNESS_VALUES.includes(config.freshness)) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Invalid freshness in ${sourceLabel}: ${JSON.stringify(config.freshness)}. ` +
|
|
177
|
+
`Expected one of ${FRESHNESS_VALUES.join(", ")}.`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function trimmedEnv(name: string): string | undefined {
|
|
183
|
+
const raw = process.env[name];
|
|
184
|
+
if (raw === undefined) return undefined;
|
|
185
|
+
const trimmed = raw.trim();
|
|
186
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function booleanEnv(name: string): boolean | undefined {
|
|
190
|
+
const raw = trimmedEnv(name);
|
|
191
|
+
if (raw === undefined) return undefined;
|
|
192
|
+
const lower = raw.toLowerCase();
|
|
193
|
+
if (lower === "true") return true;
|
|
194
|
+
if (lower === "false") return false;
|
|
195
|
+
throw new Error(`Invalid ${name}: ${JSON.stringify(raw)}. Expected 'true' or 'false'.`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
199
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
203
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function stripUndefined(config: PiCodexSearchConfig): PiCodexSearchConfig {
|
|
207
|
+
const clean: PiCodexSearchConfig = {};
|
|
208
|
+
for (const [key, value] of Object.entries(config)) {
|
|
209
|
+
if (value !== undefined) {
|
|
210
|
+
(clean as Record<string, unknown>)[key] = value;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return clean;
|
|
214
|
+
}
|