@soleri/cli 9.10.0 → 9.12.1

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,119 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Command } from 'commander';
3
+
4
+ // Mock @soleri/core before importing the command
5
+ vi.mock('@soleri/core', () => {
6
+ const mockDreamEngine = {
7
+ run: vi.fn().mockReturnValue({
8
+ durationMs: 1234,
9
+ duplicatesFound: 3,
10
+ staleArchived: 2,
11
+ contradictionsFound: 1,
12
+ totalDreams: 5,
13
+ timestamp: '2026-03-31T00:00:00.000Z',
14
+ }),
15
+ getStatus: vi.fn().mockReturnValue({
16
+ sessionsSinceLastDream: 7,
17
+ lastDreamAt: '2026-03-30T22:00:00.000Z',
18
+ lastDreamDurationMs: 1234,
19
+ totalDreams: 4,
20
+ gateEligible: true,
21
+ }),
22
+ };
23
+
24
+ return {
25
+ DreamEngine: vi.fn().mockImplementation(() => mockDreamEngine),
26
+ Vault: vi.fn().mockImplementation(() => ({
27
+ getProvider: vi.fn().mockReturnValue({}),
28
+ close: vi.fn(),
29
+ })),
30
+ Curator: vi.fn().mockImplementation(() => ({})),
31
+ SOLERI_HOME: '/tmp/soleri-test',
32
+ getSchedule: vi.fn().mockReturnValue({
33
+ isScheduled: true,
34
+ time: '22:00',
35
+ logPath: '/tmp/soleri-test/dream-cron.log',
36
+ projectDir: '/tmp/project',
37
+ }),
38
+ schedule: vi.fn().mockReturnValue({
39
+ isScheduled: true,
40
+ time: '22:03',
41
+ logPath: '/tmp/soleri-test/dream-cron.log',
42
+ projectDir: '/tmp/project',
43
+ }),
44
+ unschedule: vi.fn().mockReturnValue({
45
+ isScheduled: false,
46
+ time: null,
47
+ logPath: null,
48
+ projectDir: null,
49
+ }),
50
+ };
51
+ });
52
+
53
+ describe('dream command', () => {
54
+ let program: Command;
55
+
56
+ beforeEach(() => {
57
+ vi.clearAllMocks();
58
+ program = new Command();
59
+ program.exitOverride(); // Prevent process.exit in tests
60
+ });
61
+
62
+ it('should register dream command with subcommands', async () => {
63
+ const { registerDream } = await import('../commands/dream.js');
64
+ registerDream(program);
65
+
66
+ const dreamCmd = program.commands.find((c) => c.name() === 'dream');
67
+ expect(dreamCmd).toBeDefined();
68
+ expect(dreamCmd!.description()).toBeTruthy();
69
+
70
+ // Check subcommands exist
71
+ const subNames = dreamCmd!.commands.map((c) => c.name());
72
+ expect(subNames).toContain('schedule');
73
+ expect(subNames).toContain('unschedule');
74
+ expect(subNames).toContain('status');
75
+ });
76
+
77
+ it('should have schedule subcommand with --time option', async () => {
78
+ const { registerDream } = await import('../commands/dream.js');
79
+ registerDream(program);
80
+
81
+ const dreamCmd = program.commands.find((c) => c.name() === 'dream');
82
+ const scheduleCmd = dreamCmd!.commands.find((c) => c.name() === 'schedule');
83
+ expect(scheduleCmd).toBeDefined();
84
+
85
+ // Verify --time option is registered
86
+ const timeOption = scheduleCmd!.options.find((o) => o.long === '--time' || o.short === '-t');
87
+ expect(timeOption).toBeDefined();
88
+ });
89
+
90
+ it('should register status subcommand', async () => {
91
+ const { registerDream } = await import('../commands/dream.js');
92
+ registerDream(program);
93
+
94
+ const dreamCmd = program.commands.find((c) => c.name() === 'dream');
95
+ const statusCmd = dreamCmd!.commands.find((c) => c.name() === 'status');
96
+ expect(statusCmd).toBeDefined();
97
+ expect(statusCmd!.description()).toBeTruthy();
98
+ });
99
+
100
+ it('should register unschedule subcommand', async () => {
101
+ const { registerDream } = await import('../commands/dream.js');
102
+ registerDream(program);
103
+
104
+ const dreamCmd = program.commands.find((c) => c.name() === 'dream');
105
+ const unscheduleCmd = dreamCmd!.commands.find((c) => c.name() === 'unschedule');
106
+ expect(unscheduleCmd).toBeDefined();
107
+ expect(unscheduleCmd!.description()).toBeTruthy();
108
+ });
109
+
110
+ it('should have dream as parent command with its own action', async () => {
111
+ const { registerDream } = await import('../commands/dream.js');
112
+ registerDream(program);
113
+
114
+ const dreamCmd = program.commands.find((c) => c.name() === 'dream');
115
+ expect(dreamCmd).toBeDefined();
116
+ // The parent dream command should have a description indicating it runs consolidation
117
+ expect(dreamCmd!.description()).toMatch(/dream|consolidat|memory/i);
118
+ });
119
+ });
@@ -0,0 +1,566 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ // Mock @clack/prompts to suppress console output during tests
7
+ vi.mock('@clack/prompts', () => ({
8
+ log: {
9
+ success: vi.fn(),
10
+ error: vi.fn(),
11
+ warn: vi.fn(),
12
+ info: vi.fn(),
13
+ },
14
+ confirm: vi.fn(),
15
+ isCancel: vi.fn(),
16
+ }));
17
+
18
+ import {
19
+ detectArtifacts,
20
+ removeDirectory,
21
+ removeClaudeMdBlock,
22
+ removePermissionEntries,
23
+ removeLauncherScript,
24
+ } from '../utils/agent-artifacts.js';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Test helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function makeTempDir(suffix: string): string {
31
+ const dir = join(
32
+ tmpdir(),
33
+ `uninstall-full-test-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
34
+ );
35
+ mkdirSync(dir, { recursive: true });
36
+ return dir;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // detectArtifacts
41
+ // ---------------------------------------------------------------------------
42
+
43
+ describe('detectArtifacts', () => {
44
+ let tempDir: string;
45
+ let originalHome: string;
46
+
47
+ beforeEach(() => {
48
+ tempDir = makeTempDir('detect');
49
+ originalHome = process.env.HOME ?? '';
50
+ process.env.HOME = tempDir;
51
+ process.env.USERPROFILE = tempDir;
52
+ });
53
+
54
+ afterEach(() => {
55
+ process.env.HOME = originalHome;
56
+ if (originalHome) process.env.USERPROFILE = originalHome;
57
+ rmSync(tempDir, { recursive: true, force: true });
58
+ });
59
+
60
+ it('detects an existing agent project directory', () => {
61
+ const agentDir = join(tempDir, 'my-agent');
62
+ mkdirSync(agentDir, { recursive: true });
63
+ writeFileSync(join(agentDir, 'agent.yaml'), 'name: test\n');
64
+
65
+ const manifest = detectArtifacts('my-agent', agentDir);
66
+ expect(manifest.projectDir).not.toBeNull();
67
+ expect(manifest.projectDir!.exists).toBe(true);
68
+ expect(manifest.projectDir!.path).toBe(agentDir);
69
+ });
70
+
71
+ it('returns exists: false for a non-existent agent directory', () => {
72
+ const nonExistent = join(tempDir, 'does-not-exist');
73
+ const manifest = detectArtifacts('ghost-agent', nonExistent);
74
+
75
+ expect(manifest.projectDir).not.toBeNull();
76
+ expect(manifest.projectDir!.exists).toBe(false);
77
+ expect(manifest.claudeMdBlocks).toEqual([]);
78
+ expect(manifest.mcpServerEntries).toEqual([]);
79
+ expect(manifest.permissionEntries).toEqual([]);
80
+ });
81
+
82
+ it('detects CLAUDE.md block with matching markers', () => {
83
+ const claudeMdPath = join(tempDir, 'CLAUDE.md');
84
+ const content = [
85
+ '# My Config',
86
+ '',
87
+ '<!-- agent:test-agent:mode -->',
88
+ '## Test Agent Mode',
89
+ 'Some content here.',
90
+ '<!-- /agent:test-agent:mode -->',
91
+ '',
92
+ '# Other stuff',
93
+ ].join('\n');
94
+ writeFileSync(claudeMdPath, content);
95
+
96
+ const manifest = detectArtifacts('test-agent', join(tempDir, 'nope'));
97
+ expect(manifest.claudeMdBlocks.length).toBe(1);
98
+ expect(manifest.claudeMdBlocks[0].startLine).toBe(3);
99
+ expect(manifest.claudeMdBlocks[0].endLine).toBe(6);
100
+ expect(manifest.claudeMdBlocks[0].path).toBe(claudeMdPath);
101
+ });
102
+
103
+ it('returns empty blocks when end marker is missing', () => {
104
+ const claudeMdPath = join(tempDir, 'CLAUDE.md');
105
+ const content = [
106
+ '# My Config',
107
+ '<!-- agent:test-agent:mode -->',
108
+ '## Test Agent Mode',
109
+ 'Some content here.',
110
+ // Missing end marker
111
+ ].join('\n');
112
+ writeFileSync(claudeMdPath, content);
113
+
114
+ const manifest = detectArtifacts('test-agent', join(tempDir, 'nope'));
115
+ expect(manifest.claudeMdBlocks).toEqual([]);
116
+ });
117
+
118
+ it('detects permission entries with matching prefix', () => {
119
+ const claudeDir = join(tempDir, '.claude');
120
+ mkdirSync(claudeDir, { recursive: true });
121
+ const settingsPath = join(claudeDir, 'settings.local.json');
122
+ const settings = {
123
+ permissions: {
124
+ allow: [
125
+ 'mcp__maria__design_check',
126
+ 'mcp__maria__color_pairs',
127
+ 'mcp__ernesto__vault_search',
128
+ 'mcp__ernesto__memory_capture',
129
+ 'Bash(*)',
130
+ ],
131
+ },
132
+ };
133
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
134
+
135
+ const manifest = detectArtifacts('maria', join(tempDir, 'nope'));
136
+ expect(manifest.permissionEntries.length).toBe(1);
137
+ expect(manifest.permissionEntries[0].matches).toEqual([
138
+ 'mcp__maria__design_check',
139
+ 'mcp__maria__color_pairs',
140
+ ]);
141
+ // Ernesto entries should NOT be included
142
+ expect(manifest.permissionEntries[0].matches).not.toContain('mcp__ernesto__vault_search');
143
+ });
144
+
145
+ it('does not match permission prefix that is a substring of another agent', () => {
146
+ const claudeDir = join(tempDir, '.claude');
147
+ mkdirSync(claudeDir, { recursive: true });
148
+ const settingsPath = join(claudeDir, 'settings.local.json');
149
+ const settings = {
150
+ permissions: {
151
+ allow: ['mcp__marianne__some_op', 'mcp__maria__design_check'],
152
+ },
153
+ };
154
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
155
+
156
+ const manifest = detectArtifacts('maria', join(tempDir, 'nope'));
157
+ expect(manifest.permissionEntries.length).toBe(1);
158
+ // Only the exact prefix match — mcp__maria__ does NOT match mcp__marianne__
159
+ expect(manifest.permissionEntries[0].matches).toEqual(['mcp__maria__design_check']);
160
+ });
161
+ });
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // removeDirectory
165
+ // ---------------------------------------------------------------------------
166
+
167
+ describe('removeDirectory', () => {
168
+ let tempDir: string;
169
+
170
+ beforeEach(() => {
171
+ tempDir = makeTempDir('rmdir');
172
+ });
173
+
174
+ afterEach(() => {
175
+ rmSync(tempDir, { recursive: true, force: true });
176
+ });
177
+
178
+ it('removes a directory with files', async () => {
179
+ const target = join(tempDir, 'to-remove');
180
+ mkdirSync(join(target, 'sub'), { recursive: true });
181
+ writeFileSync(join(target, 'file.txt'), 'data');
182
+ writeFileSync(join(target, 'sub', 'nested.txt'), 'nested');
183
+
184
+ const result = await removeDirectory(target);
185
+ expect(result.removed).toBe(true);
186
+ expect(result.path).toBe(target);
187
+ expect(existsSync(target)).toBe(false);
188
+ });
189
+
190
+ it('returns removed: false when called on a non-existent path (idempotent)', async () => {
191
+ const gone = join(tempDir, 'already-gone');
192
+ const result = await removeDirectory(gone);
193
+ expect(result.removed).toBe(false);
194
+ expect(result.path).toBe(gone);
195
+ expect(result.error).toBeUndefined();
196
+ });
197
+
198
+ it('is idempotent — second call after removal returns removed: false', async () => {
199
+ const target = join(tempDir, 'once');
200
+ mkdirSync(target);
201
+ writeFileSync(join(target, 'f.txt'), 'x');
202
+
203
+ const first = await removeDirectory(target);
204
+ expect(first.removed).toBe(true);
205
+
206
+ const second = await removeDirectory(target);
207
+ expect(second.removed).toBe(false);
208
+ });
209
+ });
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // removeClaudeMdBlock
213
+ // ---------------------------------------------------------------------------
214
+
215
+ describe('removeClaudeMdBlock', () => {
216
+ let tempDir: string;
217
+
218
+ beforeEach(() => {
219
+ tempDir = makeTempDir('claudemd');
220
+ });
221
+
222
+ afterEach(() => {
223
+ rmSync(tempDir, { recursive: true, force: true });
224
+ });
225
+
226
+ it('removes the block including markers', async () => {
227
+ const filePath = join(tempDir, 'CLAUDE.md');
228
+ const content = [
229
+ '# Config',
230
+ '',
231
+ '<!-- agent:foo:mode -->',
232
+ '## Foo Mode',
233
+ 'content',
234
+ '<!-- /agent:foo:mode -->',
235
+ '',
236
+ '# Other',
237
+ ].join('\n');
238
+ writeFileSync(filePath, content);
239
+
240
+ const result = await removeClaudeMdBlock(filePath, 3, 6);
241
+ expect(result.removed).toBe(true);
242
+
243
+ const after = readFileSync(filePath, 'utf-8');
244
+ expect(after).not.toContain('<!-- agent:foo:mode -->');
245
+ expect(after).not.toContain('## Foo Mode');
246
+ expect(after).not.toContain('<!-- /agent:foo:mode -->');
247
+ expect(after).toContain('# Config');
248
+ expect(after).toContain('# Other');
249
+ });
250
+
251
+ it('collapses triple blank lines after removal', async () => {
252
+ const filePath = join(tempDir, 'CLAUDE.md');
253
+ const content = [
254
+ '# Config',
255
+ '',
256
+ '',
257
+ '<!-- agent:foo:mode -->',
258
+ 'stuff',
259
+ '<!-- /agent:foo:mode -->',
260
+ '',
261
+ '',
262
+ '# Other',
263
+ ].join('\n');
264
+ writeFileSync(filePath, content);
265
+
266
+ const result = await removeClaudeMdBlock(filePath, 4, 6);
267
+ expect(result.removed).toBe(true);
268
+
269
+ const after = readFileSync(filePath, 'utf-8');
270
+ // Should not have 3+ consecutive newlines
271
+ expect(after).not.toMatch(/\n{3,}/);
272
+ });
273
+
274
+ it('returns removed: false for a non-existent file', async () => {
275
+ const result = await removeClaudeMdBlock(join(tempDir, 'nope.md'), 1, 3);
276
+ expect(result.removed).toBe(false);
277
+ });
278
+ });
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // removePermissionEntries
282
+ // ---------------------------------------------------------------------------
283
+
284
+ describe('removePermissionEntries', () => {
285
+ let tempDir: string;
286
+
287
+ beforeEach(() => {
288
+ tempDir = makeTempDir('perms');
289
+ });
290
+
291
+ afterEach(() => {
292
+ rmSync(tempDir, { recursive: true, force: true });
293
+ });
294
+
295
+ it('removes only the matching agent entries, keeps others', async () => {
296
+ const filePath = join(tempDir, 'settings.local.json');
297
+ const settings = {
298
+ permissions: {
299
+ allow: [
300
+ 'mcp__maria__design_check',
301
+ 'mcp__maria__color_pairs',
302
+ 'mcp__ernesto__vault_search',
303
+ 'Bash(*)',
304
+ ],
305
+ },
306
+ };
307
+ writeFileSync(filePath, JSON.stringify(settings, null, 2));
308
+
309
+ const result = await removePermissionEntries(filePath, 'maria');
310
+ expect(result.removed).toBe(true);
311
+
312
+ const after = JSON.parse(readFileSync(filePath, 'utf-8'));
313
+ expect(after.permissions.allow).toEqual(['mcp__ernesto__vault_search', 'Bash(*)']);
314
+ });
315
+
316
+ it('preserves 2-space JSON indentation', async () => {
317
+ const filePath = join(tempDir, 'settings.local.json');
318
+ const settings = {
319
+ permissions: {
320
+ allow: ['mcp__agent__op', 'other'],
321
+ },
322
+ };
323
+ writeFileSync(filePath, JSON.stringify(settings, null, 2));
324
+
325
+ await removePermissionEntries(filePath, 'agent');
326
+
327
+ const raw = readFileSync(filePath, 'utf-8');
328
+ // Verify 2-space indent is present
329
+ expect(raw).toContain(' "permissions"');
330
+ // Should end with trailing newline
331
+ expect(raw.endsWith('\n')).toBe(true);
332
+ });
333
+
334
+ it('returns removed: false when no entries match', async () => {
335
+ const filePath = join(tempDir, 'settings.local.json');
336
+ const settings = {
337
+ permissions: {
338
+ allow: ['mcp__other__op', 'Bash(*)'],
339
+ },
340
+ };
341
+ writeFileSync(filePath, JSON.stringify(settings, null, 2));
342
+
343
+ const result = await removePermissionEntries(filePath, 'maria');
344
+ expect(result.removed).toBe(false);
345
+ });
346
+
347
+ it('returns removed: false for a non-existent file', async () => {
348
+ const result = await removePermissionEntries(join(tempDir, 'nope.json'), 'agent');
349
+ expect(result.removed).toBe(false);
350
+ expect(result.error).toBeUndefined();
351
+ });
352
+
353
+ it('handles empty settings file with no permissions key', async () => {
354
+ const filePath = join(tempDir, 'settings.local.json');
355
+ writeFileSync(filePath, JSON.stringify({}, null, 2));
356
+
357
+ const result = await removePermissionEntries(filePath, 'agent');
358
+ expect(result.removed).toBe(false);
359
+ });
360
+
361
+ it('handles malformed JSON without throwing', async () => {
362
+ const filePath = join(tempDir, 'settings.local.json');
363
+ writeFileSync(filePath, '{ not valid json');
364
+
365
+ const result = await removePermissionEntries(filePath, 'agent');
366
+ expect(result.removed).toBe(false);
367
+ expect(result.error).toBe('Failed to parse JSON');
368
+ });
369
+ });
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // removeLauncherScript
373
+ // ---------------------------------------------------------------------------
374
+
375
+ describe('removeLauncherScript', () => {
376
+ let tempDir: string;
377
+
378
+ beforeEach(() => {
379
+ tempDir = makeTempDir('launcher');
380
+ });
381
+
382
+ afterEach(() => {
383
+ rmSync(tempDir, { recursive: true, force: true });
384
+ });
385
+
386
+ it('removes an existing file', async () => {
387
+ const scriptPath = join(tempDir, 'my-agent');
388
+ writeFileSync(scriptPath, '#!/bin/bash\necho hello\n');
389
+
390
+ const result = await removeLauncherScript(scriptPath);
391
+ expect(result.removed).toBe(true);
392
+ expect(result.path).toBe(scriptPath);
393
+ expect(existsSync(scriptPath)).toBe(false);
394
+ });
395
+
396
+ it('returns removed: false for a non-existent path', async () => {
397
+ const result = await removeLauncherScript(join(tempDir, 'nope'));
398
+ expect(result.removed).toBe(false);
399
+ expect(result.error).toBeUndefined();
400
+ });
401
+ });
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Integration test: full detect → remove → verify cycle
405
+ // ---------------------------------------------------------------------------
406
+
407
+ describe('integration: detect → remove → verify', () => {
408
+ let tempDir: string;
409
+ let originalHome: string;
410
+ const AGENT_ID = 'test-agent';
411
+
412
+ beforeEach(() => {
413
+ tempDir = makeTempDir('integration');
414
+ originalHome = process.env.HOME ?? '';
415
+ process.env.HOME = tempDir;
416
+ process.env.USERPROFILE = tempDir;
417
+
418
+ // 1. Project directory
419
+ const projectDir = join(tempDir, 'projects', AGENT_ID);
420
+ mkdirSync(projectDir, { recursive: true });
421
+ writeFileSync(join(projectDir, 'agent.yaml'), 'name: test-agent\n');
422
+
423
+ // 2. Legacy data directory
424
+ const legacyDir = join(tempDir, `.${AGENT_ID}`);
425
+ mkdirSync(legacyDir, { recursive: true });
426
+ writeFileSync(join(legacyDir, 'vault.db'), 'fake-db');
427
+
428
+ // 3. CLAUDE.md with agent block
429
+ const claudeMdPath = join(tempDir, 'CLAUDE.md');
430
+ const claudeMdContent = [
431
+ '# Home Config',
432
+ '',
433
+ `<!-- agent:${AGENT_ID}:mode -->`,
434
+ `## ${AGENT_ID} Mode`,
435
+ 'Agent instructions here.',
436
+ `<!-- /agent:${AGENT_ID}:mode -->`,
437
+ '',
438
+ '# Other Stuff',
439
+ ].join('\n');
440
+ writeFileSync(claudeMdPath, claudeMdContent);
441
+
442
+ // 4. Permissions in settings.local.json
443
+ const claudeDir = join(tempDir, '.claude');
444
+ mkdirSync(claudeDir, { recursive: true });
445
+ const settingsPath = join(claudeDir, 'settings.local.json');
446
+ const settings = {
447
+ permissions: {
448
+ allow: [
449
+ `mcp__${AGENT_ID}__vault_search`,
450
+ `mcp__${AGENT_ID}__memory_capture`,
451
+ 'mcp__other__something',
452
+ 'Bash(*)',
453
+ ],
454
+ },
455
+ };
456
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
457
+ });
458
+
459
+ afterEach(() => {
460
+ process.env.HOME = originalHome;
461
+ if (originalHome) process.env.USERPROFILE = originalHome;
462
+ rmSync(tempDir, { recursive: true, force: true });
463
+ });
464
+
465
+ it('detects all artifacts, removes them, and verifies clean state', async () => {
466
+ const projectDir = join(tempDir, 'projects', AGENT_ID);
467
+ const legacyDir = join(tempDir, `.${AGENT_ID}`);
468
+ const claudeMdPath = join(tempDir, 'CLAUDE.md');
469
+ const settingsPath = join(tempDir, '.claude', 'settings.local.json');
470
+
471
+ // --- Phase 1: Detect ---
472
+ const manifest = detectArtifacts(AGENT_ID, projectDir);
473
+
474
+ expect(manifest.agentId).toBe(AGENT_ID);
475
+ expect(manifest.projectDir!.exists).toBe(true);
476
+ expect(manifest.dataDirLegacy!.exists).toBe(true);
477
+ expect(manifest.claudeMdBlocks.length).toBe(1);
478
+ expect(manifest.permissionEntries.length).toBe(1);
479
+ expect(manifest.permissionEntries[0].matches.length).toBe(2);
480
+
481
+ // --- Phase 2: Remove ---
482
+ // Permission entries
483
+ const permResult = await removePermissionEntries(settingsPath, AGENT_ID);
484
+ expect(permResult.removed).toBe(true);
485
+
486
+ // CLAUDE.md block
487
+ const block = manifest.claudeMdBlocks[0];
488
+ const blockResult = await removeClaudeMdBlock(block.path, block.startLine, block.endLine);
489
+ expect(blockResult.removed).toBe(true);
490
+
491
+ // Directories
492
+ const projResult = await removeDirectory(projectDir);
493
+ expect(projResult.removed).toBe(true);
494
+
495
+ const legacyResult = await removeDirectory(legacyDir);
496
+ expect(legacyResult.removed).toBe(true);
497
+
498
+ // --- Phase 3: Verify clean state ---
499
+ expect(existsSync(projectDir)).toBe(false);
500
+ expect(existsSync(legacyDir)).toBe(false);
501
+
502
+ // CLAUDE.md should not contain agent markers
503
+ const claudeAfter = readFileSync(claudeMdPath, 'utf-8');
504
+ expect(claudeAfter).not.toContain(`<!-- agent:${AGENT_ID}:mode -->`);
505
+ expect(claudeAfter).toContain('# Home Config');
506
+ expect(claudeAfter).toContain('# Other Stuff');
507
+
508
+ // Settings should not contain agent permissions but keep other entries
509
+ const settingsAfter = JSON.parse(readFileSync(settingsPath, 'utf-8'));
510
+ expect(settingsAfter.permissions.allow).toEqual(['mcp__other__something', 'Bash(*)']);
511
+ });
512
+ });
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Edge cases
516
+ // ---------------------------------------------------------------------------
517
+
518
+ describe('edge cases', () => {
519
+ let tempDir: string;
520
+ let originalHome: string;
521
+
522
+ beforeEach(() => {
523
+ tempDir = makeTempDir('edge');
524
+ originalHome = process.env.HOME ?? '';
525
+ process.env.HOME = tempDir;
526
+ process.env.USERPROFILE = tempDir;
527
+ });
528
+
529
+ afterEach(() => {
530
+ process.env.HOME = originalHome;
531
+ if (originalHome) process.env.USERPROFILE = originalHome;
532
+ rmSync(tempDir, { recursive: true, force: true });
533
+ });
534
+
535
+ it('partial install: only project dir exists, no configs', () => {
536
+ const projectDir = join(tempDir, 'projects', 'partial-agent');
537
+ mkdirSync(projectDir, { recursive: true });
538
+ writeFileSync(join(projectDir, 'agent.yaml'), 'name: partial\n');
539
+
540
+ const manifest = detectArtifacts('partial-agent', projectDir);
541
+ expect(manifest.projectDir!.exists).toBe(true);
542
+ expect(manifest.claudeMdBlocks).toEqual([]);
543
+ expect(manifest.mcpServerEntries).toEqual([]);
544
+ expect(manifest.permissionEntries).toEqual([]);
545
+ });
546
+
547
+ it('empty settings.local.json (valid JSON, no permissions key)', () => {
548
+ const claudeDir = join(tempDir, '.claude');
549
+ mkdirSync(claudeDir, { recursive: true });
550
+ writeFileSync(join(claudeDir, 'settings.local.json'), '{}');
551
+
552
+ const manifest = detectArtifacts('some-agent', join(tempDir, 'nope'));
553
+ expect(manifest.permissionEntries).toEqual([]);
554
+ });
555
+
556
+ it('malformed JSON in settings file does not throw', () => {
557
+ const claudeDir = join(tempDir, '.claude');
558
+ mkdirSync(claudeDir, { recursive: true });
559
+ writeFileSync(join(claudeDir, 'settings.local.json'), '{ broken json !!!');
560
+
561
+ expect(() => {
562
+ const manifest = detectArtifacts('some-agent', join(tempDir, 'nope'));
563
+ expect(manifest.permissionEntries).toEqual([]);
564
+ }).not.toThrow();
565
+ });
566
+ });