@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
516 lines (515 loc) • 21.8 kB
JavaScript
/**
* Uninstall logic for phren: removes MCP configs, hooks, symlinks, and data.
*/
import * as fs from "fs";
import * as path from "path";
import { execFileSync, spawnSync } from "child_process";
import { atomicWriteText, debugLog, isRecord, hookConfigPath, homeDir, homePath, findPhrenPath, getProjectDirs, readRootManifest, } from "../shared.js";
import { errorMessage } from "../utils.js";
import { FINDINGS_FILENAME } from "../data/access.js";
import { codexJsonCandidates, copilotMcpCandidates, cursorMcpCandidates, vscodeMcpCandidates, } from "../provider-adapters.js";
import { removeMcpServerAtPath, removeTomlMcpServer, isPhrenCommand, patchJsonFile, } from "./config.js";
import { DEFAULT_PHREN_PATH, log } from "./shared.js";
import { removeGeneratedHomeFiles, sweepProjectMirrors } from "./teardown.js";
const PHREN_NPM_PACKAGE_NAME = "@phren/cli";
function getNpmCommand() {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
function runSyncCommand(command, args) {
try {
const result = spawnSync(command, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
return {
ok: result.status === 0,
status: result.status,
stdout: typeof result.stdout === "string" ? result.stdout : "",
stderr: typeof result.stderr === "string" ? result.stderr : "",
};
}
catch (err) {
return {
ok: false,
status: null,
stdout: "",
stderr: errorMessage(err),
};
}
}
export function shouldUninstallCurrentGlobalPackage() {
// `npm uninstall -g` resolves against the machine's real npm prefix, which
// no amount of PHREN_PATH/HOME redirection sandboxes. A test that spawns
// `phren uninstall` against temp directories would therefore delete the
// developer's actual global install — it did, repeatedly, before this
// guard. The test helpers set this for every spawned CLI.
if (process.env.PHREN_SKIP_GLOBAL_NPM_UNINSTALL === "1") {
log(` Skipped global npm package removal (PHREN_SKIP_GLOBAL_NPM_UNINSTALL=1)`);
return false;
}
// Otherwise always attempt to remove the global package if it exists,
// regardless of whether the uninstaller was invoked from the global install
// or a local repo.
const npmRootResult = runSyncCommand(getNpmCommand(), ["root", "-g"]);
if (!npmRootResult.ok)
return false;
const npmRoot = npmRootResult.stdout.trim();
if (!npmRoot)
return false;
const globalPkgPath = path.join(npmRoot, PHREN_NPM_PACKAGE_NAME);
return fs.existsSync(globalPkgPath);
}
function uninstallCurrentGlobalPackage() {
const result = runSyncCommand(getNpmCommand(), ["uninstall", "-g", PHREN_NPM_PACKAGE_NAME]);
if (result.ok) {
log(` Removed global npm package (${PHREN_NPM_PACKAGE_NAME})`);
return;
}
const detail = result.stderr.trim() || result.stdout.trim() || (result.status === null ? "failed to start command" : `exit code ${result.status}`);
log(` Warning: could not remove global npm package (${PHREN_NPM_PACKAGE_NAME})`);
debugLog(`uninstall: global npm cleanup failed: ${detail}`);
}
// Agent skill directories to sweep for symlinks during uninstall
function agentSkillDirs() {
const home = homeDir();
return [
homePath(".claude", "skills"),
path.join(home, ".cursor", "skills"),
path.join(home, ".copilot", "skills"),
path.join(home, ".codex", "skills"),
path.join(home, ".agents", "skills"),
];
}
// Remove skill symlinks that resolve inside phrenPath. Only touches symlinks, never regular files.
function sweepSkillSymlinks(phrenPath) {
const resolvedPhren = path.resolve(phrenPath);
for (const dir of agentSkillDirs()) {
if (!fs.existsSync(dir))
continue;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
}
catch (err) {
debugLog(`sweepSkillSymlinks: readdirSync failed for ${dir}: ${errorMessage(err)}`);
continue;
}
for (const entry of entries) {
if (!entry.isSymbolicLink())
continue;
const fullPath = path.join(dir, entry.name);
try {
const target = fs.realpathSync(fullPath);
if (target.startsWith(resolvedPhren + path.sep) || target === resolvedPhren) {
fs.unlinkSync(fullPath);
log(` Removed skill symlink: ${fullPath}`);
}
}
catch {
// Broken symlink (target no longer exists) — clean it up
try {
fs.unlinkSync(fullPath);
log(` Removed broken skill symlink: ${fullPath}`);
}
catch (err2) {
debugLog(`sweepSkillSymlinks: could not remove broken symlink ${fullPath}: ${errorMessage(err2)}`);
}
}
}
// Remove phren-generated manifest files from the skills parent directory
const parentDir = path.dirname(dir);
for (const manifestFile of ["skill-manifest.json", "skill-commands.json"]) {
const manifestPath = path.join(parentDir, manifestFile);
try {
if (fs.existsSync(manifestPath)) {
fs.unlinkSync(manifestPath);
log(` Removed ${manifestFile} (${manifestPath})`);
}
}
catch (err) {
debugLog(`sweepSkillSymlinks: could not remove ${manifestPath}: ${errorMessage(err)}`);
}
}
}
}
// Filter phren hook entries from an agent hooks file. Returns true if the file was changed.
// Deletes the file if no hooks remain. `commandField` is the JSON key holding the command
// string in each hook entry (e.g. "bash" for Copilot, "command" for Codex).
function filterAgentHooks(filePath, commandField) {
if (!fs.existsSync(filePath))
return false;
try {
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
if (!isRecord(raw) || !isRecord(raw.hooks))
return false;
const hooks = raw.hooks;
let changed = false;
for (const event of Object.keys(hooks)) {
const entries = hooks[event];
if (!Array.isArray(entries))
continue;
const filtered = entries.filter((e) => !(isRecord(e) && typeof e[commandField] === "string" && isPhrenCommand(e[commandField])));
if (filtered.length !== entries.length) {
hooks[event] = filtered;
changed = true;
}
}
if (!changed)
return false;
// Remove empty hook event keys
for (const event of Object.keys(hooks)) {
if (Array.isArray(hooks[event]) && hooks[event].length === 0) {
delete hooks[event];
}
}
if (Object.keys(hooks).length === 0) {
fs.unlinkSync(filePath);
}
else {
atomicWriteText(filePath, JSON.stringify(raw, null, 2));
}
return true;
}
catch (err) {
debugLog(`filterAgentHooks: failed for ${filePath}: ${errorMessage(err)}`);
return false;
}
}
async function promptUninstallConfirm(phrenPath) {
// No TTY is not consent: agent shells, CI steps and pipes all lack one, and
// they are exactly where an accidental `phren uninstall` must not delete the
// store. --yes is the explicit opt-in.
if (!process.stdin.isTTY || !process.stdout.isTTY) {
log(`\n Refusing to delete ${phrenPath} without confirmation.`);
log(" This session is non-interactive; re-run with --yes to confirm.");
return false;
}
// Show summary of what will be deleted
try {
const projectDirs = getProjectDirs(phrenPath);
const projectCount = projectDirs.length;
let findingCount = 0;
for (const dir of projectDirs) {
const findingsFile = path.join(dir, FINDINGS_FILENAME);
if (fs.existsSync(findingsFile)) {
const content = fs.readFileSync(findingsFile, "utf8");
findingCount += content.split("\n").filter((l) => l.startsWith("- ")).length;
}
}
log(`\n Will delete: ${phrenPath}`);
log(` Contains: ${projectCount} project(s), ~${findingCount} finding(s)`);
}
catch (err) {
debugLog(`promptUninstallConfirm: summary failed: ${errorMessage(err)}`);
log(`\n Will delete: ${phrenPath}`);
}
const readline = await import("readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(`\nThis will permanently delete ${phrenPath} and all phren data. Type 'yes' to confirm: `, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === "yes");
});
});
}
export async function runUninstall(opts = {}) {
const phrenPath = findPhrenPath();
const manifest = phrenPath ? readRootManifest(phrenPath) : null;
if (manifest?.installMode === "project-local" && phrenPath) {
log("\nUninstalling project-local phren...\n");
const workspaceRoot = manifest.workspaceRoot || path.dirname(phrenPath);
const workspaceMcp = path.join(workspaceRoot, ".vscode", "mcp.json");
try {
if (removeMcpServerAtPath(workspaceMcp)) {
log(` Removed phren from VS Code workspace MCP config (${workspaceMcp})`);
}
}
catch (err) {
debugLog(`uninstall local vscode cleanup failed: ${errorMessage(err)}`);
}
fs.rmSync(phrenPath, { recursive: true, force: true });
log(` Removed ${phrenPath}`);
log("\nProject-local phren uninstalled.");
return;
}
log("\nUninstalling phren...\n");
const shouldRemoveGlobalPackage = shouldUninstallCurrentGlobalPackage();
// Confirmation prompt (shared-mode only — project-local is low-stakes)
if (!opts.yes) {
const confirmed = phrenPath
? await promptUninstallConfirm(phrenPath)
: (process.stdin.isTTY && process.stdout.isTTY
? await (async () => {
const readline = await import("readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question("This will remove all phren config and hooks. Type 'yes' to confirm: ", (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === "yes");
});
});
})()
: (() => {
log("\n Refusing to remove phren config without confirmation.");
log(" This session is non-interactive; re-run with --yes to confirm.");
return false;
})());
if (!confirmed) {
log("Uninstall cancelled.");
return;
}
}
const home = homeDir();
const machineFile = (await import("../machine-identity.js")).machineFilePath();
const settingsPath = hookConfigPath("claude");
// Remove from Claude Code ~/.claude.json (where MCP servers are actually read)
const claudeJsonPath = homePath(".claude.json");
if (fs.existsSync(claudeJsonPath)) {
try {
if (removeMcpServerAtPath(claudeJsonPath)) {
log(` Removed phren MCP server from ~/.claude.json`);
}
}
catch (e) {
log(` Warning: could not update ~/.claude.json (${e})`);
}
}
// Remove from Claude Code settings.json
if (fs.existsSync(settingsPath)) {
try {
patchJsonFile(settingsPath, (data) => {
const hooksMap = isRecord(data.hooks) ? data.hooks : (data.hooks = {});
// Remove MCP server
if (data.mcpServers?.phren) {
delete data.mcpServers.phren;
log(` Removed phren MCP server from Claude Code settings`);
}
// Remove hooks containing phren references
for (const hookEvent of ["UserPromptSubmit", "Stop", "SessionStart", "PostToolUse"]) {
const hooks = hooksMap[hookEvent];
if (!Array.isArray(hooks))
continue;
const before = hooks.length;
hooksMap[hookEvent] = hooks.filter((h) => !h.hooks?.some((hook) => typeof hook.command === "string" && isPhrenCommand(hook.command)));
const removed = before - hooksMap[hookEvent].length;
if (removed > 0)
log(` Removed ${removed} phren hook(s) from ${hookEvent}`);
}
});
}
catch (e) {
log(` Warning: could not update Claude Code settings (${e})`);
}
}
else {
log(` Claude Code settings not found at ${settingsPath} — skipping`);
}
// Remove from VS Code mcp.json
const vsCandidates = vscodeMcpCandidates().map((dir) => path.join(dir, "mcp.json"));
for (const mcpFile of vsCandidates) {
try {
if (removeMcpServerAtPath(mcpFile)) {
log(` Removed phren from VS Code MCP config (${mcpFile})`);
}
}
catch (err) {
debugLog(`uninstall: cleanup failed for ${mcpFile}: ${errorMessage(err)}`);
}
}
// Remove from Cursor MCP config
const cursorCandidates = cursorMcpCandidates();
for (const mcpFile of cursorCandidates) {
try {
if (removeMcpServerAtPath(mcpFile)) {
log(` Removed phren from Cursor MCP config (${mcpFile})`);
}
}
catch (err) {
debugLog(`uninstall: cleanup failed for ${mcpFile}: ${errorMessage(err)}`);
}
}
// Remove from Copilot CLI MCP config
const copilotCandidates = copilotMcpCandidates();
for (const mcpFile of copilotCandidates) {
try {
if (removeMcpServerAtPath(mcpFile)) {
log(` Removed phren from Copilot CLI MCP config (${mcpFile})`);
}
}
catch (err) {
debugLog(`uninstall: cleanup failed for ${mcpFile}: ${errorMessage(err)}`);
}
}
// Remove from Codex MCP config (TOML + JSON)
const codexToml = path.join(home, ".codex", "config.toml");
try {
if (removeTomlMcpServer(codexToml)) {
log(` Removed phren from Codex MCP config (${codexToml})`);
}
}
catch (err) {
debugLog(`uninstall: cleanup failed for ${codexToml}: ${errorMessage(err)}`);
}
const codexCandidates = codexJsonCandidates((process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
for (const mcpFile of codexCandidates) {
try {
if (removeMcpServerAtPath(mcpFile)) {
log(` Removed phren from Codex MCP config (${mcpFile})`);
}
}
catch (err) {
debugLog(`uninstall: cleanup failed for ${mcpFile}: ${errorMessage(err)}`);
}
}
// Remove phren entries from Copilot hooks file (filter, don't bulk-delete)
const copilotHooksFile = hookConfigPath("copilot", (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
try {
if (filterAgentHooks(copilotHooksFile, "bash")) {
log(` Removed phren entries from Copilot hooks (${copilotHooksFile})`);
}
}
catch (err) {
debugLog(`uninstall: cleanup failed for ${copilotHooksFile}: ${errorMessage(err)}`);
}
// Remove phren entries from Cursor hooks file (may contain non-phren entries)
const cursorHooksFile = hookConfigPath("cursor", (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
try {
if (fs.existsSync(cursorHooksFile)) {
const raw = JSON.parse(fs.readFileSync(cursorHooksFile, "utf8"));
let changed = false;
for (const key of ["sessionStart", "beforeSubmitPrompt", "stop"]) {
if (raw[key]?.command && typeof raw[key].command === "string" && isPhrenCommand(raw[key].command)) {
delete raw[key];
changed = true;
}
}
if (changed) {
atomicWriteText(cursorHooksFile, JSON.stringify(raw, null, 2));
log(` Removed phren entries from Cursor hooks (${cursorHooksFile})`);
}
}
}
catch (err) {
debugLog(`uninstall: cleanup failed for ${cursorHooksFile}: ${errorMessage(err)}`);
}
// Remove phren entries from Codex hooks file (filter, don't bulk-delete)
const uninstallPhrenPath = (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH;
const codexHooksFile = hookConfigPath("codex", uninstallPhrenPath);
try {
if (filterAgentHooks(codexHooksFile, "command")) {
log(` Removed phren entries from Codex hooks (${codexHooksFile})`);
}
}
catch (err) {
debugLog(`uninstall: cleanup failed for ${codexHooksFile}: ${errorMessage(err)}`);
}
// Remove session wrapper scripts (written by installSessionWrapper) and CLI wrapper
const localBinDir = path.join(home, ".local", "bin");
for (const tool of ["copilot", "cursor", "codex", "phren"]) {
const wrapperPath = path.join(localBinDir, tool);
try {
if (fs.existsSync(wrapperPath)) {
// Only remove if it's a phren wrapper (check for PHREN_PATH marker)
const content = fs.readFileSync(wrapperPath, "utf8");
if (content.includes("PHREN_PATH") && content.includes("phren")) {
fs.unlinkSync(wrapperPath);
log(` Removed ${tool} session wrapper (${wrapperPath})`);
}
}
}
catch (err) {
debugLog(`uninstall: cleanup failed for ${wrapperPath}: ${errorMessage(err)}`);
}
}
try {
if (fs.existsSync(machineFile)) {
fs.unlinkSync(machineFile);
log(` Removed machine alias (${machineFile})`);
}
}
catch (err) {
debugLog(`uninstall: cleanup failed for ${machineFile}: ${errorMessage(err)}`);
}
removeGeneratedHomeFiles();
// Remove global AGENTS.md symlink (created by linkGlobal -> ~/.claude/CLAUDE.md)
const globalClaudeLink = homePath(".claude", "CLAUDE.md");
try {
if (fs.lstatSync(globalClaudeLink).isSymbolicLink()) {
fs.unlinkSync(globalClaudeLink);
log(` Removed global AGENTS.md symlink (${globalClaudeLink})`);
}
}
catch {
// Does not exist or not a symlink — nothing to do
}
// Remove copilot-instructions.md symlink (created by linkGlobal -> ~/.github/copilot-instructions.md)
const copilotInstrLink = homePath(".github", "copilot-instructions.md");
try {
if (fs.lstatSync(copilotInstrLink).isSymbolicLink()) {
fs.unlinkSync(copilotInstrLink);
log(` Removed copilot-instructions.md symlink (${copilotInstrLink})`);
}
}
catch {
// Does not exist or not a symlink — nothing to do
}
// Sweep agent skill directories for symlinks pointing into the phren store
if (phrenPath) {
try {
sweepSkillSymlinks(phrenPath);
}
catch (err) {
debugLog(`uninstall: skill symlink sweep failed: ${errorMessage(err)}`);
}
// Remove per-project repo mirror symlinks + phren-managed .git/info/exclude
// lines BEFORE deleting the store (this reads project config from the store).
try {
sweepProjectMirrors(phrenPath);
}
catch (err) {
debugLog(`uninstall: project mirror sweep failed: ${errorMessage(err)}`);
}
}
if (phrenPath && fs.existsSync(phrenPath)) {
try {
fs.rmSync(phrenPath, { recursive: true, force: true });
log(` Removed phren root (${phrenPath})`);
}
catch (err) {
debugLog(`uninstall: cleanup failed for ${phrenPath}: ${errorMessage(err)}`);
log(` Warning: could not remove phren root (${phrenPath})`);
}
}
if (shouldRemoveGlobalPackage) {
uninstallCurrentGlobalPackage();
}
// Remove VS Code extension if installed
try {
const codeResult = execFileSync("code", ["--list-extensions"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 10_000,
});
const phrenExts = codeResult.split("\n").filter((ext) => ext.toLowerCase().includes("phren"));
for (const ext of phrenExts) {
const trimmed = ext.trim();
if (!trimmed)
continue;
try {
execFileSync("code", ["--uninstall-extension", trimmed], {
stdio: ["ignore", "pipe", "ignore"],
timeout: 15_000,
});
log(` Removed VS Code extension (${trimmed})`);
}
catch (err) {
debugLog(`uninstall: VS Code extension removal failed for ${trimmed}: ${errorMessage(err)}`);
}
}
}
catch {
// code CLI not available — skip
}
log(`\nPhren config, hooks, and installed data removed.`);
log(`Restart your agent(s) to apply changes.\n`);
}