From 664588c32d738b8c32f2cf28dd183406b7fa18e5 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 19 Aug 2026 14:53:26 +0000 Subject: [PATCH] alias: let the pit adopt the shortcuts a workflow tool offers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several tools in the roster ship a set of commands rather than one binary, and propose short words for them at the pit prompt. Until now the only way to get those words was to let the tool write ~/.moshcode/aliases.json itself — a config it does not own, and the same objection that keeps `railway setup agent` out of /install. So the tool proposes and the pit disposes. A tool declares an `aliases` spec in TOOLS; `/alias install ` runs it, reads the JSON, and merges it. Declared rather than probed: `aliases --json` guessed at every installed CLI would eventually hit one where those words mean something else. A name the operator bound themselves always wins and is named in the report rather than silently kept — theirs and the tool's suggestion are both plausible, and the pit's own aliases carry flags (--orgs, --apply) that a generic suggestion knows nothing about. Names that collide with a pit command, engine, or tool are refused for the reason /alias set refuses them: built-ins resolve first, so such an alias would be dead on arrival. Its own verb rather than a step inside /install, because writing the operator's aliases is a side effect an install command has no business having — and a roster is worth adopting long after the day a tool was installed. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 15 +++ src/aliases.mjs | 65 +++++++++++ src/cli-schema.mjs | 7 +- src/tools.mjs | 66 +++++++++++ src/tui.mjs | 99 +++++++++++++++- test/alias-install.test.mjs | 223 ++++++++++++++++++++++++++++++++++++ 6 files changed, 470 insertions(+), 5 deletions(-) create mode 100644 test/alias-install.test.mjs 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")); +});