ultraclaude-agent 0.0.24 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/__tests__/cli-commands.test.ts +227 -0
  2. package/__tests__/config-registry.test.ts +130 -0
  3. package/__tests__/matching.test.ts +154 -0
  4. package/__tests__/project-watcher.test.ts +199 -0
  5. package/__tests__/registry.test.ts +171 -0
  6. package/__tests__/repl.test.ts +262 -102
  7. package/__tests__/update-notice.test.ts +358 -0
  8. package/__tests__/usage-sync-watcher.test.ts +259 -0
  9. package/dist/cli.d.ts +4 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +86 -429
  12. package/dist/cli.js.map +1 -1
  13. package/dist/commands/registry.d.ts +47 -0
  14. package/dist/commands/registry.d.ts.map +1 -0
  15. package/dist/commands/registry.js +729 -0
  16. package/dist/commands/registry.js.map +1 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +182 -33
  19. package/dist/config.js.map +1 -1
  20. package/dist/daemon.d.ts +10 -0
  21. package/dist/daemon.d.ts.map +1 -1
  22. package/dist/daemon.js +32 -3
  23. package/dist/daemon.js.map +1 -1
  24. package/dist/index.js +1 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/matching.d.ts +20 -0
  27. package/dist/matching.d.ts.map +1 -0
  28. package/dist/matching.js +77 -0
  29. package/dist/matching.js.map +1 -0
  30. package/dist/repl.d.ts.map +1 -1
  31. package/dist/repl.js +36 -477
  32. package/dist/repl.js.map +1 -1
  33. package/dist/scripts/postinstall.js +3 -0
  34. package/dist/scripts/postinstall.js.map +1 -1
  35. package/dist/service.d.ts.map +1 -1
  36. package/dist/service.js +4 -1
  37. package/dist/service.js.map +1 -1
  38. package/dist/setup.d.ts.map +1 -1
  39. package/dist/setup.js +18 -16
  40. package/dist/setup.js.map +1 -1
  41. package/dist/status.d.ts +5 -0
  42. package/dist/status.d.ts.map +1 -1
  43. package/dist/status.js.map +1 -1
  44. package/dist/sync.js +1 -1
  45. package/dist/sync.js.map +1 -1
  46. package/dist/usage-sync.d.ts.map +1 -1
  47. package/dist/usage-sync.js +20 -5
  48. package/dist/usage-sync.js.map +1 -1
  49. package/dist/watcher.d.ts.map +1 -1
  50. package/dist/watcher.js +30 -9
  51. package/dist/watcher.js.map +1 -1
  52. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.d.ts +5 -0
  53. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.d.ts.map +1 -1
  54. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.js +5 -0
  55. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.js.map +1 -1
  56. package/node_modules/@ultra-claude/shared/dist/api/schemas/usage.d.ts +9 -8
  57. package/node_modules/@ultra-claude/shared/dist/api/schemas/usage.d.ts.map +1 -1
  58. package/node_modules/@ultra-claude/shared/dist/api/schemas/usage.js +8 -2
  59. package/node_modules/@ultra-claude/shared/dist/api/schemas/usage.js.map +1 -1
  60. package/node_modules/@ultra-claude/shared/dist/index.d.ts +1 -1
  61. package/node_modules/@ultra-claude/shared/dist/index.d.ts.map +1 -1
  62. package/node_modules/@ultra-claude/shared/dist/index.js +1 -1
  63. package/node_modules/@ultra-claude/shared/dist/index.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/cli.ts +91 -481
  66. package/src/commands/registry.ts +881 -0
  67. package/src/config.ts +206 -31
  68. package/src/daemon.ts +35 -4
  69. package/src/index.ts +1 -1
  70. package/src/matching.ts +96 -0
  71. package/src/repl.ts +40 -575
  72. package/src/scripts/postinstall.ts +3 -0
  73. package/src/service.ts +4 -1
  74. package/src/setup.ts +20 -21
  75. package/src/status.ts +6 -0
  76. package/src/sync.ts +1 -1
  77. package/src/usage-sync.ts +27 -7
  78. package/src/watcher.ts +28 -9
  79. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Tests for the non-interactive CLI surface (src/cli.ts), driven through the shared registry.
3
+ *
4
+ * Strategy: cli.ts runs `await runCli(process.argv)` at module top-level, so each case sets
5
+ * `process.argv`, calls `vi.resetModules()`, and `await import('../src/cli.js')` — which builds
6
+ * the commander program from the registry and parses the preset args. We then assert the
7
+ * config/console/exit-code effects of the account commands and `reset`.
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
+
12
+ // --- Mocks (hoisted; arrows capture the mock fns lazily at module-import time) ---
13
+
14
+ const mockLoadCredentials = vi.fn();
15
+ const mockLoadAllCredentials = vi.fn();
16
+ const mockLoadServerConfig = vi.fn();
17
+ const mockSaveServerConfig = vi.fn().mockResolvedValue(undefined);
18
+ const mockGetServerUrl = vi.fn();
19
+ const mockGetProjectId = vi.fn();
20
+ const mockLoadRegistry = vi.fn();
21
+ const mockReadPid = vi.fn();
22
+ const mockIsDaemonRunning = vi.fn();
23
+ const mockIsPidAlive = vi.fn();
24
+ const mockCleanStalePidFile = vi.fn().mockResolvedValue(undefined);
25
+ const mockResolveServerPaths = vi.fn();
26
+ const mockClearServerConfig = vi.fn().mockResolvedValue(undefined);
27
+
28
+ vi.mock('../src/config.js', () => ({
29
+ loadCredentials: (...a: unknown[]) => mockLoadCredentials(...a),
30
+ loadAllCredentials: (...a: unknown[]) => mockLoadAllCredentials(...a),
31
+ loadServerConfig: (...a: unknown[]) => mockLoadServerConfig(...a),
32
+ saveServerConfig: (...a: unknown[]) => mockSaveServerConfig(...a),
33
+ getServerUrl: (...a: unknown[]) => mockGetServerUrl(...a),
34
+ getProjectId: (...a: unknown[]) => mockGetProjectId(...a),
35
+ loadRegistry: (...a: unknown[]) => mockLoadRegistry(...a),
36
+ readPid: (...a: unknown[]) => mockReadPid(...a),
37
+ isDaemonRunning: (...a: unknown[]) => mockIsDaemonRunning(...a),
38
+ isPidAlive: (...a: unknown[]) => mockIsPidAlive(...a),
39
+ cleanStalePidFile: (...a: unknown[]) => mockCleanStalePidFile(...a),
40
+ resolveServerPaths: (...a: unknown[]) => mockResolveServerPaths(...a),
41
+ clearServerConfig: (...a: unknown[]) => mockClearServerConfig(...a),
42
+ }));
43
+
44
+ const mockUnlink = vi.fn().mockResolvedValue(undefined);
45
+ vi.mock('node:fs/promises', async (importOriginal) => {
46
+ const actual = await importOriginal<typeof import('node:fs/promises')>();
47
+ return { ...actual, unlink: mockUnlink };
48
+ });
49
+
50
+ vi.mock('../src/auth.js', () => ({ login: vi.fn() }));
51
+
52
+ const mockStopDaemon = vi.fn().mockResolvedValue(undefined);
53
+ const mockGetDaemonStatus = vi.fn();
54
+ const mockIsRunningInProcess = vi.fn().mockReturnValue(false);
55
+ const mockForkDaemon = vi.fn();
56
+ vi.mock('../src/daemon.js', () => ({
57
+ startDaemon: vi.fn(),
58
+ stopDaemon: (...a: unknown[]) => mockStopDaemon(...a),
59
+ getDaemonStatus: (...a: unknown[]) => mockGetDaemonStatus(...a),
60
+ isRunningInProcess: (...a: unknown[]) => mockIsRunningInProcess(...a),
61
+ forkDaemon: (...a: unknown[]) => mockForkDaemon(...a),
62
+ }));
63
+
64
+ vi.mock('../src/service.js', () => ({
65
+ isServiceInstalled: vi.fn().mockResolvedValue(false),
66
+ installService: vi.fn().mockResolvedValue(undefined),
67
+ }));
68
+ vi.mock('../src/sync.js', () => ({
69
+ initialSync: vi.fn().mockResolvedValue(undefined),
70
+ createSnapshot: vi.fn().mockResolvedValue(true),
71
+ }));
72
+ vi.mock('../src/watcher.js', () => ({ getCurrentBranch: vi.fn().mockReturnValue('main') }));
73
+ vi.mock('../src/logger.js', () => ({
74
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: () => ({}) },
75
+ initMultistreamLogger: vi.fn(),
76
+ }));
77
+
78
+ const SERVER_URL = 'http://localhost:3000';
79
+ const alice = {
80
+ apiKey: 'k-a',
81
+ userId: 'user-alice',
82
+ serverUrl: SERVER_URL,
83
+ email: 'alice@example.com',
84
+ };
85
+ const bob = { apiKey: 'k-b', userId: 'user-bob', serverUrl: SERVER_URL, email: 'bob@acme.io' };
86
+
87
+ function config(overrides: Record<string, unknown> = {}) {
88
+ return { defaultAccount: 'user-alice', projectAccounts: {}, ...overrides };
89
+ }
90
+
91
+ const originalArgv = process.argv;
92
+ let isTTYDescriptor: PropertyDescriptor | undefined;
93
+
94
+ /** Drive cli.ts with a preset argv. */
95
+ async function runCli(args: string[]): Promise<void> {
96
+ process.argv = ['node', 'cli.js', ...args];
97
+ vi.resetModules();
98
+ await import('../src/cli.js');
99
+ }
100
+
101
+ beforeEach(() => {
102
+ vi.clearAllMocks();
103
+ process.exitCode = 0;
104
+ isTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
105
+
106
+ mockGetServerUrl.mockReturnValue(SERVER_URL);
107
+ mockLoadCredentials.mockResolvedValue(alice);
108
+ mockLoadAllCredentials.mockResolvedValue(
109
+ new Map([
110
+ ['user-alice', alice],
111
+ ['user-bob', bob],
112
+ ]),
113
+ );
114
+ mockLoadServerConfig.mockResolvedValue(config());
115
+ mockSaveServerConfig.mockResolvedValue(undefined);
116
+ mockLoadRegistry.mockResolvedValue({
117
+ projects: [{ path: '/home/user/projects/my-app', name: 'my-app' }],
118
+ });
119
+ mockGetProjectId.mockResolvedValue('proj-uuid');
120
+ mockResolveServerPaths.mockReturnValue({ accountsDir: '/tmp/accounts', logDir: '/tmp/logs' });
121
+ mockIsRunningInProcess.mockReturnValue(false);
122
+ mockReadPid.mockResolvedValue(null);
123
+ mockClearServerConfig.mockResolvedValue(undefined);
124
+ mockUnlink.mockResolvedValue(undefined);
125
+ });
126
+
127
+ afterEach(() => {
128
+ process.argv = originalArgv;
129
+ if (isTTYDescriptor) Object.defineProperty(process.stdin, 'isTTY', isTTYDescriptor);
130
+ process.exitCode = 0;
131
+ });
132
+
133
+ describe('cli — assign', () => {
134
+ it('writes the project→account mapping to config.json', async () => {
135
+ await runCli(['assign', 'my-app', 'bob']);
136
+ expect(mockSaveServerConfig).toHaveBeenCalledWith(
137
+ SERVER_URL,
138
+ expect.objectContaining({
139
+ projectAccounts: expect.objectContaining({ '/home/user/projects/my-app': 'user-bob' }),
140
+ }),
141
+ );
142
+ });
143
+ });
144
+
145
+ describe('cli — default', () => {
146
+ it('sets the default account', async () => {
147
+ await runCli(['default', 'bob']);
148
+ expect(mockSaveServerConfig).toHaveBeenCalledWith(
149
+ SERVER_URL,
150
+ expect.objectContaining({ defaultAccount: 'user-bob' }),
151
+ );
152
+ });
153
+ });
154
+
155
+ describe('cli — auto-assign', () => {
156
+ it('on → sets autoAssignNewProjects true', async () => {
157
+ await runCli(['auto-assign', 'on']);
158
+ expect(mockSaveServerConfig).toHaveBeenCalledWith(
159
+ SERVER_URL,
160
+ expect.objectContaining({ autoAssignNewProjects: true }),
161
+ );
162
+ });
163
+
164
+ it('off → sets autoAssignNewProjects false', async () => {
165
+ await runCli(['auto-assign', 'off']);
166
+ expect(mockSaveServerConfig).toHaveBeenCalledWith(
167
+ SERVER_URL,
168
+ expect.objectContaining({ autoAssignNewProjects: false }),
169
+ );
170
+ });
171
+ });
172
+
173
+ describe('cli — remove', () => {
174
+ it('deletes the account file and updates config', async () => {
175
+ await runCli(['remove', 'bob']);
176
+ expect(mockUnlink).toHaveBeenCalledWith(expect.stringContaining('user-bob.json'));
177
+ expect(mockSaveServerConfig).toHaveBeenCalled();
178
+ });
179
+
180
+ it('warns about orphaned projects', async () => {
181
+ mockLoadServerConfig.mockResolvedValue(
182
+ config({
183
+ projectAccounts: {
184
+ '/home/user/projects/work': 'user-bob',
185
+ '/home/user/projects/api': 'user-bob',
186
+ },
187
+ }),
188
+ );
189
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
190
+ try {
191
+ await runCli(['remove', 'bob']);
192
+ const output = logSpy.mock.calls.flat().join('\n');
193
+ expect(output).toMatch(/warn/i);
194
+ expect(output).toContain('2');
195
+ } finally {
196
+ logSpy.mockRestore();
197
+ }
198
+ });
199
+ });
200
+
201
+ describe('cli — accounts / projects (read)', () => {
202
+ it('accounts lists logged-in accounts', async () => {
203
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
204
+ try {
205
+ await runCli(['accounts']);
206
+ const output = logSpy.mock.calls.flat().join('\n');
207
+ expect(output).toContain('alice@example.com');
208
+ expect(output).toContain('bob@acme.io');
209
+ } finally {
210
+ logSpy.mockRestore();
211
+ }
212
+ });
213
+ });
214
+
215
+ describe('cli — reset', () => {
216
+ it('--yes wipes configuration', async () => {
217
+ await runCli(['reset', '--yes']);
218
+ expect(mockClearServerConfig).toHaveBeenCalledWith(SERVER_URL);
219
+ });
220
+
221
+ it('refuses (exit 1, no wipe) without --yes in a non-TTY', async () => {
222
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
223
+ await runCli(['reset']);
224
+ expect(process.exitCode).toBe(1);
225
+ expect(mockClearServerConfig).not.toHaveBeenCalled();
226
+ });
227
+ });
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { join } from 'node:path';
3
+ import { mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ // These tests seed a real temp directory tree that mimics ~/.claude/projects/
7
+ // and exercise loadRegistry against it via a mocked os.homedir().
8
+
9
+ const mockHomedir = vi.fn<() => string>();
10
+ const mockPlatform = vi.fn<() => NodeJS.Platform>().mockReturnValue('linux');
11
+
12
+ vi.mock('node:os', async (importOriginal) => {
13
+ const actual = await importOriginal<typeof import('node:os')>();
14
+ return {
15
+ ...actual,
16
+ homedir: () => mockHomedir(),
17
+ platform: () => mockPlatform(),
18
+ };
19
+ });
20
+
21
+ describe('config — loadRegistry path decoding', () => {
22
+ let tmpHome: string;
23
+
24
+ beforeEach(async () => {
25
+ vi.resetModules();
26
+ tmpHome = await mkdtemp(join(tmpdir(), 'uc-registry-test-'));
27
+ mockHomedir.mockReturnValue(tmpHome);
28
+ mockPlatform.mockReturnValue('linux');
29
+ });
30
+
31
+ afterEach(async () => {
32
+ await rm(tmpHome, { recursive: true, force: true });
33
+ });
34
+
35
+ async function seedUltraProject(realPath: string, appContext?: string) {
36
+ await mkdir(join(realPath, '.claude', 'ultra'), { recursive: true });
37
+ await writeFile(join(realPath, '.claude', 'ultra', 'version.json'), '{}');
38
+ if (appContext) {
39
+ await writeFile(join(realPath, '.claude', 'ultra', 'app-context.md'), appContext);
40
+ }
41
+ }
42
+
43
+ async function seedSessionFile(encodedEntry: string, fileName: string, cwd: string) {
44
+ const entryDir = join(tmpHome, '.claude', 'projects', encodedEntry);
45
+ await mkdir(entryDir, { recursive: true });
46
+ const line = JSON.stringify({ type: 'user', cwd }) + '\n';
47
+ await writeFile(join(entryDir, fileName), line);
48
+ return join(entryDir, fileName);
49
+ }
50
+
51
+ // --- REPRODUCTION TEST ---
52
+ // Pre-patch: hyphen-permutation decoder cannot reinsert a space, so the
53
+ // entry is silently dropped and loadRegistry returns zero projects.
54
+ // Post-patch: session-file cwd lookup recovers the real path verbatim.
55
+ it('REPRODUCTION: resolves a path containing spaces via session cwd', async () => {
56
+ const realPath = join(tmpHome, 'Users', 'fake', 'WS LAB', 'proj');
57
+ await seedUltraProject(realPath);
58
+ await seedSessionFile('-Users-fake-WS-LAB-proj', 'session-abc.jsonl', realPath);
59
+
60
+ const { loadRegistry } = await import('../src/config.js');
61
+ const registry = await loadRegistry();
62
+
63
+ expect(registry.projects).toHaveLength(1);
64
+ expect(registry.projects[0]!.path).toBe(realPath);
65
+ });
66
+
67
+ it('falls back to legacy permutation when no session files exist', async () => {
68
+ // The legacy decoder searches from absolute `/` and has no knowledge of our
69
+ // mocked homedir. So to exercise the fallback safely we make tmpHome itself
70
+ // the "real project" — mkdtemp produces a real absolute path that is
71
+ // guaranteed to exist and is safe to put marker files under.
72
+ await seedUltraProject(tmpHome);
73
+ const encoded = tmpHome.replace(/\//g, '-'); // e.g. -tmp-uc-registry-test-XXXXXX
74
+ // Empty encoded entry — no .jsonl files → forces legacy fallback path
75
+ await mkdir(join(tmpHome, '.claude', 'projects', encoded), { recursive: true });
76
+
77
+ const { loadRegistry } = await import('../src/config.js');
78
+ const registry = await loadRegistry();
79
+
80
+ expect(registry.projects).toHaveLength(1);
81
+ expect(registry.projects[0]!.path).toBe(tmpHome);
82
+ });
83
+
84
+ it('reads project name from app-context.md', async () => {
85
+ const realPath = join(tmpHome, 'Users', 'alice', 'My Project');
86
+ await seedUltraProject(
87
+ realPath,
88
+ '# Context\n\n**Name:** Alice\'s Thing\n\nSome description.\n',
89
+ );
90
+ await seedSessionFile('-Users-alice-My-Project', 'session-1.jsonl', realPath);
91
+
92
+ const { loadRegistry } = await import('../src/config.js');
93
+ const registry = await loadRegistry();
94
+
95
+ expect(registry.projects).toHaveLength(1);
96
+ expect(registry.projects[0]!.name).toBe("Alice's Thing");
97
+ });
98
+
99
+ it('picks cwd from the newest session file when multiple exist', async () => {
100
+ const realPath = join(tmpHome, 'srv', 'app with spaces');
101
+ await seedUltraProject(realPath);
102
+ const encodedEntry = '-srv-app-with-spaces';
103
+
104
+ // Older session — points at a stale, non-existent path
105
+ const older = await seedSessionFile(encodedEntry, 'session-old.jsonl', '/tmp/does-not-exist');
106
+ // Force older mtime
107
+ const past = new Date(Date.now() - 60_000);
108
+ await utimes(older, past, past);
109
+
110
+ // Newer session — points at the real path
111
+ await seedSessionFile(encodedEntry, 'session-new.jsonl', realPath);
112
+
113
+ const { loadRegistry } = await import('../src/config.js');
114
+ const registry = await loadRegistry();
115
+
116
+ expect(registry.projects).toHaveLength(1);
117
+ expect(registry.projects[0]!.path).toBe(realPath);
118
+ });
119
+
120
+ it('drops entries whose resolved cwd has no .claude/ultra/version.json', async () => {
121
+ const realPath = join(tmpHome, 'not', 'ultra', 'project');
122
+ await mkdir(realPath, { recursive: true }); // exists, but no marker
123
+ await seedSessionFile('-not-ultra-project', 'session-1.jsonl', realPath);
124
+
125
+ const { loadRegistry } = await import('../src/config.js');
126
+ const registry = await loadRegistry();
127
+
128
+ expect(registry.projects).toHaveLength(0);
129
+ });
130
+ });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Tests for the shared fuzzy matchers (src/matching.ts).
3
+ * Pure module — no mocks needed. Both command surfaces consume these via the registry.
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { matchProject, matchAccount, accountDisplay } from '../src/matching.js';
8
+ import type { AgentCredentials, ProjectRegistryEntry } from '@ultra-claude/shared';
9
+
10
+ function project(path: string): ProjectRegistryEntry {
11
+ return { path, name: path.split('/').pop()! };
12
+ }
13
+
14
+ function account(overrides: Partial<AgentCredentials> = {}): AgentCredentials {
15
+ return {
16
+ apiKey: 'key',
17
+ userId: 'user-x',
18
+ serverUrl: 'http://localhost:3000',
19
+ email: 'x@example.com',
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ describe('matchProject', () => {
25
+ const projects = [
26
+ project('/home/user/projects/my-app'),
27
+ project('/home/user/projects/my-api'),
28
+ project('/home/user/projects/website'),
29
+ ];
30
+
31
+ it('matches an exact basename', () => {
32
+ const r = matchProject('website', projects);
33
+ expect(r.error).toBeNull();
34
+ expect(r.match?.path).toBe('/home/user/projects/website');
35
+ });
36
+
37
+ it('matches a unique prefix', () => {
38
+ const r = matchProject('webs', projects);
39
+ expect(r.error).toBeNull();
40
+ expect(r.match?.path).toBe('/home/user/projects/website');
41
+ });
42
+
43
+ it('matches a unique substring (includes)', () => {
44
+ const r = matchProject('bsit', projects);
45
+ expect(r.error).toBeNull();
46
+ expect(r.match?.path).toBe('/home/user/projects/website');
47
+ });
48
+
49
+ it('reports no match', () => {
50
+ const r = matchProject('nonexistent', projects);
51
+ expect(r.match).toBeNull();
52
+ expect(r.error).toMatch(/no project matching/i);
53
+ });
54
+
55
+ it('reports ambiguity when multiple projects match', () => {
56
+ const r = matchProject('my-a', projects);
57
+ expect(r.match).toBeNull();
58
+ expect(r.error).toMatch(/ambiguous/i);
59
+ expect(r.error).toContain('my-app');
60
+ expect(r.error).toContain('my-api');
61
+ });
62
+
63
+ it('prefers an exact basename over a broader prefix collision', () => {
64
+ const withExact = [project('/a/my'), project('/b/my-app')];
65
+ const r = matchProject('my', withExact);
66
+ expect(r.error).toBeNull();
67
+ expect(r.match?.path).toBe('/a/my');
68
+ });
69
+
70
+ it('is case-insensitive', () => {
71
+ const r = matchProject('WEBSITE', projects);
72
+ expect(r.match?.path).toBe('/home/user/projects/website');
73
+ });
74
+ });
75
+
76
+ describe('matchAccount', () => {
77
+ const alice = account({ userId: 'user-alice', email: 'alice@example.com' });
78
+ const bob = account({ userId: 'user-bob', email: 'bob@acme.io' });
79
+ const accounts = new Map([
80
+ ['user-alice', alice],
81
+ ['user-bob', bob],
82
+ ]);
83
+
84
+ it('matches an exact email', () => {
85
+ const r = matchAccount('bob@acme.io', accounts);
86
+ expect(r.error).toBeNull();
87
+ expect(r.match?.userId).toBe('user-bob');
88
+ });
89
+
90
+ it('matches an exact userId', () => {
91
+ const r = matchAccount('user-alice', accounts);
92
+ expect(r.error).toBeNull();
93
+ expect(r.match?.userId).toBe('user-alice');
94
+ });
95
+
96
+ it('matches an email prefix (before @)', () => {
97
+ const r = matchAccount('bob', accounts);
98
+ expect(r.error).toBeNull();
99
+ expect(r.match?.userId).toBe('user-bob');
100
+ });
101
+
102
+ it('matches an email startsWith', () => {
103
+ const r = matchAccount('ali', accounts);
104
+ expect(r.error).toBeNull();
105
+ expect(r.match?.userId).toBe('user-alice');
106
+ });
107
+
108
+ it('is case-insensitive', () => {
109
+ const r = matchAccount('ALICE@EXAMPLE.COM', accounts);
110
+ expect(r.match?.userId).toBe('user-alice');
111
+ });
112
+
113
+ it('reports when there are no accounts', () => {
114
+ const r = matchAccount('anyone', new Map());
115
+ expect(r.match).toBeNull();
116
+ expect(r.error).toMatch(/no accounts found/i);
117
+ });
118
+
119
+ it('reports no match when query matches nothing', () => {
120
+ const r = matchAccount('charlie', accounts);
121
+ expect(r.match).toBeNull();
122
+ expect(r.error).toMatch(/no account matching/i);
123
+ });
124
+
125
+ it('reports ambiguity (richer logic preserved from the REPL)', () => {
126
+ const a1 = account({ userId: 'user-1', email: 'sam@one.com' });
127
+ const a2 = account({ userId: 'user-2', email: 'sam@two.com' });
128
+ const ambiguous = new Map([
129
+ ['user-1', a1],
130
+ ['user-2', a2],
131
+ ]);
132
+ const r = matchAccount('sam', ambiguous);
133
+ expect(r.match).toBeNull();
134
+ expect(r.error).toMatch(/ambiguous/i);
135
+ expect(r.error).toContain('sam@one.com');
136
+ expect(r.error).toContain('sam@two.com');
137
+ });
138
+
139
+ it('matches by userId when email is absent', () => {
140
+ const noEmail = account({ userId: 'user-keyonly', email: undefined });
141
+ const r = matchAccount('user-keyonly', new Map([['user-keyonly', noEmail]]));
142
+ expect(r.match?.userId).toBe('user-keyonly');
143
+ });
144
+ });
145
+
146
+ describe('accountDisplay', () => {
147
+ it('prefers email', () => {
148
+ expect(accountDisplay(account({ email: 'a@b.com', userId: 'u' }))).toBe('a@b.com');
149
+ });
150
+
151
+ it('falls back to userId when email is absent', () => {
152
+ expect(accountDisplay(account({ email: undefined, userId: 'user-x' }))).toBe('user-x');
153
+ });
154
+ });