import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; export interface CharlesConfig { /** Charles Web Interface username */ username: string; /** Charles Web Interface password */ password: string; /** Charles HTTP proxy host */ proxyHost: string; /** Charles HTTP proxy port */ proxyPort: number; } export const DEFAULT_CONFIG: Readonly = { username: "", password: "", proxyHost: "control.charles", proxyPort: 80, }; export type ConfigKey = keyof CharlesConfig; const CONFIG_KEYS: readonly ConfigKey[] = [ "username", "password", "proxyHost", "proxyPort", ] as const; /** Human-readable labels for the /charles UI (English). */ export const CONFIG_LABELS: Record = { username: "Username", password: "Password", proxyHost: "Proxy host", proxyPort: "Proxy port", }; /** Short descriptions shown under the selected row. */ export const CONFIG_DESCRIPTIONS: Record = { username: "Charles Web Interface username (optional, no auth by default)", password: "Charles Web Interface password (optional, no auth by default)", proxyHost: "Host where the Charles HTTP proxy listens", proxyPort: "Port where the Charles HTTP proxy listens (default 8888)", }; /** CLI aliases for `/charles set `. */ export const CONFIG_CLI_ALIASES: Record = { user: "username", username: "username", pass: "password", password: "password", host: "proxyHost", proxyhost: "proxyHost", "proxy-host": "proxyHost", port: "proxyPort", proxyport: "proxyPort", "proxy-port": "proxyPort", }; function expandHome(path: string): string { if (path === "~") return homedir(); if (path.startsWith("~/") || path.startsWith("~\\")) { return join(homedir(), path.slice(2)); } return path; } function resolvePiAgentDir(): string { const configured = process.env.PI_CODING_AGENT_DIR; if (configured) return expandHome(configured); return join(homedir(), ".pi", "agent"); } /** Persistent config path: ~/.pi/agent/extensions/pi-charles/config.json */ export function getConfigPath(): string { return join(resolvePiAgentDir(), "extensions", "pi-charles", "config.json"); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function normalizePort(value: unknown, fallback: number): number { const n = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN; if (!Number.isFinite(n) || n < 1 || n > 65535) return fallback; return Math.floor(n); } function normalizeString(value: unknown, fallback: string): string { return typeof value === "string" && value.length > 0 ? value : fallback; } /** Load file config only (no env overlay). Missing file → defaults. */ export function loadFileConfig(): CharlesConfig { const path = getConfigPath(); if (!existsSync(path)) { return { ...DEFAULT_CONFIG }; } try { const raw = JSON.parse(readFileSync(path, "utf8")) as unknown; if (!isRecord(raw)) return { ...DEFAULT_CONFIG }; return { username: normalizeString(raw.username, DEFAULT_CONFIG.username), password: normalizeString(raw.password, DEFAULT_CONFIG.password), proxyHost: normalizeString(raw.proxyHost, DEFAULT_CONFIG.proxyHost), proxyPort: normalizePort(raw.proxyPort, DEFAULT_CONFIG.proxyPort), }; } catch { return { ...DEFAULT_CONFIG }; } } /** Save file config atomically. */ export function saveFileConfig(config: CharlesConfig): void { const path = getConfigPath(); const dir = dirname(path); mkdirSync(dir, { recursive: true }); const tmp = `${path}.${process.pid}.tmp`; const body = JSON.stringify( { username: config.username, password: config.password, proxyHost: config.proxyHost, proxyPort: config.proxyPort, }, null, 2, ); writeFileSync(tmp, body + "\n", "utf8"); try { renameSync(tmp, path); } catch { writeFileSync(path, body + "\n", "utf8"); try { unlinkSync(tmp); } catch { // ignore } } } /** Reset file config to defaults. */ export function resetFileConfig(): CharlesConfig { const config = { ...DEFAULT_CONFIG }; saveFileConfig(config); return config; } /** * Effective runtime config. * Priority: env vars > file config > defaults. */ export function getConfig(): CharlesConfig { const file = loadFileConfig(); return { username: process.env.CHARLES_USER || file.username, password: process.env.CHARLES_PASS || file.password, proxyHost: process.env.CHARLES_PROXY_HOST || file.proxyHost, proxyPort: process.env.CHARLES_PROXY_PORT ? normalizePort(process.env.CHARLES_PROXY_PORT, file.proxyPort) : file.proxyPort, }; } /** Which keys are currently overridden by environment variables. */ export function envOverriddenKeys(): ConfigKey[] { const keys: ConfigKey[] = []; if (process.env.CHARLES_USER) keys.push("username"); if (process.env.CHARLES_PASS) keys.push("password"); if (process.env.CHARLES_PROXY_HOST) keys.push("proxyHost"); if (process.env.CHARLES_PROXY_PORT) keys.push("proxyPort"); return keys; } /** Mask a secret for display. */ export function maskSecret(value: string): string { if (!value) return "(empty)"; if (value.length <= 2) return "•".repeat(value.length); return "•".repeat(Math.min(value.length, 8)); } /** Display value for a config key (password masked). */ export function displayConfigValue( key: ConfigKey, config: CharlesConfig, ): string { if (key === "password") return maskSecret(config.password); if (key === "proxyPort") return String(config.proxyPort); return config[key]; } /** Apply a single key update; returns null on validation error. */ export function applyConfigValue( config: CharlesConfig, key: ConfigKey, raw: string, ): | { config: CharlesConfig; error?: undefined } | { config: CharlesConfig; error: string } { const value = raw.trim(); if (key === "proxyPort") { const port = normalizePort(value, Number.NaN); if (!Number.isFinite(port)) { return { config, error: "Proxy port must be an integer between 1 and 65535", }; } return { config: { ...config, proxyPort: port } }; } if (!value) { return { config, error: `${CONFIG_LABELS[key]} cannot be empty` }; } return { config: { ...config, [key]: value } }; } export function isConfigKey(value: string): value is ConfigKey { return (CONFIG_KEYS as readonly string[]).includes(value); } export function formatConfigSummary(config: CharlesConfig): string { const lines = CONFIG_KEYS.map((key) => { const marked = envOverriddenKeys().includes(key) ? " (env override)" : ""; return ` ${CONFIG_LABELS[key].padEnd(12)} ${displayConfigValue(key, config)}${marked}`; }); return [ "Charles connection settings:", ...lines, ` Config file ${getConfigPath()}`, ].join("\n"); }