feat(cli): clide claude account verbs — add/list/login/set/unset/remove (T-480 part 1)

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>
This commit is contained in:
2026-06-27 17:30:01 +02:00
co-authored by Claude Opus 4.8
parent 1fa7dee863
commit 744b6cff43
8 changed files with 455 additions and 0 deletions
@@ -110,6 +110,26 @@ class AccountRegistry {
return name == null ? null : accountByName(name);
}
/// The raw account NAME bound to [cwd] — independent of whether that account
/// still exists in the registry — or null when unbound. (`accountForWorkspace`
/// resolves to the Account and is null for a dangling binding; this is the
/// stored name, for list/unset reporting.)
String? boundName(String cwd) => _store.get<String>(bindingKey(cwd));
/// Every account name some workspace is bound to — for "is this account in
/// use" checks before removal (T-480). Scans the `app.claude.account.<hash>`
/// binding keys (NOT the `app.claude.accounts` list, a different key).
Set<String> boundAccountNames() {
const prefix = 'app.claude.account.';
final out = <String>{};
for (final key in _store.keysAt(SettingsScope.app)) {
if (!key.startsWith(prefix)) continue;
final v = _store.get<String>(key);
if (v != null) out.add(v);
}
return out;
}
/// Add (or replace, by name) an account. New default dir is the caller's
/// concern (T-480); the registry stores whatever [dir] it's given.
Future<void> registerAccount(String name, String dir) async {