diff --git a/README.md b/README.md index d830378..7be5ef2 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,25 @@ Each target is updated with its own native updater when it has one (e.g. `opencode upgrade`, `aider --upgrade`) and re-run through its installer otherwise — MoshCode never vendors it. In the TUI: `/upgrade [name…]`. +## Shell completion + +MoshCode can print context-aware completion scripts for its commands, engines, +workflow tools, options, and file arguments. Load the one for your current +shell: + +```sh +# Bash (~/.bashrc) +source <(moshcode completion bash) + +# Zsh (~/.zshrc, after any existing compinit/Oh My Zsh setup) +source <(moshcode completion zsh) + +# Fish (~/.config/fish/config.fish) +moshcode completion fish | source +``` + +Put the matching line in your shell profile to enable it in future sessions. + ## PRD — plan before you mosh Write a product requirements doc *first*, then let your coding agents build to it. diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 1f44d1a..86b608f 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -22,6 +22,8 @@ import { loginAuto, whoami, logout } from "../src/auth.mjs"; import { tui } from "../src/tui.mjs"; import { consoleCommand } from "../src/console.mjs"; import { dnsCommand } from "../src/dns.mjs"; +import { completionScript } from "../src/completion.mjs"; +import { CORE_CLI_COMMAND_NAMES } from "../src/cli-schema.mjs"; import { moshcodeVersion } from "../src/ui.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); @@ -123,7 +125,7 @@ function help() { // Every workflow tool is exposed as a CLI verb, so derive them from TOOLS // rather than repeating the roster here — a tool missing from this list gets // misfiled as a moshscript-only local verb. - const cliVerbs = ["run","agents","start","install","upgrade","mcp","skill","prd","pwd", ...Object.keys(TOOLS)]; + const cliVerbs = [...CORE_CLI_COMMAND_NAMES, ...Object.keys(TOOLS)]; const local = vocab.filter((c) => !cliVerbs.includes(c.name)); const cli = vocab.filter((c) => !local.includes(c)); console.log(`moshcode — metal scripting toolkit 🤘 @@ -167,6 +169,7 @@ usage: moshcode engines [--json] list engines + install status moshcode tools [--json] list workflow tools + install status moshcode commands [--json] list built-in moshscript commands + moshcode completion print a shell completion script moshcode help this engines (moshcode is a wrapper — it installs/drives these): @@ -323,6 +326,15 @@ async function main() { } return; } + if (cmd === "completion") { + try { + process.stdout.write(completionScript(rest[0])); + } catch (e) { + console.error(`usage: moshcode completion \n${e.message || e}`); + process.exitCode = 1; + } + return; + } if (cmd === "login") { const device = rest.includes("--device") || rest.includes("-d") || !process.stdin.isTTY; const browser = rest.includes("--browser") || rest.includes("-b"); diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs new file mode 100644 index 0000000..a00c954 --- /dev/null +++ b/src/cli-schema.mjs @@ -0,0 +1,61 @@ +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: "upgrade", description: "update moshcode, engines, or tools" }, + { name: "update", description: "alias for upgrade" }, + { name: "mcp", description: "register and inspect MCP servers" }, + { name: "skill", description: "install and inspect agent skills" }, + { name: "skills", description: "alias for skill" }, + { name: "prd", description: "create or list product requirement documents" }, + { name: "login", description: "authenticate with app.moshcode.sh" }, + { name: "whoami", description: "show the logged-in account" }, + { name: "logout", description: "clear the logged-in account" }, + { name: "console", description: "serve or connect to the browser terminal" }, + { name: "dns", description: "manage DNS records" }, + { name: "pwd", description: "show the current directory and git context" }, + { name: "where", description: "alias for pwd" }, + { name: "engines", description: "list engines and installation status" }, + { name: "tools", description: "list workflow tools and installation status" }, + { name: "commands", description: "list built-in moshscript commands" }, + { name: "completion", description: "print a shell completion script" }, + { name: "run", description: "run a moshscript" }, + { name: "help", description: "show command help" }, + { name: "version", description: "show the installed version" }, + { name: "--version", description: "show the installed version" }, + { name: "-v", description: "show the installed version" }, +]; + +export const CORE_CLI_COMMAND_NAMES = CORE_CLI_COMMANDS.map(({ name }) => name); + +export const MCP_VERBS = [ + { + name: "install", + description: "register an MCP server across engines", + acceptsServerSpec: true, + }, + { + name: "add", + description: "register a named MCP server", + acceptsServerSpec: true, + }, + { name: "catalog", description: "show known MCP servers" }, + { name: "list", description: "show MCP support and install status" }, +]; + +export const SKILL_VERBS = [ + { + name: "install", + description: "install a skill across supported engines", + acceptsSource: true, + }, + { name: "list", description: "show skills support and install status" }, +]; + +export const UPGRADE_TARGETS = [ + { name: "all", description: "update moshcode and all installed integrations" }, + { name: "self", description: "update moshcode itself" }, + { name: "moshcode", description: "alias for self" }, + { name: "engines", description: "update all installed engines" }, + { name: "tools", description: "update all installed workflow tools" }, +]; diff --git a/src/completion.mjs b/src/completion.mjs new file mode 100644 index 0000000..03f4c4c --- /dev/null +++ b/src/completion.mjs @@ -0,0 +1,299 @@ +import { + CORE_CLI_COMMANDS, + MCP_VERBS, + SKILL_VERBS, + UPGRADE_TARGETS, +} from "./cli-schema.mjs"; +import { ENGINES, ENGINE_ALIASES } from "./engines.mjs"; +import { TOOLS } from "./tools.mjs"; + +export const COMPLETION_SHELLS = ["bash", "zsh", "fish"]; + +function entry(name, description) { + const value = String(name); + if (!/^-{0,2}[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value)) { + throw new Error(`completion name is not shell-safe: ${JSON.stringify(value)}`); + } + return { name: value, description: String(description).replace(/\s+/g, " ").trim() }; +} + +function uniqueEntries(entries) { + const found = new Map(); + for (const item of entries) { + const normalized = entry(item.name, item.description); + if (!found.has(normalized.name)) found.set(normalized.name, normalized); + } + return [...found.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +export function completionModel() { + const engines = Object.entries(ENGINES).map(([name, engine]) => entry(name, engine.desc)); + const engineAliases = Object.entries(ENGINE_ALIASES).map(([name, target]) => ( + entry(name, `alias for ${target}`) + )); + const tools = Object.entries(TOOLS).map(([name, tool]) => entry(name, tool.desc)); + const install = uniqueEntries([...engines, ...tools]); + + return { + top: uniqueEntries([...CORE_CLI_COMMANDS, ...engines, ...engineAliases, ...tools]), + engines: uniqueEntries([...engines, ...engineAliases]), + install, + upgrade: uniqueEntries([ + ...UPGRADE_TARGETS, + ...engines, + ...engineAliases, + ...tools, + ]), + mcp: uniqueEntries(MCP_VERBS), + mcpServerSpecs: uniqueEntries(MCP_VERBS.filter(({ acceptsServerSpec }) => acceptsServerSpec)), + skills: uniqueEntries(SKILL_VERBS), + skillSources: uniqueEntries(SKILL_VERBS.filter(({ acceptsSource }) => acceptsSource)), + shells: COMPLETION_SHELLS.map((name) => entry(name, `generate ${name} completion`)), + }; +} + +function names(entries) { + return entries.map(({ name }) => name).join(" "); +} + +function shellQuote(value) { + return `'${String(value).replaceAll("'", "'\\''")}'`; +} + +function zshValues(entries) { + return entries.map(({ name, description }) => shellQuote(`${name}:${description}`)).join(" "); +} + +function shellMatches(variable, entries) { + return `[[ ${entries.map(({ name }) => `"$${variable}" == "${name}"`).join(" || ")} ]]`; +} + +function fishQuote(value) { + return `'${String(value).replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; +} + +function fishEntries(condition, entries) { + return entries.map(({ name, description }) => ( + `complete -c moshcode -n ${fishQuote(condition)} -a ${fishQuote(name)} -d ${fishQuote(description)}` + )).join("\n"); +} + +function bashCompletion(model) { + return `# bash completion for moshcode +_moshcode_completion() { + local cur subcommand nested choices + COMPREPLY=() + cur="\${COMP_WORDS[COMP_CWORD]-}" + subcommand="\${COMP_WORDS[1]-}" + nested="\${COMP_WORDS[2]-}" + choices="" + + if (( COMP_CWORD == 1 )); then + choices="${names(model.top)}" + else + case "$subcommand" in + agents|start) + (( COMP_CWORD == 2 )) && choices="${names(model.engines)}" + ;; + install) + (( COMP_CWORD == 2 )) && choices="${names(model.install)}" + ;; + upgrade|update) + choices="${names(model.upgrade)}" + ;; + completion) + (( COMP_CWORD == 2 )) && choices="${names(model.shells)}" + ;; + mcp) + if (( COMP_CWORD == 2 )); then + choices="${names(model.mcp)}" + elif ${shellMatches("nested", model.mcpServerSpecs)} && [[ "$cur" == -* ]]; then + choices="--name --transport -t --env -e --header -H --" + fi + ;; + skill|skills) + if (( COMP_CWORD == 2 )); then + choices="${names(model.skills)}" + elif ${shellMatches("nested", model.skillSources)} && [[ "$cur" == -* ]]; then + choices="--name" + fi + ;; + login) + [[ "$cur" == -* ]] && choices="--browser -b --device -d" + ;; + engines|tools|commands) + [[ "$cur" == -* ]] && choices="--json" + ;; + run) + [[ "$cur" == -* ]] && choices="--dry-run --max -n" + ;; + console) + if (( COMP_CWORD == 2 )); then + choices="serve --url" + elif [[ "$nested" == "serve" && "$cur" == -* ]]; then + choices="--port --ttyd --bind" + fi + ;; + esac + fi + + if [[ -n "$choices" ]]; then + COMPREPLY=( $(compgen -W "$choices" -- "$cur") ) + fi +} +complete -o bashdefault -o default -F _moshcode_completion moshcode +`; +} + +function zshCompletion(model) { + return `#compdef moshcode +# zsh completion for moshcode +_moshcode() { + local -a choices + + if (( CURRENT == 2 )); then + choices=(${zshValues(model.top)}) + _describe "moshcode command" choices + return + fi + + case "\${words[2]}" in + agents|start) + if (( CURRENT == 3 )); then + choices=(${zshValues(model.engines)}) + _describe "engine" choices + else + _files + fi + ;; + install) + if (( CURRENT == 3 )); then + choices=(${zshValues(model.install)}) + _describe "install target" choices + else + _files + fi + ;; + upgrade|update) + choices=(${zshValues(model.upgrade)}) + _describe "upgrade target" choices + ;; + completion) + if (( CURRENT == 3 )); then + choices=(${zshValues(model.shells)}) + _describe "shell" choices + fi + ;; + mcp) + if (( CURRENT == 3 )); then + choices=(${zshValues(model.mcp)}) + _describe "mcp command" choices + elif ${shellMatches("{words[3]}", model.mcpServerSpecs)}; then + if [[ "$PREFIX" == -* ]]; then + _values "mcp option" --name --transport -t --env -e --header -H -- + else + _files + fi + fi + ;; + skill|skills) + if (( CURRENT == 3 )); then + choices=(${zshValues(model.skills)}) + _describe "skill command" choices + elif ${shellMatches("{words[3]}", model.skillSources)}; then + if [[ "$PREFIX" == -* ]]; then _values "skill option" --name; else _files; fi + fi + ;; + login) + _values "login option" --browser -b --device -d + ;; + engines|tools|commands) + _values "option" --json + ;; + run) + if [[ "$PREFIX" == -* ]]; then + _values "run option" --dry-run --max -n + else + _files + fi + ;; + console) + if (( CURRENT == 3 )); then + _values "console command" serve --url + elif [[ "\${words[3]}" == "serve" && "$PREFIX" == -* ]]; then + _values "console option" --port --ttyd --bind + else + _files + fi + ;; + *) + _files + ;; + esac +} + +if (( ! $+functions[compdef] )); then + autoload -Uz compinit + compinit +fi +compdef _moshcode moshcode +`; +} + +function fishCompletion(model) { + const atFirstArgument = "__fish_use_subcommand"; + const atSecondToken = (commands) => ( + `__moshcode_command_is ${commands}; and __moshcode_arg_index 2` + ); + const nestedCondition = (command, entries) => ( + entries.map(({ name }) => `__moshcode_nested_is ${command} ${name}`).join("; or ") + ); + + return `# fish completion for moshcode +function __moshcode_command_is + set -l tokens (commandline -opc) + test (count $tokens) -ge 2; and contains -- $tokens[2] $argv +end + +function __moshcode_nested_is + set -l tokens (commandline -opc) + test (count $tokens) -ge 3; and test "$tokens[2]" = "$argv[1]"; and test "$tokens[3]" = "$argv[2]" +end + +function __moshcode_arg_index + test (count (commandline -opc)) -eq $argv[1] +end + +${fishEntries(atFirstArgument, model.top)} +${fishEntries(atSecondToken("agents start"), model.engines)} +${fishEntries(atSecondToken("install"), model.install)} +${fishEntries("__moshcode_command_is upgrade update", model.upgrade)} +${fishEntries(atSecondToken("completion"), model.shells)} +${fishEntries(atSecondToken("mcp"), model.mcp)} +${fishEntries(atSecondToken("skill skills"), model.skills)} +complete -c moshcode -n '__moshcode_command_is login' -l browser -s b -d 'use browser authentication' +complete -c moshcode -n '__moshcode_command_is login' -l device -s d -d 'use device-code authentication' +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 '${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' +complete -c moshcode -n '__moshcode_nested_is console serve' -l ttyd -r -d 'ttyd host and port' +complete -c moshcode -n '__moshcode_nested_is console serve' -l bind -r -d 'bind address' +complete -c moshcode -n '${nestedCondition("mcp", model.mcpServerSpecs)}' -l name -r -d 'server name' +complete -c moshcode -n '${nestedCondition("mcp", model.mcpServerSpecs)}' -l transport -s t -r -d 'MCP transport' +complete -c moshcode -n '${nestedCondition("mcp", model.mcpServerSpecs)}' -l env -s e -r -d 'environment KEY=VALUE' +complete -c moshcode -n '${nestedCondition("mcp", model.mcpServerSpecs)}' -l header -s H -r -d 'HTTP Name: Value header' +complete -c moshcode -n '${nestedCondition("skill", model.skillSources)}; or ${nestedCondition("skills", model.skillSources)}' -l name -r -d 'installed skill name' +`; +} + +export function completionScript(shell) { + const normalized = String(shell || "").trim().toLowerCase(); + const model = completionModel(); + if (normalized === "bash") return bashCompletion(model); + if (normalized === "zsh") return zshCompletion(model); + if (normalized === "fish") return fishCompletion(model); + throw new Error(`unsupported shell ${JSON.stringify(shell)}; choose: ${COMPLETION_SHELLS.join(", ")}`); +} diff --git a/src/engines.mjs b/src/engines.mjs index 9af65c0..0679fb2 100644 --- a/src/engines.mjs +++ b/src/engines.mjs @@ -89,7 +89,7 @@ export function upgradeSpec(engine) { } /** Aliases so `/agents cc` etc. resolve. */ -const ALIASES = { +export const ENGINE_ALIASES = { cc: "claude", "claude-code": "claude", openai: "codex", gpt: "codex", google: "gemini", pc: "privacycode", getprivacycode: "privacycode", privacy: "privacycode", }; @@ -101,7 +101,7 @@ export function resolveEngine(token) { // Own properties only: ENGINES/ALIASES are plain object literals, so a name // like `constructor` or `__proto__` would otherwise resolve to something off // Object.prototype and be handed on as an engine with no bin/install. - const key = Object.hasOwn(ENGINES, t) ? t : Object.hasOwn(ALIASES, t) ? ALIASES[t] : null; + const key = Object.hasOwn(ENGINES, t) ? t : Object.hasOwn(ENGINE_ALIASES, t) ? ENGINE_ALIASES[t] : null; return key ? [key, ENGINES[key]] : null; } diff --git a/src/integrations.mjs b/src/integrations.mjs index df01b21..345a634 100644 --- a/src/integrations.mjs +++ b/src/integrations.mjs @@ -9,6 +9,7 @@ import { SKILL_ENGINES, planSkillInstall, runSkillInstall, skillName, } from "./skills.mjs"; import { catalogList, resolveCatalog } from "./mcp-catalog.mjs"; +import { MCP_VERBS, SKILL_VERBS } from "./cli-schema.mjs"; import { acid, ash, bone, ok, err, info } from "./ui.mjs"; function splitKV(pair) { @@ -34,7 +35,11 @@ export function parseMcp(tokens) { const verb = tokens[0]; if (!verb || verb === "list") return { list: true }; if (verb === "catalog") return { showCatalog: true }; - if (verb !== "install" && verb !== "add") return { error: `unknown mcp verb "${verb}" — try install, add, catalog, or list` }; + const verbSchema = MCP_VERBS.find(({ name }) => name === verb); + if (!verbSchema?.acceptsServerSpec) { + const choices = MCP_VERBS.map(({ name }) => name); + return { error: `unknown mcp verb "${verb}" — try ${choices.slice(0, -1).join(", ")}, or ${choices.at(-1)}` }; + } const rest = tokens.slice(1); let name, transport, cmdParts = null; @@ -175,7 +180,10 @@ export async function mcpCommand(tokens) { export async function skillCommand(tokens) { const verb = tokens[0]; if (!verb || verb === "list") { printSkillTargets(); return; } - if (verb !== "install") { console.log(err(`unknown skill verb "${verb}" — try install or list`)); return; } + if (verb !== "install") { + console.log(err(`unknown skill verb "${verb}" — try ${SKILL_VERBS.map(({ name }) => name).join(" or ")}`)); + return; + } const rest = tokens.slice(1); let name, source; diff --git a/test/completion.test.mjs b/test/completion.test.mjs new file mode 100644 index 0000000..c8c499a --- /dev/null +++ b/test/completion.test.mjs @@ -0,0 +1,156 @@ +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 { + COMPLETION_SHELLS, + completionModel, + completionScript, +} from "../src/completion.mjs"; +import { MCP_VERBS, UPGRADE_TARGETS } from "../src/cli-schema.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("'", "'\\''")}'`; +} + +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); +} + +test("completion model derives engines, aliases, and tools from their registries", () => { + const model = completionModel(); + const top = new Set(names(model.top)); + const install = new Set(names(model.install)); + const engines = new Set(names(model.engines)); + + for (const name of Object.keys(ENGINES)) { + assert.ok(top.has(name)); + assert.ok(install.has(name)); + assert.ok(engines.has(name)); + } + for (const name of Object.keys(ENGINE_ALIASES)) { + assert.ok(top.has(name)); + assert.ok(!install.has(name)); + assert.ok(engines.has(name)); + } + for (const name of Object.keys(TOOLS)) { + assert.ok(top.has(name)); + assert.ok(install.has(name)); + } + + const upgrade = new Set(names(model.upgrade)); + for (const { name } of UPGRADE_TARGETS) assert.ok(upgrade.has(name)); + + assert.deepEqual( + new Set(names(model.mcpServerSpecs)), + new Set(MCP_VERBS.filter(({ acceptsServerSpec }) => acceptsServerSpec).map(({ name }) => name)), + ); +}); + +test("completion schema covers every explicitly dispatched CLI command", () => { + const source = readFileSync(BIN, "utf8"); + const dispatched = [...source.matchAll(/cmd === "([^"]+)"/g)].map((match) => match[1]); + const top = new Set(names(completionModel().top)); + + for (const command of [...dispatched, "help"]) { + assert.ok(top.has(command), `${command} is dispatched but missing from completion`); + } +}); + +for (const shell of COMPLETION_SHELLS) { + test(`completionScript emits context-aware ${shell} completion`, () => { + const output = completionScript(shell); + assert.match(output, /moshcode/); + assert.match(output, /completion/); + assert.match(output, /\blist\b/); + assert.match(output, /\bcatalog\b/); + }); + + test(`moshcode completion ${shell} prints exactly the generated script`, () => { + const result = spawnSync(process.execPath, [BIN, "completion", shell], { + encoding: "utf8", + }); + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.equal(result.stdout, completionScript(shell)); + }); +} + +for (const [shell, args] of [ + ["bash", ["-n", "-c"]], + ["zsh", ["-n", "-c"]], + ["fish", ["--no-execute", "-c"]], +]) { + test(`${shell} accepts its generated completion script`, (t) => { + const result = spawnSync(shell, [...args, completionScript(shell)], { + encoding: "utf8", + }); + if (result.error?.code === "ENOENT") { + t.skip(`${shell} is not installed`); + return; + } + assert.equal(result.status, 0, result.stderr); + }); +} + +test("bash completion respects argument depth and preserves file fallbacks", () => { + assert.ok(bashCompletions(["moshcode", "cl"]).includes("claude")); + assert.ok(bashCompletions(["moshcode", "agents", ""]).includes("cc")); + assert.ok(bashCompletions(["moshcode", "install", ""]).includes("claude")); + assert.deepEqual(bashCompletions(["moshcode", "install", "claude", ""]), []); + + const mcp = bashCompletions(["moshcode", "mcp", ""]); + assert.ok(mcp.includes("list")); + assert.ok(mcp.includes("catalog")); + assert.deepEqual(bashCompletions(["moshcode", "mcp", "install", ""]), []); + + assert.deepEqual(bashCompletions(["moshcode", "run", ""]), []); + assert.ok(bashCompletions(["moshcode", "run", "--"]).includes("--dry-run")); + assert.ok(bashCompletions(["moshcode", "upgrade", "claude", ""]).includes("codex")); + assert.ok(bashCompletions(["moshcode", "upgrade", ""]).includes("all")); + assert.ok(bashCompletions(["moshcode", "upgrade", ""]).includes("moshcode")); + + const strictScript = `set -u +${completionScript("bash")} +COMP_WORDS=(moshcode "") +COMP_CWORD=1 +_moshcode_completion +`; + const strictResult = spawnSync("bash", ["-c", strictScript], { encoding: "utf8" }); + assert.equal(strictResult.status, 0, strictResult.stderr); + + assert.match( + completionScript("bash"), + /complete -o bashdefault -o default -F _moshcode_completion moshcode/, + ); +}); + +test("completion normalizes shell names and rejects unsupported values", () => { + assert.equal(completionScript(" BASH "), completionScript("bash")); + assert.throws(() => completionScript(), /unsupported shell/); + assert.throws(() => completionScript("powershell"), /choose: bash, zsh, fish/); + + const result = spawnSync(process.execPath, [BIN, "completion"], { + encoding: "utf8", + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /usage: moshcode completion /); +});