@phren/cli 0.0.36 → 0.0.38

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,493 @@
1
+ /**
2
+ * Uninstall logic for phren: removes MCP configs, hooks, symlinks, and data.
3
+ */
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import { execFileSync, spawnSync } from "child_process";
7
+ import { atomicWriteText, debugLog, isRecord, hookConfigPath, homeDir, homePath, findPhrenPath, getProjectDirs, readRootManifest, } from "../shared.js";
8
+ import { errorMessage } from "../utils.js";
9
+ import { codexJsonCandidates, copilotMcpCandidates, cursorMcpCandidates, vscodeMcpCandidates, } from "../provider-adapters.js";
10
+ import { removeMcpServerAtPath, removeTomlMcpServer, isPhrenCommand, patchJsonFile, } from "./config.js";
11
+ import { DEFAULT_PHREN_PATH, log } from "./shared.js";
12
+ const PHREN_NPM_PACKAGE_NAME = "@phren/cli";
13
+ function getNpmCommand() {
14
+ return process.platform === "win32" ? "npm.cmd" : "npm";
15
+ }
16
+ function runSyncCommand(command, args) {
17
+ try {
18
+ const result = spawnSync(command, args, {
19
+ encoding: "utf8",
20
+ stdio: ["ignore", "pipe", "pipe"],
21
+ });
22
+ return {
23
+ ok: result.status === 0,
24
+ status: result.status,
25
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
26
+ stderr: typeof result.stderr === "string" ? result.stderr : "",
27
+ };
28
+ }
29
+ catch (err) {
30
+ return {
31
+ ok: false,
32
+ status: null,
33
+ stdout: "",
34
+ stderr: errorMessage(err),
35
+ };
36
+ }
37
+ }
38
+ function shouldUninstallCurrentGlobalPackage() {
39
+ // Always attempt to remove the global package if it exists, regardless of
40
+ // whether the uninstaller was invoked from the global install or a local repo.
41
+ const npmRootResult = runSyncCommand(getNpmCommand(), ["root", "-g"]);
42
+ if (!npmRootResult.ok)
43
+ return false;
44
+ const npmRoot = npmRootResult.stdout.trim();
45
+ if (!npmRoot)
46
+ return false;
47
+ const globalPkgPath = path.join(npmRoot, PHREN_NPM_PACKAGE_NAME);
48
+ return fs.existsSync(globalPkgPath);
49
+ }
50
+ function uninstallCurrentGlobalPackage() {
51
+ const result = runSyncCommand(getNpmCommand(), ["uninstall", "-g", PHREN_NPM_PACKAGE_NAME]);
52
+ if (result.ok) {
53
+ log(` Removed global npm package (${PHREN_NPM_PACKAGE_NAME})`);
54
+ return;
55
+ }
56
+ const detail = result.stderr.trim() || result.stdout.trim() || (result.status === null ? "failed to start command" : `exit code ${result.status}`);
57
+ log(` Warning: could not remove global npm package (${PHREN_NPM_PACKAGE_NAME})`);
58
+ debugLog(`uninstall: global npm cleanup failed: ${detail}`);
59
+ }
60
+ // Agent skill directories to sweep for symlinks during uninstall
61
+ function agentSkillDirs() {
62
+ const home = homeDir();
63
+ return [
64
+ homePath(".claude", "skills"),
65
+ path.join(home, ".cursor", "skills"),
66
+ path.join(home, ".copilot", "skills"),
67
+ path.join(home, ".codex", "skills"),
68
+ ];
69
+ }
70
+ // Remove skill symlinks that resolve inside phrenPath. Only touches symlinks, never regular files.
71
+ function sweepSkillSymlinks(phrenPath) {
72
+ const resolvedPhren = path.resolve(phrenPath);
73
+ for (const dir of agentSkillDirs()) {
74
+ if (!fs.existsSync(dir))
75
+ continue;
76
+ let entries;
77
+ try {
78
+ entries = fs.readdirSync(dir, { withFileTypes: true });
79
+ }
80
+ catch (err) {
81
+ debugLog(`sweepSkillSymlinks: readdirSync failed for ${dir}: ${errorMessage(err)}`);
82
+ continue;
83
+ }
84
+ for (const entry of entries) {
85
+ if (!entry.isSymbolicLink())
86
+ continue;
87
+ const fullPath = path.join(dir, entry.name);
88
+ try {
89
+ const target = fs.realpathSync(fullPath);
90
+ if (target.startsWith(resolvedPhren + path.sep) || target === resolvedPhren) {
91
+ fs.unlinkSync(fullPath);
92
+ log(` Removed skill symlink: ${fullPath}`);
93
+ }
94
+ }
95
+ catch {
96
+ // Broken symlink (target no longer exists) — clean it up
97
+ try {
98
+ fs.unlinkSync(fullPath);
99
+ log(` Removed broken skill symlink: ${fullPath}`);
100
+ }
101
+ catch (err2) {
102
+ debugLog(`sweepSkillSymlinks: could not remove broken symlink ${fullPath}: ${errorMessage(err2)}`);
103
+ }
104
+ }
105
+ }
106
+ // Remove phren-generated manifest files from the skills parent directory
107
+ const parentDir = path.dirname(dir);
108
+ for (const manifestFile of ["skill-manifest.json", "skill-commands.json"]) {
109
+ const manifestPath = path.join(parentDir, manifestFile);
110
+ try {
111
+ if (fs.existsSync(manifestPath)) {
112
+ fs.unlinkSync(manifestPath);
113
+ log(` Removed ${manifestFile} (${manifestPath})`);
114
+ }
115
+ }
116
+ catch (err) {
117
+ debugLog(`sweepSkillSymlinks: could not remove ${manifestPath}: ${errorMessage(err)}`);
118
+ }
119
+ }
120
+ }
121
+ }
122
+ // Filter phren hook entries from an agent hooks file. Returns true if the file was changed.
123
+ // Deletes the file if no hooks remain. `commandField` is the JSON key holding the command
124
+ // string in each hook entry (e.g. "bash" for Copilot, "command" for Codex).
125
+ function filterAgentHooks(filePath, commandField) {
126
+ if (!fs.existsSync(filePath))
127
+ return false;
128
+ try {
129
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
130
+ if (!isRecord(raw) || !isRecord(raw.hooks))
131
+ return false;
132
+ const hooks = raw.hooks;
133
+ let changed = false;
134
+ for (const event of Object.keys(hooks)) {
135
+ const entries = hooks[event];
136
+ if (!Array.isArray(entries))
137
+ continue;
138
+ const filtered = entries.filter((e) => !(isRecord(e) && typeof e[commandField] === "string" && isPhrenCommand(e[commandField])));
139
+ if (filtered.length !== entries.length) {
140
+ hooks[event] = filtered;
141
+ changed = true;
142
+ }
143
+ }
144
+ if (!changed)
145
+ return false;
146
+ // Remove empty hook event keys
147
+ for (const event of Object.keys(hooks)) {
148
+ if (Array.isArray(hooks[event]) && hooks[event].length === 0) {
149
+ delete hooks[event];
150
+ }
151
+ }
152
+ if (Object.keys(hooks).length === 0) {
153
+ fs.unlinkSync(filePath);
154
+ }
155
+ else {
156
+ atomicWriteText(filePath, JSON.stringify(raw, null, 2));
157
+ }
158
+ return true;
159
+ }
160
+ catch (err) {
161
+ debugLog(`filterAgentHooks: failed for ${filePath}: ${errorMessage(err)}`);
162
+ return false;
163
+ }
164
+ }
165
+ async function promptUninstallConfirm(phrenPath) {
166
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
167
+ return true;
168
+ // Show summary of what will be deleted
169
+ try {
170
+ const projectDirs = getProjectDirs(phrenPath);
171
+ const projectCount = projectDirs.length;
172
+ let findingCount = 0;
173
+ for (const dir of projectDirs) {
174
+ const findingsFile = path.join(dir, "FINDINGS.md");
175
+ if (fs.existsSync(findingsFile)) {
176
+ const content = fs.readFileSync(findingsFile, "utf8");
177
+ findingCount += content.split("\n").filter((l) => l.startsWith("- ")).length;
178
+ }
179
+ }
180
+ log(`\n Will delete: ${phrenPath}`);
181
+ log(` Contains: ${projectCount} project(s), ~${findingCount} finding(s)`);
182
+ }
183
+ catch (err) {
184
+ debugLog(`promptUninstallConfirm: summary failed: ${errorMessage(err)}`);
185
+ log(`\n Will delete: ${phrenPath}`);
186
+ }
187
+ const readline = await import("readline");
188
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
189
+ return new Promise((resolve) => {
190
+ rl.question(`\nThis will permanently delete ${phrenPath} and all phren data. Type 'yes' to confirm: `, (answer) => {
191
+ rl.close();
192
+ resolve(answer.trim().toLowerCase() === "yes");
193
+ });
194
+ });
195
+ }
196
+ export async function runUninstall(opts = {}) {
197
+ const phrenPath = findPhrenPath();
198
+ const manifest = phrenPath ? readRootManifest(phrenPath) : null;
199
+ if (manifest?.installMode === "project-local" && phrenPath) {
200
+ log("\nUninstalling project-local phren...\n");
201
+ const workspaceRoot = manifest.workspaceRoot || path.dirname(phrenPath);
202
+ const workspaceMcp = path.join(workspaceRoot, ".vscode", "mcp.json");
203
+ try {
204
+ if (removeMcpServerAtPath(workspaceMcp)) {
205
+ log(` Removed phren from VS Code workspace MCP config (${workspaceMcp})`);
206
+ }
207
+ }
208
+ catch (err) {
209
+ debugLog(`uninstall local vscode cleanup failed: ${errorMessage(err)}`);
210
+ }
211
+ fs.rmSync(phrenPath, { recursive: true, force: true });
212
+ log(` Removed ${phrenPath}`);
213
+ log("\nProject-local phren uninstalled.");
214
+ return;
215
+ }
216
+ log("\nUninstalling phren...\n");
217
+ const shouldRemoveGlobalPackage = shouldUninstallCurrentGlobalPackage();
218
+ // Confirmation prompt (shared-mode only — project-local is low-stakes)
219
+ if (!opts.yes) {
220
+ const confirmed = phrenPath
221
+ ? await promptUninstallConfirm(phrenPath)
222
+ : (process.stdin.isTTY && process.stdout.isTTY
223
+ ? await (async () => {
224
+ const readline = await import("readline");
225
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
226
+ return new Promise((resolve) => {
227
+ rl.question("This will remove all phren config and hooks. Type 'yes' to confirm: ", (answer) => {
228
+ rl.close();
229
+ resolve(answer.trim().toLowerCase() === "yes");
230
+ });
231
+ });
232
+ })()
233
+ : true);
234
+ if (!confirmed) {
235
+ log("Uninstall cancelled.");
236
+ return;
237
+ }
238
+ }
239
+ const home = homeDir();
240
+ const machineFile = (await import("../machine-identity.js")).machineFilePath();
241
+ const settingsPath = hookConfigPath("claude");
242
+ // Remove from Claude Code ~/.claude.json (where MCP servers are actually read)
243
+ const claudeJsonPath = homePath(".claude.json");
244
+ if (fs.existsSync(claudeJsonPath)) {
245
+ try {
246
+ if (removeMcpServerAtPath(claudeJsonPath)) {
247
+ log(` Removed phren MCP server from ~/.claude.json`);
248
+ }
249
+ }
250
+ catch (e) {
251
+ log(` Warning: could not update ~/.claude.json (${e})`);
252
+ }
253
+ }
254
+ // Remove from Claude Code settings.json
255
+ if (fs.existsSync(settingsPath)) {
256
+ try {
257
+ patchJsonFile(settingsPath, (data) => {
258
+ const hooksMap = isRecord(data.hooks) ? data.hooks : (data.hooks = {});
259
+ // Remove MCP server
260
+ if (data.mcpServers?.phren) {
261
+ delete data.mcpServers.phren;
262
+ log(` Removed phren MCP server from Claude Code settings`);
263
+ }
264
+ // Remove hooks containing phren references
265
+ for (const hookEvent of ["UserPromptSubmit", "Stop", "SessionStart", "PostToolUse"]) {
266
+ const hooks = hooksMap[hookEvent];
267
+ if (!Array.isArray(hooks))
268
+ continue;
269
+ const before = hooks.length;
270
+ hooksMap[hookEvent] = hooks.filter((h) => !h.hooks?.some((hook) => typeof hook.command === "string" && isPhrenCommand(hook.command)));
271
+ const removed = before - hooksMap[hookEvent].length;
272
+ if (removed > 0)
273
+ log(` Removed ${removed} phren hook(s) from ${hookEvent}`);
274
+ }
275
+ });
276
+ }
277
+ catch (e) {
278
+ log(` Warning: could not update Claude Code settings (${e})`);
279
+ }
280
+ }
281
+ else {
282
+ log(` Claude Code settings not found at ${settingsPath} — skipping`);
283
+ }
284
+ // Remove from VS Code mcp.json
285
+ const vsCandidates = vscodeMcpCandidates().map((dir) => path.join(dir, "mcp.json"));
286
+ for (const mcpFile of vsCandidates) {
287
+ try {
288
+ if (removeMcpServerAtPath(mcpFile)) {
289
+ log(` Removed phren from VS Code MCP config (${mcpFile})`);
290
+ }
291
+ }
292
+ catch (err) {
293
+ debugLog(`uninstall: cleanup failed for ${mcpFile}: ${errorMessage(err)}`);
294
+ }
295
+ }
296
+ // Remove from Cursor MCP config
297
+ const cursorCandidates = cursorMcpCandidates();
298
+ for (const mcpFile of cursorCandidates) {
299
+ try {
300
+ if (removeMcpServerAtPath(mcpFile)) {
301
+ log(` Removed phren from Cursor MCP config (${mcpFile})`);
302
+ }
303
+ }
304
+ catch (err) {
305
+ debugLog(`uninstall: cleanup failed for ${mcpFile}: ${errorMessage(err)}`);
306
+ }
307
+ }
308
+ // Remove from Copilot CLI MCP config
309
+ const copilotCandidates = copilotMcpCandidates();
310
+ for (const mcpFile of copilotCandidates) {
311
+ try {
312
+ if (removeMcpServerAtPath(mcpFile)) {
313
+ log(` Removed phren from Copilot CLI MCP config (${mcpFile})`);
314
+ }
315
+ }
316
+ catch (err) {
317
+ debugLog(`uninstall: cleanup failed for ${mcpFile}: ${errorMessage(err)}`);
318
+ }
319
+ }
320
+ // Remove from Codex MCP config (TOML + JSON)
321
+ const codexToml = path.join(home, ".codex", "config.toml");
322
+ try {
323
+ if (removeTomlMcpServer(codexToml)) {
324
+ log(` Removed phren from Codex MCP config (${codexToml})`);
325
+ }
326
+ }
327
+ catch (err) {
328
+ debugLog(`uninstall: cleanup failed for ${codexToml}: ${errorMessage(err)}`);
329
+ }
330
+ const codexCandidates = codexJsonCandidates((process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
331
+ for (const mcpFile of codexCandidates) {
332
+ try {
333
+ if (removeMcpServerAtPath(mcpFile)) {
334
+ log(` Removed phren from Codex MCP config (${mcpFile})`);
335
+ }
336
+ }
337
+ catch (err) {
338
+ debugLog(`uninstall: cleanup failed for ${mcpFile}: ${errorMessage(err)}`);
339
+ }
340
+ }
341
+ // Remove phren entries from Copilot hooks file (filter, don't bulk-delete)
342
+ const copilotHooksFile = hookConfigPath("copilot", (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
343
+ try {
344
+ if (filterAgentHooks(copilotHooksFile, "bash")) {
345
+ log(` Removed phren entries from Copilot hooks (${copilotHooksFile})`);
346
+ }
347
+ }
348
+ catch (err) {
349
+ debugLog(`uninstall: cleanup failed for ${copilotHooksFile}: ${errorMessage(err)}`);
350
+ }
351
+ // Remove phren entries from Cursor hooks file (may contain non-phren entries)
352
+ const cursorHooksFile = hookConfigPath("cursor", (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
353
+ try {
354
+ if (fs.existsSync(cursorHooksFile)) {
355
+ const raw = JSON.parse(fs.readFileSync(cursorHooksFile, "utf8"));
356
+ let changed = false;
357
+ for (const key of ["sessionStart", "beforeSubmitPrompt", "stop"]) {
358
+ if (raw[key]?.command && typeof raw[key].command === "string" && isPhrenCommand(raw[key].command)) {
359
+ delete raw[key];
360
+ changed = true;
361
+ }
362
+ }
363
+ if (changed) {
364
+ atomicWriteText(cursorHooksFile, JSON.stringify(raw, null, 2));
365
+ log(` Removed phren entries from Cursor hooks (${cursorHooksFile})`);
366
+ }
367
+ }
368
+ }
369
+ catch (err) {
370
+ debugLog(`uninstall: cleanup failed for ${cursorHooksFile}: ${errorMessage(err)}`);
371
+ }
372
+ // Remove phren entries from Codex hooks file (filter, don't bulk-delete)
373
+ const uninstallPhrenPath = (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH;
374
+ const codexHooksFile = hookConfigPath("codex", uninstallPhrenPath);
375
+ try {
376
+ if (filterAgentHooks(codexHooksFile, "command")) {
377
+ log(` Removed phren entries from Codex hooks (${codexHooksFile})`);
378
+ }
379
+ }
380
+ catch (err) {
381
+ debugLog(`uninstall: cleanup failed for ${codexHooksFile}: ${errorMessage(err)}`);
382
+ }
383
+ // Remove session wrapper scripts (written by installSessionWrapper) and CLI wrapper
384
+ const localBinDir = path.join(home, ".local", "bin");
385
+ for (const tool of ["copilot", "cursor", "codex", "phren"]) {
386
+ const wrapperPath = path.join(localBinDir, tool);
387
+ try {
388
+ if (fs.existsSync(wrapperPath)) {
389
+ // Only remove if it's a phren wrapper (check for PHREN_PATH marker)
390
+ const content = fs.readFileSync(wrapperPath, "utf8");
391
+ if (content.includes("PHREN_PATH") && content.includes("phren")) {
392
+ fs.unlinkSync(wrapperPath);
393
+ log(` Removed ${tool} session wrapper (${wrapperPath})`);
394
+ }
395
+ }
396
+ }
397
+ catch (err) {
398
+ debugLog(`uninstall: cleanup failed for ${wrapperPath}: ${errorMessage(err)}`);
399
+ }
400
+ }
401
+ try {
402
+ if (fs.existsSync(machineFile)) {
403
+ fs.unlinkSync(machineFile);
404
+ log(` Removed machine alias (${machineFile})`);
405
+ }
406
+ }
407
+ catch (err) {
408
+ debugLog(`uninstall: cleanup failed for ${machineFile}: ${errorMessage(err)}`);
409
+ }
410
+ const contextFile = homePath(".phren-context.md");
411
+ try {
412
+ if (fs.existsSync(contextFile)) {
413
+ fs.unlinkSync(contextFile);
414
+ log(` Removed machine context file (${contextFile})`);
415
+ }
416
+ }
417
+ catch (err) {
418
+ debugLog(`uninstall: cleanup failed for ${contextFile}: ${errorMessage(err)}`);
419
+ }
420
+ // Remove global CLAUDE.md symlink (created by linkGlobal -> ~/.claude/CLAUDE.md)
421
+ const globalClaudeLink = homePath(".claude", "CLAUDE.md");
422
+ try {
423
+ if (fs.lstatSync(globalClaudeLink).isSymbolicLink()) {
424
+ fs.unlinkSync(globalClaudeLink);
425
+ log(` Removed global CLAUDE.md symlink (${globalClaudeLink})`);
426
+ }
427
+ }
428
+ catch {
429
+ // Does not exist or not a symlink — nothing to do
430
+ }
431
+ // Remove copilot-instructions.md symlink (created by linkGlobal -> ~/.github/copilot-instructions.md)
432
+ const copilotInstrLink = homePath(".github", "copilot-instructions.md");
433
+ try {
434
+ if (fs.lstatSync(copilotInstrLink).isSymbolicLink()) {
435
+ fs.unlinkSync(copilotInstrLink);
436
+ log(` Removed copilot-instructions.md symlink (${copilotInstrLink})`);
437
+ }
438
+ }
439
+ catch {
440
+ // Does not exist or not a symlink — nothing to do
441
+ }
442
+ // Sweep agent skill directories for symlinks pointing into the phren store
443
+ if (phrenPath) {
444
+ try {
445
+ sweepSkillSymlinks(phrenPath);
446
+ }
447
+ catch (err) {
448
+ debugLog(`uninstall: skill symlink sweep failed: ${errorMessage(err)}`);
449
+ }
450
+ }
451
+ if (phrenPath && fs.existsSync(phrenPath)) {
452
+ try {
453
+ fs.rmSync(phrenPath, { recursive: true, force: true });
454
+ log(` Removed phren root (${phrenPath})`);
455
+ }
456
+ catch (err) {
457
+ debugLog(`uninstall: cleanup failed for ${phrenPath}: ${errorMessage(err)}`);
458
+ log(` Warning: could not remove phren root (${phrenPath})`);
459
+ }
460
+ }
461
+ if (shouldRemoveGlobalPackage) {
462
+ uninstallCurrentGlobalPackage();
463
+ }
464
+ // Remove VS Code extension if installed
465
+ try {
466
+ const codeResult = execFileSync("code", ["--list-extensions"], {
467
+ encoding: "utf8",
468
+ stdio: ["ignore", "pipe", "ignore"],
469
+ timeout: 10_000,
470
+ });
471
+ const phrenExts = codeResult.split("\n").filter((ext) => ext.toLowerCase().includes("phren"));
472
+ for (const ext of phrenExts) {
473
+ const trimmed = ext.trim();
474
+ if (!trimmed)
475
+ continue;
476
+ try {
477
+ execFileSync("code", ["--uninstall-extension", trimmed], {
478
+ stdio: ["ignore", "pipe", "ignore"],
479
+ timeout: 15_000,
480
+ });
481
+ log(` Removed VS Code extension (${trimmed})`);
482
+ }
483
+ catch (err) {
484
+ debugLog(`uninstall: VS Code extension removal failed for ${trimmed}: ${errorMessage(err)}`);
485
+ }
486
+ }
487
+ }
488
+ catch {
489
+ // code CLI not available — skip
490
+ }
491
+ log(`\nPhren config, hooks, and installed data removed.`);
492
+ log(`Restart your agent(s) to apply changes.\n`);
493
+ }