import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent"; import type { CodexSearchConfigFile, CodexSearchProvider, ResolvedCodexSearchConfig, SearchContextSize, WebSearchLocation, WebSearchMode, } from "./types.ts"; export const CONFIG_FILENAME = "pi-codex-search.json"; export const DEFAULT_SEARCH_MODE: WebSearchMode = "live"; export const DEFAULT_MAX_OUTPUT_TOKENS = 10_000; export const DEFAULT_TIMEOUT_MS = 300_000; const CONFIG_KEYS = new Set([ "provider", "baseUrl", "apiKey", "model", "mode", "search_context_size", "allowed_domains", "user_location", "max_output_tokens", "timeout_ms", ]); const LOCATION_KEYS = new Set(["country", "region", "city", "timezone"]); const PROVIDERS = new Set(["openai-compatible", "openai-codex"]); const MODES = new Set(["disabled", "cached", "indexed", "live"]); const CONTEXT_SIZES = new Set(["low", "medium", "high"]); const DOMAIN_RE = /^(?:[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/i; export type ConfigScope = "global" | "project"; export interface ConfigPaths { global: string; project: string; } export interface LoadConfigOptions { cwd: string; projectTrusted: boolean; agentDir?: string; env?: NodeJS.ProcessEnv; } export class CodexSearchConfigError extends Error { readonly kind: "missing" | "invalid"; constructor(message: string, kind: "missing" | "invalid" = "invalid") { super(message); this.name = "CodexSearchConfigError"; this.kind = kind; } } export function getCodexSearchConfigPaths(cwd: string, agentDir = getAgentDir()): ConfigPaths { return { global: join(agentDir, CONFIG_FILENAME), project: join(cwd, CONFIG_DIR_NAME, CONFIG_FILENAME), }; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } async function readOptionalConfig(path: string): Promise { let text: string; try { text = await readFile(path, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw new CodexSearchConfigError( `Cannot read Pi Codex Search config ${path}: ${error instanceof Error ? error.message : String(error)}`, ); } let value: unknown; try { value = JSON.parse(text); } catch (error) { throw new CodexSearchConfigError(`Invalid JSON in ${path}: ${error instanceof Error ? error.message : String(error)}`); } if (!isRecord(value)) throw new CodexSearchConfigError(`Config ${path} must contain a JSON object`); const unknownKeys = Object.keys(value).filter((key) => !CONFIG_KEYS.has(key)); if (unknownKeys.length > 0) { throw new CodexSearchConfigError(`Unknown ${path} field(s): ${unknownKeys.join(", ")}`); } return value as CodexSearchConfigFile; } function environmentConfig(env: NodeJS.ProcessEnv): CodexSearchConfigFile | undefined { const config: CodexSearchConfigFile = {}; if (env.PI_CODEX_SEARCH_PROVIDER) { config.provider = env.PI_CODEX_SEARCH_PROVIDER as CodexSearchProvider; } if (env.PI_CODEX_SEARCH_BASE_URL) config.baseUrl = env.PI_CODEX_SEARCH_BASE_URL; if (env.PI_CODEX_SEARCH_API_KEY) config.apiKey = env.PI_CODEX_SEARCH_API_KEY; if (env.PI_CODEX_SEARCH_MODEL) config.model = env.PI_CODEX_SEARCH_MODEL; if (env.PI_CODEX_SEARCH_MODE) config.mode = env.PI_CODEX_SEARCH_MODE as WebSearchMode; if (env.PI_CODEX_SEARCH_CONTEXT_SIZE) { config.search_context_size = env.PI_CODEX_SEARCH_CONTEXT_SIZE as SearchContextSize; } if (env.PI_CODEX_SEARCH_ALLOWED_DOMAINS) { config.allowed_domains = env.PI_CODEX_SEARCH_ALLOWED_DOMAINS .split(",") .map((domain) => domain.trim()) .filter(Boolean); } const location: WebSearchLocation = {}; if (env.PI_CODEX_SEARCH_LOCATION_COUNTRY) location.country = env.PI_CODEX_SEARCH_LOCATION_COUNTRY; if (env.PI_CODEX_SEARCH_LOCATION_REGION) location.region = env.PI_CODEX_SEARCH_LOCATION_REGION; if (env.PI_CODEX_SEARCH_LOCATION_CITY) location.city = env.PI_CODEX_SEARCH_LOCATION_CITY; if (env.PI_CODEX_SEARCH_LOCATION_TIMEZONE) location.timezone = env.PI_CODEX_SEARCH_LOCATION_TIMEZONE; if (Object.keys(location).length > 0) config.user_location = location; if (env.PI_CODEX_SEARCH_MAX_OUTPUT_TOKENS) { config.max_output_tokens = Number(env.PI_CODEX_SEARCH_MAX_OUTPUT_TOKENS); } if (env.PI_CODEX_SEARCH_TIMEOUT_MS) config.timeout_ms = Number(env.PI_CODEX_SEARCH_TIMEOUT_MS); return Object.keys(config).length > 0 ? config : undefined; } function mergeConfigs(base: CodexSearchConfigFile, override: CodexSearchConfigFile): CodexSearchConfigFile { const providerChanged = override.provider !== undefined && override.provider !== base.provider; return { ...base, ...(providerChanged ? { baseUrl: undefined, apiKey: undefined } : {}), ...override, user_location: base.user_location || override.user_location ? { ...base.user_location, ...override.user_location } : undefined, }; } function validateBaseUrl(value: unknown): string { if (typeof value !== "string" || value.trim().length === 0) { throw new CodexSearchConfigError("openai-compatible requires baseUrl and apiKey"); } let url: URL; try { url = new URL(value.trim()); } catch { throw new CodexSearchConfigError("baseUrl must be a valid HTTP(S) URL"); } if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) { throw new CodexSearchConfigError("baseUrl must be a credential-free HTTP(S) URL"); } return value.trim().replace(/\/+$/, ""); } function validateLocation(value: unknown): WebSearchLocation | undefined { if (value === undefined) return undefined; if (!isRecord(value)) throw new CodexSearchConfigError("user_location must be an object"); const unknownKeys = Object.keys(value).filter((key) => !LOCATION_KEYS.has(key)); if (unknownKeys.length > 0) { throw new CodexSearchConfigError(`Unknown user_location field(s): ${unknownKeys.join(", ")}`); } const result: WebSearchLocation = {}; for (const key of LOCATION_KEYS) { const item = value[key]; if (item === undefined) continue; if (typeof item !== "string" || item.trim().length === 0) { throw new CodexSearchConfigError(`user_location.${key} must be a non-empty string`); } result[key as keyof WebSearchLocation] = item.trim(); } return Object.keys(result).length > 0 ? result : undefined; } function validatePositiveInteger(value: unknown, field: string, min: number, max: number): number { if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) { throw new CodexSearchConfigError(`${field} must be an integer from ${min} to ${max}`); } return value as number; } function validateResolvedConfig(config: CodexSearchConfigFile, source: string): ResolvedCodexSearchConfig { if (!config.provider) { throw new CodexSearchConfigError( `Pi Codex Search has no provider. Run /codex-search-config or set provider in ${CONFIG_FILENAME}.`, "missing", ); } if ((config.provider as string) === "openai") { throw new CodexSearchConfigError( 'provider "openai" was renamed to "openai-compatible"; configure baseUrl and apiKey for the direct gateway connection', ); } if (!PROVIDERS.has(config.provider)) { throw new CodexSearchConfigError(`provider must be one of: ${[...PROVIDERS].join(", ")}`); } if (typeof config.model !== "string" || config.model.trim().length === 0) { throw new CodexSearchConfigError( `Pi Codex Search has no model. Run /codex-search-config or set model in ${CONFIG_FILENAME}.`, "missing", ); } let directConnection: { baseUrl: string; apiKey: string } | undefined; if (config.provider === "openai-compatible") { const baseUrl = validateBaseUrl(config.baseUrl); if (typeof config.apiKey !== "string" || config.apiKey.trim().length === 0) { throw new CodexSearchConfigError("openai-compatible requires baseUrl and apiKey"); } directConnection = { baseUrl, apiKey: config.apiKey.trim() }; } else if (config.baseUrl !== undefined || config.apiKey !== undefined) { throw new CodexSearchConfigError("openai-codex does not accept baseUrl or apiKey; authenticate it through Pi /login"); } const mode = config.mode ?? DEFAULT_SEARCH_MODE; if (!MODES.has(mode)) throw new CodexSearchConfigError(`mode must be one of: ${[...MODES].join(", ")}`); if (config.search_context_size !== undefined && !CONTEXT_SIZES.has(config.search_context_size)) { throw new CodexSearchConfigError(`search_context_size must be one of: ${[...CONTEXT_SIZES].join(", ")}`); } let allowedDomains: string[] | undefined; if (config.allowed_domains !== undefined) { if (!Array.isArray(config.allowed_domains)) { throw new CodexSearchConfigError("allowed_domains must be an array"); } allowedDomains = [...new Set(config.allowed_domains.map((domain) => { if (typeof domain !== "string" || !DOMAIN_RE.test(domain.trim())) { throw new CodexSearchConfigError(`Invalid allowed domain: ${String(domain)}`); } return domain.trim().toLowerCase(); }))]; if (allowedDomains.length === 0) allowedDomains = undefined; if (allowedDomains && allowedDomains.length > 100) { throw new CodexSearchConfigError("allowed_domains accepts at most 100 domains"); } } const resolvedConfig = { model: config.model.trim(), mode, searchContextSize: config.search_context_size, allowedDomains, userLocation: validateLocation(config.user_location), maxOutputTokens: config.max_output_tokens === undefined ? DEFAULT_MAX_OUTPUT_TOKENS : validatePositiveInteger(config.max_output_tokens, "max_output_tokens", 1, 50_000), timeoutMs: config.timeout_ms === undefined ? DEFAULT_TIMEOUT_MS : validatePositiveInteger(config.timeout_ms, "timeout_ms", 1_000, 600_000), source, }; if (config.provider === "openai-compatible") { if (!directConnection) { throw new CodexSearchConfigError("openai-compatible requires baseUrl and apiKey"); } return { provider: "openai-compatible", ...directConnection, ...resolvedConfig, }; } return { provider: "openai-codex", ...resolvedConfig, }; } export async function loadCodexSearchConfig(options: LoadConfigOptions): Promise { const paths = getCodexSearchConfigPaths(options.cwd, options.agentDir); const globalConfig = await readOptionalConfig(paths.global); const projectConfig = options.projectTrusted ? await readOptionalConfig(paths.project) : undefined; const envConfig = environmentConfig(options.env ?? process.env); let merged: CodexSearchConfigFile = {}; const sources: string[] = []; if (globalConfig) { merged = mergeConfigs(merged, globalConfig); sources.push(paths.global); } if (projectConfig) { merged = mergeConfigs(merged, projectConfig); sources.push(paths.project); } if (envConfig) { merged = mergeConfigs(merged, envConfig); sources.push("environment"); } if (sources.length === 0) { throw new CodexSearchConfigError( `Pi Codex Search is not configured. Run /codex-search-config or create ${paths.global}.`, "missing", ); } return validateResolvedConfig(merged, sources.join(" + ")); } export async function saveCodexSearchConfig( scope: ConfigScope, cwd: string, config: CodexSearchConfigFile, agentDir = getAgentDir(), ): Promise { const path = getCodexSearchConfigPaths(cwd, agentDir)[scope]; validateResolvedConfig(config, path); await mkdir(join(path, ".."), { recursive: true }); await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); await chmod(path, 0o600); return path; }