Skip to content

Commit 57ada5d

Browse files
fix(completion): complete the uninstall command and its targets
`moshcode uninstall <engine|tool>` shipped in #150 and is documented in `moshcode help`, but it was never added to CORE_CLI_COMMANDS. Shell completion is generated from that roster, so tab completion did not know the command existed: `moshcode un<TAB>` offered nothing, and `moshcode uninstall <TAB>` offered no targets. The `remove` alias was missing for the same reason. completion.test.mjs already guards this ("completion schema covers every explicitly dispatched CLI command") and has been failing on main since #150 landed. Add uninstall and remove to the roster, and give uninstall a target bucket wired into the bash, zsh and fish generators. The bucket is the same ENGINES + TOOLS roster the dispatch resolves against, so every offered target is one uninstall can actually accept. Flags --yes, -y and --dry-run complete after a target.
1 parent af572d5 commit 57ada5d

3 files changed

Lines changed: 152 additions & 0 deletions

File tree

src/cli-schema.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export const CORE_CLI_COMMANDS = [
22
{ name: "agents", description: "list engines or launch one autonomously" },
33
{ name: "start", description: "launch an engine with its native defaults" },
44
{ name: "install", description: "install an engine or workflow tool" },
5+
{ name: "uninstall", description: "take an engine or workflow tool off this machine" },
6+
{ name: "remove", description: "alias for uninstall" },
57
{ name: "upgrade", description: "update moshcode, engines, or tools" },
68
{ name: "update", description: "alias for upgrade" },
79
{ name: "mcp", description: "register and inspect MCP servers" },

src/completion.mjs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export function completionModel() {
3838
top: uniqueEntries([...CORE_CLI_COMMANDS, ...engines, ...engineAliases, ...tools]),
3939
engines: uniqueEntries([...engines, ...engineAliases]),
4040
install,
41+
// `uninstall <engine|tool>` resolves its target against the same ENGINES and
42+
// TOOLS rosters `install` does, so it offers the same targets. Kept as its
43+
// own key rather than reusing `install` so the two can diverge without a
44+
// silent surprise in one of them.
45+
uninstall: install,
4146
upgrade: uniqueEntries([
4247
...UPGRADE_TARGETS,
4348
...engines,
@@ -98,6 +103,13 @@ _moshcode_completion() {
98103
install)
99104
(( COMP_CWORD == 2 )) && choices="${names(model.install)}"
100105
;;
106+
uninstall|remove)
107+
if (( COMP_CWORD == 2 )); then
108+
choices="${names(model.uninstall)}"
109+
elif [[ "$cur" == -* ]]; then
110+
choices="--yes -y --dry-run"
111+
fi
112+
;;
101113
upgrade|update)
102114
choices="${names(model.upgrade)}"
103115
;;
@@ -174,6 +186,14 @@ _moshcode() {
174186
_files
175187
fi
176188
;;
189+
uninstall|remove)
190+
if (( CURRENT == 3 )); then
191+
choices=(${zshValues(model.uninstall)})
192+
_describe "uninstall target" choices
193+
else
194+
_values "uninstall option" --yes -y --dry-run
195+
fi
196+
;;
177197
upgrade|update)
178198
choices=(${zshValues(model.upgrade)})
179199
_describe "upgrade target" choices
@@ -267,6 +287,7 @@ end
267287
${fishEntries(atFirstArgument, model.top)}
268288
${fishEntries(atSecondToken("agents start"), model.engines)}
269289
${fishEntries(atSecondToken("install"), model.install)}
290+
${fishEntries(atSecondToken("uninstall remove"), model.uninstall)}
270291
${fishEntries("__moshcode_command_is upgrade update", model.upgrade)}
271292
${fishEntries(atSecondToken("completion"), model.shells)}
272293
${fishEntries(atSecondToken("mcp"), model.mcp)}
@@ -276,6 +297,8 @@ complete -c moshcode -n '__moshcode_command_is login' -l device -s d -d 'use dev
276297
complete -c moshcode -n '__moshcode_command_is engines tools commands' -l json -d 'print JSON'
277298
complete -c moshcode -n '__moshcode_command_is run' -l dry-run -d 'show actions without executing'
278299
complete -c moshcode -n '__moshcode_command_is run' -l max -s n -r -d 'maximum loop count'
300+
complete -c moshcode -n '__moshcode_command_is uninstall remove' -l yes -s y -d 'confirm deleting a binary'
301+
complete -c moshcode -n '__moshcode_command_is uninstall remove' -l dry-run -d 'show the plan without removing'
279302
complete -c moshcode -n '${atSecondToken("console")}' -a 'serve' -d 'serve a browser terminal'
280303
complete -c moshcode -n '${atSecondToken("console")}' -a '--url' -d 'print a gateway URL'
281304
complete -c moshcode -n '__moshcode_nested_is console serve' -l port -r -d 'local HTTP port'

test/completion-uninstall.test.mjs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import assert from "node:assert/strict";
2+
import { spawnSync } from "node:child_process";
3+
import { readFileSync } from "node:fs";
4+
import test from "node:test";
5+
import { fileURLToPath } from "node:url";
6+
7+
import { completionModel, completionScript } from "../src/completion.mjs";
8+
import { ENGINE_ALIASES, ENGINES } from "../src/engines.mjs";
9+
import { TOOLS } from "../src/tools.mjs";
10+
11+
const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url));
12+
13+
function names(entries) {
14+
return entries.map(({ name }) => name);
15+
}
16+
17+
function bashQuote(value) {
18+
return `'${String(value).replaceAll("'", "'\\''")}'`;
19+
}
20+
21+
// Source the real generated script in a real bash and call the real completion
22+
// function, rather than asserting against the script text. A script can mention
23+
// a word and still never offer it.
24+
function bashCompletions(tokens) {
25+
const script = `${completionScript("bash")}
26+
COMP_WORDS=(${tokens.map(bashQuote).join(" ")})
27+
COMP_CWORD=${tokens.length - 1}
28+
_moshcode_completion
29+
printf '%s\\n' "\${COMPREPLY[@]}"
30+
`;
31+
const result = spawnSync("bash", ["-c", script], { encoding: "utf8" });
32+
assert.equal(result.status, 0, result.stderr);
33+
return result.stdout.split("\n").filter(Boolean);
34+
}
35+
36+
// The roster `uninstall` actually resolves its target against: bin/moshcode.mjs
37+
// checks `Object.hasOwn(ENGINES, target) || Object.hasOwn(TOOLS, target)`.
38+
const REMOVABLE = [...new Set([...Object.keys(ENGINES), ...Object.keys(TOOLS)])];
39+
40+
// --- the bug: `uninstall` shipped with no completion at all -----------------
41+
42+
test("uninstall and its remove alias are offered as top-level commands", () => {
43+
const top = new Set(names(completionModel().top));
44+
assert.ok(top.has("uninstall"), "uninstall missing from top-level completion");
45+
assert.ok(top.has("remove"), "remove missing from top-level completion");
46+
});
47+
48+
test("typing `moshcode un` completes to uninstall in bash", () => {
49+
assert.deepEqual(bashCompletions(["moshcode", "un"]), ["uninstall"]);
50+
});
51+
52+
test("`moshcode uninstall <TAB>` offers every engine and tool it can remove", () => {
53+
const offered = new Set(bashCompletions(["moshcode", "uninstall", ""]));
54+
for (const name of REMOVABLE) {
55+
assert.ok(offered.has(name), `${name} can be uninstalled but is not offered`);
56+
}
57+
});
58+
59+
test("the remove alias completes its targets too", () => {
60+
assert.deepEqual(
61+
new Set(bashCompletions(["moshcode", "remove", ""])),
62+
new Set(bashCompletions(["moshcode", "uninstall", ""])),
63+
);
64+
});
65+
66+
test("uninstall flags complete after a target", () => {
67+
const offered = new Set(bashCompletions(["moshcode", "uninstall", "claude", "--"]));
68+
assert.ok(offered.has("--yes"), "--yes is required to delete a binary but is not offered");
69+
assert.ok(offered.has("--dry-run"));
70+
});
71+
72+
test("zsh and fish completions cover uninstall as well as bash", () => {
73+
for (const shell of ["zsh", "fish"]) {
74+
const script = completionScript(shell);
75+
assert.match(script, /\buninstall\b/, `${shell} completion never mentions uninstall`);
76+
assert.match(script, /\bremove\b/, `${shell} completion never mentions remove`);
77+
}
78+
});
79+
80+
test("every command bin/moshcode.mjs dispatches on is completable", () => {
81+
// The same guarantee completion.test.mjs asserts, kept here so a new command
82+
// added without a completion entry fails next to the uninstall regression.
83+
const source = readFileSync(BIN, "utf8");
84+
const dispatched = [...source.matchAll(/cmd === "([^"]+)"/g)].map((m) => m[1]);
85+
const top = new Set(names(completionModel().top));
86+
assert.ok(dispatched.includes("uninstall"), "guard is stale: uninstall is no longer dispatched");
87+
for (const command of dispatched) {
88+
assert.ok(top.has(command), `${command} is dispatched but missing from completion`);
89+
}
90+
});
91+
92+
// --- controls: these pass before and after, in the opposite direction -------
93+
// They stop the fix buying a passing suite by over-offering or by disturbing
94+
// the completions that already worked.
95+
96+
test("uninstall offers exactly the removable roster and nothing more", () => {
97+
assert.deepEqual(new Set(names(completionModel().uninstall)), new Set(REMOVABLE));
98+
});
99+
100+
test("uninstall does not offer engine aliases, which its dispatch cannot resolve", () => {
101+
// `uninstall` looks the target up with Object.hasOwn(ENGINES, target), so an
102+
// alias would be offered and then rejected. install behaves the same way.
103+
const offered = new Set(names(completionModel().uninstall));
104+
for (const alias of Object.keys(ENGINE_ALIASES)) {
105+
assert.ok(!offered.has(alias), `${alias} is an alias and would not resolve`);
106+
}
107+
});
108+
109+
test("install completion is unchanged by the uninstall wiring", () => {
110+
assert.deepEqual(new Set(names(completionModel().install)), new Set(REMOVABLE));
111+
assert.deepEqual(new Set(bashCompletions(["moshcode", "install", ""])), new Set(REMOVABLE));
112+
});
113+
114+
test("an unrelated command still completes nothing", () => {
115+
assert.deepEqual(bashCompletions(["moshcode", "whoami", ""]), []);
116+
});
117+
118+
test("uninstall completes targets only in the target position", () => {
119+
// COMP_CWORD 3 without a flag prefix must not re-offer the roster.
120+
const offered = bashCompletions(["moshcode", "uninstall", "claude", ""]);
121+
assert.deepEqual(offered, []);
122+
});
123+
124+
test("the generated scripts still parse in their own shell where available", () => {
125+
const bash = spawnSync("bash", ["-n", "-c", completionScript("bash")], { encoding: "utf8" });
126+
assert.equal(bash.status, 0, bash.stderr);
127+
});

0 commit comments

Comments
 (0)