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
+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),
);