@soleri/cli 9.11.0 → 9.13.0

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,366 @@
1
+ /**
2
+ * Detect all artifacts left by an installed Soleri agent.
3
+ * Read-only — never modifies the filesystem.
4
+ */
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { rm, readFile, writeFile, unlink } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { homedir } from 'node:os';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Public types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface ArtifactLocation {
15
+ path: string;
16
+ exists: boolean;
17
+ }
18
+
19
+ export interface ClaudeMdBlock {
20
+ path: string;
21
+ startLine: number;
22
+ endLine: number;
23
+ startMarker: string;
24
+ endMarker: string;
25
+ }
26
+
27
+ export interface McpServerEntry {
28
+ file: string;
29
+ key: string;
30
+ target: 'claude' | 'codex' | 'opencode';
31
+ }
32
+
33
+ export interface PermissionEntry {
34
+ file: string;
35
+ pattern: string;
36
+ matches: string[];
37
+ }
38
+
39
+ export interface ArtifactManifest {
40
+ agentId: string;
41
+ projectDir: ArtifactLocation | null;
42
+ dataDir: ArtifactLocation | null;
43
+ dataDirLegacy: ArtifactLocation | null;
44
+ claudeMdBlocks: ClaudeMdBlock[];
45
+ mcpServerEntries: McpServerEntry[];
46
+ permissionEntries: PermissionEntry[];
47
+ launcherScript: ArtifactLocation | null;
48
+ }
49
+
50
+ export interface RemovalResult {
51
+ removed: boolean;
52
+ path: string;
53
+ error?: string;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const SOLERI_HOME = process.env.SOLERI_HOME ?? join(homedir(), '.soleri');
61
+
62
+ function escapeRegExp(s: string): string {
63
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
64
+ }
65
+
66
+ function location(p: string): ArtifactLocation {
67
+ return { path: p, exists: existsSync(p) };
68
+ }
69
+
70
+ /**
71
+ * Read a file safely — returns null if the file doesn't exist or can't be read.
72
+ */
73
+ function safeRead(filePath: string): string | null {
74
+ try {
75
+ if (!existsSync(filePath)) return null;
76
+ return readFileSync(filePath, 'utf-8');
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Parse a JSON file safely, optionally stripping single-line comments first.
84
+ */
85
+ function safeParseJson(filePath: string, stripComments = false): Record<string, unknown> | null {
86
+ const raw = safeRead(filePath);
87
+ if (raw === null) return null;
88
+ try {
89
+ const content = stripComments ? raw.replace(/^\s*\/\/.*$/gm, '') : raw;
90
+ return JSON.parse(content) as Record<string, unknown>;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Detection: CLAUDE.md blocks
98
+ // ---------------------------------------------------------------------------
99
+
100
+ function detectClaudeMdBlocks(agentId: string): ClaudeMdBlock[] {
101
+ const home = homedir();
102
+ const paths = [join(home, 'CLAUDE.md'), join(home, '.claude', 'CLAUDE.md')];
103
+
104
+ const startMarker = `<!-- agent:${agentId}:mode -->`;
105
+ const endMarker = `<!-- /agent:${agentId}:mode -->`;
106
+ const blocks: ClaudeMdBlock[] = [];
107
+
108
+ for (const filePath of paths) {
109
+ const content = safeRead(filePath);
110
+ if (content === null) continue;
111
+
112
+ const lines = content.split('\n');
113
+ let startLine: number | null = null;
114
+
115
+ for (let i = 0; i < lines.length; i++) {
116
+ const trimmed = lines[i].trim();
117
+ if (trimmed === startMarker) {
118
+ startLine = i + 1; // 1-based
119
+ } else if (trimmed === endMarker && startLine !== null) {
120
+ blocks.push({
121
+ path: filePath,
122
+ startLine,
123
+ endLine: i + 1, // 1-based
124
+ startMarker,
125
+ endMarker,
126
+ });
127
+ startLine = null;
128
+ }
129
+ }
130
+ }
131
+
132
+ return blocks;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Detection: MCP server entries
137
+ // ---------------------------------------------------------------------------
138
+
139
+ function detectMcpServerEntries(agentId: string): McpServerEntry[] {
140
+ const home = homedir();
141
+ const entries: McpServerEntry[] = [];
142
+ const escapedId = escapeRegExp(agentId);
143
+
144
+ // Claude: ~/.claude.json → mcpServers.<key>
145
+ const claudeConfigPath = join(home, '.claude.json');
146
+ const claudeConfig = safeParseJson(claudeConfigPath);
147
+ if (claudeConfig) {
148
+ const servers = claudeConfig.mcpServers as Record<string, unknown> | undefined;
149
+ if (servers && typeof servers === 'object') {
150
+ for (const key of Object.keys(servers)) {
151
+ if (key.includes(agentId)) {
152
+ entries.push({ file: claudeConfigPath, key, target: 'claude' });
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ // Codex: ~/.codex/config.toml → [mcp_servers.<agentId>]
159
+ const codexConfigPath = join(home, '.codex', 'config.toml');
160
+ const codexContent = safeRead(codexConfigPath);
161
+ if (codexContent !== null) {
162
+ const sectionRegex = new RegExp(`\\[mcp_servers\\.${escapedId}\\]`);
163
+ if (sectionRegex.test(codexContent)) {
164
+ entries.push({ file: codexConfigPath, key: agentId, target: 'codex' });
165
+ }
166
+ }
167
+
168
+ // OpenCode: ~/.config/opencode/opencode.json → mcp.<key>
169
+ const opencodeConfigPath = join(home, '.config', 'opencode', 'opencode.json');
170
+ const opencodeConfig = safeParseJson(opencodeConfigPath, true);
171
+ if (opencodeConfig) {
172
+ const servers = opencodeConfig.mcp as Record<string, unknown> | undefined;
173
+ if (servers && typeof servers === 'object') {
174
+ for (const key of Object.keys(servers)) {
175
+ if (key.includes(agentId)) {
176
+ entries.push({ file: opencodeConfigPath, key, target: 'opencode' });
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ return entries;
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Detection: Permission entries
187
+ // ---------------------------------------------------------------------------
188
+
189
+ function detectPermissionEntries(agentId: string): PermissionEntry[] {
190
+ const home = homedir();
191
+ const settingsPath = join(home, '.claude', 'settings.local.json');
192
+ const config = safeParseJson(settingsPath);
193
+ if (!config) return [];
194
+
195
+ const permissions = config.permissions as Record<string, unknown> | undefined;
196
+ if (!permissions || typeof permissions !== 'object') return [];
197
+
198
+ const allowList = permissions.allow;
199
+ if (!Array.isArray(allowList)) return [];
200
+
201
+ const prefix = `mcp__${agentId}__`;
202
+ const matches = allowList.filter(
203
+ (entry: unknown) => typeof entry === 'string' && entry.startsWith(prefix),
204
+ ) as string[];
205
+
206
+ if (matches.length === 0) return [];
207
+
208
+ return [{ file: settingsPath, pattern: prefix, matches }];
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Main entry point
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /**
216
+ * Detect all artifacts installed by a Soleri agent.
217
+ * Non-destructive — reads the filesystem but never modifies it.
218
+ */
219
+ export function detectArtifacts(agentId: string, agentDir?: string): ArtifactManifest {
220
+ const home = homedir();
221
+
222
+ // Project directory
223
+ const projectPath = agentDir ?? join(home, 'projects', agentId);
224
+ const projectDir = location(projectPath);
225
+
226
+ // Data directory (current): ~/.soleri/<agentId>/
227
+ const dataDir = location(join(SOLERI_HOME, agentId));
228
+
229
+ // Data directory (legacy): ~/.<agentId>/
230
+ const dataDirLegacy = location(join(home, `.${agentId}`));
231
+
232
+ // Launcher script: /usr/local/bin/<agentId>
233
+ const launcherScript = location(join('/usr/local/bin', agentId));
234
+
235
+ return {
236
+ agentId,
237
+ projectDir,
238
+ dataDir,
239
+ dataDirLegacy,
240
+ claudeMdBlocks: detectClaudeMdBlocks(agentId),
241
+ mcpServerEntries: detectMcpServerEntries(agentId),
242
+ permissionEntries: detectPermissionEntries(agentId),
243
+ launcherScript,
244
+ };
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Removal handlers
249
+ // ---------------------------------------------------------------------------
250
+
251
+ /**
252
+ * Remove a directory recursively.
253
+ * Idempotent — returns { removed: false } if the directory doesn't exist.
254
+ */
255
+ export async function removeDirectory(dirPath: string): Promise<RemovalResult> {
256
+ try {
257
+ if (!existsSync(dirPath)) {
258
+ return { removed: false, path: dirPath };
259
+ }
260
+ await rm(dirPath, { recursive: true, force: true });
261
+ return { removed: true, path: dirPath };
262
+ } catch (err) {
263
+ return { removed: false, path: dirPath, error: (err as Error).message };
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Remove a CLAUDE.md block by line range (1-based, inclusive).
269
+ * Collapses triple+ blank lines down to at most two consecutive newlines.
270
+ * Idempotent — returns { removed: false } if the file doesn't exist.
271
+ */
272
+ export async function removeClaudeMdBlock(
273
+ filePath: string,
274
+ startLine: number,
275
+ endLine: number,
276
+ ): Promise<RemovalResult> {
277
+ try {
278
+ if (!existsSync(filePath)) {
279
+ return { removed: false, path: filePath };
280
+ }
281
+
282
+ const content = await readFile(filePath, 'utf-8');
283
+ const lines = content.split('\n');
284
+
285
+ // Remove lines from startLine to endLine (1-based, inclusive)
286
+ lines.splice(startLine - 1, endLine - startLine + 1);
287
+
288
+ // Collapse triple+ blank lines to max 2 consecutive newlines
289
+ const result = lines.join('\n').replace(/\n{3,}/g, '\n\n');
290
+
291
+ await writeFile(filePath, result, 'utf-8');
292
+ return { removed: true, path: filePath };
293
+ } catch (err) {
294
+ return { removed: false, path: filePath, error: (err as Error).message };
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Remove permission entries for an agent from settings.local.json.
300
+ * Filters out entries from permissions.allow that start with `mcp__<agentId>__`.
301
+ * Idempotent — returns { removed: false } if the file doesn't exist or has no matches.
302
+ */
303
+ export async function removePermissionEntries(
304
+ filePath: string,
305
+ agentId: string,
306
+ ): Promise<RemovalResult> {
307
+ try {
308
+ if (!existsSync(filePath)) {
309
+ return { removed: false, path: filePath };
310
+ }
311
+
312
+ const raw = await readFile(filePath, 'utf-8');
313
+ let config: Record<string, unknown>;
314
+ try {
315
+ config = JSON.parse(raw) as Record<string, unknown>;
316
+ } catch {
317
+ return { removed: false, path: filePath, error: 'Failed to parse JSON' };
318
+ }
319
+
320
+ const permissions = config.permissions as Record<string, unknown> | undefined;
321
+ if (!permissions || typeof permissions !== 'object') {
322
+ return { removed: false, path: filePath };
323
+ }
324
+
325
+ const allowList = permissions.allow;
326
+ if (!Array.isArray(allowList)) {
327
+ return { removed: false, path: filePath };
328
+ }
329
+
330
+ const prefix = `mcp__${agentId}__`;
331
+ const filtered = allowList.filter(
332
+ (entry: unknown) => !(typeof entry === 'string' && entry.startsWith(prefix)),
333
+ );
334
+
335
+ if (filtered.length === allowList.length) {
336
+ return { removed: false, path: filePath };
337
+ }
338
+
339
+ permissions.allow = filtered;
340
+ await writeFile(filePath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
341
+ return { removed: true, path: filePath };
342
+ } catch (err) {
343
+ return { removed: false, path: filePath, error: (err as Error).message };
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Remove a launcher script (e.g. /usr/local/bin/<agentId>).
349
+ * Idempotent — returns { removed: false } if the file doesn't exist.
350
+ */
351
+ export async function removeLauncherScript(scriptPath: string): Promise<RemovalResult> {
352
+ try {
353
+ if (!existsSync(scriptPath)) {
354
+ return { removed: false, path: scriptPath };
355
+ }
356
+ await unlink(scriptPath);
357
+ return { removed: true, path: scriptPath };
358
+ } catch (err) {
359
+ const code = (err as NodeJS.ErrnoException).code;
360
+ const message =
361
+ code === 'EACCES'
362
+ ? `Permission denied — you may need sudo to remove ${scriptPath}`
363
+ : (err as Error).message;
364
+ return { removed: false, path: scriptPath, error: message };
365
+ }
366
+ }