417 lines
12 KiB
Dart
417 lines
12 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
const supportedThemeSettings = <String>[
|
|
'auto',
|
|
'dark',
|
|
'light',
|
|
'light-daltonized',
|
|
'dark-daltonized',
|
|
'light-ansi',
|
|
'dark-ansi',
|
|
];
|
|
|
|
const supportedThemeNames = <String>[
|
|
'dark',
|
|
'light',
|
|
'light-daltonized',
|
|
'dark-daltonized',
|
|
'light-ansi',
|
|
'dark-ansi',
|
|
];
|
|
|
|
const supportedAgentColors = <String>[
|
|
'red',
|
|
'blue',
|
|
'green',
|
|
'yellow',
|
|
'purple',
|
|
'orange',
|
|
'pink',
|
|
'cyan',
|
|
];
|
|
|
|
const supportedEffortLevels = <String>['low', 'medium', 'high', 'max'];
|
|
|
|
const supportedPermissionModes = <String>[
|
|
'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 <String>[],
|
|
this.alwaysAskRules = const <String>[],
|
|
this.alwaysDenyRules = const <String>[],
|
|
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<String, dynamic> 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<String> alwaysAllowRules;
|
|
final List<String> alwaysAskRules;
|
|
final List<String> alwaysDenyRules;
|
|
final String editorMode;
|
|
final String? effortLevel;
|
|
final bool fastMode;
|
|
|
|
// hook configs keyed by event name
|
|
final Map<String, String>? hooks;
|
|
|
|
// mcp server configs - each entry is a map of server name -> config object
|
|
final Map<String, Map<String, dynamic>>? 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<String>? alwaysAllowRules,
|
|
List<String>? alwaysAskRules,
|
|
List<String>? 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<String, String>?,
|
|
mcpServers: identical(mcpServers, _sentinel) ? this.mcpServers : mcpServers as Map<String, Map<String, dynamic>>?,
|
|
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<String, dynamic> toJson() {
|
|
return <String, dynamic>{
|
|
'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<String> 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<String?> readPlan() async {
|
|
final file = File(planFilePath);
|
|
if (!await file.exists()) {
|
|
return null;
|
|
}
|
|
|
|
return file.readAsString();
|
|
}
|
|
|
|
Future<void> 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<SettingsStore> 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<String, dynamic>) {
|
|
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<void> save() async {
|
|
final file = File(path);
|
|
await file.parent.create(recursive: true);
|
|
await file.writeAsString('${_encoder.convert(settings.toJson())}\n');
|
|
}
|
|
|
|
Future<void> update(
|
|
LocalSettings Function(LocalSettings current) transform,
|
|
) async {
|
|
settings = transform(settings);
|
|
await save();
|
|
}
|
|
}
|
|
|
|
const _sentinel = Object();
|
|
|
|
bool? _readBool(Map<String, dynamic> json, String key) {
|
|
final value = json[key];
|
|
if (value is bool) {
|
|
return value;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
String? _readString(Map<String, dynamic> json, String key) {
|
|
final value = json[key];
|
|
if (value is String && value.isNotEmpty) {
|
|
return value;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
List<String> _readStringList(Map<String, dynamic> json, String key) {
|
|
final value = json[key];
|
|
if (value is! List) {
|
|
return const <String>[];
|
|
}
|
|
|
|
return value
|
|
.whereType<String>()
|
|
.where((item) => item.trim().isNotEmpty)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
Map<String, String>? _readStringMap(Map<String, dynamic> json, String key) {
|
|
final value = json[key];
|
|
if (value is! Map) {
|
|
return null;
|
|
}
|
|
|
|
final result = <String, String>{};
|
|
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<String, Map<String, dynamic>>? _readMcpServers(Map<String, dynamic> json) {
|
|
final value = json['mcpServers'];
|
|
if (value is! Map) {
|
|
return null;
|
|
}
|
|
|
|
final result = <String, Map<String, dynamic>>{};
|
|
|
|
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;
|
|
}
|