/** * Configuration system for claw-web. * EXACT parity with original claw-code Rust config.rs: * - File-based persistence (.claw.json, .claw/settings.json, .claw/settings.local.json) * - Config discovery from user home + project cwd * - Deep merge with source priority (User < Project < Local) * - MCP server configs (stdio, sse, http, ws) * - Hooks config (PreToolUse, PostToolUse) * - Plugin config (enabled, directories, install root) * - Permission mode config * - Model config */ import * as fs from "fs"; import * as path from "path"; // ─── Config Source (matches original ConfigSource enum) ───────────────── export enum ConfigSource { User = "user", Project = "project", Local = "local", } // ─── Permission Modes (matches original ResolvedPermissionMode) ───────── export enum ResolvedPermissionMode { ReadOnly = "read_only", WorkspaceWrite = "workspace_write", DangerFullAccess = "full_access", } // ─── Config Entry (matches original ConfigEntry) ──────────────────────── export interface ConfigEntry { source: ConfigSource; path: string; } // ─── MCP Transport Types (matches original McpTransport) ──────────────── export enum McpTransport { Stdio = "stdio", Sse = "sse", Http = "http", Ws = "ws", Sdk = "sdk", ManagedProxy = "managed_proxy", } // ─── MCP Server Configs (matches original McpServerConfig variants) ───── export interface McpStdioServerConfig { transport: McpTransport.Stdio; command: string; args: string[]; env: Record; } export interface McpRemoteServerConfig { transport: McpTransport.Sse | McpTransport.Http; url: string; headers: Record; headersHelper?: string; oauth?: McpOAuthConfig; } export interface McpWebSocketServerConfig { transport: McpTransport.Ws; url: string; headers: Record; headersHelper?: string; } export interface McpSdkServerConfig { transport: McpTransport.Sdk; name: string; } export interface McpManagedProxyServerConfig { transport: McpTransport.ManagedProxy; url: string; id: string; } export interface McpOAuthConfig { clientId?: string; callbackPort?: number; authServerMetadataUrl?: string; xaa?: boolean; } export type McpServerConfig = | McpStdioServerConfig | McpRemoteServerConfig | McpWebSocketServerConfig | McpSdkServerConfig | McpManagedProxyServerConfig; export interface ScopedMcpServerConfig { scope: ConfigSource; config: McpServerConfig; } // ─── Hooks Config (matches original RuntimeHookConfig) ────────────────── export interface RuntimeHookConfig { preToolUse: string[]; postToolUse: string[]; } // ─── Plugin Config (matches original RuntimePluginConfig) ─────────────── export interface RuntimePluginConfig { enabledPlugins: Record; externalDirectories: string[]; installRoot?: string; registryPath?: string; bundledRoot?: string; } // ─── OAuth Config (matches original OAuthConfig) ──────────────────────── export interface OAuthConfig { clientId: string; authorizeUrl: string; tokenUrl: string; callbackPort?: number; manualRedirectUrl?: string; scopes: string[]; } // ─── Sandbox Config (matches original SandboxConfig) ──────────────────── export interface SandboxConfig { isolationMode?: "none" | "read_only" | "full"; } // ─── Feature Config (matches original RuntimeFeatureConfig) ───────────── export interface RuntimeFeatureConfig { hooks: RuntimeHookConfig; plugins: RuntimePluginConfig; mcp: { servers: Record }; oauth?: OAuthConfig; model?: string; permissionMode?: ResolvedPermissionMode; sandbox: SandboxConfig; } // ─── Runtime Config (matches original RuntimeConfig) ──────────────────── export interface RuntimeConfig { merged: Record; loadedEntries: ConfigEntry[]; featureConfig: RuntimeFeatureConfig; } // ─── RuntimeConfig accessor helpers (match original Rust impl) ────────── export function configHooks(config: RuntimeConfig): RuntimeHookConfig { return config.featureConfig.hooks; } export function configPlugins(config: RuntimeConfig): RuntimePluginConfig { return config.featureConfig.plugins; } export function configMcp(config: RuntimeConfig): { servers: Record } { return config.featureConfig.mcp; } export function configOAuth(config: RuntimeConfig): OAuthConfig | undefined { return config.featureConfig.oauth; } export function configModel(config: RuntimeConfig): string | undefined { return config.featureConfig.model; } export function configPermissionMode(config: RuntimeConfig): ResolvedPermissionMode | undefined { return config.featureConfig.permissionMode; } export function configSandbox(config: RuntimeConfig): SandboxConfig { return config.featureConfig.sandbox; } /** * Merge hooks config using extend_unique — matches original RuntimeHookConfig::merged() */ export function mergeHooksConfig(base: RuntimeHookConfig, other: RuntimeHookConfig): RuntimeHookConfig { const merged = { preToolUse: [...base.preToolUse], postToolUse: [...base.postToolUse] }; extendUnique(merged.preToolUse, other.preToolUse); extendUnique(merged.postToolUse, other.postToolUse); return merged; } export function configWithHooks(config: RuntimeConfig, hooks: RuntimeHookConfig): RuntimeConfig { return { ...config, featureConfig: { ...config.featureConfig, hooks }, }; } export function configWithPlugins(config: RuntimeConfig, plugins: RuntimePluginConfig): RuntimeConfig { return { ...config, featureConfig: { ...config.featureConfig, plugins }, }; } export function configGet(config: RuntimeConfig, key: string): any { return config.merged[key]; } export function configAsJson(config: RuntimeConfig): Record { return { ...config.merged }; } export function configEmpty(): RuntimeConfig { return { merged: {}, loadedEntries: [], featureConfig: { hooks: { preToolUse: [], postToolUse: [] }, plugins: { enabledPlugins: {}, externalDirectories: [] }, mcp: { servers: {} }, model: undefined, permissionMode: undefined, sandbox: {}, }, }; } // ─── Default config home (matches original default_config_home) ───────── function defaultConfigHome(): string { const xdgConfig = process.env.XDG_CONFIG_HOME; if (xdgConfig) return path.join(xdgConfig, "claw-code"); const home = process.env.HOME || "/home/ubuntu"; return path.join(home, ".config", "claw-code"); } // ─── Config Loader (matches original ConfigLoader) ────────────────────── export class ConfigLoader { private cwd: string; private configHome: string; constructor(cwd: string, configHome?: string) { this.cwd = cwd; this.configHome = configHome || defaultConfigHome(); } static defaultFor(cwd: string): ConfigLoader { return new ConfigLoader(cwd, defaultConfigHome()); } getConfigHome(): string { return this.configHome; } /** * Discover config file paths in priority order. * Matches original discover() method: * 1. ~/.claw.json (user legacy) * 2. ~/.config/claw-code/settings.json (user) * 3. /.claw.json (project) * 4. /.claw/settings.json (project) * 5. /.claw/settings.local.json (local) */ discover(): ConfigEntry[] { const userLegacyPath = path.join( path.dirname(this.configHome), "..", ".claw.json" ); return [ { source: ConfigSource.User, path: userLegacyPath }, { source: ConfigSource.User, path: path.join(this.configHome, "settings.json") }, { source: ConfigSource.Project, path: path.join(this.cwd, ".claw.json") }, { source: ConfigSource.Project, path: path.join(this.cwd, ".claw", "settings.json") }, { source: ConfigSource.Local, path: path.join(this.cwd, ".claw", "settings.local.json") }, ]; } /** * Load and merge all config files. * Matches original load() method. */ load(): RuntimeConfig { const merged: Record = {}; const loadedEntries: ConfigEntry[] = []; const mcpServers: Record = {}; for (const entry of this.discover()) { const value = readOptionalJsonObject(entry.path); if (value === null) continue; // Parse MCP servers if (value.mcpServers && typeof value.mcpServers === "object") { for (const [name, serverValue] of Object.entries(value.mcpServers)) { const parsed = parseMcpServerConfig(name, serverValue as any); if (parsed) { mcpServers[name] = { scope: entry.source, config: parsed }; } } } // Deep merge deepMergeObjects(merged, value); loadedEntries.push(entry); } const featureConfig: RuntimeFeatureConfig = { hooks: parseOptionalHooksConfig(merged), plugins: parseOptionalPluginConfig(merged), mcp: { servers: mcpServers }, oauth: parseOptionalOAuthConfig(merged), model: typeof merged.model === "string" ? merged.model : undefined, permissionMode: parseOptionalPermissionMode(merged), sandbox: parseOptionalSandboxConfig(merged), }; return { merged, loadedEntries, featureConfig }; } } // ─── ConfigError (matches original ConfigError enum) ───────────────────────────── export enum ConfigErrorKind { FileNotFound = "file_not_found", ParseError = "parse_error", ValidationError = "validation_error", IoError = "io_error", } export class ConfigError extends Error { kind: ConfigErrorKind; filePath?: string; constructor(kind: ConfigErrorKind, message: string, filePath?: string) { super(message); this.kind = kind; this.filePath = filePath; this.name = "ConfigError"; } static fileNotFound(filePath: string): ConfigError { return new ConfigError(ConfigErrorKind.FileNotFound, `Config file not found: ${filePath}`, filePath); } static parseError(filePath: string, reason: string): ConfigError { return new ConfigError(ConfigErrorKind.ParseError, `Failed to parse config ${filePath}: ${reason}`, filePath); } static validationError(message: string): ConfigError { return new ConfigError(ConfigErrorKind.ValidationError, message); } } // ─── RuntimeConfig accessor: loaded_entries() ────────────────────────────── /** * Get the list of loaded config entries with their sources. * Matches original RuntimeConfig::loaded_entries(). */ export function configLoadedEntries(config: RuntimeConfig): ConfigEntry[] { return [...config.loadedEntries]; } /** * Full config serialization — matches original RuntimeConfig::as_json(). * Returns the complete merged config with feature config overlay. */ export function configAsJsonFull(config: RuntimeConfig): Record { return { ...config.merged, _feature: { hooks: config.featureConfig.hooks, plugins: config.featureConfig.plugins, mcp: { servers: Object.fromEntries( Object.entries(config.featureConfig.mcp.servers).map(([name, scoped]) => [ name, { scope: scoped.scope, ...scoped.config }, ]) ), }, model: config.featureConfig.model, permissionMode: config.featureConfig.permissionMode, sandbox: config.featureConfig.sandbox, }, _loaded: config.loadedEntries.map(e => ({ source: e.source, path: e.path })), }; } // ─── Singleton config instance ────────────────────────────────────────────────────── let _cachedConfig: RuntimeConfig | null = null; let _cachedCwd: string | null = null; /** * Get or load the runtime config for the given working directory. * Caches the result until invalidated. */ export function getConfig(cwd: string): RuntimeConfig { if (_cachedConfig && _cachedCwd === cwd) return _cachedConfig; const loader = ConfigLoader.defaultFor(cwd); _cachedConfig = loader.load(); _cachedCwd = cwd; return _cachedConfig; } /** * Invalidate the cached config (e.g., after /config set). */ export function invalidateConfigCache(): void { _cachedConfig = null; _cachedCwd = null; } /** * Write a config value to the project-level settings file. * Matches the /config set behavior. */ export function setConfigValue(cwd: string, key: string, value: any): void { const configPath = path.join(cwd, ".claw", "settings.json"); const dir = path.dirname(configPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } let existing: Record = {}; try { const raw = fs.readFileSync(configPath, "utf-8"); existing = JSON.parse(raw); } catch { // File doesn't exist or invalid JSON } // Support dot-notation keys (e.g., "hooks.PreToolUse") const keys = key.split("."); let obj = existing; for (let i = 0; i < keys.length - 1; i++) { if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") { obj[keys[i]] = {}; } obj = obj[keys[i]]; } obj[keys[keys.length - 1]] = value; fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + "\n", "utf-8"); invalidateConfigCache(); } /** * Get a config value by dot-notation key. */ export function getConfigValue(cwd: string, key: string): any { const config = getConfig(cwd); const keys = key.split("."); let obj: any = config.merged; for (const k of keys) { if (obj === undefined || obj === null) return undefined; obj = obj[k]; } return obj; } // ─── Helper Functions ─────────────────────────────────────────────────── function readOptionalJsonObject(filePath: string): Record | null { try { const contents = fs.readFileSync(filePath, "utf-8"); if (!contents.trim()) return {}; const parsed = JSON.parse(contents); if (typeof parsed !== "object" || Array.isArray(parsed)) { // Legacy .claw.json files might not be objects if (filePath.endsWith(".claw.json")) return null; return null; } return parsed; } catch { return null; } } function deepMergeObjects(target: Record, source: Record): void { for (const [key, value] of Object.entries(source)) { if ( key !== "mcpServers" && // MCP servers handled separately value !== null && typeof value === "object" && !Array.isArray(value) && target[key] && typeof target[key] === "object" && !Array.isArray(target[key]) ) { deepMergeObjects(target[key], value); } else { target[key] = value; } } } function parseMcpServerConfig(name: string, value: any): McpServerConfig | null { if (!value || typeof value !== "object") return null; // Determine transport type if (value.command) { return { transport: McpTransport.Stdio, command: value.command, args: Array.isArray(value.args) ? value.args : [], env: value.env && typeof value.env === "object" ? value.env : {}, }; } if (value.url) { const transport = value.transport || "sse"; if (transport === "ws" || transport === "websocket") { return { transport: McpTransport.Ws, url: value.url, headers: value.headers || {}, headersHelper: value.headersHelper, }; } if (transport === "managed_proxy") { return { transport: McpTransport.ManagedProxy, url: value.url, id: value.id || name, }; } return { transport: transport === "http" ? McpTransport.Http : McpTransport.Sse, url: value.url, headers: value.headers || {}, headersHelper: value.headersHelper, oauth: value.oauth ? { clientId: value.oauth.clientId, callbackPort: value.oauth.callbackPort, authServerMetadataUrl: value.oauth.authServerMetadataUrl, xaa: value.oauth.xaa, } : undefined, }; } if (value.name) { return { transport: McpTransport.Sdk, name: value.name }; } return null; } // ─── extend_unique / push_unique (matches original config.rs) ────────────── function pushUnique(target: string[], value: string): void { if (!target.includes(value)) { target.push(value); } } function extendUnique(target: string[], values: string[]): void { for (const value of values) { pushUnique(target, value); } } function parseOptionalHooksConfig(merged: Record): RuntimeHookConfig { const hooks = merged.hooks; if (!hooks || typeof hooks !== "object") { return { preToolUse: [], postToolUse: [] }; } // Support all naming conventions: PascalCase (original), snake_case, camelCase const pre = hooks.PreToolUse || hooks.pre_tool_use || hooks.preToolUse || []; const post = hooks.PostToolUse || hooks.post_tool_use || hooks.postToolUse || []; return { preToolUse: Array.isArray(pre) ? pre.filter((s: any) => typeof s === "string") : [], postToolUse: Array.isArray(post) ? post.filter((s: any) => typeof s === "string") : [], }; } function parseOptionalPluginConfig(merged: Record): RuntimePluginConfig { const config: RuntimePluginConfig = { enabledPlugins: {}, externalDirectories: [], }; if (merged.enabledPlugins && typeof merged.enabledPlugins === "object") { for (const [k, v] of Object.entries(merged.enabledPlugins)) { if (typeof v === "boolean") config.enabledPlugins[k] = v; } } const plugins = merged.plugins; if (!plugins || typeof plugins !== "object") return config; if (plugins.enabled && typeof plugins.enabled === "object") { for (const [k, v] of Object.entries(plugins.enabled)) { if (typeof v === "boolean") config.enabledPlugins[k] = v; } } if (Array.isArray(plugins.externalDirectories)) { config.externalDirectories = plugins.externalDirectories.filter((s: any) => typeof s === "string"); } if (typeof plugins.installRoot === "string") config.installRoot = plugins.installRoot; if (typeof plugins.registryPath === "string") config.registryPath = plugins.registryPath; if (typeof plugins.bundledRoot === "string") config.bundledRoot = plugins.bundledRoot; return config; } function parseOptionalOAuthConfig(merged: Record): OAuthConfig | undefined { const oauth = merged.oauth; if (!oauth || typeof oauth !== "object") return undefined; if (!oauth.clientId || !oauth.authorizeUrl || !oauth.tokenUrl) return undefined; return { clientId: oauth.clientId, authorizeUrl: oauth.authorizeUrl, tokenUrl: oauth.tokenUrl, callbackPort: oauth.callbackPort, manualRedirectUrl: oauth.manualRedirectUrl, scopes: Array.isArray(oauth.scopes) ? oauth.scopes : [], }; } function parseOptionalPermissionMode(merged: Record): ResolvedPermissionMode | undefined { const mode = merged.permissions?.mode || merged.permissionMode; if (!mode || typeof mode !== "string") return undefined; const normalized = mode.toLowerCase().replace(/[-_\s]/g, ""); if (normalized === "readonly" || normalized === "readOnly") return ResolvedPermissionMode.ReadOnly; if (normalized === "workspacewrite" || normalized === "workspace") return ResolvedPermissionMode.WorkspaceWrite; if (normalized === "fullaccess" || normalized === "danger" || normalized === "dangerfullaccess") return ResolvedPermissionMode.DangerFullAccess; return undefined; } function parseOptionalSandboxConfig(merged: Record): SandboxConfig { const sandbox = merged.sandbox; if (!sandbox || typeof sandbox !== "object") return {}; return { isolationMode: sandbox.isolationMode, }; }