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 \//); +});