ultraclaude-agent 0.0.20 → 0.0.21

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.
@@ -0,0 +1,407 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { join } from 'node:path';
3
+
4
+ // Mock node:os for consistent homedir
5
+ vi.mock('node:os', async (importOriginal) => {
6
+ const actual = await importOriginal<typeof import('node:os')>();
7
+ return {
8
+ ...actual,
9
+ homedir: () => '/home/testuser',
10
+ };
11
+ });
12
+
13
+ // Mock fs/promises
14
+ const mockReadFile = vi.fn();
15
+ const mockWriteFile = vi.fn().mockResolvedValue(undefined);
16
+ const mockRename = vi.fn().mockResolvedValue(undefined);
17
+ const mockMkdir = vi.fn().mockResolvedValue(undefined);
18
+ const mockUnlink = vi.fn().mockResolvedValue(undefined);
19
+ const mockReaddir = vi.fn();
20
+ const mockStat = vi.fn();
21
+
22
+ vi.mock('node:fs/promises', () => ({
23
+ readFile: (...args: unknown[]) => mockReadFile(...args),
24
+ writeFile: (...args: unknown[]) => mockWriteFile(...args),
25
+ rename: (...args: unknown[]) => mockRename(...args),
26
+ mkdir: (...args: unknown[]) => mockMkdir(...args),
27
+ unlink: (...args: unknown[]) => mockUnlink(...args),
28
+ readdir: (...args: unknown[]) => mockReaddir(...args),
29
+ stat: (...args: unknown[]) => mockStat(...args),
30
+ }));
31
+
32
+ // Mock child_process
33
+ const mockExecFileSync = vi.fn();
34
+ const mockSpawn = vi.fn();
35
+
36
+ vi.mock('node:child_process', () => ({
37
+ execFileSync: (...args: unknown[]) => mockExecFileSync(...args),
38
+ spawn: (...args: unknown[]) => mockSpawn(...args),
39
+ }));
40
+
41
+ // Mock logger
42
+ vi.mock('../src/logger.js', () => ({
43
+ logger: {
44
+ child: () => ({
45
+ info: vi.fn(),
46
+ warn: vi.fn(),
47
+ error: vi.fn(),
48
+ debug: vi.fn(),
49
+ }),
50
+ info: vi.fn(),
51
+ warn: vi.fn(),
52
+ error: vi.fn(),
53
+ debug: vi.fn(),
54
+ },
55
+ }));
56
+
57
+ const PROFILES_DIR = '/home/testuser/.claude/ultra/claude-profiles';
58
+ const BACKUPS_DIR = join(PROFILES_DIR, 'backups');
59
+
60
+ const sampleProfile = {
61
+ name: 'work',
62
+ email: 'user@work.com',
63
+ orgName: 'WorkOrg',
64
+ subscriptionType: 'team',
65
+ savedAt: '2026-04-11T18:05:36Z',
66
+ credentials: {
67
+ claudeAiOauth: {
68
+ accessToken: 'sk-ant-oat01-test',
69
+ refreshToken: 'sk-ant-ort01-test',
70
+ expiresAt: Date.now() + 3600_000, // 1 hour from now
71
+ scopes: ['user:profile', 'user:inference'],
72
+ subscriptionType: 'team',
73
+ rateLimitTier: 'default_claude_max_5x',
74
+ },
75
+ },
76
+ };
77
+
78
+ describe('claude-profiles', () => {
79
+ beforeEach(() => {
80
+ vi.resetAllMocks();
81
+ // Default: claude binary exists
82
+ mockExecFileSync.mockReturnValue('/usr/bin/claude');
83
+ });
84
+
85
+ describe('loadProfile', () => {
86
+ it('returns profile when file exists', async () => {
87
+ mockReadFile.mockResolvedValue(JSON.stringify(sampleProfile));
88
+ const { loadProfile } = await import('../src/claude-profiles.js');
89
+ const profile = await loadProfile('work');
90
+ expect(profile).toEqual(sampleProfile);
91
+ expect(mockReadFile).toHaveBeenCalledWith(
92
+ join(PROFILES_DIR, 'work.json'),
93
+ 'utf8',
94
+ );
95
+ });
96
+
97
+ it('returns null when file does not exist', async () => {
98
+ mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
99
+ const { loadProfile } = await import('../src/claude-profiles.js');
100
+ const profile = await loadProfile('nonexistent');
101
+ expect(profile).toBeNull();
102
+ });
103
+ });
104
+
105
+ describe('saveProfile', () => {
106
+ it('writes profile with atomic temp+rename pattern', async () => {
107
+ const { saveProfile } = await import('../src/claude-profiles.js');
108
+ await saveProfile(sampleProfile);
109
+
110
+ // Should mkdir for profiles dir
111
+ expect(mockMkdir).toHaveBeenCalled();
112
+
113
+ // Should write to .tmp file with mode 0o600
114
+ expect(mockWriteFile).toHaveBeenCalledWith(
115
+ expect.stringMatching(/\.tmp$/),
116
+ expect.stringContaining('"name": "work"'),
117
+ expect.objectContaining({ mode: 0o600 }),
118
+ );
119
+
120
+ // Should rename .tmp to final path
121
+ expect(mockRename).toHaveBeenCalledWith(
122
+ expect.stringMatching(/\.tmp$/),
123
+ join(PROFILES_DIR, 'work.json'),
124
+ );
125
+ });
126
+ });
127
+
128
+ describe('deleteProfileFile', () => {
129
+ it('deletes profile file', async () => {
130
+ const { deleteProfileFile } = await import('../src/claude-profiles.js');
131
+ const result = await deleteProfileFile('work');
132
+ expect(result).toBe(true);
133
+ expect(mockUnlink).toHaveBeenCalledWith(join(PROFILES_DIR, 'work.json'));
134
+ });
135
+
136
+ it('returns false when file does not exist', async () => {
137
+ mockUnlink.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
138
+ const { deleteProfileFile } = await import('../src/claude-profiles.js');
139
+ const result = await deleteProfileFile('nonexistent');
140
+ expect(result).toBe(false);
141
+ });
142
+ });
143
+
144
+ describe('listProfiles', () => {
145
+ it('returns all profiles sorted by name', async () => {
146
+ const personalProfile = { ...sampleProfile, name: 'personal', email: 'me@home.com' };
147
+ mockReaddir.mockResolvedValue(['work.json', 'personal.json', 'active.json']);
148
+ mockReadFile
149
+ .mockResolvedValueOnce(JSON.stringify(sampleProfile))
150
+ .mockResolvedValueOnce(JSON.stringify(personalProfile));
151
+
152
+ const { listProfiles } = await import('../src/claude-profiles.js');
153
+ const profiles = await listProfiles();
154
+
155
+ expect(profiles).toHaveLength(2);
156
+ expect(profiles[0]!.name).toBe('personal');
157
+ expect(profiles[1]!.name).toBe('work');
158
+ });
159
+
160
+ it('skips active.json', async () => {
161
+ mockReaddir.mockResolvedValue(['active.json']);
162
+ const { listProfiles } = await import('../src/claude-profiles.js');
163
+ const profiles = await listProfiles();
164
+ expect(profiles).toHaveLength(0);
165
+ });
166
+
167
+ it('returns empty array when directory does not exist', async () => {
168
+ mockReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
169
+ const { listProfiles } = await import('../src/claude-profiles.js');
170
+ const profiles = await listProfiles();
171
+ expect(profiles).toHaveLength(0);
172
+ });
173
+ });
174
+
175
+ describe('active profile tracking', () => {
176
+ it('loads active profile', async () => {
177
+ const active = { profile: 'work', switchedAt: '2026-04-11T18:05:36Z' };
178
+ mockReadFile.mockResolvedValue(JSON.stringify(active));
179
+ const { loadActiveProfile } = await import('../src/claude-profiles.js');
180
+ const result = await loadActiveProfile();
181
+ expect(result).toEqual(active);
182
+ });
183
+
184
+ it('sets active profile with atomic write', async () => {
185
+ const { setActiveProfile } = await import('../src/claude-profiles.js');
186
+ await setActiveProfile('work');
187
+
188
+ expect(mockWriteFile).toHaveBeenCalledWith(
189
+ expect.stringMatching(/\.tmp$/),
190
+ expect.stringContaining('"profile": "work"'),
191
+ expect.objectContaining({ mode: 0o600 }),
192
+ );
193
+ });
194
+ });
195
+
196
+ describe('getExpiryStatus', () => {
197
+ it('returns valid for tokens expiring in >2 hours', async () => {
198
+ const { getExpiryStatus } = await import('../src/claude-profiles.js');
199
+ const profile = {
200
+ ...sampleProfile,
201
+ credentials: {
202
+ claudeAiOauth: { ...sampleProfile.credentials.claudeAiOauth, expiresAt: Date.now() + 5 * 3600_000 },
203
+ },
204
+ };
205
+ const status = getExpiryStatus(profile);
206
+ expect(status.status).toBe('valid');
207
+ expect(status.label).toContain('\u2713');
208
+ });
209
+
210
+ it('returns expiring for tokens expiring in <2 hours', async () => {
211
+ const { getExpiryStatus } = await import('../src/claude-profiles.js');
212
+ const profile = {
213
+ ...sampleProfile,
214
+ credentials: {
215
+ claudeAiOauth: { ...sampleProfile.credentials.claudeAiOauth, expiresAt: Date.now() + 30 * 60_000 },
216
+ },
217
+ };
218
+ const status = getExpiryStatus(profile);
219
+ expect(status.status).toBe('expiring');
220
+ expect(status.label).toContain('\u26a0');
221
+ });
222
+
223
+ it('returns expired for past timestamps', async () => {
224
+ const { getExpiryStatus } = await import('../src/claude-profiles.js');
225
+ const profile = {
226
+ ...sampleProfile,
227
+ credentials: {
228
+ claudeAiOauth: { ...sampleProfile.credentials.claudeAiOauth, expiresAt: Date.now() - 60_000 },
229
+ },
230
+ };
231
+ const status = getExpiryStatus(profile);
232
+ expect(status.status).toBe('expired');
233
+ expect(status.label).toContain('\u2717');
234
+ });
235
+ });
236
+
237
+ describe('pruneBackups', () => {
238
+ it('removes backups older than maxAgeDays', async () => {
239
+ const now = Date.now();
240
+ const oldTime = now - 8 * 24 * 3600_000; // 8 days ago
241
+ const recentTime = now - 1 * 24 * 3600_000; // 1 day ago
242
+
243
+ mockReaddir.mockResolvedValue(['old.json', 'recent.json']);
244
+ mockStat
245
+ .mockResolvedValueOnce({ mtimeMs: oldTime })
246
+ .mockResolvedValueOnce({ mtimeMs: recentTime });
247
+
248
+ const { pruneBackups } = await import('../src/claude-profiles.js');
249
+ const pruned = await pruneBackups(7);
250
+
251
+ expect(pruned).toBe(1);
252
+ expect(mockUnlink).toHaveBeenCalledWith(join(BACKUPS_DIR, 'old.json'));
253
+ expect(mockUnlink).not.toHaveBeenCalledWith(join(BACKUPS_DIR, 'recent.json'));
254
+ });
255
+
256
+ it('returns 0 when no old backups exist', async () => {
257
+ mockReaddir.mockResolvedValue(['recent.json']);
258
+ mockStat.mockResolvedValue({ mtimeMs: Date.now() });
259
+
260
+ const { pruneBackups } = await import('../src/claude-profiles.js');
261
+ const pruned = await pruneBackups(7);
262
+ expect(pruned).toBe(0);
263
+ });
264
+ });
265
+
266
+ describe('createBackup', () => {
267
+ it('writes backup with timestamp filename', async () => {
268
+ const { createBackup } = await import('../src/claude-profiles.js');
269
+ const creds = sampleProfile.credentials;
270
+ const path = await createBackup(creds);
271
+
272
+ expect(path).toMatch(/backups\/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z\.json$/);
273
+ expect(mockMkdir).toHaveBeenCalledWith(
274
+ BACKUPS_DIR,
275
+ expect.objectContaining({ mode: 0o700 }),
276
+ );
277
+ expect(mockWriteFile).toHaveBeenCalledWith(
278
+ expect.stringContaining('backups/'),
279
+ expect.any(String),
280
+ expect.objectContaining({ mode: 0o600 }),
281
+ );
282
+ });
283
+ });
284
+
285
+ describe('self-trigger flag', () => {
286
+ it('starts as false', async () => {
287
+ const { isSelfTriggered, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
288
+ clearSelfTriggerFlag(); // reset state
289
+ expect(isSelfTriggered()).toBe(false);
290
+ });
291
+
292
+ it('setSelfTriggerFlag sets to true', async () => {
293
+ const { setSelfTriggerFlag, isSelfTriggered, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
294
+ clearSelfTriggerFlag();
295
+ setSelfTriggerFlag();
296
+ expect(isSelfTriggered()).toBe(true);
297
+ });
298
+
299
+ it('clearSelfTriggerFlag resets to false', async () => {
300
+ const { setSelfTriggerFlag, clearSelfTriggerFlag, isSelfTriggered } = await import('../src/claude-profiles.js');
301
+ setSelfTriggerFlag();
302
+ clearSelfTriggerFlag();
303
+ expect(isSelfTriggered()).toBe(false);
304
+ });
305
+ });
306
+
307
+ describe('writeCredentialsFile', () => {
308
+ it('performs atomic write without touching self-trigger flag', async () => {
309
+ const { writeCredentialsFile, isSelfTriggered, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
310
+ clearSelfTriggerFlag();
311
+
312
+ await writeCredentialsFile(sampleProfile.credentials);
313
+
314
+ // Flag should NOT be set — writeCredentialsFile is a pure write
315
+ expect(isSelfTriggered()).toBe(false);
316
+
317
+ // Should use atomic write pattern
318
+ expect(mockWriteFile).toHaveBeenCalledWith(
319
+ expect.stringMatching(/\.tmp$/),
320
+ expect.any(String),
321
+ expect.objectContaining({ mode: 0o600 }),
322
+ );
323
+ expect(mockRename).toHaveBeenCalled();
324
+ });
325
+ });
326
+
327
+ describe('runClaudeAuthStatus', () => {
328
+ it('returns null when claude binary not found', async () => {
329
+ mockExecFileSync.mockImplementation(() => {
330
+ throw new Error('not found');
331
+ });
332
+ const { runClaudeAuthStatus } = await import('../src/claude-profiles.js');
333
+ const result = await runClaudeAuthStatus();
334
+ expect(result).toBeNull();
335
+ });
336
+
337
+ it('parses JSON output from claude auth status', async () => {
338
+ // Mock spawn to emit JSON
339
+ const mockChild = {
340
+ stdout: { on: vi.fn() },
341
+ stderr: { on: vi.fn() },
342
+ on: vi.fn(),
343
+ kill: vi.fn(),
344
+ };
345
+ mockSpawn.mockReturnValue(mockChild);
346
+
347
+ const { runClaudeAuthStatus } = await import('../src/claude-profiles.js');
348
+ const promise = runClaudeAuthStatus();
349
+
350
+ // Simulate stdout data
351
+ const stdoutHandler = mockChild.stdout.on.mock.calls.find(
352
+ (c: unknown[]) => c[0] === 'data',
353
+ )![1] as (data: Buffer) => void;
354
+ stdoutHandler(Buffer.from(JSON.stringify({
355
+ email: 'user@work.com',
356
+ orgName: 'WorkOrg',
357
+ subscriptionType: 'team',
358
+ loggedIn: true,
359
+ })));
360
+
361
+ // Simulate close
362
+ const closeHandler = mockChild.on.mock.calls.find(
363
+ (c: unknown[]) => c[0] === 'close',
364
+ )![1] as (code: number) => void;
365
+ closeHandler(0);
366
+
367
+ const result = await promise;
368
+ expect(result).toEqual({
369
+ email: 'user@work.com',
370
+ orgName: 'WorkOrg',
371
+ subscriptionType: 'team',
372
+ loggedIn: true,
373
+ });
374
+ });
375
+
376
+ it('parses text output as fallback', async () => {
377
+ const mockChild = {
378
+ stdout: { on: vi.fn() },
379
+ stderr: { on: vi.fn() },
380
+ on: vi.fn(),
381
+ kill: vi.fn(),
382
+ };
383
+ mockSpawn.mockReturnValue(mockChild);
384
+
385
+ const { runClaudeAuthStatus } = await import('../src/claude-profiles.js');
386
+ const promise = runClaudeAuthStatus();
387
+
388
+ const stdoutHandler = mockChild.stdout.on.mock.calls.find(
389
+ (c: unknown[]) => c[0] === 'data',
390
+ )![1] as (data: Buffer) => void;
391
+ stdoutHandler(Buffer.from('Logged in as: user@work.com\nOrg: WorkOrg\nSubscription: team\n'));
392
+
393
+ const closeHandler = mockChild.on.mock.calls.find(
394
+ (c: unknown[]) => c[0] === 'close',
395
+ )![1] as (code: number) => void;
396
+ closeHandler(0);
397
+
398
+ const result = await promise;
399
+ expect(result).toEqual({
400
+ email: 'user@work.com',
401
+ orgName: 'WorkOrg',
402
+ subscriptionType: 'team',
403
+ loggedIn: true,
404
+ });
405
+ });
406
+ });
407
+ });
@@ -0,0 +1,229 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock node:os for consistent homedir
4
+ vi.mock('node:os', async (importOriginal) => {
5
+ const actual = await importOriginal<typeof import('node:os')>();
6
+ return {
7
+ ...actual,
8
+ homedir: () => '/home/testuser',
9
+ };
10
+ });
11
+
12
+ // Mock fs/promises
13
+ const mockReadFile = vi.fn();
14
+ const mockWriteFile = vi.fn().mockResolvedValue(undefined);
15
+ const mockRename = vi.fn().mockResolvedValue(undefined);
16
+ const mockMkdir = vi.fn().mockResolvedValue(undefined);
17
+ const mockUnlink = vi.fn().mockResolvedValue(undefined);
18
+ const mockReaddir = vi.fn();
19
+ const mockStat = vi.fn();
20
+
21
+ vi.mock('node:fs/promises', () => ({
22
+ readFile: (...args: unknown[]) => mockReadFile(...args),
23
+ writeFile: (...args: unknown[]) => mockWriteFile(...args),
24
+ rename: (...args: unknown[]) => mockRename(...args),
25
+ mkdir: (...args: unknown[]) => mockMkdir(...args),
26
+ unlink: (...args: unknown[]) => mockUnlink(...args),
27
+ readdir: (...args: unknown[]) => mockReaddir(...args),
28
+ stat: (...args: unknown[]) => mockStat(...args),
29
+ }));
30
+
31
+ // Mock child_process with callback-storage spawn mock
32
+ const mockExecFileSync = vi.fn().mockReturnValue('/usr/bin/claude');
33
+
34
+ type Handler = (...args: unknown[]) => void;
35
+
36
+ function createMockChild(output: string, exitCode = 0) {
37
+ const stdoutHandlers: Record<string, Handler[]> = {};
38
+ const stderrHandlers: Record<string, Handler[]> = {};
39
+ const childHandlers: Record<string, Handler[]> = {};
40
+
41
+ const mockStdout = {
42
+ on(event: string, handler: Handler) {
43
+ (stdoutHandlers[event] ??= []).push(handler);
44
+ return mockStdout;
45
+ },
46
+ };
47
+ const mockStderr = {
48
+ on(event: string, handler: Handler) {
49
+ (stderrHandlers[event] ??= []).push(handler);
50
+ return mockStderr;
51
+ },
52
+ };
53
+ const child = {
54
+ stdout: mockStdout,
55
+ stderr: mockStderr,
56
+ kill: vi.fn(),
57
+ on(event: string, handler: Handler) {
58
+ (childHandlers[event] ??= []).push(handler);
59
+ // Auto-fire data + close after handlers are registered
60
+ // Use setTimeout to ensure all handlers are registered first
61
+ if (event === 'close') {
62
+ setTimeout(() => {
63
+ for (const h of stdoutHandlers['data'] ?? []) h(Buffer.from(output));
64
+ for (const h of childHandlers['close'] ?? []) h(exitCode);
65
+ }, 0);
66
+ }
67
+ return child;
68
+ },
69
+ };
70
+
71
+ return child;
72
+ }
73
+
74
+ const mockSpawn = vi.fn();
75
+
76
+ vi.mock('node:child_process', () => ({
77
+ execFileSync: (...args: unknown[]) => mockExecFileSync(...args),
78
+ spawn: (...args: unknown[]) => mockSpawn(...args),
79
+ }));
80
+
81
+ // Mock logger
82
+ vi.mock('../src/logger.js', () => ({
83
+ logger: {
84
+ child: () => ({
85
+ info: vi.fn(),
86
+ warn: vi.fn(),
87
+ error: vi.fn(),
88
+ debug: vi.fn(),
89
+ }),
90
+ info: vi.fn(),
91
+ warn: vi.fn(),
92
+ error: vi.fn(),
93
+ debug: vi.fn(),
94
+ },
95
+ }));
96
+
97
+ const sampleCredentials = {
98
+ claudeAiOauth: {
99
+ accessToken: 'sk-ant-oat01-test',
100
+ refreshToken: 'sk-ant-ort01-test',
101
+ expiresAt: Date.now() + 3600_000,
102
+ scopes: ['user:profile', 'user:inference'],
103
+ subscriptionType: 'team',
104
+ rateLimitTier: 'default_claude_max_5x',
105
+ },
106
+ };
107
+
108
+ const sampleProfile = {
109
+ name: 'work',
110
+ email: 'user@work.com',
111
+ orgName: 'WorkOrg',
112
+ subscriptionType: 'team',
113
+ savedAt: '2026-04-11T18:05:36Z',
114
+ credentials: sampleCredentials,
115
+ };
116
+
117
+ describe('handleCredentialChange', () => {
118
+ beforeEach(() => {
119
+ vi.resetAllMocks();
120
+ mockExecFileSync.mockReturnValue('/usr/bin/claude');
121
+ });
122
+
123
+ it('skips processing when self-triggered', async () => {
124
+ const {
125
+ setSelfTriggerFlag,
126
+ isSelfTriggered,
127
+ handleCredentialChange,
128
+ } = await import('../src/claude-profiles.js');
129
+
130
+ setSelfTriggerFlag();
131
+ await handleCredentialChange();
132
+
133
+ // Self-trigger flag should be cleared
134
+ expect(isSelfTriggered()).toBe(false);
135
+
136
+ // Should NOT have read credentials or spawned auth status
137
+ expect(mockReadFile).not.toHaveBeenCalled();
138
+ expect(mockSpawn).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it('creates backup on external change', async () => {
142
+ const { handleCredentialChange, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
143
+ clearSelfTriggerFlag();
144
+
145
+ mockReadFile.mockResolvedValue(JSON.stringify(sampleCredentials));
146
+
147
+ mockSpawn.mockReturnValue(createMockChild(JSON.stringify({
148
+ email: 'unknown@other.com',
149
+ orgName: 'OtherOrg',
150
+ subscriptionType: 'free',
151
+ loggedIn: true,
152
+ })));
153
+
154
+ // No saved profiles
155
+ mockReaddir.mockResolvedValue([]);
156
+
157
+ await handleCredentialChange();
158
+
159
+ // Should have written a backup file
160
+ expect(mockWriteFile).toHaveBeenCalledWith(
161
+ expect.stringContaining('backups/'),
162
+ expect.stringContaining('claudeAiOauth'),
163
+ expect.objectContaining({ mode: 0o600 }),
164
+ );
165
+ });
166
+
167
+ it('updates matching profile when email matches', async () => {
168
+ const { handleCredentialChange, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
169
+ clearSelfTriggerFlag();
170
+
171
+ // readFile calls: readCredentialsFile, then listProfiles reads profile
172
+ let readFileCallCount = 0;
173
+ mockReadFile.mockImplementation(() => {
174
+ readFileCallCount++;
175
+ if (readFileCallCount <= 1) {
176
+ return Promise.resolve(JSON.stringify(sampleCredentials));
177
+ }
178
+ return Promise.resolve(JSON.stringify(sampleProfile));
179
+ });
180
+
181
+ mockSpawn.mockReturnValue(createMockChild(JSON.stringify({
182
+ email: 'user@work.com',
183
+ orgName: 'WorkOrg',
184
+ subscriptionType: 'team',
185
+ loggedIn: true,
186
+ })));
187
+
188
+ mockReaddir.mockResolvedValue(['work.json']);
189
+
190
+ await handleCredentialChange();
191
+
192
+ // Should have written: backup + updated profile (2 writeFile calls)
193
+ expect(mockWriteFile).toHaveBeenCalledTimes(2);
194
+
195
+ // Second write should be the profile update (tmp file for atomic write)
196
+ const secondWrite = mockWriteFile.mock.calls[1]!;
197
+ expect(secondWrite[0]).toMatch(/\.tmp$/);
198
+ const writtenProfile = JSON.parse(secondWrite[1] as string);
199
+ expect(writtenProfile.name).toBe('work');
200
+ expect(writtenProfile.email).toBe('user@work.com');
201
+ });
202
+
203
+ it('logs suggestion when email matches no profile', async () => {
204
+ const { handleCredentialChange, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
205
+ clearSelfTriggerFlag();
206
+
207
+ mockReadFile.mockResolvedValue(JSON.stringify(sampleCredentials));
208
+
209
+ mockSpawn.mockReturnValue(createMockChild(JSON.stringify({
210
+ email: 'unknown@other.com',
211
+ orgName: 'OtherOrg',
212
+ subscriptionType: 'free',
213
+ loggedIn: true,
214
+ })));
215
+
216
+ // No profiles
217
+ mockReaddir.mockResolvedValue([]);
218
+
219
+ await handleCredentialChange();
220
+
221
+ // Should have written backup only (1 writeFile call)
222
+ expect(mockWriteFile).toHaveBeenCalledTimes(1);
223
+ expect(mockWriteFile).toHaveBeenCalledWith(
224
+ expect.stringContaining('backups/'),
225
+ expect.any(String),
226
+ expect.any(Object),
227
+ );
228
+ });
229
+ });