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
2 changes: 2 additions & 0 deletions src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const CORE_CLI_COMMANDS = [
{ name: "agents", description: "list engines or launch one autonomously" },
{ name: "start", description: "launch an engine with its native defaults" },
{ name: "install", description: "install an engine or workflow tool" },
{ name: "uninstall", description: "take an engine or workflow tool off this machine" },
{ name: "remove", description: "alias for uninstall" },
{ name: "upgrade", description: "update moshcode, engines, or tools" },
{ name: "update", description: "alias for upgrade" },
{ name: "mcp", description: "register and inspect MCP servers" },
Expand Down
23 changes: 23 additions & 0 deletions src/completion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export function completionModel() {
top: uniqueEntries([...CORE_CLI_COMMANDS, ...engines, ...engineAliases, ...tools]),
engines: uniqueEntries([...engines, ...engineAliases]),
install,
// `uninstall <engine|tool>` resolves its target against the same ENGINES and
// TOOLS rosters `install` does, so it offers the same targets. Kept as its
// own key rather than reusing `install` so the two can diverge without a
// silent surprise in one of them.
uninstall: install,
upgrade: uniqueEntries([
...UPGRADE_TARGETS,
...engines,
Expand Down Expand Up @@ -98,6 +103,13 @@ _moshcode_completion() {
install)
(( COMP_CWORD == 2 )) && choices="${names(model.install)}"
;;
uninstall|remove)
if (( COMP_CWORD == 2 )); then
choices="${names(model.uninstall)}"
elif [[ "$cur" == -* ]]; then
choices="--yes -y --dry-run"
fi
;;
upgrade|update)
choices="${names(model.upgrade)}"
;;
Expand Down Expand Up @@ -174,6 +186,14 @@ _moshcode() {
_files
fi
;;
uninstall|remove)
if (( CURRENT == 3 )); then
choices=(${zshValues(model.uninstall)})
_describe "uninstall target" choices
else
_values "uninstall option" --yes -y --dry-run
fi
;;
upgrade|update)
choices=(${zshValues(model.upgrade)})
_describe "upgrade target" choices
Expand Down Expand Up @@ -267,6 +287,7 @@ end
${fishEntries(atFirstArgument, model.top)}
${fishEntries(atSecondToken("agents start"), model.engines)}
${fishEntries(atSecondToken("install"), model.install)}
${fishEntries(atSecondToken("uninstall remove"), model.uninstall)}
${fishEntries("__moshcode_command_is upgrade update", model.upgrade)}
${fishEntries(atSecondToken("completion"), model.shells)}
${fishEntries(atSecondToken("mcp"), model.mcp)}
Expand All @@ -276,6 +297,8 @@ complete -c moshcode -n '__moshcode_command_is login' -l device -s d -d 'use dev
complete -c moshcode -n '__moshcode_command_is engines tools commands' -l json -d 'print JSON'
complete -c moshcode -n '__moshcode_command_is run' -l dry-run -d 'show actions without executing'
complete -c moshcode -n '__moshcode_command_is run' -l max -s n -r -d 'maximum loop count'
complete -c moshcode -n '__moshcode_command_is uninstall remove' -l yes -s y -d 'confirm deleting a binary'
complete -c moshcode -n '__moshcode_command_is uninstall remove' -l dry-run -d 'show the plan without removing'
complete -c moshcode -n '${atSecondToken("console")}' -a 'serve' -d 'serve a browser terminal'
complete -c moshcode -n '${atSecondToken("console")}' -a '--url' -d 'print a gateway URL'
complete -c moshcode -n '__moshcode_nested_is console serve' -l port -r -d 'local HTTP port'
Expand Down
127 changes: 127 additions & 0 deletions test/completion-uninstall.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import test from "node:test";
import { fileURLToPath } from "node:url";

import { completionModel, completionScript } from "../src/completion.mjs";
import { ENGINE_ALIASES, ENGINES } from "../src/engines.mjs";
import { TOOLS } from "../src/tools.mjs";

const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url));

function names(entries) {
return entries.map(({ name }) => name);
}

function bashQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}

// Source the real generated script in a real bash and call the real completion
// function, rather than asserting against the script text. A script can mention
// a word and still never offer it.
function bashCompletions(tokens) {
const script = `${completionScript("bash")}
COMP_WORDS=(${tokens.map(bashQuote).join(" ")})
COMP_CWORD=${tokens.length - 1}
_moshcode_completion
printf '%s\\n' "\${COMPREPLY[@]}"
`;
const result = spawnSync("bash", ["-c", script], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
return result.stdout.split("\n").filter(Boolean);
}

// The roster `uninstall` actually resolves its target against: bin/moshcode.mjs
// checks `Object.hasOwn(ENGINES, target) || Object.hasOwn(TOOLS, target)`.
const REMOVABLE = [...new Set([...Object.keys(ENGINES), ...Object.keys(TOOLS)])];

// --- the bug: `uninstall` shipped with no completion at all -----------------

test("uninstall and its remove alias are offered as top-level commands", () => {
const top = new Set(names(completionModel().top));
assert.ok(top.has("uninstall"), "uninstall missing from top-level completion");
assert.ok(top.has("remove"), "remove missing from top-level completion");
});

test("typing `moshcode un` completes to uninstall in bash", () => {
assert.deepEqual(bashCompletions(["moshcode", "un"]), ["uninstall"]);
});

test("`moshcode uninstall <TAB>` offers every engine and tool it can remove", () => {
const offered = new Set(bashCompletions(["moshcode", "uninstall", ""]));
for (const name of REMOVABLE) {
assert.ok(offered.has(name), `${name} can be uninstalled but is not offered`);
}
});

test("the remove alias completes its targets too", () => {
assert.deepEqual(
new Set(bashCompletions(["moshcode", "remove", ""])),
new Set(bashCompletions(["moshcode", "uninstall", ""])),
);
});

test("uninstall flags complete after a target", () => {
const offered = new Set(bashCompletions(["moshcode", "uninstall", "claude", "--"]));
assert.ok(offered.has("--yes"), "--yes is required to delete a binary but is not offered");
assert.ok(offered.has("--dry-run"));
});

test("zsh and fish completions cover uninstall as well as bash", () => {
for (const shell of ["zsh", "fish"]) {
const script = completionScript(shell);
assert.match(script, /\buninstall\b/, `${shell} completion never mentions uninstall`);
assert.match(script, /\bremove\b/, `${shell} completion never mentions remove`);
}
});

test("every command bin/moshcode.mjs dispatches on is completable", () => {
// The same guarantee completion.test.mjs asserts, kept here so a new command
// added without a completion entry fails next to the uninstall regression.
const source = readFileSync(BIN, "utf8");
const dispatched = [...source.matchAll(/cmd === "([^"]+)"/g)].map((m) => m[1]);
const top = new Set(names(completionModel().top));
assert.ok(dispatched.includes("uninstall"), "guard is stale: uninstall is no longer dispatched");
for (const command of dispatched) {
assert.ok(top.has(command), `${command} is dispatched but missing from completion`);
}
});

// --- controls: these pass before and after, in the opposite direction -------
// They stop the fix buying a passing suite by over-offering or by disturbing
// the completions that already worked.

test("uninstall offers exactly the removable roster and nothing more", () => {
assert.deepEqual(new Set(names(completionModel().uninstall)), new Set(REMOVABLE));
});

test("uninstall does not offer engine aliases, which its dispatch cannot resolve", () => {
// `uninstall` looks the target up with Object.hasOwn(ENGINES, target), so an
// alias would be offered and then rejected. install behaves the same way.
const offered = new Set(names(completionModel().uninstall));
for (const alias of Object.keys(ENGINE_ALIASES)) {
assert.ok(!offered.has(alias), `${alias} is an alias and would not resolve`);
}
});

test("install completion is unchanged by the uninstall wiring", () => {
assert.deepEqual(new Set(names(completionModel().install)), new Set(REMOVABLE));
assert.deepEqual(new Set(bashCompletions(["moshcode", "install", ""])), new Set(REMOVABLE));
});

test("an unrelated command still completes nothing", () => {
assert.deepEqual(bashCompletions(["moshcode", "whoami", ""]), []);
});

test("uninstall completes targets only in the target position", () => {
// COMP_CWORD 3 without a flag prefix must not re-offer the roster.
const offered = bashCompletions(["moshcode", "uninstall", "claude", ""]);
assert.deepEqual(offered, []);
});

test("the generated scripts still parse in their own shell where available", () => {
const bash = spawnSync("bash", ["-n", "-c", completionScript("bash")], { encoding: "utf8" });
assert.equal(bash.status, 0, bash.stderr);
});
Loading