From a8d321f344b859b95aee94a266e249da7f502dd5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 27 Jun 2026 16:42:39 +0200 Subject: [PATCH] feat(claude): per-repo account registry + storage foundation (T-483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the multi-account epic (T-476): a Flutter-free AccountRegistry over the app-scope SettingsStore (per-user, never committed to a repo). - `app.claude.accounts` — the {name, dir} account list (dir = CLAUDE_CONFIG_DIR). - `app.claude.account.` — per-workspace binding to an account name; the hash is the SAME FNV-1a 64-bit hex D-70 uses for the socket path, trailing separators stripped so /repo and /repo/ map alike. - accountForWorkspace(cwd) resolves an account or null (a binding to a removed account degrades to null = Claude's default, never errors). - probeExistingAccountDirs(home): read-only bootstrap probe for adoptable ~/.claude-* config dirs (welcome-view UX is T-481). Keys are app.-prefixed because SettingsStore requires a scope prefix; app scope already provides the per-user persistence T-483 wants (T-356 will consolidate more state later but isn't a hard blocker). No spawn/UI/CLI — downstream tickets. Unit tests cover CRUD, the disk round-trip, hash/trailing-slash mapping, independent bindings, and probe filtering. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 124 ++++++++++++++ lib/builtin/claude/src/account_registry.dart | 154 ++++++++++++++++++ .../builtin/claude/account_registry_test.dart | 123 ++++++++++++++ 4 files changed, 403 insertions(+) create mode 100644 lib/builtin/claude/src/account_registry.dart create mode 100644 test/builtin/claude/account_registry_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 00da4d1f..915c0dc6 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -6786,3 +6786,5 @@ control that fetches it (T-415). The three-tab card facelift stays. Q-34 answered: budget surfaces once, on Activity.', NULL, '2026-06-27 05:55:56', '2026-06-27 05:55:56.468', '2026-06-27 05:55:56.468', NULL, 'ba7329e54bf44c2cb5a9e7fb63e685f3', 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 ('06FB0TNQM6QCYT7JBF352YVJ48', 'status', 'backlog', 'in_progress', NULL, '2026-06-27 06:06:03', '2026-06-27 06:06:03.770', '2026-06-27 06:06:03.770', NULL, 'c1cff9a2f682f3bb8f719f6771cf36c3', 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 ('06FB0TNQM6QCYT7JBF352YVJ48', 'status', 'in_progress', 'done', NULL, '2026-06-27 13:51:01', '2026-06-27 13:51:01.108', '2026-06-27 13:51:01.108', NULL, 'd87bdd8b0216a2362c9c41d9f5fa0afa', 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', '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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 84b2966b..c85c7193 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -8456,3 +8456,127 @@ Fix (scope to confirm): - Add a way to enumerate/identify live instances (e.g. a `clide instances` verb, or include the instance id/socket path + pid in `clide version`) so a human or agent can pick the right one. Acceptance: a bogus `CLIDE_SOCK` fails loudly instead of returning data from a different instance; a valid `CLIDE_SOCK` pins the CLI to that instance; startup leaves exactly one live socket for one running app (no orphan accumulation); there is a CLI affordance to list/identify running instances.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-05 21:28:48', '2026-06-27 13:51:01.108', NULL, 'fc3fc8365369e5c99b03aafccc3cda0f', 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 ('06FFW49VYMPF18PYQXD9PCMHN8', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: storage schema + registry + bootstrap migration', 'Foundation for the multi-account epic (T-476): the user-scope persistence layer that every other child consumes. + +## Scope + +Two durable concerns, plus a first-run migration: + +### Account registry + +List of `(name, configDir)` pairs in user-scope clide settings — the catalog of Claude accounts this user has set up. Account names are user-chosen (`personal`, `work`, `client-acme`); `configDir` is the `CLAUDE_CONFIG_DIR` path Claude Code will read from. Default dir for a new account is `~/.claude-/`, but an explicit path is allowed (so an existing `~/.claude-personal/` can be adopted as-is). + +### Workspace binding + +`claude.account.` → ``. Workspace hash is the same FNV-1a 64-bit hex used by D-70 sockets (`fnv1a64Hex` in `lib/src/ipc/paths.dart:101`) — single source of truth. Unset binding means "use Claude''s default" (no `CLAUDE_CONFIG_DIR` injection). + +### Bootstrap migration + +On first run, scan `~` for directories matching `.claude-*` that look like a Claude config dir (have a `.claude.json` or `sessions/` inside). Surface the list — actually offering to register them is welcome-view UX (T-481); this ticket just provides the probe. + +## Where + +- `SettingsStore` schema additions in `lib/kernel/src/settings.dart` (or wherever the schema-driven settings layer lands — coordinate with T-356). +- A new `lib/builtin/claude/src/account_registry.dart` (or similar) holding the typed reader/writer + the migration probe. +- Flutter-free; unit-tested directly. + +## API shape (sketch — refine in implementation) + +``` +class AccountRegistry { + List get accounts; + Account? accountForWorkspace(String cwd); + Account? accountByName(String name); + void registerAccount(String name, String dir); + void removeAccount(String name); + void bindWorkspace(String cwd, String name); + void unbindWorkspace(String cwd); +} +List probeExistingAccountDirs(String home); +``` + +All persistence flows through SettingsStore so the existing `.clide/settings.yaml` (or post-T-356 user-scope equivalent) reload/watch/serialize semantics carry over for free. + +## Acceptance + +- Schema declares `claude.accounts` (list of `{name, dir}`) and `claude.account.` (string). +- `accountForWorkspace(cwd)` resolves an account, or null when no binding. +- Registry CRUD (register/remove/bind/unbind) round-trips through SettingsStore. +- Bootstrap probe returns existing `~/.claude-*` dirs as candidates without mutating anything. +- Unit tests cover registry CRUD, workspace-hash mapping (canonicalised cwd, same hash for trailing-slash variants), probe behaviour (skips non-config dirs). +- No process spawning, no UI, no CLI — those are downstream tickets. + +## Depends on + +- T-356 (settings → user scope). The binding keys are PER-USER, not per-repo (you don''t want "which account does this repo use" committed to the repo). Land after T-356 ships its user-scope settings store. + +## Consumers + +- T-478 (env injection) — reads `accountForWorkspace(cwd)` at spawn time. +- T-479 (IDE-lock multiplex) — enumerates active accounts to write per-dir locks. +- T-480 (CLI verbs) — registry mutations behind every account command. +- T-481 (UI) — renders registry + binding state; calls the CRUD via T-480. +- T-482 (settings UI) — schema-driven rows hit these keys. +', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 14:40:34.929', NULL, 'c719afcccdcc45df698e4271b61a7721', 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 ('06FFW49VYMPF18PYQXD9PCMHN8', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: storage schema + registry + bootstrap migration', 'Foundation for the multi-account epic (T-476): the user-scope persistence layer that every other child consumes. + +## Scope + +Two durable concerns, plus a first-run migration: + +### Account registry + +List of `(name, configDir)` pairs in user-scope clide settings — the catalog of Claude accounts this user has set up. Account names are user-chosen (`personal`, `work`, `client-acme`); `configDir` is the `CLAUDE_CONFIG_DIR` path Claude Code will read from. Default dir for a new account is `~/.claude-/`, but an explicit path is allowed (so an existing `~/.claude-personal/` can be adopted as-is). + +### Workspace binding + +`claude.account.` → ``. Workspace hash is the same FNV-1a 64-bit hex used by D-70 sockets (`fnv1a64Hex` in `lib/src/ipc/paths.dart:101`) — single source of truth. Unset binding means "use Claude''s default" (no `CLAUDE_CONFIG_DIR` injection). + +### Bootstrap migration + +On first run, scan `~` for directories matching `.claude-*` that look like a Claude config dir (have a `.claude.json` or `sessions/` inside). Surface the list — actually offering to register them is welcome-view UX (T-481); this ticket just provides the probe. + +## Where + +- `SettingsStore` schema additions in `lib/kernel/src/settings.dart` (or wherever the schema-driven settings layer lands — coordinate with T-356). +- A new `lib/builtin/claude/src/account_registry.dart` (or similar) holding the typed reader/writer + the migration probe. +- Flutter-free; unit-tested directly. + +## API shape (sketch — refine in implementation) + +``` +class AccountRegistry { + List get accounts; + Account? accountForWorkspace(String cwd); + Account? accountByName(String name); + void registerAccount(String name, String dir); + void removeAccount(String name); + void bindWorkspace(String cwd, String name); + void unbindWorkspace(String cwd); +} +List probeExistingAccountDirs(String home); +``` + +All persistence flows through SettingsStore so the existing `.clide/settings.yaml` (or post-T-356 user-scope equivalent) reload/watch/serialize semantics carry over for free. + +## Acceptance + +- Schema declares `claude.accounts` (list of `{name, dir}`) and `claude.account.` (string). +- `accountForWorkspace(cwd)` resolves an account, or null when no binding. +- Registry CRUD (register/remove/bind/unbind) round-trips through SettingsStore. +- Bootstrap probe returns existing `~/.claude-*` dirs as candidates without mutating anything. +- Unit tests cover registry CRUD, workspace-hash mapping (canonicalised cwd, same hash for trailing-slash variants), probe behaviour (skips non-config dirs). +- No process spawning, no UI, no CLI — those are downstream tickets. + +## Depends on + +- T-356 (settings → user scope). The binding keys are PER-USER, not per-repo (you don''t want "which account does this repo use" committed to the repo). Land after T-356 ships its user-scope settings store. + +## Consumers + +- T-478 (env injection) — reads `accountForWorkspace(cwd)` at spawn time. +- T-479 (IDE-lock multiplex) — enumerates active accounts to write per-dir locks. +- T-480 (CLI verbs) — registry mutations behind every account command. +- T-481 (UI) — renders registry + binding state; calls the CRUD via T-480. +- T-482 (settings UI) — schema-driven rows hit these keys. +', 'done', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 14:42:39.552', NULL, '74c4a4292386cf5b311a4475551a65e1', 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 new file mode 100644 index 00000000..535f278f --- /dev/null +++ b/lib/builtin/claude/src/account_registry.dart @@ -0,0 +1,154 @@ +/// Per-repo Claude account registry (T-483, epic T-476) — the user-scope +/// persistence + typed reader/writer every other child of the epic consumes. +/// +/// Two durable concerns, both in the app (user) settings layer so they are +/// PER-USER, never committed to a repo: +/// +/// - **Account registry** `app.claude.accounts` — a list of `{name, dir}` +/// pairs: the Claude accounts this user set up. `dir` is the +/// `CLAUDE_CONFIG_DIR` Claude Code reads from for that account. +/// - **Workspace binding** `app.claude.account.` → account +/// name. The hash is the SAME FNV-1a 64-bit hex D-70 uses for the socket +/// path ([fnv1a64Hex]/[canonicalWorkspaceKey]), so a workspace's account and +/// its socket agree. Unset = use Claude's default (no injection). +/// +/// Flutter-free (foundation only, via SettingsStore); no process spawning, no +/// UI, no CLI — those are downstream tickets (T-484/T-480/T-481/T-482). +library; + +import 'dart:io'; + +import 'package:clide/kernel/src/settings.dart'; +import 'package:clide/src/ipc/paths.dart' show canonicalWorkspaceKey, fnv1a64Hex; + +/// One configured Claude account: a user-chosen [name] (`personal`, `work`, +/// `client-acme`) and the [dir] Claude Code reads as `CLAUDE_CONFIG_DIR`. +class Account { + const Account({required this.name, required this.dir}); + + final String name; + final String dir; + + Map toJson() => {'name': name, 'dir': dir}; + + @override + bool operator ==(Object other) => other is Account && other.name == name && other.dir == dir; + + @override + int get hashCode => Object.hash(name, dir); + + @override + String toString() => 'Account($name → $dir)'; +} + +/// A `~/.claude-*` directory the bootstrap probe found that looks like a Claude +/// config dir — an adoption candidate. [name] is the suggested account name +/// (the suffix after `.claude-`); registering it is welcome-view UX (T-481). +class DetectedAccount { + const DetectedAccount({required this.name, required this.dir}); + + final String name; + final String dir; + + @override + bool operator ==(Object other) => other is DetectedAccount && other.name == name && other.dir == dir; + + @override + int get hashCode => Object.hash(name, dir); +} + +class AccountRegistry { + AccountRegistry(this._store); + + final SettingsStore _store; + + /// Key holding the `{name, dir}` account list (app/user scope). + static const accountsKey = 'app.claude.accounts'; + + /// Per-workspace binding key: account name keyed by workspace hash. + static String bindingKey(String cwd) => 'app.claude.account.${workspaceHash(cwd)}'; + + /// FNV-1a 64-bit hex of the canonicalised workspace root — the SAME hash D-70 + /// derives for the socket path, so a workspace's binding and its socket + /// agree. Trailing separators are stripped so `/repo` and `/repo/` map alike. + static String workspaceHash(String cwd) => fnv1a64Hex(canonicalWorkspaceKey(_stripTrailingSep(cwd))); + + static String _stripTrailingSep(String p) { + var s = p; + while (s.length > 1 && (s.endsWith('/') || s.endsWith(r'\'))) { + s = s.substring(0, s.length - 1); + } + return s; + } + + /// The configured accounts, in stored order. Tolerant of a malformed or + /// partially-written entry (skips anything missing a string name + dir). + List get accounts { + final raw = _store.get(accountsKey); + if (raw == null) return const []; + final out = []; + for (final e in raw) { + if (e is Map && e['name'] is String && e['dir'] is String) { + out.add(Account(name: e['name'] as String, dir: e['dir'] as String)); + } + } + return out; + } + + Account? accountByName(String name) { + for (final a in accounts) { + if (a.name == name) return a; + } + return null; + } + + /// The account bound to [cwd], or null when unbound (or bound to a name that + /// no longer exists — treated as unbound, so a removed account degrades to + /// Claude's default rather than erroring). + Account? accountForWorkspace(String cwd) { + final name = _store.get(bindingKey(cwd)); + return name == null ? null : accountByName(name); + } + + /// 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 { + await _writeAccounts([...accounts.where((a) => a.name != name), Account(name: name, dir: dir)]); + } + + Future removeAccount(String name) async { + await _writeAccounts(accounts.where((a) => a.name != name).toList()); + } + + Future bindWorkspace(String cwd, String name) async { + await _store.setAt(SettingsScope.app, bindingKey(cwd), name); + } + + Future unbindWorkspace(String cwd) async { + await _store.removeAt(SettingsScope.app, bindingKey(cwd)); + } + + Future _writeAccounts(List list) async { + await _store.setAt(SettingsScope.app, accountsKey, [for (final a in list) a.toJson()]); + } +} + +/// Bootstrap probe (T-483): existing `~/.claude-*` directories that look like a +/// Claude config dir (have a `.claude.json` file or a `sessions/` dir), as +/// adoption candidates. Pure read — mutates nothing; the welcome view (T-481) +/// decides whether to register them. Sorted by suggested name. +List probeExistingAccountDirs(String home) { + final out = []; + final dir = Directory(home); + if (!dir.existsSync()) return out; + for (final entry in dir.listSync(followLinks: false)) { + if (entry is! Directory) continue; + final base = entry.path.split(Platform.pathSeparator).last; + if (!base.startsWith('.claude-')) continue; + final looksLikeConfig = File('${entry.path}/.claude.json').existsSync() || Directory('${entry.path}/sessions').existsSync(); + if (!looksLikeConfig) continue; + out.add(DetectedAccount(name: base.substring('.claude-'.length), dir: entry.path)); + } + out.sort((a, b) => a.name.compareTo(b.name)); + return out; +} diff --git a/test/builtin/claude/account_registry_test.dart b/test/builtin/claude/account_registry_test.dart new file mode 100644 index 00000000..8f4f8f9d --- /dev/null +++ b/test/builtin/claude/account_registry_test.dart @@ -0,0 +1,123 @@ +/// T-483: AccountRegistry — user-scope storage + typed reader/writer for the +/// per-repo Claude account epic (T-476). Persistence flows through a real +/// SettingsStore over a temp appDir, so these cover the round-trip (incl. a +/// reload from disk) without mocking the store. +library; + +import 'dart:io'; + +import 'package:clide/builtin/claude/src/account_registry.dart'; +import 'package:clide/kernel/src/settings.dart'; +import 'package:clide/src/ipc/paths.dart' show canonicalWorkspaceKey, fnv1a64Hex; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Directory appDir; + late SettingsStore store; + late AccountRegistry reg; + + setUp(() async { + appDir = await Directory.systemTemp.createTemp('clide-accounts-'); + store = SettingsStore(appDir: appDir); + await store.load(); + reg = AccountRegistry(store); + }); + tearDown(() { + store.dispose(); + if (appDir.existsSync()) appDir.deleteSync(recursive: true); + }); + + group('account registry CRUD', () { + test('register + read back; replace by name; remove', () async { + expect(reg.accounts, isEmpty); + await reg.registerAccount('personal', '/home/u/.claude-personal'); + await reg.registerAccount('work', '/home/u/.claude-work'); + expect(reg.accounts, [const Account(name: 'personal', dir: '/home/u/.claude-personal'), const Account(name: 'work', dir: '/home/u/.claude-work')]); + expect(reg.accountByName('work')?.dir, '/home/u/.claude-work'); + expect(reg.accountByName('nope'), isNull); + + // Re-register by the same name replaces (no duplicate). + await reg.registerAccount('personal', '/home/u/.claude-personal2'); + expect(reg.accounts.where((a) => a.name == 'personal'), hasLength(1)); + expect(reg.accountByName('personal')?.dir, '/home/u/.claude-personal2'); + + await reg.removeAccount('personal'); + expect(reg.accountByName('personal'), isNull); + expect(reg.accounts, hasLength(1)); + }); + + test('the accounts list survives a reload from disk', () async { + await reg.registerAccount('work', '/home/u/.claude-work'); + // Fresh store over the same appDir, loaded from the YAML on disk. + final store2 = SettingsStore(appDir: appDir); + await store2.load(); + addTearDown(store2.dispose); + final reg2 = AccountRegistry(store2); + expect(reg2.accounts, [const Account(name: 'work', dir: '/home/u/.claude-work')]); + }); + }); + + group('workspace binding', () { + test('bind resolves an account; unbind clears it; unknown name → null', () async { + await reg.registerAccount('work', '/home/u/.claude-work'); + expect(reg.accountForWorkspace('/repo/a'), isNull); + + await reg.bindWorkspace('/repo/a', 'work'); + expect(reg.accountForWorkspace('/repo/a')?.dir, '/home/u/.claude-work'); + + // A binding to a removed account degrades to null (Claude default). + await reg.removeAccount('work'); + expect(reg.accountForWorkspace('/repo/a'), isNull); + + await reg.registerAccount('work', '/home/u/.claude-work'); + expect(reg.accountForWorkspace('/repo/a')?.dir, '/home/u/.claude-work'); + await reg.unbindWorkspace('/repo/a'); + expect(reg.accountForWorkspace('/repo/a'), isNull); + }); + + test('workspace hash is the D-70 socket hash and ignores trailing slashes', () { + expect(AccountRegistry.workspaceHash('/repo/a'), AccountRegistry.workspaceHash('/repo/a/')); + expect(AccountRegistry.workspaceHash('/repo/a'), AccountRegistry.workspaceHash('/repo/a///')); + expect(AccountRegistry.workspaceHash('/repo/a'), isNot(AccountRegistry.workspaceHash('/repo/b'))); + // Same algorithm/input as the socket path (single source of truth, D-70). + expect(AccountRegistry.workspaceHash('/repo/a'), fnv1a64Hex(canonicalWorkspaceKey('/repo/a'))); + // The binding key is namespaced under app.claude.account.. + expect(AccountRegistry.bindingKey('/repo/a'), 'app.claude.account.${AccountRegistry.workspaceHash('/repo/a')}'); + }); + + test('two workspaces bind independently', () async { + await reg.registerAccount('personal', '/p'); + await reg.registerAccount('work', '/w'); + await reg.bindWorkspace('/repo/a', 'personal'); + await reg.bindWorkspace('/repo/b', 'work'); + expect(reg.accountForWorkspace('/repo/a')?.name, 'personal'); + expect(reg.accountForWorkspace('/repo/b')?.name, 'work'); + }); + }); + + group('bootstrap probe', () { + test('returns ~/.claude-* dirs that look like config dirs, skipping the rest', () async { + final home = await Directory.systemTemp.createTemp('clide-home-'); + addTearDown(() => home.deleteSync(recursive: true)); + // A config dir marked by .claude.json. + Directory('${home.path}/.claude-personal').createSync(); + File('${home.path}/.claude-personal/.claude.json').writeAsStringSync('{}'); + // A config dir marked by sessions/. + Directory('${home.path}/.claude-work/sessions').createSync(recursive: true); + // A .claude-* dir that is NOT a config dir (no marker) — skipped. + Directory('${home.path}/.claude-empty').createSync(); + // The real ~/.claude (no `-suffix`) — not a candidate. + Directory('${home.path}/.claude').createSync(); + // An unrelated dir — skipped. + Directory('${home.path}/projects').createSync(); + + final found = probeExistingAccountDirs(home.path); + expect(found.map((d) => d.name), ['personal', 'work']); + expect(found.first.dir, '${home.path}/.claude-personal'); + }); + + test('missing home returns empty, mutates nothing', () { + expect(probeExistingAccountDirs('/no/such/home/${DateTime.now().microsecondsSinceEpoch}'), isEmpty); + }); + }); +}