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