From cd6f996dc97220ef321a3c4aef44ebff3abe5533 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 10 Aug 2026 09:36:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(pit):=20/alias=20=E2=80=94=20name=20the=20?= =?UTF-8?q?lines=20you=20keep=20retyping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pit is a prompt people sit at all day, and the things they retype are their own: `git status`, `pnpm -r test`, `/agents claude --resume`. Shell aliases cannot help, because the pit is not a shell — and `!git status` is exactly the keystrokes an alias is supposed to save. /alias set gs "git status" → /gs, and /gs -sb appends /alias set cx "/agents codex" → a pit command, not a shell one /alias → what is defined /alias rm gs A value runs in $SHELL unless it starts with `/`, in which case it is a pit command. Shell-by-default because that is what the prompt is mostly asked for, and the leading slash is already how the pit spells its own verbs, so the rule reads the same way the rest of the pit does. An alias expands into a line that goes back through the top of the dispatch loop rather than through a second copy of the dispatcher, which is what makes `/gs -sb` append the way a shell alias does and lets one alias name another. Expansion is bounded, so two aliases naming each other report a loop instead of spinning the prompt. Aliases are dispatched last, so they can never shadow a built-in — and `/alias set` refuses a name the pit already owns (commands, engines and their aliases, tools) rather than accepting a shortcut that would be silently dead. The store is ~/.moshcode/aliases.json, owner-only for the same reason the history file is: values are whatever was typed, and people alias commands that carry tokens. A corrupt or hand-broken file reads as no aliases rather than taking the prompt down. Also teaches renderPitCommand the synopsis/examples/note shapes renderCommand already reads, so a pit-only verb with sub-verbs can document itself; `/quit` and friends are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 18 ++++ src/aliases.mjs | 160 +++++++++++++++++++++++++++++++++++ src/cli-schema.mjs | 18 ++++ src/help.mjs | 15 +++- src/tui.mjs | 170 ++++++++++++++++++++++++++++++++++--- test/aliases.test.mjs | 192 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 558 insertions(+), 15 deletions(-) create mode 100644 src/aliases.mjs create mode 100644 test/aliases.test.mjs diff --git a/README.md b/README.md index a052ae6..afa7213 100644 --- a/README.md +++ b/README.md @@ -543,6 +543,24 @@ an equity's score, and each response ships the `caveats` that say so. Prices are Alpaca's US venue alone and can differ materially from other exchanges. Research aid, not advice — and like `stocks`, nothing under `crypto` can place an order. +### Aliases (`/alias`) + +The pit is a prompt you sit at all day, so it lets you name the lines you keep +retyping. An alias runs in `$SHELL` unless it starts with `/`, in which case it +is a pit command: + +```text +/alias set gs "git status" # then /gs — and /gs -sb appends to it +/alias set cx "/agents codex" # a pit command, not a shell one +/alias # what is defined +/alias rm gs +``` + +They live in `~/.moshcode/aliases.json` (owner-only, like the history file) and +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. + ### 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 new file mode 100644 index 0000000..c0f5db2 --- /dev/null +++ b/src/aliases.mjs @@ -0,0 +1,160 @@ +// Named shortcuts for whatever you type at the mosh prompt. +// +// The pit is a prompt people sit at all day, and the things they retype are +// their own: `git status`, `pnpm -r test`, `/agents claude --resume`. Shell +// aliases can't help — the pit is not a shell, and `!git status` is exactly the +// keystrokes an alias is supposed to save. So the pit keeps its own. +// +// An alias is a name and a line. The line is a shell command unless it starts +// with `/`, in which case it is a pit command: +// +// /alias set gs "git status" → /gs runs `$SHELL -c "git status"` +// /alias set cc "/agents claude" → /cc opens claude autonomously +// +// Shell-by-default because that is what the prompt is mostly asked for, and the +// leading slash is already how the pit spells its own verbs — so the rule reads +// the same way the rest of the pit does rather than being a new convention. +// +// Anything the pit can dispatch is fair game as a value, which is what keeps +// this from needing to grow a type: a bookmarklet or a URL becomes an alias the +// day the pit gets a verb that opens one, with no change here. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** Owner-only, and for the same reason ~/.moshcode_history is: values are + * whatever was typed, and people alias commands that carry tokens. */ +const FILE_MODE = 0o600; + +const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/; + +/** A value long enough to be a pasted mistake rather than a command. */ +const MAX_VALUE = 4096; + +/** + * How many times one line may expand before the pit gives up. + * + * Aliases can name aliases (`/alias set st "/gs --short"`), which is useful and + * also the one way to write a loop: two aliases naming each other would spin + * the dispatch loop forever. Ten is far past any chain a person builds on + * purpose. + */ +export const MAX_EXPANSIONS = 10; + +/** Where the aliases live. Derived per call so tests can move $HOME. */ +export function aliasFile() { + return path.join(os.homedir(), ".moshcode", "aliases.json"); +} + +/** + * Every alias, as a plain name → line map. + * + * A file that is missing, unreadable, or not the shape we wrote reads as "no + * aliases" rather than throwing: this is called on the dispatch path for every + * unrecognised command, and a hand-edited file with a stray comma must not take + * the prompt down with it. Entries whose value is not a string are dropped for + * the same reason. + */ +export function loadAliases() { + let raw; + try { raw = fs.readFileSync(aliasFile(), "utf8"); } + catch { return {}; } + let parsed; + try { parsed = JSON.parse(raw); } + catch { return {}; } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const out = {}; + for (const [name, value] of Object.entries(parsed)) { + if (typeof value === "string" && value.trim()) out[name.toLowerCase()] = value; + } + return out; +} + +/** Write the map back, creating ~/.moshcode if this is the first alias. */ +function saveAliases(aliases) { + const file = aliasFile(); + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + // Sorted so the file reads like a list rather than like insertion order, and + // so hand edits produce a small diff. + const ordered = Object.fromEntries(Object.keys(aliases).sort().map((k) => [k, aliases[k]])); + fs.writeFileSync(file, `${JSON.stringify(ordered, null, 2)}\n`, { mode: FILE_MODE }); + // `mode` only applies at creation, so an existing file keeps whatever the + // umask gave it. Tighten every write, the way the history file does. + try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ } +} + +/** The name as it is stored, or "" for anything that cannot be one. */ +export function normalizeName(name) { + const clean = String(name ?? "").trim().toLowerCase().replace(/^\//, ""); + return NAME_RE.test(clean) ? clean : ""; +} + +/** One alias's line, or null. */ +export function getAlias(name) { + const key = normalizeName(name); + if (!key) return null; + const aliases = loadAliases(); + return Object.hasOwn(aliases, key) ? aliases[key] : null; +} + +/** + * Define an alias. Returns { ok, error, name, value, previous }. + * + * `isReserved` asks the pit whether a name is already its own — a command, an + * engine, a tool. A predicate rather than a list because the dispatcher decides + * that by resolving, aliases included, and a list copied out of the rosters + * here would be a second answer that drifts from the first. A colliding name is + * refused rather than shadowed: built-ins are checked first, so an alias named + * `agents` would be silently dead, and a shortcut that does nothing is worse + * than one that was never accepted. + */ +export function setAlias(name, value, { isReserved = () => false } = {}) { + const key = normalizeName(name); + if (!key) { + return { ok: false, error: `"${name}" isn't a usable alias name — letters, digits, . _ - and it must start with a letter or digit` }; + } + if (isReserved(key)) { + return { ok: false, error: `/${key} is already a pit command, engine, or tool — pick another name` }; + } + const line = String(value ?? "").trim(); + if (!line) return { ok: false, error: "an alias needs something to run" }; + if (line.includes("\n")) return { ok: false, error: "an alias is a single line" }; + if (line.length > MAX_VALUE) return { ok: false, error: `that value is ${line.length} characters — the cap is ${MAX_VALUE}` }; + + const aliases = loadAliases(); + const previous = Object.hasOwn(aliases, key) ? aliases[key] : null; + aliases[key] = line; + try { saveAliases(aliases); } + catch (e) { return { ok: false, error: `can't write ${aliasFile()}: ${e.message}` }; } + return { ok: true, name: key, value: line, previous }; +} + +/** Forget one. Returns { ok, error, name, value }. */ +export function removeAlias(name) { + const key = normalizeName(name); + const aliases = loadAliases(); + if (!key || !Object.hasOwn(aliases, key)) { + return { ok: false, error: `no alias named "${String(name ?? "").replace(/^\//, "")}"` }; + } + const value = aliases[key]; + delete aliases[key]; + try { saveAliases(aliases); } + catch (e) { return { ok: false, error: `can't write ${aliasFile()}: ${e.message}` }; } + return { ok: true, name: key, value }; +} + +/** + * The line an alias becomes, with anything else the user typed appended. + * + * Appended rather than substituted, the way a shell alias behaves: `/gs -sb` is + * `git status -sb`. `args` is the raw remainder of the typed line, not the + * tokenized parts, so the user's own quoting survives into `$SHELL -c`. + * + * The `!` is what routes a bare value to the shell — the pit already reads a + * leading `!` as "run this in $SHELL", so an alias does not need a second path + * through it. + */ +export function expandAlias(value, args = "") { + const line = `${String(value).trim()}${args ? ` ${args}` : ""}`; + return /^[/!]/.test(line) ? line : `!${line}`; +} diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 0487778..3883140 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -895,6 +895,24 @@ 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, + 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"], + ], + 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 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.", + }, { name: "help", aliases: ["?", "h"], args: "[command]", pitOnly: true, description: "this, or one command in detail" }, { name: "quit", aliases: ["exit", "q"], pitOnly: true, diff --git a/src/help.mjs b/src/help.mjs index 5a92a2f..822e087 100644 --- a/src/help.mjs +++ b/src/help.mjs @@ -449,8 +449,21 @@ export function renderPitCommand(name) { } } const out = [`/${entry.name} — ${entry.description}`]; - if (entry.args) out.push("", "usage:", row(`/${entry.name} ${entry.args}`, "", 44)); + // A pit-only verb may write its own synopsis/examples/note, the same shapes + // renderCommand reads. Without them the args string is the whole usage, which + // is enough for `/quit` and not enough for anything with sub-verbs. + const synopsis = entry.synopsis || (entry.args ? [[`/${entry.name} ${entry.args}`, ""]] : []); + if (synopsis.length) { + out.push("", "usage:"); + for (const [line, note] of synopsis) out.push(row(line, note, 44)); + } if (entry.aliases?.length) out.push("", `aliases: ${entry.aliases.map((a) => `/${a}`).join(", ")}`); + const examples = entry.examples || []; + if (examples.length) { + out.push("", "examples:"); + for (const [line, note] of examples) out.push(row(line, note ? `# ${note}` : "", 44)); + } + if (entry.note) out.push("", wrap(entry.note, 0)); return out.join("\n"); } diff --git a/src/tui.mjs b/src/tui.mjs index 8612541..af00e0b 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -27,6 +27,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 { herdCommand, herdStart, renderRoster, roster, splitDetachArgs } from "./herd-cli.mjs"; import { detectSubstrate, substrateNote } from "./herd.mjs"; @@ -128,9 +129,32 @@ export function splitCommandLine(line) { // hands this straight to `$SHELL -c`, the same way `!cmd` does: the shell does // its own parsing, so re-joining the tokenized parts would strip the user's // quotes and escapes and silently split `-m "two words"` into two arguments. -function commandRemainder(line) { - const firstWord = /^\s*\S+\s*/.exec(String(line)); - return firstWord ? String(line).slice(firstWord[0].length).trim() : ""; +function commandRemainder(line, words = 1) { + let out = String(line); + for (let i = 0; i < words; i++) { + const firstWord = /^\s*\S+\s*/.exec(out); + out = firstWord ? out.slice(firstWord[0].length) : ""; + } + return out.trim(); +} + +/** + * The value half of `/alias set `, as the user meant it. + * + * `/alias set gs "git status"` quotes the value because that is the obvious way + * to write it, and `/alias set gc git commit -m "wip"` does not because the + * quotes there belong to the shell. Tokenizing tells the two apart: exactly one + * token means the whole value was quoted, so use it with the quotes stripped; + * anything else is a bare command line, and it goes through verbatim so the + * user's own quoting survives into `$SHELL -c`. + */ +export function aliasValue(line) { + const raw = commandRemainder(line, 3); // past "/alias", "set", "" + if (!raw) return ""; + let parts; + try { parts = splitCommandLine(raw); } + catch { return raw; } + return parts.length === 1 ? parts[0] : raw; } function printEngines(json = false) { @@ -209,6 +233,94 @@ function printSocials() { console.log(ash(" the browser always asks you to confirm before anything is published")); } +/** + * Is this name the pit's own? + * + * Asked by resolving it the way the dispatcher does, rather than by consulting + * a list: the dispatcher checks pit verbs, then engines, then tools, and only + * then aliases, so anything that resolves earlier would shadow an alias of the + * same name into silence. + */ +function isReservedName(name) { + const key = String(name).toLowerCase(); + return Boolean(findPitCommand(key) || resolveEngine(key) || resolveTool(key) || RENAMED_COMMANDS[key]); +} + +function printAliases({ json = false } = {}) { + const aliases = loadAliases(); + const names = Object.keys(aliases).sort(); + if (json) { console.log(JSON.stringify(aliases, null, 2)); return; } + if (!names.length) { + console.log(info(`no aliases yet — ${acid('/alias set gs "git status"')} then ${acid("/gs")}.`)); + return; + } + console.log(bone(" aliases") + ash(" — run one with ") + acid("/") + ash(" · ") + acid("/alias rm ") + ash(" to forget")); + const width = Math.max(...names.map((n) => n.length)); + for (const name of names) { + // A leading slash marks the ones that are pit commands rather than shell, + // which is the only thing about a value that is not already visible. + const value = aliases[name]; + const kind = value.startsWith("/") ? ash("pit ") : ash("shell"); + console.log(` ${acid(`/${name}`.padEnd(width + 1))} ${kind} ${bone(value)}`); + } +} + +/** + * `/alias` — define, list, and forget the shortcuts (src/aliases.mjs). + * + * `line` comes in alongside the tokenized `rest` because the value is a command + * line, not an argument list: re-joining tokens would drop the quoting that the + * shell still has to read. + */ +function aliasCommand(rest, line) { + const json = rest.includes("--json"); + // `--json` is the listing's flag wherever it appears, so `/alias --json` is a + // listing rather than a verb nobody recognises. The value in `set` is read + // from the raw line, not from here, so an aliased command that itself passes + // --json is untouched by this. + const [verb, ...args] = rest.filter((a) => a !== "--json"); + const sub = String(verb ?? "").toLowerCase(); + + if (!verb || sub === "list" || sub === "ls") { + printAliases({ json }); + return; + } + if (sub === "set" || sub === "add") { + const name = args[0]; + const value = aliasValue(line); + if (!name || !value) { + console.log(err('usage: /alias set ""')); + console.log(ash(" the command runs in $SHELL unless it starts with / — then it's a pit command")); + return; + } + const result = setAlias(name, value, { isReserved: isReservedName }); + if (!result.ok) { console.log(err(result.error)); return; } + console.log(ok(`${acid(`/${result.name}`)} → ${bone(result.value)}`)); + if (result.previous) console.log(ash(` replaced: ${result.previous}`)); + return; + } + if (sub === "rm" || sub === "remove" || sub === "unset" || sub === "delete" || sub === "del") { + if (!args[0]) { console.log(err("usage: /alias rm ")); return; } + const result = removeAlias(args[0]); + console.log(result.ok ? ok(`forgot ${acid(`/${result.name}`)} ${ash(`(was: ${result.value})`)}`) : err(result.error)); + return; + } + if (sub === "get" || sub === "show") { + if (!args[0]) { console.log(err("usage: /alias get ")); return; } + const value = getAlias(args[0]); + console.log(value == null + ? err(`no alias named "${String(args[0]).replace(/^\//, "")}"`) + : ` ${acid(`/${String(args[0]).toLowerCase().replace(/^\//, "")}`)} ${ash("→")} ${bone(value)}`); + return; + } + // A bare `/alias gs "git status"` is what people type once they know the + // command exists, so treat an unknown verb as the name in `set` — but only + // 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`)); +} + /** * The moshscript vocabulary, split the way the CLI's help splits it. * @@ -526,19 +638,31 @@ export async function tui() { const { restoreTee, drainRemote, atPrompt } = await startMirror(); let rl = mkrl(); + // An alias expands into a line that is dispatched exactly as if it had been + // typed, so it goes back through the top of this loop instead of through a + // second copy of the dispatcher. `expansions` bounds a chain of aliases that + // name each other; it resets whenever a real line is read. + let pending = null; + let expansions = 0; for (;;) { let line; - // Arm the prompt first, THEN release any command waiting from the web: - // rl.write() only lands as input once readline is actually asking. - const answer = ask(rl); - atPrompt(rl); - drainRemote(); - try { line = await answer; } catch { break; } - finally { atPrompt(null); } - if (line == null) break; // Ctrl-D - line = line.trim(); - if (!line) continue; - saveHistory(); // readline just recorded this line into the shared history + if (pending != null) { + line = pending; + pending = null; + } else { + // Arm the prompt first, THEN release any command waiting from the web: + // rl.write() only lands as input once readline is actually asking. + const answer = ask(rl); + atPrompt(rl); + drainRemote(); + try { line = await answer; } catch { break; } + finally { atPrompt(null); } + if (line == null) break; // Ctrl-D + expansions = 0; + line = line.trim(); + if (!line) continue; + saveHistory(); // readline just recorded this line into the shared history + } // vim-style shell escape: `!` drops into $SHELL, `!` runs one-off. We // take the raw remainder (not the tokenized parts) so quoting is preserved. @@ -586,6 +710,7 @@ export async function tui() { rl = mkrl(); continue; } + if (cmd === "alias" || cmd === "aliases") { aliasCommand(rest, line); continue; } if (cmd === "pwd" || cmd === "where") { printPwd(); continue; } if (cmd === "login") { const device = rest.includes("--device") || rest.includes("device") || rest.includes("-d"); @@ -785,6 +910,23 @@ export async function tui() { rl = mkrl(); continue; } + // A user-defined alias (src/aliases.mjs) — last, so it can never shadow a + // built-in, and so an alias that names one is dead rather than surprising. + // /alias set refuses those names for exactly this reason. + const aliased = getAlias(cmd); + if (aliased) { + if (expansions >= MAX_EXPANSIONS) { + console.log(err(`/${cmd} keeps expanding — ${MAX_EXPANSIONS} rounds and still not a command. check /alias list for a loop.`)); + continue; + } + expansions += 1; + pending = expandAlias(aliased, commandRemainder(line)); + // Echoed because the line that runs is not the line that was typed, and a + // shell command that fails is a lot easier to read when what actually ran + // is on the screen above it. + console.log(ash(` ▸ ${pending}`)); + continue; + } // A renamed verb gets pointed at its replacement; `/ticker` was a pit // command for a release, so a bare "unknown command" is a dead end here. const renamed = RENAMED_COMMANDS[cmd]; diff --git a/test/aliases.test.mjs b/test/aliases.test.mjs new file mode 100644 index 0000000..57c9642 --- /dev/null +++ b/test/aliases.test.mjs @@ -0,0 +1,192 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { aliasValue } from "../src/tui.mjs"; + +const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url)); + +/** + * Drive a pit, one command per prompt. + * + * Not `stdin.end(everything)`: readline in non-terminal mode emits every + * buffered line at once and only the one a `question` is waiting on survives, + * so a pasted-in script loses all but its first command. Feeding the next line + * when the prompt comes back is what a person does anyway, and it is the only + * way to test a command whose effect is visible in a later one. + * + * $HOME is a temp dir so the suite never reads or writes the aliases of whoever + * is running it — src/aliases.mjs derives the path per call for this reason. + */ +function runTui(lines, { home } = {}) { + const HOME = home || mkdtempSync(join(tmpdir(), "moshcode-alias-")); + 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, + // No mirror and no MOTD: this must not touch the network. + MOSHCODE_NO_MIRROR: "1", + }, + }); + let stdout = ""; + let stderr = ""; + let seen = 0; + child.stdout.on("data", (chunk) => { + stdout += chunk; + // One line per prompt that has appeared but not yet been answered. + 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", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("close", (status) => resolve({ status, stdout, stderr, home: HOME })); + }); +} + +const aliasesOf = (home) => JSON.parse(readFileSync(join(home, ".moshcode", "aliases.json"), "utf8")); + +/* --------------------------------------------------------------- the value */ + +test("a fully quoted alias value loses its quotes, a bare one keeps them", () => { + assert.equal(aliasValue('/alias set gs "git status"'), "git status"); + assert.equal(aliasValue("/alias set gs 'git status'"), "git status"); + // Bare: the quotes are the shell's, so they survive verbatim. + assert.equal(aliasValue('/alias set gc git commit -m "wip"'), 'git commit -m "wip"'); + assert.equal(aliasValue("/alias set gs"), ""); +}); + +/* ------------------------------------------------------------- set and run */ + +test("/alias set defines a shell alias that / then runs", async () => { + const result = await runTui([ + '/alias set hi "echo aliased-hello"', + "/hi", + ]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /\/hi/); + assert.match(result.stdout, /aliased-hello/, "the aliased shell command should have run"); + assert.deepEqual(aliasesOf(result.home), { hi: "echo aliased-hello" }); +}); + +test("an alias appends whatever else was typed, quoting intact", async () => { + const result = await runTui([ + '/alias set say "echo"', + '/say "two words"', + ]); + + assert.match(result.stdout, /two words/); + // One argument, not two: the raw remainder reaches $SHELL -c with its quotes. + assert.doesNotMatch(result.stdout, /two\nwords/); +}); + +test("a value starting with / is dispatched as a pit command", async () => { + const result = await runTui([ + '/alias set p "/pwd"', + "/p", + ]); + + assert.match(result.stdout, /repo|not a git repo/, "expected /pwd's output"); +}); + +test("aliases persist into the next pit", async () => { + const first = await runTui('/alias set hi "echo first-run"'); + const second = await runTui("/hi", { home: first.home }); + assert.match(second.stdout, /first-run/); +}); + +/* -------------------------------------------------------------- guardrails */ + +test("/alias set refuses a name the pit already owns", async () => { + const result = await runTui([ + '/alias set agents "echo nope"', + '/alias set claude "echo nope"', + // An engine's alias, not its name — /cc already opens claude, so an alias + // by that name would be dead the moment it was defined. + '/alias set cc "echo nope"', + '/alias set gh "echo nope"', + ]); + + const refusals = result.stdout.match(/already a pit command, engine, or tool/g) || []; + assert.equal(refusals.length, 4, "pit commands, engines (and their aliases), and tools are all reserved"); +}); + +test("/alias set refuses a name that could not be typed as a command", async () => { + const result = await runTui([ + '/alias set "two words" "echo nope"', + '/alias set -x "echo nope"', + ]); + + const refusals = result.stdout.match(/isn't a usable alias name/g) || []; + assert.equal(refusals.length, 2); +}); + +test("a loop between two aliases stops instead of hanging the pit", async () => { + const result = await runTui([ + '/alias set a "/b"', + '/alias set b "/a"', + "/a", + ]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /keeps expanding/); +}); + +test("the alias file is owner-only, like the history file", async (t) => { + if (process.platform === "win32") return t.skip("POSIX permissions only"); + const result = await runTui('/alias set hi "echo x"'); + const mode = statSync(join(result.home, ".moshcode", "aliases.json")).mode & 0o777; + assert.equal(mode, 0o600); +}); + +test("a corrupt alias file reads as no aliases rather than taking the pit down", async () => { + const home = mkdtempSync(join(tmpdir(), "moshcode-alias-")); + mkdirSync(join(home, ".moshcode"), { recursive: true }); + writeFileSync(join(home, ".moshcode", "aliases.json"), "{ not json at all"); + + const result = await runTui("/alias", { home }); + assert.equal(result.status, 0); + assert.match(result.stdout, /no aliases yet/); +}); + +/* ------------------------------------------------------- list, get, remove */ + +test("/alias list --json prints the map, /alias rm forgets one", async () => { + const first = await runTui([ + '/alias set hi "echo hi"', + '/alias set agentx "/agents codex"', + "/alias list --json", + ]); + + const json = first.stdout.match(/\{[\s\S]*?\}/); + assert.ok(json, "expected a JSON object"); + assert.deepEqual(JSON.parse(json[0]), { agentx: "/agents codex", hi: "echo hi" }); + + const second = await runTui(["/alias rm hi", "/alias get hi"], { home: first.home }); + assert.match(second.stdout, /forgot/); + assert.match(second.stdout, /no alias named "hi"/); + assert.deepEqual(aliasesOf(first.home), { agentx: "/agents codex" }); +}); + +test("/alias defines one without the set verb", async () => { + const result = await runTui(['/alias hi "echo shorthand"', "/hi"]); + assert.match(result.stdout, /shorthand/); +}); + +test("/help alias explains the shell-versus-pit rule", async () => { + const result = await runTui("/help alias"); + assert.match(result.stdout, /runs in \$SHELL unless it starts with \//); +});