diff --git a/README.md b/README.md index 4dce871..0f17328 100644 --- a/README.md +++ b/README.md @@ -805,6 +805,21 @@ survive between sessions. A name that is already a pit command, an engine, or a tool is refused rather than shadowed — built-ins are dispatched first, so such an alias would never run. +Some workflow tools ship a set of commands rather than one binary, and propose +short words for them. `/alias install` adopts those: + +```text +/alias install cli-tools # /blog, /free, /whois — from the tool itself +/alias install --all # every tool that offers them +``` + +The tool proposes and the pit disposes: moshcode reads the suggestions and +writes the file, so nothing else reaches into a config it does not own. A name +you bound yourself always wins and is reported rather than replaced — your +`/prs` may carry `--orgs` flags a generic suggestion knows nothing about. +Deliberately its own verb rather than a step inside `/install`, which has no +business rewriting your aliases as a side effect. + ### Social posting from the pit The pit can hand a prepared post to Bluesky or Nostr without storing either diff --git a/src/aliases.mjs b/src/aliases.mjs index dd8326c..810e819 100644 --- a/src/aliases.mjs +++ b/src/aliases.mjs @@ -158,3 +158,68 @@ export function expandAlias(value, args = "") { const line = `${String(value).trim()}${args ? ` ${args}` : ""}`; return /^[/!]/.test(line) ? line : `!${line}`; } + +/** + * How many aliases one tool may offer in a single install. + * + * A bound, not a judgement about any tool: the proposal is a subprocess's + * stdout being written into the operator's config, and an unbounded loop over + * whatever it printed is how a bug in a tool becomes a thousand-line + * aliases.json. No CLI here offers more than a handful. + */ +export const MAX_PROPOSED = 64; + +/** + * Merge a tool's proposed aliases into the operator's file, in one write. + * + * Existing names always win. The file is the operator's: silently repointing a + * word they bound themselves is the kind of change nothing surfaces until the + * wrong command runs — and the pit's own aliases carry flags (`--orgs`, + * `--apply`) that a tool's generic suggestion does not know about. + * + * Names that collide with a pit command, engine, or tool are refused rather + * than written, for the reason setAlias refuses them: built-ins are resolved + * first, so such an alias would be silently dead. + * + * Returns { ok, error, added, kept, refused } — each a list of + * { name, value, previous? , reason? } so the caller can say what happened + * without re-deriving it. + */ +export function mergeAliases(proposed, { isReserved = () => false } = {}) { + const added = []; + const kept = []; + const refused = []; + if (!proposed || typeof proposed !== "object" || Array.isArray(proposed)) { + return { ok: false, error: "that isn't a set of aliases", added, kept, refused }; + } + + const existing = loadAliases(); + const merged = { ...existing }; + // Sorted so the report reads the same way twice regardless of the order the + // tool happened to print, and truncated rather than refused: a tool that + // offers too many still gets its first MAX_PROPOSED written, and the caller + // is told what was dropped. + const entries = Object.entries(proposed).sort(([a], [b]) => a.localeCompare(b)); + const dropped = Math.max(0, entries.length - MAX_PROPOSED); + + for (const [rawName, rawValue] of entries.slice(0, MAX_PROPOSED)) { + const name = normalizeName(rawName); + const value = typeof rawValue === "string" ? rawValue.trim() : ""; + if (!name) { refused.push({ name: String(rawName), reason: "not a usable alias name" }); continue; } + if (!value) { refused.push({ name, reason: "nothing to run" }); continue; } + if (value.includes("\n")) { refused.push({ name, reason: "an alias is a single line" }); continue; } + if (value.length > MAX_VALUE) { refused.push({ name, reason: `${value.length} characters — the cap is ${MAX_VALUE}` }); continue; } + if (isReserved(name)) { refused.push({ name, reason: "already a pit command, engine, or tool" }); continue; } + if (Object.hasOwn(existing, name)) { kept.push({ name, value: existing[name], proposed: value }); continue; } + merged[name] = value; + added.push({ name, value }); + } + + // Nothing new is still a success — "already up to date" is the common case on + // a second run, and writing the file again to say so would only churn mtime. + if (added.length) { + try { saveAliases(merged); } + catch (e) { return { ok: false, error: `can't write ${aliasFile()}: ${e.message}`, added: [], kept, refused }; } + } + return { ok: true, added, kept, refused, dropped }; +} diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 685357a..5ad8052 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -1176,23 +1176,26 @@ export const PIT_COMMANDS = [ description: "show the current dir + git repo/branch/origin" }, { name: "shell", aliases: ["sh"], args: "[cmd]", pitOnly: true, description: "drop into $SHELL (exit → back to the pit); also !cmd" }, - { name: "alias", aliases: ["aliases"], args: 'set "" | list | get | rm', pitOnly: true, + { name: "alias", aliases: ["aliases"], args: 'set "" | list | get | rm | install ', pitOnly: true, description: "name a line you keep retyping; / runs it", synopsis: [ ['/alias set ""', "define one (also: /alias \"\")"], ["/alias [list] [--json]", "every alias"], ["/alias get ", "what one expands to"], ["/alias rm ", "forget one"], + ["/alias install | --all", "adopt the aliases a workflow tool offers"], ], examples: [ ['/alias set gs "git status"', "then /gs — and /gs -sb appends"], // Deliberately not `cc`: that one is already how the pit spells claude, // so the example would print a refusal for anyone who typed it. ['/alias set cx "/agents codex"', "a pit command, not a shell one"], + ["/alias install cli-tools", "/blog, /free, /whois — from the tool itself"], ["/alias rm gs", ""], ], note: "the command runs in $SHELL unless it starts with / — then it is a pit command. " - + "Aliases live in ~/.moshcode/aliases.json and cannot shadow a pit command, engine, or tool.", + + "Aliases live in ~/.moshcode/aliases.json and cannot shadow a pit command, engine, or tool. " + + "/alias install never overwrites a name you bound yourself.", }, { name: "help", aliases: ["?", "h"], args: "[command]", pitOnly: true, description: "this, or one command in detail" }, diff --git a/src/tools.mjs b/src/tools.mjs index 3c5fb0d..046a731 100644 --- a/src/tools.mjs +++ b/src/tools.mjs @@ -4,6 +4,7 @@ // c0upons owns community coupons and bounties, the cloud CLIs below own // deploys/secrets/infra, Coral owns read-only data access across those systems, // and moshcode only conducts their native command lines. +import { spawnSync } from "node:child_process"; import { homedir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -69,6 +70,12 @@ export const TOOLS = { // elsewhere, and `cli-tools update` refuses to move a dirty or diverged // tree rather than discarding work. upgrade: { cmd: "cli-tools", args: ["update"] }, + // The pit aliases this set offers, read by `/alias install cli-tools`. + // Declared rather than probed: a command that prints a set of aliases is + // only safe to run against a tool we already know answers it, and an + // `aliases --json` guessed at every installed CLI would eventually hit one + // where those words mean something else entirely. + aliases: { cmd: "cli-tools", args: ["aliases", "--json"] }, }, secrets: { desc: "LogicSRC — end-to-end-encrypted team credential sharing (login, teams, credentials)", @@ -306,6 +313,65 @@ export function openTool(tool, args = [], opts = {}) { return openPassthrough(tool, args, opts); } +/** The command that prints a tool's proposed pit aliases, or null. */ +export function toolAliasSpec(tool) { + return tool?.aliases || null; +} + +/** Every tool that offers pit aliases, as `[key, tool]`. */ +export function toolsWithAliases() { + return Object.entries(TOOLS).filter(([, tool]) => toolAliasSpec(tool)); +} + +/** + * How long a tool gets to print its aliases before we stop waiting. + * + * This runs on an interactive verb, so the failure we care about is a CLI that + * blocks on something — a login prompt, a network read — rather than one that + * is merely slow. Printing a constant should take milliseconds. + */ +const ALIAS_TIMEOUT_MS = 10_000; + +/** + * Ask a tool for the pit aliases it proposes: `{ ok, aliases, error }`. + * + * Captured rather than passed through, because the output is data we are about + * to merge into the operator's config and not something to put on their + * screen. Every failure is a returned reason instead of a throw — a tool that + * is not installed, prints nothing, or prints something that is not JSON is an + * ordinary outcome of this verb, not an error the pit should fall over on. + * + * `run` is injectable so the tests do not need seven CLIs on PATH. + */ +export function readToolAliases(tool, { run = spawnSync } = {}) { + const spec = toolAliasSpec(tool); + if (!spec) return { ok: false, error: "offers no aliases" }; + let result; + try { + result = run(spec.cmd, spec.args, { + encoding: "utf8", + timeout: ALIAS_TIMEOUT_MS, + // No inherited stdin: a tool that decides to ask a question here would + // otherwise hang the pit on a prompt nobody can see. + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (e) { + return { ok: false, error: e.message }; + } + if (result.error) return { ok: false, error: result.error.message }; + if (result.status !== 0) { + const said = String(result.stderr || "").trim().split("\n")[0]; + return { ok: false, error: said || `${spec.cmd} exited ${result.status}` }; + } + let parsed; + try { parsed = JSON.parse(String(result.stdout || "")); } + catch { return { ok: false, error: `${spec.cmd} didn't print JSON` }; } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { ok: false, error: `${spec.cmd} printed JSON, but not a set of aliases` }; + } + return { ok: true, aliases: parsed }; +} + // Generic utilities used by the app/package surface. /** diff --git a/src/tui.mjs b/src/tui.mjs index b59ae57..0375d9f 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -8,7 +8,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { ENGINES, agentLaunchArgs, resolveEngine, engineStatus, openSession } from "./engines.mjs"; -import { TOOLS, resolveTool, toolStatus, openTool } from "./tools.mjs"; +import { TOOLS, resolveTool, toolStatus, openTool, readToolAliases, toolsWithAliases } from "./tools.mjs"; import { tradeArgs, tradeUsage } from "./trade.mjs"; import { postSocial, socialRoster } from "./socials.mjs"; import { runUpgrade } from "./upgrade.mjs"; @@ -31,7 +31,7 @@ import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs"; import { RENAMED_COMMANDS, findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs"; import { openNewTab } from "./tabs.mjs"; -import { MAX_EXPANSIONS, expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs"; +import { MAX_EXPANSIONS, expandAlias, getAlias, loadAliases, mergeAliases, removeAlias, setAlias } from "./aliases.mjs"; import { herdCommand, herdStart, renderRoster, roster, splitDetachArgs } from "./herd-cli.mjs"; import { detectSubstrate, substrateNote } from "./herd.mjs"; @@ -269,6 +269,92 @@ function printAliases({ json = false } = {}) { } } +/** + * `/alias install ` — adopt the pit aliases a workflow tool offers. + * + * The tools in src/tools.mjs are separate products with their own release + * cycles, and several of them ship a set of commands rather than one binary. + * Which short words those deserve at this prompt is a question only the tool + * can answer, and only moshcode can act on: the file is ours, so a tool that + * wrote it directly would be reaching into a config it does not own — the same + * objection that keeps `railway setup agent` out of /install. + * + * So the tool proposes and the pit disposes. Deliberately its own verb rather + * than a step inside /install: writing the operator's aliases is a side effect + * an install command has no business having, and the roster is worth adopting + * long after the day a tool was installed. + */ +function aliasInstallCommand(args) { + const all = args.includes("--all"); + const names = args.filter((a) => !a.startsWith("-")); + if (!all && !names.length) { + const offered = toolsWithAliases().map(([key]) => key); + console.log(err("usage: /alias install | --all")); + console.log(ash(offered.length + ? ` tools that offer aliases: ${offered.join(", ")}` + : " no tool offers aliases yet")); + return; + } + + const wanted = all + ? toolsWithAliases() + : names.map((name) => [name, resolveTool(name)?.[1] ?? null]); + + // A run over --all reports per tool, because "3 added, 2 kept" for a roster + // is the useful shape; a single named tool reports per alias, because those + // are the words you are about to type. + let touched = 0; + for (const [key, tool] of wanted) { + const label = acid(`/${key}`); + if (!tool) { console.log(err(`no tool named "${key}" — /tools for the roster`)); continue; } + if (!tool.aliases) { + // Unreachable under --all, whose roster is exactly the tools that offer + // aliases, so this can only be one the operator named by hand. + console.log(info(`${label} offers no aliases`)); + continue; + } + const read = readToolAliases(tool); + if (!read.ok) { + console.log(err(`${label} — ${read.error}`)); + if (!isInstalledTool(key)) console.log(ash(` not installed here — /install ${key}`)); + continue; + } + const result = mergeAliases(read.aliases, { isReserved: isReservedName }); + if (!result.ok) { console.log(err(`${label} — ${result.error}`)); continue; } + touched += result.added.length; + + if (all) { + const parts = [`${result.added.length} added`]; + if (result.kept.length) parts.push(`${result.kept.length} kept`); + if (result.refused.length) parts.push(`${result.refused.length} refused`); + console.log(` ${label.padEnd(20)} ${ash(parts.join(", "))}`); + continue; + } + for (const { name, value } of result.added) { + console.log(` ${ok(`${acid(`/${name}`)} ${ash("→")} ${bone(value)}`)}`); + } + // Named rather than counted: an alias the operator already owns is the one + // case where nothing changed *and* they need to know which word it was, + // because theirs and the tool's suggestion are both plausible. + for (const { name, value } of result.kept) { + console.log(` ${info(`kept your own ${acid(`/${name}`)} ${ash(`(${value})`)}`)}`); + } + for (const { name, reason } of result.refused) { + console.log(` ${warn(`skipped ${acid(`/${name}`)} ${ash(`— ${reason}`)}`)}`); + } + if (result.dropped) console.log(ash(` ${result.dropped} more offered than /alias install writes at once`)); + if (!result.added.length && !result.kept.length && !result.refused.length) { + console.log(info(`${label} offers no aliases`)); + } + } + if (touched) console.log(ash(" run one with / · /alias list for all of them")); +} + +/** Is this tool's native executable present? Used only to explain a failure. */ +function isInstalledTool(key) { + return Boolean(toolStatus().find((entry) => entry.key === key)?.installed); +} + /** * `/alias` — define, list, and forget the shortcuts (src/aliases.mjs). * @@ -289,6 +375,13 @@ function aliasCommand(rest, line) { printAliases({ json }); return; } + // Before `set`, because `install` is a verb and not a name: falling through + // to the bare-`/alias ` shorthand would define an alias called + // "install" pointing at whatever came next. + if (sub === "install" || sub === "adopt") { + aliasInstallCommand(args); + return; + } if (sub === "set" || sub === "add") { const name = args[0]; const value = aliasValue(line); @@ -322,7 +415,7 @@ function aliasCommand(rest, line) { // when there is a value after it, or `/alias gs` would silently define // nothing. if (args.length) { aliasCommand(["set", ...rest], `/alias set ${commandRemainder(line)}`); return; } - console.log(err(`unknown /alias verb "${verb}" — set, list, get, rm`)); + console.log(err(`unknown /alias verb "${verb}" — set, list, get, rm, install`)); } /** diff --git a/test/alias-install.test.mjs b/test/alias-install.test.mjs new file mode 100644 index 0000000..17a5c03 --- /dev/null +++ b/test/alias-install.test.mjs @@ -0,0 +1,223 @@ +// `/alias install ` — the pit adopting the shortcuts a workflow tool +// offers, without ever letting the tool write ~/.moshcode/aliases.json itself. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { mergeAliases } from "../src/aliases.mjs"; +import { TOOLS, readToolAliases, toolsWithAliases } from "../src/tools.mjs"; + +const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url)); + +/* --------------------------------------------------------- reading a tool */ + +/** A spawnSync stand-in that answers with whatever this test wants. */ +const answers = ({ stdout = "", stderr = "", status = 0, error = null }) => () => + ({ stdout, stderr, status, error }); + +test("readToolAliases parses the tool's JSON", () => { + const result = readToolAliases(TOOLS["cli-tools"], { + run: answers({ stdout: '{"blog":"blog-post","free":"domainfree"}' }), + }); + assert.deepEqual(result, { ok: true, aliases: { blog: "blog-post", free: "domainfree" } }); +}); + +test("readToolAliases reports rather than throws when the tool misbehaves", () => { + const cases = [ + [{ stdout: "not json" }, /didn't print JSON/], + [{ stdout: "[1,2]" }, /not a set of aliases/], + [{ status: 127, stderr: "command not found" }, /command not found/], + [{ error: new Error("spawn ENOENT") }, /ENOENT/], + ]; + for (const [reply, expected] of cases) { + const result = readToolAliases(TOOLS["cli-tools"], { run: answers(reply) }); + assert.equal(result.ok, false); + assert.match(result.error, expected); + } +}); + +test("a tool that declares no aliases says so instead of being probed", () => { + let ran = false; + const result = readToolAliases(TOOLS.railway, { run: () => { ran = true; return {}; } }); + assert.equal(result.ok, false); + assert.equal(ran, false, "an undeclared tool must never be executed on a guess"); +}); + +test("the --all roster is exactly the tools that declare aliases", () => { + const keys = toolsWithAliases().map(([key]) => key); + assert.ok(keys.includes("cli-tools")); + assert.ok(!keys.includes("railway")); + for (const key of keys) assert.ok(TOOLS[key].aliases, `${key} should declare an aliases spec`); +}); + +/* ------------------------------------------------------------ the merge */ + +/** + * Run one merge against an empty, throwaway aliases file. + * + * src/aliases.mjs derives the path from the home directory on every call for + * exactly this reason. Without the swap these read — and, on any name the + * proposal adds, *write* — the aliases of whoever is running the suite. + */ +function inFreshHome(fn) { + const previous = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; + const home = mkdtempSync(join(tmpdir(), "moshcode-merge-")); + process.env.HOME = home; + process.env.USERPROFILE = home; + try { return fn(home); } + finally { + process.env.HOME = previous.HOME; + process.env.USERPROFILE = previous.USERPROFILE; + } +} + +test("mergeAliases refuses names the pit already owns", () => { + inFreshHome(() => { + const result = mergeAliases( + { blog: "blog-post", agents: "echo nope" }, + { isReserved: (name) => name === "agents" }, + ); + assert.deepEqual(result.added.map((a) => a.name), ["blog"]); + assert.deepEqual(result.refused.map((a) => a.name), ["agents"]); + }); +}); + +test("mergeAliases keeps an existing line and reports which one", () => { + inFreshHome((home) => { + mkdirSync(join(home, ".moshcode"), { recursive: true }); + writeFileSync(join(home, ".moshcode", "aliases.json"), '{"prs":"gh-prs-all"}\n'); + const result = mergeAliases({ prs: "gh-prs", free: "domainfree" }); + assert.deepEqual(result.added.map((a) => a.name), ["free"]); + assert.deepEqual(result.kept, [{ name: "prs", value: "gh-prs-all", proposed: "gh-prs" }]); + assert.equal(aliasesOf(home).prs, "gh-prs-all"); + }); +}); + +test("mergeAliases refuses a value that isn't a single usable line", () => { + inFreshHome(() => { + const result = mergeAliases({ a: "", b: "one\ntwo", c: 42, d: "x".repeat(5000) }); + assert.equal(result.added.length, 0); + assert.deepEqual(result.refused.map((a) => a.name).sort(), ["a", "b", "c", "d"]); + }); +}); + +test("mergeAliases writes at most MAX_PROPOSED and says what it dropped", () => { + inFreshHome(() => { + const flood = Object.fromEntries( + Array.from({ length: 100 }, (_, i) => [`n${String(i).padStart(3, "0")}`, "echo x"]), + ); + const result = mergeAliases(flood); + assert.equal(result.dropped, 36); + assert.equal(result.added.length, 64); + }); +}); + +test("mergeAliases treats anything that isn't an object as no aliases at all", () => { + inFreshHome(() => { + for (const bad of [null, [1, 2], "blog", 7]) { + const result = mergeAliases(bad); + assert.equal(result.ok, false); + assert.equal(result.added.length, 0); + } + }); +}); + +/* ------------------------------------------------------------ in the pit */ + +/** + * Drive a pit with a fake `cli-tools` first on PATH. + * + * A fake rather than the real one because the assertion is about what the pit + * does with a tool's answer, and a suite that only passes on a machine where + * profullstack/cli-tools happens to be installed is a suite that reports the + * wrong thing on CI. + * + * One line per prompt: readline in non-terminal mode drops every buffered line + * but the one a `question` is waiting on. Same reason as test/aliases.test.mjs. + */ +function runTui(lines, { home, toolStdout = '{"blog":"blog-post","free":"domainfree"}', toolStatus = 0 } = {}) { + const HOME = home || mkdtempSync(join(tmpdir(), "moshcode-alias-install-")); + const binDir = join(HOME, "fakebin"); + mkdirSync(binDir, { recursive: true }); + const fake = join(binDir, "cli-tools"); + writeFileSync(fake, `#!/bin/sh\ncat <<'JSON'\n${toolStdout}\nJSON\nexit ${toolStatus}\n`); + chmodSync(fake, 0o755); + + const queue = [...(Array.isArray(lines) ? lines : [lines]), "/quit"]; + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [BIN], { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + HOME, + USERPROFILE: HOME, + PATH: `${binDir}:${process.env.PATH}`, + MOSHCODE_NO_MIRROR: "1", + }, + }); + let stdout = ""; + let seen = 0; + child.stdout.on("data", (chunk) => { + stdout += chunk; + const prompts = stdout.split("mosh ▸").length - 1; + while (seen < prompts) { + seen += 1; + const next = queue.shift(); + if (next === undefined) { child.stdin.end(); return; } + child.stdin.write(`${next}\n`); + } + }); + child.stderr.on("data", () => {}); + child.on("error", reject); + child.on("close", (status) => resolve({ status, stdout, home: HOME })); + }); +} + +const aliasesOf = (home) => JSON.parse(readFileSync(join(home, ".moshcode", "aliases.json"), "utf8")); + +test("/alias install adopts a tool's aliases, and / then runs one", async () => { + const result = await runTui([ + "/alias install cli-tools", + "/alias get blog", + ]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /\/blog/); + assert.match(result.stdout, /blog-post/); + assert.deepEqual(aliasesOf(result.home), { blog: "blog-post", free: "domainfree" }); +}); + +test("/alias install never overwrites a name you bound yourself", async () => { + const first = await runTui('/alias set blog "blog-post --mine"'); + const second = await runTui("/alias install cli-tools", { home: first.home }); + + assert.match(second.stdout, /kept your own/); + assert.equal(aliasesOf(second.home).blog, "blog-post --mine", "the operator's line must survive"); + assert.equal(aliasesOf(second.home).free, "domainfree", "the rest should still be adopted"); +}); + +test("/alias install is idempotent", async () => { + const first = await runTui("/alias install cli-tools"); + const second = await runTui("/alias install cli-tools", { home: first.home }); + assert.deepEqual(aliasesOf(second.home), aliasesOf(first.home)); +}); + +test("/alias install reports a tool that fails instead of writing anything", async () => { + const result = await runTui("/alias install cli-tools", { toolStdout: "boom", toolStatus: 1 }); + assert.match(result.stdout, /cli-tools/); + assert.doesNotMatch(result.stdout, /✓ \/blog/); +}); + +test("/alias install names an unknown tool rather than defining an alias for it", async () => { + const result = await runTui("/alias install nosuchtool"); + assert.match(result.stdout, /no tool named/); +}); + +test('"install" is a verb, so it never becomes an alias called install', async () => { + const result = await runTui(["/alias install cli-tools", "/alias list"]); + assert.ok(!Object.hasOwn(aliasesOf(result.home), "install")); +});