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:
@@ -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'));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user