@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.
- package/README.md +44 -15
- package/dist/commands/agent.js +11 -7
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/create.js +11 -2
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/dev.js +11 -9
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/dream.d.ts +10 -0
- package/dist/commands/dream.js +151 -0
- package/dist/commands/dream.js.map +1 -0
- package/dist/commands/uninstall.js +161 -3
- package/dist/commands/uninstall.js.map +1 -1
- package/dist/main.js +2 -0
- package/dist/main.js.map +1 -1
- package/dist/utils/agent-artifacts.d.ts +63 -0
- package/dist/utils/agent-artifacts.js +276 -0
- package/dist/utils/agent-artifacts.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/dream.test.ts +119 -0
- package/src/__tests__/uninstall-full.test.ts +566 -0
- package/src/commands/agent.ts +14 -10
- package/src/commands/create.ts +10 -2
- package/src/commands/dev.ts +19 -9
- package/src/commands/dream.ts +174 -0
- package/src/commands/uninstall.ts +214 -31
- package/src/main.ts +2 -0
- package/src/utils/agent-artifacts.ts +366 -0
|
@@ -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
|
+
});
|