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
@@ -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;
+37
View File
@@ -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 <name> [--dir <path>]` — register an account. Default dir `~/.claude-<name>/`. 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: <name|null>, detected: [<dir>...]}`. `boundAccount` is for the current workspace; `detected` is the T-477 probe output (existing `~/.claude-*/` dirs not yet registered).
- `clide claude account login <name>` spawn `CLAUDE_CONFIG_DIR=<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 <name>` 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 `<name>` 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 <name> [--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 `<dir>/ide/<pid>.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);
@@ -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 {
+9
View File
@@ -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<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) {
+51
View File
@@ -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<void> 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<void> 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<String> boundAccountNames() => _reg.boundAccountNames();
@override
String defaultDirFor(String name) => '$_home/.claude-$name';
@override
List<String> 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<void> add(String name, String dir) => _reg.registerAccount(name, dir);
@override
Future<void> remove(String name) => _reg.removeAccount(name);
@override
Future<void> bind(String cwd, String name) => _reg.bindWorkspace(cwd, name);
@override
Future<void> unbind(String cwd) => _reg.unbindWorkspace(cwd);
}
class _BusEventSink implements DaemonEventSink {
_BusEventSink(this._bus);
final DaemonBus _bus;
+164
View File
@@ -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 <name> [--dir <path>]`
/// `clide claude account list`
/// `clide claude account login <name>`
/// `clide claude account set <name>`
/// `clide claude account unset`
/// `clide claude account remove <name> [--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<String> boundAccountNames();
/// Default config dir for a new account [name] (e.g. `~/.claude-<name>`).
String defaultDirFor(String name);
/// Unregistered `~/.claude-*` dirs the bootstrap probe found (adoption hints).
List<String> detectedDirs();
Future<void> add(String name, String dir);
Future<void> remove(String name);
Future<void> bind(String cwd, String name);
Future<void> 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<IpcResponse> _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 <name>');
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 <name>');
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 <name>');
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 <name>');
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=<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<String, Object?> 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),
);
@@ -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', () {
@@ -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 = <String, String>{}; // cwd → account name
List<String> detected = const [];
@override
List<({String name, String dir})> get accounts => List.of(_accounts);
@override
String? boundAccountName(String cwd) => _bindings[cwd];
@override
Set<String> boundAccountNames() => _bindings.values.toSet();
@override
String defaultDirFor(String name) => '/home/u/.claude-$name';
@override
List<String> detectedDirs() => detected;
@override
Future<void> add(String name, String dir) async => _accounts.add((name: name, dir: dir));
@override
Future<void> remove(String name) async => _accounts.removeWhere((a) => a.name == name);
@override
Future<void> bind(String cwd, String name) async => _bindings[cwd] = name;
@override
Future<void> unbind(String cwd) async => _bindings.remove(cwd);
}
void main() {
late _FakeStore store;
late List<({String publisher, String channel, Map<String, Object?> 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<IpcResponse> run(List<String> positional, {Map<String, Object?>? 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'));
});
}