From 744b6cff433fa25161f49fd4b2929c510f57a9a3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 27 Jun 2026 17:30:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=20clide=20claude=20account=20verbs?= =?UTF-8?q?=20=E2=80=94=20add/list/login/set/unset/remove=20(T-480=20part?= =?UTF-8?q?=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 [--dir] register (default ~/.claude-); idempotent, clear conflict error - list {accounts, boundAccount (this workspace), detected} - set bind this workspace (persists) - unset clear this workspace's binding - remove [--purge] registry-remove; refuses while any workspace is bound - login (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) --- .pql/changelog/ticket_history/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 37 ++++ lib/builtin/claude/src/account_registry.dart | 20 +++ lib/kernel/src/settings.dart | 9 + lib/main.dart | 51 ++++++ lib/src/daemon/claude_account_commands.dart | 164 ++++++++++++++++++ .../builtin/claude/account_registry_test.dart | 17 ++ test/daemon/claude_account_commands_test.dart | 156 +++++++++++++++++ 8 files changed, 455 insertions(+) create mode 100644 lib/src/daemon/claude_account_commands.dart create mode 100644 test/daemon/claude_account_commands_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 225c5405..a65342fe 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -6789,3 +6789,4 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49VYMPF18PYQXD9PCMHN8', 'status', 'backlog', 'in_progress', NULL, '2026-06-27 14:40:34', '2026-06-27 14:40:34.929', '2026-06-27 14:40:34.929', NULL, '72f31963c666be8d4891f4886c4ddc3f', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49VYMPF18PYQXD9PCMHN8', 'status', 'in_progress', 'done', NULL, '2026-06-27 14:42:39', '2026-06-27 14:42:39.553', '2026-06-27 14:42:39.553', NULL, 'fcd6750756465c07eb8a568ef7c26362', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W3V175EM4F8ZHC6JFM4', 'status', 'backlog', 'done', NULL, '2026-06-27 14:59:54', '2026-06-27 14:59:54.883', '2026-06-27 14:59:54.883', NULL, '84fd321fa07b452d52be53a28e433d9a', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W95HJK08BCRQSWFZBF4', 'status', 'backlog', 'in_progress', NULL, '2026-06-27 15:21:57', '2026-06-27 15:21:57.422', '2026-06-27 15:21:57.422', NULL, 'bd509581fcdeb4a965164421c8d2b094', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index be56501e..80a15698 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -8619,3 +8619,40 @@ Every hosted session (primary / secondary / fork / teammate) inherits the bindin - Restarting an already-running session on binding change — that lives in T-480''s `set` verb (it owns the close-then-spawn flow). - IDE bridge discovery for the bound account — T-479. ', 'done', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 14:59:54.882', NULL, 'e66f082fc7dc8dcca681eaa62be4e507', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W95HJK08BCRQSWFZBF4', 'story', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: clide CLI verbs (account add/login/set/unset/list/remove)', 'The `clide claude account …` dispatcher and verbs — D-6 parity for the multi-account feature (T-476). UI tickets (T-481 / T-482) call these verbs only; no UI writes the registry directly. + +## Verbs + +- `clide claude account add [--dir ]` — register an account. Default dir `~/.claude-/`. Idempotent (re-add with the same args is a no-op; conflicting args returns a clear userError). Does NOT spawn `claude login` — composability with `login`. +- `clide claude account list` — JSON: `{accounts: [{name, dir}], boundAccount: , detected: [...]}`. `boundAccount` is for the current workspace; `detected` is the T-477 probe output (existing `~/.claude-*/` dirs not yet registered). +- `clide claude account login ` — spawn `CLAUDE_CONFIG_DIR= claude login` in a managed terminal pane (`lib/builtin/terminal/`). Claude CLI owns the OAuth browser bounce. Verb returns once the pane spawns; OAuth completion is observed by the pane lifecycle, not awaited by the verb. +- `clide claude account set ` — bind the current workspace AND respawn the active Claude pane(s) on the new account. Respawn flow uses `ClaudeSessionOrchestrator.close` (which already awaits real process death per T-437) followed by a fresh `spawn` with the same id. Honest userError if `` isn''t registered. +- `clide claude account unset` — clear the binding for this workspace; respawn pane(s) on the default account. Emits a binding-removed event so T-479''s lock multiplexer can prune the matching `ide/` lock. +- `clide claude account remove [--purge]` — registry-remove; refuses if any open workspace is bound to it (with a clear message). `--purge` additionally `rm -rf`s the `configDir` after registry removal (off by default; require confirmation). + +## Where + +- Dispatcher: new `lib/src/daemon/claude_account_commands.dart` — mirror the shape of `image_commands.dart` (CommandSchema-declared positionals + flags, Flutter-free handler). +- A MessageBus channel — e.g. `accountActionChannel = ''claude.account''` — for non-CLI consumers (the Claude extension + UI) to observe registry/binding changes. +- Registers in `DaemonDispatcher` alongside the existing `image.show` / `pql.*` verbs. + +## Acceptance + +1. All six verbs round-trip through `clide capabilities` and execute against the live dispatcher. +2. `clide claude account add work` registers and persists across clide restarts. +3. `clide claude account list` returns the registry + this workspace''s binding + the detected-dirs probe results. +4. `clide claude account login work` opens a managed terminal pane in which `claude login` runs against `~/.claude-work/`; OAuth completion writes credentials into that dir. +5. `clide claude account set work` while a Claude pane is open: pane respawns with `CLAUDE_CONFIG_DIR=~/.claude-work/` (verified end-to-end against the live process). +6. `clide claude account unset` returns the pane to the default config dir; the matching `/ide/.lock` (T-479) is removed. +7. `clide claude account remove work` refuses while a workspace is bound; succeeds once unset. `--purge` removes the underlying directory after registry removal. +8. Tests follow the `image_commands` shape: schema declaration, Flutter-free handler tests, dispatcher registration check. + +## Depends on + +- T-477 (storage / registry). +- T-478 (env injection) — so the respawn under `set`/`unset` actually changes which account the new process uses. + +## Decision ref + +D-6 (CLI parity). +', 'in_progress', 'medium', NULL, NULL, 'D-6', '2026-06-25 09:16:42', '2026-06-27 15:21:57.421', NULL, 'affb7565fa860ff7787c03f9f159ad8f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/lib/builtin/claude/src/account_registry.dart b/lib/builtin/claude/src/account_registry.dart index 535f278f..2f8a42c2 100644 --- a/lib/builtin/claude/src/account_registry.dart +++ b/lib/builtin/claude/src/account_registry.dart @@ -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(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.` + /// binding keys (NOT the `app.claude.accounts` list, a different key). + Set boundAccountNames() { + const prefix = 'app.claude.account.'; + final out = {}; + for (final key in _store.keysAt(SettingsScope.app)) { + if (!key.startsWith(prefix)) continue; + final v = _store.get(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 registerAccount(String name, String dir) async { diff --git a/lib/kernel/src/settings.dart b/lib/kernel/src/settings.dart index 8e27b231..ce7065fd 100644 --- a/lib/kernel/src/settings.dart +++ b/lib/kernel/src/settings.dart @@ -113,6 +113,15 @@ class SettingsStore extends ChangeNotifier { 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 keysAt(SettingsScope layer) => switch (layer) { + SettingsScope.app => _appValues.keys, + SettingsScope.project => _projectValues.keys, + SettingsScope.ext => const [], + }; + /// 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) { diff --git a/lib/main.dart b/lib/main.dart index c1c1d7e9..7a482424 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -35,7 +35,9 @@ import 'package:clide/builtin/welcome/welcome.dart'; import 'dart:io' show Directory, File, Platform, pid; import 'package:clide/kernel/kernel.dart'; +import 'package:clide/builtin/claude/src/account_registry.dart'; import 'package:clide/clide.dart' show clideVersion; +import 'package:clide/src/daemon/claude_account_commands.dart'; import 'package:clide/src/daemon/dispatcher.dart'; import 'package:clide/src/daemon/editor_commands.dart'; import 'package:clide/src/daemon/files_commands.dart'; @@ -337,6 +339,21 @@ Future main() async { return file.existsSync() ? file.absolute.path : null; }, ); + // `clide claude account …` — manage per-repo Claude accounts (T-480, epic + // T-476). Registry reads/writes go through the user-scope SettingsStore; + // side-effects (respawn, login pane, --purge) are published on + // accountActionChannel for the Claude extension to perform. + registerClaudeAccountCommands( + dispatcher, + () { + final settings = kernelSettings; + final home = Platform.environment['HOME']; + if (settings == null || home == null || home.isEmpty) return null; + return _AccountStoreAdapter(AccountRegistry(settings), home); + }, + publisher: () => kernelMessages?.publish, + workspaceCwd: () => workRoot.path, + ); // `clide status` — one-shot orientation snapshot (T-221): active pane, // focused file + selection, git summary, layout. Assembled here where the // live kernel + subsystem state is in scope; the reader's viewed doc is @@ -512,6 +529,40 @@ Future main() async { runApp(ClideApp(services: services)); } +/// Adapts the foundation-bound [AccountRegistry] + bootstrap probe to the +/// Flutter-free [AccountStore] port the `claude account` verbs use (T-480). +class _AccountStoreAdapter implements AccountStore { + _AccountStoreAdapter(this._reg, this._home); + final AccountRegistry _reg; + final String _home; + + @override + List<({String name, String dir})> get accounts => [for (final a in _reg.accounts) (name: a.name, dir: a.dir)]; + @override + String? boundAccountName(String cwd) => _reg.boundName(cwd); + @override + Set boundAccountNames() => _reg.boundAccountNames(); + @override + String defaultDirFor(String name) => '$_home/.claude-$name'; + @override + List detectedDirs() { + final registered = {for (final a in _reg.accounts) a.dir}; + return [ + for (final d in probeExistingAccountDirs(_home)) + if (!registered.contains(d.dir)) d.dir, + ]; + } + + @override + Future add(String name, String dir) => _reg.registerAccount(name, dir); + @override + Future remove(String name) => _reg.removeAccount(name); + @override + Future bind(String cwd, String name) => _reg.bindWorkspace(cwd, name); + @override + Future unbind(String cwd) => _reg.unbindWorkspace(cwd); +} + class _BusEventSink implements DaemonEventSink { _BusEventSink(this._bus); final DaemonBus _bus; diff --git a/lib/src/daemon/claude_account_commands.dart b/lib/src/daemon/claude_account_commands.dart new file mode 100644 index 00000000..d49d52d7 --- /dev/null +++ b/lib/src/daemon/claude_account_commands.dart @@ -0,0 +1,164 @@ +/// Registers the `claude account …` verbs — the CLI half of the per-repo +/// Claude account feature (T-480, epic T-476; D-6 CLI parity). UI tickets +/// (T-481/T-482) call these verbs only; nothing else writes the registry. +/// +/// `clide claude account add [--dir ]` +/// `clide claude account list` +/// `clide claude account login ` +/// `clide claude account set ` +/// `clide claude account unset` +/// `clide claude account remove [--purge]` +/// +/// The argv grammar splits the first two tokens as `subsystem.verb`, so the +/// command id is `claude.account` and the sub-verb arrives as the first +/// positional. Registry reads/writes go through an injected [AccountStore] port +/// and side-effects (respawn on set/unset, the login terminal pane, --purge rm) +/// are published on [accountActionChannel] for the Claude extension to perform — +/// keeping this handler Flutter-free so it runs under `dart test`. +library; + +import '../ipc/command_schema.dart'; +import '../ipc/envelope.dart'; +import '../ipc/schema_v1.dart'; +import 'dispatcher.dart'; +import 'ui_command.dart' show MessagePublisher; + +/// The MessageBus channel account actions publish on; the Claude extension +/// subscribes to the same literal to perform the side-effects. +const accountActionChannel = 'claude.account'; + +/// Flutter-free port over the (foundation-bound) AccountRegistry, injected so +/// this command runs under `dart test`. main.dart adapts the real registry + +/// bootstrap probe to it. +abstract class AccountStore { + /// Registered accounts as `(name, dir)` records, in stored order. + List<({String name, String dir})> get accounts; + + /// The account name bound to [cwd], or null. + String? boundAccountName(String cwd); + + /// Every account name some workspace is bound to (for the in-use check). + Set boundAccountNames(); + + /// Default config dir for a new account [name] (e.g. `~/.claude-`). + String defaultDirFor(String name); + + /// Unregistered `~/.claude-*` dirs the bootstrap probe found (adoption hints). + List detectedDirs(); + + Future add(String name, String dir); + Future remove(String name); + Future bind(String cwd, String name); + Future unbind(String cwd); +} + +/// Register `claude.account`. [store] / [publisher] / [workspaceCwd] are +/// late-bound closures (captured post-boot in main.dart); each may be null in +/// a headless context, in which case the verb degrades to a clear error. +void registerClaudeAccountCommands( + DaemonDispatcher d, + AccountStore? Function() store, { + MessagePublisher? Function()? publisher, + String? Function()? workspaceCwd, +}) { + d.register( + 'claude.account', + (req) async => _dispatch(req, store(), publisher?.call(), workspaceCwd?.call()), + schema: const CommandSchema( + positional: ['action', 'name'], + args: { + 'action': ArgSpec(required: true, rejectLeadingDash: true), + 'name': ArgSpec(rejectLeadingDash: true), + 'dir': ArgSpec(), + 'purge': ArgSpec(type: ArgType.boolean), + }, + ), + ); +} + +({String name, String dir})? _byName(AccountStore store, String name) { + for (final a in store.accounts) { + if (a.name == name) return a; + } + return null; +} + +Future _dispatch(IpcRequest req, AccountStore? store, MessagePublisher? publish, String? cwd) async { + if (store == null) return _err(req.id, 'account registry unavailable in this context'); + final action = (req.args['action'] as String?)?.trim(); + final name = (req.args['name'] as String?)?.trim(); + final dir = (req.args['dir'] as String?)?.trim(); + final purge = req.args['purge'] == true; + + switch (action) { + case 'list': + return _ok(req.id, { + 'accounts': [ + for (final a in store.accounts) {'name': a.name, 'dir': a.dir}, + ], + 'boundAccount': cwd == null ? null : store.boundAccountName(cwd), + 'detected': store.detectedDirs(), + }); + + case 'add': + if (name == null || name.isEmpty) return _err(req.id, 'account add requires a '); + final target = (dir == null || dir.isEmpty) ? store.defaultDirFor(name) : dir; + final existing = _byName(store, name); + if (existing != null) { + // Idempotent: same dir is a no-op; a conflicting --dir is a userError. + if (existing.dir == target) return _ok(req.id, {'name': name, 'dir': target, 'created': false}); + return _err(req.id, 'account "$name" already exists at ${existing.dir}', hint: 'remove it first, or omit --dir to keep it'); + } + await store.add(name, target); + return _ok(req.id, {'name': name, 'dir': target, 'created': true}); + + case 'remove': + if (name == null || name.isEmpty) return _err(req.id, 'account remove requires a '); + if (_byName(store, name) == null) return _err(req.id, 'no such account: "$name"'); + if (store.boundAccountNames().contains(name)) { + return _err(req.id, 'account "$name" is bound to a workspace', hint: 'clide claude account unset (in that workspace) first'); + } + await store.remove(name); + // The dir delete is IO the extension owns (this handler is Flutter-free). + if (purge) publish?.call('cli', accountActionChannel, {'action': 'purge', 'name': name}); + return _ok(req.id, {'removed': name, 'purge': purge}); + + case 'set': + if (name == null || name.isEmpty) return _err(req.id, 'account set requires a '); + if (cwd == null) return _err(req.id, 'no workspace to bind'); + if (_byName(store, name) == null) return _err(req.id, 'no such account: "$name"', hint: 'clide claude account add $name'); + await store.bind(cwd, name); + // The extension respawns the active pane(s) on the new account. + publish?.call('cli', accountActionChannel, {'action': 'set', 'name': name, 'cwd': cwd}); + return _ok(req.id, {'bound': name, 'cwd': cwd}); + + case 'unset': + if (cwd == null) return _err(req.id, 'no workspace to unbind'); + final prev = store.boundAccountName(cwd); + await store.unbind(cwd); + publish?.call('cli', accountActionChannel, {'action': 'unset', 'cwd': cwd, 'previous': prev}); + return _ok(req.id, {'unbound': prev, 'cwd': cwd}); + + case 'login': + if (name == null || name.isEmpty) return _err(req.id, 'account login requires a '); + final acct = _byName(store, name); + if (acct == null) return _err(req.id, 'no such account: "$name"', hint: 'clide claude account add $name'); + // The extension spawns `CLAUDE_CONFIG_DIR= claude login` in a pane. + publish?.call('cli', accountActionChannel, {'action': 'login', 'name': name, 'dir': acct.dir}); + return _ok(req.id, {'login': name, 'dir': acct.dir}); + + default: + return _err( + req.id, + 'unknown account action: ${action == null || action.isEmpty ? '(none)' : action}', + hint: 'use: add | list | login | set | unset | remove', + ); + } +} + +IpcResponse _ok(String id, Map data) => IpcResponse.ok(id: id, data: data); + +IpcResponse _err(String id, String message, {String? hint}) => IpcResponse.err( + id: id, + error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint), +); diff --git a/test/builtin/claude/account_registry_test.dart b/test/builtin/claude/account_registry_test.dart index 8f4f8f9d..9bd84bd5 100644 --- a/test/builtin/claude/account_registry_test.dart +++ b/test/builtin/claude/account_registry_test.dart @@ -93,6 +93,23 @@ void main() { expect(reg.accountForWorkspace('/repo/a')?.name, 'personal'); expect(reg.accountForWorkspace('/repo/b')?.name, 'work'); }); + + test('boundName is the raw binding (survives account removal); boundAccountNames lists in-use names', () async { + await reg.registerAccount('personal', '/p'); + await reg.registerAccount('work', '/w'); + expect(reg.boundName('/repo/a'), isNull); + expect(reg.boundAccountNames(), isEmpty); + + await reg.bindWorkspace('/repo/a', 'personal'); + await reg.bindWorkspace('/repo/b', 'work'); + expect(reg.boundName('/repo/a'), 'personal'); + expect(reg.boundAccountNames(), {'personal', 'work'}); + + // Removing the account leaves the raw binding; only resolution degrades. + await reg.removeAccount('personal'); + expect(reg.boundName('/repo/a'), 'personal'); + expect(reg.accountForWorkspace('/repo/a'), isNull); + }); }); group('bootstrap probe', () { diff --git a/test/daemon/claude_account_commands_test.dart b/test/daemon/claude_account_commands_test.dart new file mode 100644 index 00000000..5da1d90e --- /dev/null +++ b/test/daemon/claude_account_commands_test.dart @@ -0,0 +1,156 @@ +/// Tests for `claude account …` (T-480, epic T-476; D-6 CLI parity). Verifies +/// each verb's registry effect, the published action payloads, and honest +/// userErrors — against a fake AccountStore so the handler stays Flutter-free +/// (runs under `dart test`). +library; + +import 'package:clide/clide.dart'; +import 'package:clide/src/daemon/claude_account_commands.dart'; +import 'package:test/test.dart'; + +class _FakeStore implements AccountStore { + final _accounts = <({String name, String dir})>[]; + final _bindings = {}; // cwd → account name + List detected = const []; + + @override + List<({String name, String dir})> get accounts => List.of(_accounts); + @override + String? boundAccountName(String cwd) => _bindings[cwd]; + @override + Set boundAccountNames() => _bindings.values.toSet(); + @override + String defaultDirFor(String name) => '/home/u/.claude-$name'; + @override + List detectedDirs() => detected; + @override + Future add(String name, String dir) async => _accounts.add((name: name, dir: dir)); + @override + Future remove(String name) async => _accounts.removeWhere((a) => a.name == name); + @override + Future bind(String cwd, String name) async => _bindings[cwd] = name; + @override + Future unbind(String cwd) async => _bindings.remove(cwd); +} + +void main() { + late _FakeStore store; + late List<({String publisher, String channel, Map data})> published; + late DaemonDispatcher d; + + void wire({String? cwd = '/repo', bool withStore = true}) { + store = _FakeStore(); + published = []; + d = DaemonDispatcher(); + registerClaudeAccountCommands( + d, + () => withStore ? store : null, + publisher: () => + (p, c, data) => published.add((publisher: p, channel: c, data: data)), + workspaceCwd: () => cwd, + ); + } + + Future run(List positional, {Map? flags}) => + d.dispatch(IpcRequest(id: '1', cmd: 'claude.account', args: {'positional': positional, 'flags': ?flags})); + + test('registered on the dispatcher → shows in capabilities (T-480 #1)', () async { + wire(); + final caps = await d.dispatch(IpcRequest(id: 'c', cmd: 'capabilities', args: const {})); + expect((caps.data['commands'] as Map).containsKey('claude.account'), isTrue); + }); + + group('add', () { + test('registers with the default dir, then is idempotent, then conflicts', () async { + wire(); + var r = await run(['add', 'work']); + expect(r.ok, isTrue, reason: r.error?.message); + expect(r.data, {'name': 'work', 'dir': '/home/u/.claude-work', 'created': true}); + + r = await run(['add', 'work']); // same args → no-op + expect(r.data['created'], isFalse); + + r = await run(['add', 'work'], flags: {'dir': '/other'}); // conflicting dir + expect(r.ok, isFalse); + expect(r.error?.message, contains('already exists')); + }); + + test('--dir uses the explicit path; missing name errors', () async { + wire(); + expect((await run(['add', 'work'], flags: {'dir': '/custom'})).data['dir'], '/custom'); + expect((await run(['add'])).ok, isFalse); + }); + }); + + test('list returns accounts + this workspace binding + detected dirs', () async { + wire(); + store.detected = ['/home/u/.claude-old']; + await run(['add', 'work']); + await run(['set', 'work']); + final r = await run(['list']); + expect(r.data['accounts'], [ + {'name': 'work', 'dir': '/home/u/.claude-work'}, + ]); + expect(r.data['boundAccount'], 'work'); + expect(r.data['detected'], ['/home/u/.claude-old']); + }); + + test('set binds + publishes; unknown account errors', () async { + wire(); + expect((await run(['set', 'nope'])).ok, isFalse); + await run(['add', 'work']); + final r = await run(['set', 'work']); + expect(r.ok, isTrue, reason: r.error?.message); + expect(store.boundAccountName('/repo'), 'work'); + expect(published.single.channel, accountActionChannel); + expect(published.single.data, {'action': 'set', 'name': 'work', 'cwd': '/repo'}); + }); + + test('unset clears the binding + publishes the previous account', () async { + wire(); + await run(['add', 'work']); + await run(['set', 'work']); + published.clear(); + final r = await run(['unset']); + expect(r.ok, isTrue); + expect(store.boundAccountName('/repo'), isNull); + expect(published.single.data, {'action': 'unset', 'cwd': '/repo', 'previous': 'work'}); + }); + + test('remove refuses while bound, succeeds once unset; --purge publishes', () async { + wire(); + await run(['add', 'work']); + await run(['set', 'work']); + expect((await run(['remove', 'work'])).ok, isFalse, reason: 'bound → refused'); + await run(['unset']); + published.clear(); + final r = await run(['remove', 'work'], flags: {'purge': true}); + expect(r.ok, isTrue, reason: r.error?.message); + expect(store.accounts, isEmpty); + expect(published.single.data, {'action': 'purge', 'name': 'work'}); + }); + + test('login publishes a login action with the dir; unknown account errors', () async { + wire(); + expect((await run(['login', 'work'])).ok, isFalse); + await run(['add', 'work']); + published.clear(); + final r = await run(['login', 'work']); + expect(r.ok, isTrue, reason: r.error?.message); + expect(published.single.data, {'action': 'login', 'name': 'work', 'dir': '/home/u/.claude-work'}); + }); + + test('unknown action → userError listing the verbs', () async { + wire(); + final r = await run(['frobnicate']); + expect(r.ok, isFalse); + expect(r.error?.message, contains('unknown account action')); + }); + + test('degrades to a clear error when no registry is wired', () async { + wire(withStore: false); + final r = await run(['list']); + expect(r.ok, isFalse); + expect(r.error?.message, contains('unavailable')); + }); +}