import 'dart:convert'; import 'dart:io'; const supportedThemeSettings = [ 'auto', 'dark', 'light', 'light-daltonized', 'dark-daltonized', 'light-ansi', 'dark-ansi', ]; const supportedThemeNames = [ 'dark', 'light', 'light-daltonized', 'dark-daltonized', 'light-ansi', 'dark-ansi', ]; const supportedAgentColors = [ 'red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan', ]; const supportedEffortLevels = ['low', 'medium', 'high', 'max']; const supportedPermissionModes = [ 'acceptEdits', 'auto', 'bubble', 'bypassPermissions', 'default', 'dontAsk', 'plan', ]; String getConfigHomeDir() { final home = Platform.environment['HOME']; if (home == null || home.isEmpty) { return joinPath(Directory.current.path, '.clawd_code'); } return joinPath(home, '.clawd_code'); } String getSettingsFilePath() { return joinPath(getConfigHomeDir(), 'settings.json'); } String getPlansDirectoryPath() { return joinPath(getConfigHomeDir(), 'plans'); } String joinPath(String base, String child) { if (base.endsWith(Platform.pathSeparator)) { return '$base$child'; } return '$base${Platform.pathSeparator}$child'; } class LocalSettings { const LocalSettings({ this.advisorModel, this.alwaysAllowRules = const [], this.alwaysAskRules = const [], this.alwaysDenyRules = const [], this.editorMode = 'normal', this.effortLevel, this.fastMode = false, this.hooks, this.mcpServers, this.model, this.openRouterApiKey, this.outputStyle, this.permissionMode = 'default', this.privacyLevel, this.statusLinePrompt, this.telemetry, this.theme = 'dark', }); factory LocalSettings.fromJson(Map json) { return LocalSettings( advisorModel: _readString(json, 'advisorModel'), alwaysAllowRules: _readStringList(json, 'alwaysAllowRules'), alwaysAskRules: _readStringList(json, 'alwaysAskRules'), alwaysDenyRules: _readStringList(json, 'alwaysDenyRules'), editorMode: _readString(json, 'editorMode') ?? 'normal', effortLevel: _readString(json, 'effortLevel'), fastMode: _readBool(json, 'fastMode') ?? false, hooks: _readStringMap(json, 'hooks'), mcpServers: _readMcpServers(json), model: _readString(json, 'model'), openRouterApiKey: _readString(json, 'openRouterApiKey'), outputStyle: _readString(json, 'outputStyle'), permissionMode: _readString(json, 'permissionMode') ?? 'default', privacyLevel: _readString(json, 'privacyLevel'), statusLinePrompt: _readString(json, 'statusLinePrompt'), telemetry: _readString(json, 'telemetry'), theme: _readString(json, 'theme') ?? 'dark', ); } // advisor model name - optional final String? advisorModel; final List alwaysAllowRules; final List alwaysAskRules; final List alwaysDenyRules; final String editorMode; final String? effortLevel; final bool fastMode; // hook configs keyed by event name final Map? hooks; // mcp server configs - each entry is a map of server name -> config object final Map>? mcpServers; final String? model; final String? openRouterApiKey; final String? outputStyle; final String permissionMode; final String? privacyLevel; final String? statusLinePrompt; final String? telemetry; final String theme; LocalSettings copyWith({ Object? advisorModel = _sentinel, List? alwaysAllowRules, List? alwaysAskRules, List? alwaysDenyRules, String? editorMode, Object? effortLevel = _sentinel, bool? fastMode, Object? hooks = _sentinel, Object? mcpServers = _sentinel, Object? model = _sentinel, Object? openRouterApiKey = _sentinel, Object? outputStyle = _sentinel, String? permissionMode, Object? privacyLevel = _sentinel, Object? statusLinePrompt = _sentinel, Object? telemetry = _sentinel, String? theme, }) { return LocalSettings( advisorModel: identical(advisorModel, _sentinel) ? this.advisorModel : advisorModel as String?, alwaysAllowRules: alwaysAllowRules ?? this.alwaysAllowRules, alwaysAskRules: alwaysAskRules ?? this.alwaysAskRules, alwaysDenyRules: alwaysDenyRules ?? this.alwaysDenyRules, editorMode: editorMode ?? this.editorMode, effortLevel: identical(effortLevel, _sentinel) ? this.effortLevel : effortLevel as String?, fastMode: fastMode ?? this.fastMode, hooks: identical(hooks, _sentinel) ? this.hooks : hooks as Map?, mcpServers: identical(mcpServers, _sentinel) ? this.mcpServers : mcpServers as Map>?, model: identical(model, _sentinel) ? this.model : model as String?, openRouterApiKey: identical(openRouterApiKey, _sentinel) ? this.openRouterApiKey : openRouterApiKey as String?, outputStyle: identical(outputStyle, _sentinel) ? this.outputStyle : outputStyle as String?, permissionMode: permissionMode ?? this.permissionMode, privacyLevel: identical(privacyLevel, _sentinel) ? this.privacyLevel : privacyLevel as String?, statusLinePrompt: identical(statusLinePrompt, _sentinel) ? this.statusLinePrompt : statusLinePrompt as String?, telemetry: identical(telemetry, _sentinel) ? this.telemetry : telemetry as String?, theme: theme ?? this.theme, ); } // Merges this (base) with an override layer. // For nullable fields: override wins only if non-null. // For list fields: override wins if non-empty. // For non-nullable fields with defaults: override wins if different from its default. LocalSettings mergeWith(LocalSettings? override) { if (override == null) return this; return LocalSettings( advisorModel: override.advisorModel ?? advisorModel, alwaysAllowRules: override.alwaysAllowRules.isNotEmpty ? override.alwaysAllowRules : alwaysAllowRules, alwaysAskRules: override.alwaysAskRules.isNotEmpty ? override.alwaysAskRules : alwaysAskRules, alwaysDenyRules: override.alwaysDenyRules.isNotEmpty ? override.alwaysDenyRules : alwaysDenyRules, editorMode: override.editorMode != 'normal' ? override.editorMode : editorMode, effortLevel: override.effortLevel ?? effortLevel, fastMode: override.fastMode ? true : fastMode, hooks: override.hooks ?? hooks, mcpServers: override.mcpServers ?? mcpServers, model: override.model ?? model, openRouterApiKey: override.openRouterApiKey ?? openRouterApiKey, outputStyle: override.outputStyle ?? outputStyle, permissionMode: override.permissionMode != 'default' ? override.permissionMode : permissionMode, privacyLevel: override.privacyLevel ?? privacyLevel, statusLinePrompt: override.statusLinePrompt ?? statusLinePrompt, telemetry: override.telemetry ?? telemetry, theme: override.theme != 'dark' ? override.theme : theme, ); } Map toJson() { return { 'advisorModel': advisorModel, 'alwaysAllowRules': alwaysAllowRules, 'alwaysAskRules': alwaysAskRules, 'alwaysDenyRules': alwaysDenyRules, 'editorMode': editorMode, 'effortLevel': effortLevel, 'fastMode': fastMode, 'hooks': hooks, 'mcpServers': mcpServers, 'model': model, 'openRouterApiKey': openRouterApiKey, 'outputStyle': outputStyle, 'permissionMode': permissionMode, 'privacyLevel': privacyLevel, 'statusLinePrompt': statusLinePrompt, 'telemetry': telemetry, 'theme': theme, }; } } class SessionState { SessionState({required this.workingDirectory, String? effortValue}) : effortValue = effortValue, startedAt = DateTime.now().toUtc(), sessionId = _generateSessionId(); String? effortValue; int commandsExecuted = 0; bool planModeEnabled = false; bool briefModeEnabled = false; bool bughunterMode = false; String? sessionColor; // set via /advisor String? advisorModel; // tag toggled via /tag command String? sessionTag; // name set via /rename String? sessionName; // extra dirs added via /add-dir final List additionalDirectories = []; final DateTime startedAt; final String workingDirectory; final String sessionId; static String _generateSessionId() { final now = DateTime.now(); final rnd = (now.millisecondsSinceEpoch % 1000000).toString().padLeft(6, '0'); return 'sess_${now.millisecondsSinceEpoch}_$rnd'; } String get planFilePath { return joinPath(getPlansDirectoryPath(), 'active-plan.md'); } Future readPlan() async { final file = File(planFilePath); if (!await file.exists()) { return null; } return file.readAsString(); } Future writePlan(String content) async { final directory = Directory(getPlansDirectoryPath()); await directory.create(recursive: true); await File(planFilePath).writeAsString(content); } } class SettingsStore { SettingsStore._({required this.path, required this.settings}); static const _encoder = JsonEncoder.withIndent(' '); final String path; LocalSettings settings; static Future load() async { final path = getSettingsFilePath(); final file = File(path); if (!await file.exists()) { final store = SettingsStore._( path: path, settings: const LocalSettings(), ); await store.save(); return store; } try { final raw = await file.readAsString(); final decoded = jsonDecode(raw); if (decoded is Map) { return SettingsStore._( path: path, settings: LocalSettings.fromJson(decoded), ); } if (decoded is Map) { return SettingsStore._( path: path, settings: LocalSettings.fromJson( decoded.map((key, value) => MapEntry(key.toString(), value)), ), ); } } catch (_) { // Fall back to defaults if the file is unreadable. } final store = SettingsStore._(path: path, settings: const LocalSettings()); await store.save(); return store; } Future save() async { final file = File(path); await file.parent.create(recursive: true); await file.writeAsString('${_encoder.convert(settings.toJson())}\n'); } Future update( LocalSettings Function(LocalSettings current) transform, ) async { settings = transform(settings); await save(); } } const _sentinel = Object(); bool? _readBool(Map json, String key) { final value = json[key]; if (value is bool) { return value; } return null; } String? _readString(Map json, String key) { final value = json[key]; if (value is String && value.isNotEmpty) { return value; } return null; } List _readStringList(Map json, String key) { final value = json[key]; if (value is! List) { return const []; } return value .whereType() .where((item) => item.trim().isNotEmpty) .toList(growable: false); } Map? _readStringMap(Map json, String key) { final value = json[key]; if (value is! Map) { return null; } final result = {}; for (final entry in value.entries) { if (entry.key is String && entry.value is String) { result[entry.key as String] = entry.value as String; } } return result.isEmpty ? null : result; } Map>? _readMcpServers(Map json) { final value = json['mcpServers']; if (value is! Map) { return null; } final result = >{}; for (final entry in value.entries) { if (entry.key is! String) continue; final serverCfg = entry.value; if (serverCfg is Map) { result[entry.key as String] = serverCfg.map( (k, v) => MapEntry(k.toString(), v), ); } } return result.isEmpty ? null : result; }