Spaces:
Sleeping
Sleeping
| /** | |
| * 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<string, string>; | |
| } | |
| export interface McpRemoteServerConfig { | |
| transport: McpTransport.Sse | McpTransport.Http; | |
| url: string; | |
| headers: Record<string, string>; | |
| headersHelper?: string; | |
| oauth?: McpOAuthConfig; | |
| } | |
| export interface McpWebSocketServerConfig { | |
| transport: McpTransport.Ws; | |
| url: string; | |
| headers: Record<string, string>; | |
| 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<string, boolean>; | |
| 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<string, ScopedMcpServerConfig> }; | |
| oauth?: OAuthConfig; | |
| model?: string; | |
| permissionMode?: ResolvedPermissionMode; | |
| sandbox: SandboxConfig; | |
| } | |
| // βββ Runtime Config (matches original RuntimeConfig) ββββββββββββββββββββ | |
| export interface RuntimeConfig { | |
| merged: Record<string, any>; | |
| 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<string, ScopedMcpServerConfig> } { | |
| 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<string, any> { | |
| 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. <cwd>/.claw.json (project) | |
| * 4. <cwd>/.claw/settings.json (project) | |
| * 5. <cwd>/.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<string, any> = {}; | |
| const loadedEntries: ConfigEntry[] = []; | |
| const mcpServers: Record<string, ScopedMcpServerConfig> = {}; | |
| 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<string, any> { | |
| 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<string, any> = {}; | |
| 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<string, any> | 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<string, any>, source: Record<string, any>): 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<string, any>): 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<string, any>): 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<string, any>): 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<string, any>): 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<string, any>): SandboxConfig { | |
| const sandbox = merged.sandbox; | |
| if (!sandbox || typeof sandbox !== "object") return {}; | |
| return { | |
| isolationMode: sandbox.isolationMode, | |
| }; | |
| } | |