Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions bin/moshcode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@ import path from "node:path";
import { runScript } from "../src/runtime.mjs";
import { moshVocabulary } from "../src/commands.mjs";
import {
ENGINES,
agentLaunchArgs,
engineList,
ENGINES,
engineStatus,
resolveEngine,
openSession,
resolveEngine,
resolveExecutable,
runCmd,
} from "../src/engines.mjs";
import { TOOLS, toolList, toolStatus, resolveTool, openTool } from "../src/tools.mjs";
import { runUpgrade } from "../src/upgrade.mjs";
import { describeUninstall, uninstallPlan } from "../src/uninstall.mjs";
import { mcpCommand, skillCommand } from "../src/integrations.mjs";
import { locate, tilde } from "../src/pwd.mjs";
import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs";
Expand Down Expand Up @@ -150,6 +152,7 @@ usage:
moshcode skill install <git-url> install a skill across every engine that
supports it (claude/gemini)
moshcode install <engine|tool> install a coding engine or workflow tool
moshcode uninstall <engine|tool> take one back off this machine
moshcode upgrade [target…] update moshcode + installed engines/tools
(no args = everything; name targets to
narrow, e.g. \`upgrade ugig\`)
Expand Down Expand Up @@ -288,6 +291,59 @@ async function main() {
if (result.code === 0) console.log(`\n✓ ${target} installed. run it with \`${bin}\`. 🤘`);
return backToPit(`install ${target}`, result.code);
}
if (cmd === "uninstall" || cmd === "remove") {
const target = rest.find((a) => !a.startsWith("-"))?.toLowerCase();
const entry = target
&& ((Object.hasOwn(ENGINES, target) && ENGINES[target]) || (Object.hasOwn(TOOLS, target) && TOOLS[target]));
if (!target || !entry) {
console.error(`usage: moshcode uninstall <engine|tool>\nengines:\n${engineList()}\ntools:\n${toolList()}`);
process.exit(target ? 1 : 0);
}

const binPath = resolveExecutable(entry.bin, entry.binDirs);
const plan = uninstallPlan(entry, { binPath });

if (plan.kind === "absent" || plan.kind === "refused") {
for (const w of plan.warnings) console.error(w);
process.exitCode = plan.kind === "refused" ? 1 : 0;
return;
}

console.log(`🎸 uninstalling ${target} — ${entry.desc}`);
console.log(describeUninstall(plan));
if (rest.includes("--dry-run")) return;

// Removing a binary is not something to do because a flag was left off.
// An npm uninstall is reversible with one command and does not ask.
if (plan.kind === "binary" && !rest.includes("--yes") && !rest.includes("-y")) {
console.error(`\nthis deletes ${binPath}. re-run with --yes to do it.`);
process.exitCode = 1;
return;
}

for (const step of plan.steps) {
if (step.kind === "remove") {
try {
fs.rmSync(step.path, { force: true });
console.log(`\n✓ removed ${step.path}`);
} catch (err) {
console.error(`could not remove ${step.path}: ${err.message}`);
process.exitCode = 1;
return;
}
} else {
const result = await runCmd(step.command, step.args);
if (!result.ok || result.code !== 0) {
console.error(`uninstall failed: ${result.error?.message || `exit ${result.code}`}`);
process.exitCode = 1;
return;
}
console.log(`\n✓ ${target} uninstalled. 🤘`);
}
}
return backToPit(`uninstall ${target}`, 0);
}

if (cmd === "upgrade" || cmd === "update") {
console.log("🎸 moshcode upgrade — updating moshcode + installed engines/tools 🤘");
const results = await runUpgrade(rest);
Expand Down
2 changes: 1 addition & 1 deletion src/engines.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ function executableCandidates(bin, extraDirs = []) {
return candidates;
}

function resolveExecutable(bin, extraDirs = []) {
export function resolveExecutable(bin, extraDirs = []) {
for (const candidate of executableCandidates(bin, extraDirs)) {
try {
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
Expand Down
112 changes: 112 additions & 0 deletions src/uninstall.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Taking a tool back off the machine.
//
// `moshcode install` runs whatever each tool ships as its installer, and those
// come in two shapes: `npm install -g <pkg>`, and `curl … | sh`. Only the first
// has an inverse anyone wrote down. The second drops a binary somewhere and
// leaves no record, so removing it means finding that binary and deleting it —
// which is a different kind of operation and is treated like one here.
//
// The rules that follow from that:
//
// - an npm install is undone by npm, which knows what it put where
// - a script install is undone by removing the binary the tool reports, and
// only when it sits somewhere a per-user installer would plausibly have put
// it. `/usr/bin/git` is not something this should ever offer to delete
// - anything else says so rather than guessing
//
// Plans are data so the whole decision is testable without deleting anything,
// and so `--dry-run` can show exactly what would go.

import { dirname } from "node:path";
import { homedir } from "node:os";

/**
* Where a per-user installer legitimately puts a binary.
*
* A deny-list would be the wrong shape here: the question is not "is this
* dangerous" but "did something we ran plausibly create this", and only an
* allow-list answers that. A binary in /usr/bin arrived from the system package
* manager and is not ours to remove.
*/
export function safePrefixes(home = homedir()) {
return [
`${home}/.local/bin`,
`${home}/.bun/bin`,
`${home}/.cargo/bin`,
`${home}/.deno/bin`,
`${home}/bin`,
`${home}/.npm-global/bin`,
`${home}/.volta/bin`,
"/usr/local/bin",
"/opt/homebrew/bin",
];
}

/** The npm package an install spec would install, or null if it is not npm. */
export function npmPackageOf(install) {
if (!install) return null;
const npmish = ["npm", "pnpm", "yarn", "bun"].includes(install.cmd);
if (!npmish) return null;
const args = install.args || [];
if (!args.includes("-g") && !args.includes("--global")) return null;
// The package is the last argument that is not a flag or a subcommand.
const skip = new Set(["install", "add", "i", "-g", "--global"]);
const pkg = [...args].reverse().find((a) => !a.startsWith("-") && !skip.has(a));
return pkg || null;
}

/**
* How to remove `entry`, given where its binary currently is.
*
* `binPath` is what `which <bin>` reported, or null when it is not on PATH.
*/
export function uninstallPlan(entry, { binPath = null, home = homedir() } = {}) {
const pkg = npmPackageOf(entry?.install);
if (pkg) {
return {
kind: "npm",
steps: [{ kind: "run", command: entry.install.cmd, args: ["uninstall", "-g", pkg] }],
warnings: [],
};
}

if (!binPath) {
return {
kind: "absent",
steps: [],
warnings: [`${entry?.bin || "it"} is not on your PATH — nothing to remove`],
};
}

const dir = dirname(binPath);
if (!safePrefixes(home).includes(dir)) {
// Refused rather than confirmed-with-a-scary-prompt: a binary here came
// from somewhere else, and the person who put it there knows how to remove
// it. Deleting it because a prompt was clicked through is worse than not
// offering.
return {
kind: "refused",
steps: [],
warnings: [
`${binPath} is not in a directory moshcode installs into.`,
"It was put there by something else — a system package manager, or by hand — so removing it is that thing's job.",
],
};
}

return {
kind: "binary",
steps: [{ kind: "remove", path: binPath }],
warnings: [
"This removes the binary only. Anything it wrote to your home directory — config, caches, credentials — stays.",
],
};
}

/** The plan as a line someone can read before agreeing to it. */
export function describeUninstall(plan) {
return plan.steps
.map((s) => (s.kind === "run" ? `run ${s.command} ${s.args.join(" ")}` : `remove ${s.path}`))
.concat((plan.warnings || []).map((w) => `note ${w}`))
.join("\n");
}
75 changes: 75 additions & 0 deletions test/uninstall.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Taking a tool back off the machine.
//
// The dangerous branch is the one that deletes a file, so most of this is about
// when it refuses to.
import assert from "node:assert/strict";
import test from "node:test";

import { describeUninstall, npmPackageOf, safePrefixes, uninstallPlan } from "../src/uninstall.mjs";

const HOME = "/home/someone";
const npmEntry = { bin: "claude", desc: "…", install: { cmd: "npm", args: ["install", "-g", "@anthropic-ai/claude-code"] } };
const scriptEntry = { bin: "opencode", desc: "…", install: { cmd: "bash", args: ["-c", "curl -fsSL https://opencode.ai/install | bash"] } };

test("an npm install is undone by npm", () => {
const plan = uninstallPlan(npmEntry, { binPath: "/usr/bin/claude", home: HOME });

// npm knows what it put where, so the binary's location does not matter —
// note this passes a path that the binary branch would refuse.
assert.equal(plan.kind, "npm");
assert.deepEqual(plan.steps, [{ kind: "run", command: "npm", args: ["uninstall", "-g", "@anthropic-ai/claude-code"] }]);
});

test("the package name is read out of the install spec", () => {
assert.equal(npmPackageOf({ cmd: "npm", args: ["install", "-g", "@openai/codex"] }), "@openai/codex");
assert.equal(npmPackageOf({ cmd: "pnpm", args: ["add", "-g", "thing"] }), "thing");
assert.equal(npmPackageOf({ cmd: "npm", args: ["install", "--global", "thing"] }), "thing");

// Not npm, or not global — neither has an npm inverse.
assert.equal(npmPackageOf({ cmd: "bash", args: ["-c", "curl … | bash"] }), null);
assert.equal(npmPackageOf({ cmd: "npm", args: ["install", "thing"] }), null, "a local install is not ours");
assert.equal(npmPackageOf(null), null);
});

test("a script install removes the binary it dropped", () => {
const plan = uninstallPlan(scriptEntry, { binPath: `${HOME}/.local/bin/opencode`, home: HOME });

assert.equal(plan.kind, "binary");
assert.deepEqual(plan.steps, [{ kind: "remove", path: `${HOME}/.local/bin/opencode` }]);
// Said out loud, because "uninstalled" reads as "gone" and it is not.
assert.match(plan.warnings.join(" "), /config, caches, credentials — stays/);
});

test("it refuses to delete a binary it could not have installed", () => {
// The whole point of the allow-list. A binary here came from a package
// manager or from someone's own hands, and deleting it because a prompt was
// clicked through is worse than not offering.
for (const path of ["/usr/bin/opencode", "/bin/opencode", "/snap/bin/opencode", "/etc/opencode"]) {
const plan = uninstallPlan(scriptEntry, { binPath: path, home: HOME });
assert.equal(plan.kind, "refused", path);
assert.equal(plan.steps.length, 0, `${path} must produce no steps`);
assert.match(plan.warnings.join(" "), /not in a directory moshcode installs into/);
}
});

test("the places a per-user installer legitimately writes are allowed", () => {
for (const dir of safePrefixes(HOME)) {
const plan = uninstallPlan(scriptEntry, { binPath: `${dir}/opencode`, home: HOME });
assert.equal(plan.kind, "binary", dir);
}
});

test("a tool that is not there says so rather than failing", () => {
const plan = uninstallPlan(scriptEntry, { binPath: null, home: HOME });
assert.equal(plan.kind, "absent");
assert.equal(plan.steps.length, 0);
assert.match(plan.warnings.join(" "), /not on your PATH/);
});

test("a plan reads as something you can agree to first", () => {
assert.match(describeUninstall(uninstallPlan(npmEntry, { home: HOME })), /run\s+npm uninstall -g/);
assert.match(
describeUninstall(uninstallPlan(scriptEntry, { binPath: `${HOME}/bin/opencode`, home: HOME })),
/remove\s+\/home\/someone\/bin\/opencode/,
);
});
Loading