The CLI half of the multi-account feature (epic T-476; D-6 parity). A new
`claude.account` dispatcher command multiplexes the six sub-verbs over an
injected, Flutter-free AccountStore port (runs under `dart test`):
- add <name> [--dir] register (default ~/.claude-<name>); idempotent, clear
conflict error
- list {accounts, boundAccount (this workspace), detected}
- set <name> bind this workspace (persists)
- unset clear this workspace's binding
- remove <name> [--purge] registry-remove; refuses while any workspace is
bound
- login <name> (publishes the login action)
Registry reads/writes go through the user-scope SettingsStore; side-effects
that only the UI layer can do — respawn on set/unset, the `claude login`
terminal pane, and the --purge rm — are published on accountActionChannel for
the Claude extension to consume (that consumer is T-480 part 2). main.dart
adapts the real AccountRegistry to the port and registers the command alongside
image.show / status.
Adds SettingsStore.keysAt (binding enumeration) and AccountRegistry.boundName /
boundAccountNames. No changelog yet — set/unset don't auto-respawn until part 2,
so the feature isn't user-complete. Verb behaviour + payloads + the in-use
guard are unit-tested against a fake store.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
354 lines
11 KiB
Dart
354 lines
11 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:yaml/yaml.dart';
|
|
|
|
enum SettingsScope { app, project, ext }
|
|
|
|
class SettingsStore extends ChangeNotifier {
|
|
SettingsStore({required this.appDir, this.projectDir, this.onError});
|
|
|
|
final Directory appDir;
|
|
Directory? projectDir;
|
|
|
|
/// Surfaces load/parse problems (wired to the kernel Logger by the
|
|
/// facade). A parse failure must not pass silently — it used to reset
|
|
/// every setting on the next write (T-376).
|
|
final void Function(String message)? onError;
|
|
|
|
final Map<String, Object?> _appValues = <String, Object?>{};
|
|
final Map<String, Object?> _projectValues = <String, Object?>{};
|
|
|
|
// Writes are fire-and-forget (callers don't await `set`); if the store is
|
|
// disposed while one is mid-flight (app shutdown, a closing test), skip the
|
|
// post-write notify rather than asserting on a disposed ChangeNotifier.
|
|
bool _disposed = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_disposed = true;
|
|
super.dispose();
|
|
}
|
|
|
|
void _safeNotify() {
|
|
if (_disposed) return;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> load() async {
|
|
_appValues
|
|
..clear()
|
|
..addAll(await _readFile(_appFile));
|
|
_projectValues.clear();
|
|
if (projectDir != null) {
|
|
_projectValues.addAll(await _readFile(_projectFile));
|
|
}
|
|
_safeNotify();
|
|
}
|
|
|
|
Future<void> setProjectDir(Directory? dir) async {
|
|
projectDir = dir;
|
|
_projectValues.clear();
|
|
if (dir != null) {
|
|
_projectValues.addAll(await _readFile(_projectFile));
|
|
}
|
|
_safeNotify();
|
|
}
|
|
|
|
File get _appFile => File('${appDir.path}/settings.yaml');
|
|
File get _projectFile => File('${projectDir!.path}/.clide/settings.yaml');
|
|
|
|
T? get<T>(String key) {
|
|
final v = _lookup(key);
|
|
if (v is T) return v;
|
|
if (T == int && v is num) return v.toInt() as T;
|
|
if (T == double && v is num) return v.toDouble() as T;
|
|
return null;
|
|
}
|
|
|
|
Object? _lookup(String key) {
|
|
switch (_scopeOf(key)) {
|
|
case SettingsScope.app:
|
|
return _appValues[key];
|
|
case SettingsScope.project:
|
|
return _projectValues[key];
|
|
case SettingsScope.ext:
|
|
// project overrides app for the same ext.* key
|
|
return _projectValues.containsKey(key) ? _projectValues[key] : _appValues[key];
|
|
}
|
|
}
|
|
|
|
Future<void> set<T>(String key, T value) async {
|
|
switch (_scopeOf(key)) {
|
|
case SettingsScope.app:
|
|
_appValues[key] = value;
|
|
await _writeFile(_appFile, _appValues);
|
|
case SettingsScope.project:
|
|
if (projectDir == null) {
|
|
throw StateError('Cannot set project-scoped key with no project open: $key');
|
|
}
|
|
_projectValues[key] = value;
|
|
await _writeFile(_projectFile, _projectValues);
|
|
case SettingsScope.ext:
|
|
// default: store under app until an ext manifest requests project scope
|
|
_appValues[key] = value;
|
|
await _writeFile(_appFile, _appValues);
|
|
}
|
|
_safeNotify();
|
|
}
|
|
|
|
// --- Scope-explicit access (per-field scope tags, T-449) ---------------
|
|
//
|
|
// [get]/[set] resolve a key by its prefix; the settings panel's scope tag
|
|
// needs to read, write, and clear a key at a *specific* storage layer. There
|
|
// are two storage files: app (`~/.clide`, "Always") and project (`.clide`,
|
|
// "Project"). `ext.*` keys may live in either (project overrides app);
|
|
// `app.*`/`project.*` keys live only in their prefix's layer.
|
|
|
|
/// Raw value stored in a specific storage layer (no cross-layer fallback).
|
|
/// [SettingsScope.ext] is a key class, not a layer, so it returns null.
|
|
Object? rawAt(SettingsScope layer, String key) => switch (layer) {
|
|
SettingsScope.app => _appValues[key],
|
|
SettingsScope.project => _projectValues[key],
|
|
SettingsScope.ext => null,
|
|
};
|
|
|
|
/// Every key currently stored in [layer] (no cross-layer merge) — for
|
|
/// prefix-scan consumers like the per-workspace account-binding enumerator
|
|
/// (T-480). [SettingsScope.ext] is a key class, not a layer → empty.
|
|
Iterable<String> keysAt(SettingsScope layer) => switch (layer) {
|
|
SettingsScope.app => _appValues.keys,
|
|
SettingsScope.project => _projectValues.keys,
|
|
SettingsScope.ext => const <String>[],
|
|
};
|
|
|
|
/// The storage layer currently supplying [key]'s value (project overrides app
|
|
/// for `ext.*`), or null when unset (Default). Honors the key's prefix.
|
|
SettingsScope? effectiveLayer(String key) {
|
|
switch (_scopeOf(key)) {
|
|
case SettingsScope.app:
|
|
return _appValues.containsKey(key) ? SettingsScope.app : null;
|
|
case SettingsScope.project:
|
|
return _projectValues.containsKey(key) ? SettingsScope.project : null;
|
|
case SettingsScope.ext:
|
|
if (_projectValues.containsKey(key)) return SettingsScope.project;
|
|
if (_appValues.containsKey(key)) return SettingsScope.app;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// The storage layers [key] may be written to, by prefix: `app.*` → [app];
|
|
/// `project.*` → [project]; `ext.*` → [project, app].
|
|
List<SettingsScope> writableLayers(String key) {
|
|
switch (_scopeOf(key)) {
|
|
case SettingsScope.app:
|
|
return const [SettingsScope.app];
|
|
case SettingsScope.project:
|
|
return const [SettingsScope.project];
|
|
case SettingsScope.ext:
|
|
return const [SettingsScope.project, SettingsScope.app];
|
|
}
|
|
}
|
|
|
|
/// Write [key] = [value] into a specific storage layer. Throws if the project
|
|
/// layer is requested with no project open, or if [SettingsScope.ext] (not a
|
|
/// layer) is passed.
|
|
Future<void> setAt(SettingsScope layer, String key, Object? value) async {
|
|
switch (layer) {
|
|
case SettingsScope.app:
|
|
_appValues[key] = value;
|
|
await _writeFile(_appFile, _appValues);
|
|
case SettingsScope.project:
|
|
if (projectDir == null) {
|
|
throw StateError('Cannot set project-scoped key with no project open: $key');
|
|
}
|
|
_projectValues[key] = value;
|
|
await _writeFile(_projectFile, _projectValues);
|
|
case SettingsScope.ext:
|
|
throw ArgumentError('ext is a key class, not a storage layer');
|
|
}
|
|
_safeNotify();
|
|
}
|
|
|
|
/// Remove [key] from a specific storage layer (no-op if absent).
|
|
Future<void> removeAt(SettingsScope layer, String key) async {
|
|
switch (layer) {
|
|
case SettingsScope.app:
|
|
if (_appValues.remove(key) != null) await _writeFile(_appFile, _appValues);
|
|
case SettingsScope.project:
|
|
if (projectDir != null && _projectValues.remove(key) != null) {
|
|
await _writeFile(_projectFile, _projectValues);
|
|
}
|
|
case SettingsScope.ext:
|
|
throw ArgumentError('ext is a key class, not a storage layer');
|
|
}
|
|
_safeNotify();
|
|
}
|
|
|
|
Future<Map<String, Object?>> _readFile(File f) async {
|
|
String txt;
|
|
try {
|
|
if (!await f.exists()) return <String, Object?>{};
|
|
txt = await f.readAsString();
|
|
} catch (_) {
|
|
// On web (or in sandboxes where the path isn't readable) silently
|
|
// degrade to an empty in-memory catalog. `set` will no-op too.
|
|
return <String, Object?>{};
|
|
}
|
|
if (txt.trim().isEmpty) return <String, Object?>{};
|
|
try {
|
|
final yaml = loadYaml(txt);
|
|
final out = <String, Object?>{};
|
|
if (yaml is Map) _flatten(yaml, '', out);
|
|
return out;
|
|
} catch (e) {
|
|
// A parse failure must not silently reset the user's settings — the
|
|
// next `set` overwrites the file with the (now empty) in-memory map.
|
|
// Preserve the original for recovery and say so (T-376).
|
|
try {
|
|
await File('${f.path}.broken').writeAsString(txt);
|
|
} catch (_) {}
|
|
onError?.call('failed to parse ${f.path}: $e — original preserved at ${f.path}.broken');
|
|
return <String, Object?>{};
|
|
}
|
|
}
|
|
|
|
Future<void> _writeFile(File f, Map<String, Object?> flat) async {
|
|
try {
|
|
await f.parent.create(recursive: true);
|
|
// Temp-file + rename: a crash mid-write must not truncate the live
|
|
// settings file (T-376).
|
|
final tmp = File('${f.path}.tmp');
|
|
await tmp.writeAsString(_emitYaml(_unflatten(flat)));
|
|
await tmp.rename(f.path);
|
|
} catch (_) {
|
|
// Web / read-only sandbox: in-memory update remains valid, we
|
|
// just can't persist. Callers already called notifyListeners.
|
|
}
|
|
}
|
|
|
|
static SettingsScope _scopeOf(String key) {
|
|
if (key.startsWith('app.')) return SettingsScope.app;
|
|
if (key.startsWith('project.')) return SettingsScope.project;
|
|
if (key.startsWith('ext.')) return SettingsScope.ext;
|
|
throw ArgumentError('Settings key must start with app.|project.|ext.: "$key"');
|
|
}
|
|
}
|
|
|
|
void _flatten(Map src, String prefix, Map<String, Object?> into) {
|
|
src.forEach((k, v) {
|
|
final key = prefix.isEmpty ? '$k' : '$prefix.$k';
|
|
if (v is Map) {
|
|
_flatten(v, key, into);
|
|
} else if (v is YamlList) {
|
|
into[key] = v.toList();
|
|
} else {
|
|
into[key] = v;
|
|
}
|
|
});
|
|
}
|
|
|
|
Map<String, Object?> _unflatten(Map<String, Object?> flat) {
|
|
final root = <String, Object?>{};
|
|
flat.forEach((k, v) {
|
|
final parts = k.split('.');
|
|
var cursor = root;
|
|
for (var i = 0; i < parts.length - 1; i++) {
|
|
final next = cursor[parts[i]];
|
|
if (next is Map<String, Object?>) {
|
|
cursor = next;
|
|
} else {
|
|
final fresh = <String, Object?>{};
|
|
cursor[parts[i]] = fresh;
|
|
cursor = fresh;
|
|
}
|
|
}
|
|
cursor[parts.last] = v;
|
|
});
|
|
return root;
|
|
}
|
|
|
|
String _emitYaml(Object? value, {int indent = 0}) {
|
|
final buf = StringBuffer();
|
|
_emit(buf, value, indent);
|
|
return buf.toString();
|
|
}
|
|
|
|
void _emit(StringBuffer buf, Object? v, int indent) {
|
|
final pad = ' ' * indent;
|
|
if (v is Map) {
|
|
if (v.isEmpty) {
|
|
buf.writeln('{}');
|
|
return;
|
|
}
|
|
v.forEach((k, vv) {
|
|
buf.write('$pad$k:');
|
|
if (vv is Map && vv.isNotEmpty) {
|
|
buf.writeln();
|
|
_emit(buf, vv, indent + 1);
|
|
} else if (vv is List && vv.isNotEmpty) {
|
|
buf.writeln();
|
|
for (final item in vv) {
|
|
buf.write('$pad- ');
|
|
_emitScalar(buf, item);
|
|
buf.writeln();
|
|
}
|
|
} else {
|
|
buf.write(' ');
|
|
_emitScalar(buf, vv);
|
|
buf.writeln();
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
_emitScalar(buf, v);
|
|
buf.writeln();
|
|
}
|
|
|
|
void _emitScalar(StringBuffer buf, Object? v) {
|
|
if (v == null) {
|
|
buf.write('null');
|
|
} else if (v is bool || v is num) {
|
|
buf.write(v);
|
|
} else if (v is String) {
|
|
if (_needsQuoting(v)) {
|
|
buf.write('"${v.replaceAll(r'\', r'\\').replaceAll('"', r'\"')}"');
|
|
} else {
|
|
buf.write(v);
|
|
}
|
|
} else if (v is List) {
|
|
buf.write('[');
|
|
for (var i = 0; i < v.length; i++) {
|
|
if (i > 0) buf.write(', ');
|
|
_emitScalar(buf, v[i]);
|
|
}
|
|
buf.write(']');
|
|
} else if (v is Map) {
|
|
// YAML flow mapping — maps nested inside lists (e.g. keymap overlay
|
|
// entries) used to fall through to toString() and corrupt on the
|
|
// next read (T-376).
|
|
buf.write('{');
|
|
var first = true;
|
|
v.forEach((k, vv) {
|
|
if (!first) buf.write(', ');
|
|
first = false;
|
|
_emitScalar(buf, '$k');
|
|
buf.write(': ');
|
|
_emitScalar(buf, vv);
|
|
});
|
|
buf.write('}');
|
|
} else {
|
|
buf.write('"${v.toString()}"');
|
|
}
|
|
}
|
|
|
|
bool _needsQuoting(String s) {
|
|
if (s.isEmpty) return true;
|
|
if (RegExp(r'[:\#\n\r\t]').hasMatch(s)) return true;
|
|
if (s != s.trim()) return true;
|
|
const reserved = {'true', 'false', 'null', 'yes', 'no', 'on', 'off', '~'};
|
|
if (reserved.contains(s.toLowerCase())) return true;
|
|
if (num.tryParse(s) != null) return true;
|
|
return false;
|
|
}
|