Skip to content

Commit cd6f996

Browse files
ralyodioclaude
andcommitted
feat(pit): /alias — name the lines you keep retyping
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) <noreply@anthropic.com>
1 parent 0eaafa0 commit cd6f996

6 files changed

Lines changed: 558 additions & 15 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,24 @@ an equity's score, and each response ships the `caveats` that say so. Prices are
543543
Alpaca's US venue alone and can differ materially from other exchanges. Research
544544
aid, not advice — and like `stocks`, nothing under `crypto` can place an order.
545545

546+
### Aliases (`/alias`)
547+
548+
The pit is a prompt you sit at all day, so it lets you name the lines you keep
549+
retyping. An alias runs in `$SHELL` unless it starts with `/`, in which case it
550+
is a pit command:
551+
552+
```text
553+
/alias set gs "git status" # then /gs — and /gs -sb appends to it
554+
/alias set cx "/agents codex" # a pit command, not a shell one
555+
/alias # what is defined
556+
/alias rm gs
557+
```
558+
559+
They live in `~/.moshcode/aliases.json` (owner-only, like the history file) and
560+
survive between sessions. A name that is already a pit command, an engine, or a
561+
tool is refused rather than shadowed — built-ins are dispatched first, so such
562+
an alias would never run.
563+
546564
### Social posting from the pit
547565

548566
The pit can hand a prepared post to Bluesky or Nostr without storing either

src/aliases.mjs

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Named shortcuts for whatever you type at the mosh prompt.
2+
//
3+
// The pit is a prompt people sit at all day, and the things they retype are
4+
// their own: `git status`, `pnpm -r test`, `/agents claude --resume`. Shell
5+
// aliases can't help — the pit is not a shell, and `!git status` is exactly the
6+
// keystrokes an alias is supposed to save. So the pit keeps its own.
7+
//
8+
// An alias is a name and a line. The line is a shell command unless it starts
9+
// with `/`, in which case it is a pit command:
10+
//
11+
// /alias set gs "git status" → /gs runs `$SHELL -c "git status"`
12+
// /alias set cc "/agents claude" → /cc opens claude autonomously
13+
//
14+
// Shell-by-default because that is what the prompt is mostly asked for, and the
15+
// leading slash is already how the pit spells its own verbs — so the rule reads
16+
// the same way the rest of the pit does rather than being a new convention.
17+
//
18+
// Anything the pit can dispatch is fair game as a value, which is what keeps
19+
// this from needing to grow a type: a bookmarklet or a URL becomes an alias the
20+
// day the pit gets a verb that opens one, with no change here.
21+
import fs from "node:fs";
22+
import os from "node:os";
23+
import path from "node:path";
24+
25+
/** Owner-only, and for the same reason ~/.moshcode_history is: values are
26+
* whatever was typed, and people alias commands that carry tokens. */
27+
const FILE_MODE = 0o600;
28+
29+
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
30+
31+
/** A value long enough to be a pasted mistake rather than a command. */
32+
const MAX_VALUE = 4096;
33+
34+
/**
35+
* How many times one line may expand before the pit gives up.
36+
*
37+
* Aliases can name aliases (`/alias set st "/gs --short"`), which is useful and
38+
* also the one way to write a loop: two aliases naming each other would spin
39+
* the dispatch loop forever. Ten is far past any chain a person builds on
40+
* purpose.
41+
*/
42+
export const MAX_EXPANSIONS = 10;
43+
44+
/** Where the aliases live. Derived per call so tests can move $HOME. */
45+
export function aliasFile() {
46+
return path.join(os.homedir(), ".moshcode", "aliases.json");
47+
}
48+
49+
/**
50+
* Every alias, as a plain name → line map.
51+
*
52+
* A file that is missing, unreadable, or not the shape we wrote reads as "no
53+
* aliases" rather than throwing: this is called on the dispatch path for every
54+
* unrecognised command, and a hand-edited file with a stray comma must not take
55+
* the prompt down with it. Entries whose value is not a string are dropped for
56+
* the same reason.
57+
*/
58+
export function loadAliases() {
59+
let raw;
60+
try { raw = fs.readFileSync(aliasFile(), "utf8"); }
61+
catch { return {}; }
62+
let parsed;
63+
try { parsed = JSON.parse(raw); }
64+
catch { return {}; }
65+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
66+
const out = {};
67+
for (const [name, value] of Object.entries(parsed)) {
68+
if (typeof value === "string" && value.trim()) out[name.toLowerCase()] = value;
69+
}
70+
return out;
71+
}
72+
73+
/** Write the map back, creating ~/.moshcode if this is the first alias. */
74+
function saveAliases(aliases) {
75+
const file = aliasFile();
76+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
77+
// Sorted so the file reads like a list rather than like insertion order, and
78+
// so hand edits produce a small diff.
79+
const ordered = Object.fromEntries(Object.keys(aliases).sort().map((k) => [k, aliases[k]]));
80+
fs.writeFileSync(file, `${JSON.stringify(ordered, null, 2)}\n`, { mode: FILE_MODE });
81+
// `mode` only applies at creation, so an existing file keeps whatever the
82+
// umask gave it. Tighten every write, the way the history file does.
83+
try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ }
84+
}
85+
86+
/** The name as it is stored, or "" for anything that cannot be one. */
87+
export function normalizeName(name) {
88+
const clean = String(name ?? "").trim().toLowerCase().replace(/^\//, "");
89+
return NAME_RE.test(clean) ? clean : "";
90+
}
91+
92+
/** One alias's line, or null. */
93+
export function getAlias(name) {
94+
const key = normalizeName(name);
95+
if (!key) return null;
96+
const aliases = loadAliases();
97+
return Object.hasOwn(aliases, key) ? aliases[key] : null;
98+
}
99+
100+
/**
101+
* Define an alias. Returns { ok, error, name, value, previous }.
102+
*
103+
* `isReserved` asks the pit whether a name is already its own — a command, an
104+
* engine, a tool. A predicate rather than a list because the dispatcher decides
105+
* that by resolving, aliases included, and a list copied out of the rosters
106+
* here would be a second answer that drifts from the first. A colliding name is
107+
* refused rather than shadowed: built-ins are checked first, so an alias named
108+
* `agents` would be silently dead, and a shortcut that does nothing is worse
109+
* than one that was never accepted.
110+
*/
111+
export function setAlias(name, value, { isReserved = () => false } = {}) {
112+
const key = normalizeName(name);
113+
if (!key) {
114+
return { ok: false, error: `"${name}" isn't a usable alias name — letters, digits, . _ - and it must start with a letter or digit` };
115+
}
116+
if (isReserved(key)) {
117+
return { ok: false, error: `/${key} is already a pit command, engine, or tool — pick another name` };
118+
}
119+
const line = String(value ?? "").trim();
120+
if (!line) return { ok: false, error: "an alias needs something to run" };
121+
if (line.includes("\n")) return { ok: false, error: "an alias is a single line" };
122+
if (line.length > MAX_VALUE) return { ok: false, error: `that value is ${line.length} characters — the cap is ${MAX_VALUE}` };
123+
124+
const aliases = loadAliases();
125+
const previous = Object.hasOwn(aliases, key) ? aliases[key] : null;
126+
aliases[key] = line;
127+
try { saveAliases(aliases); }
128+
catch (e) { return { ok: false, error: `can't write ${aliasFile()}: ${e.message}` }; }
129+
return { ok: true, name: key, value: line, previous };
130+
}
131+
132+
/** Forget one. Returns { ok, error, name, value }. */
133+
export function removeAlias(name) {
134+
const key = normalizeName(name);
135+
const aliases = loadAliases();
136+
if (!key || !Object.hasOwn(aliases, key)) {
137+
return { ok: false, error: `no alias named "${String(name ?? "").replace(/^\//, "")}"` };
138+
}
139+
const value = aliases[key];
140+
delete aliases[key];
141+
try { saveAliases(aliases); }
142+
catch (e) { return { ok: false, error: `can't write ${aliasFile()}: ${e.message}` }; }
143+
return { ok: true, name: key, value };
144+
}
145+
146+
/**
147+
* The line an alias becomes, with anything else the user typed appended.
148+
*
149+
* Appended rather than substituted, the way a shell alias behaves: `/gs -sb` is
150+
* `git status -sb`. `args` is the raw remainder of the typed line, not the
151+
* tokenized parts, so the user's own quoting survives into `$SHELL -c`.
152+
*
153+
* The `!` is what routes a bare value to the shell — the pit already reads a
154+
* leading `!` as "run this in $SHELL", so an alias does not need a second path
155+
* through it.
156+
*/
157+
export function expandAlias(value, args = "") {
158+
const line = `${String(value).trim()}${args ? ` ${args}` : ""}`;
159+
return /^[/!]/.test(line) ? line : `!${line}`;
160+
}

src/cli-schema.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,24 @@ export const PIT_COMMANDS = [
895895
description: "show the current dir + git repo/branch/origin" },
896896
{ name: "shell", aliases: ["sh"], args: "[cmd]", pitOnly: true,
897897
description: "drop into $SHELL (exit → back to the pit); also !cmd" },
898+
{ name: "alias", aliases: ["aliases"], args: 'set <name> "<cmd>" | list | get | rm', pitOnly: true,
899+
description: "name a line you keep retyping; /<name> runs it",
900+
synopsis: [
901+
['/alias set <name> "<command>"', "define one (also: /alias <name> \"<command>\")"],
902+
["/alias [list] [--json]", "every alias"],
903+
["/alias get <name>", "what one expands to"],
904+
["/alias rm <name>", "forget one"],
905+
],
906+
examples: [
907+
['/alias set gs "git status"', "then /gs — and /gs -sb appends"],
908+
// Deliberately not `cc`: that one is already how the pit spells claude,
909+
// so the example would print a refusal for anyone who typed it.
910+
['/alias set cx "/agents codex"', "a pit command, not a shell one"],
911+
["/alias rm gs", ""],
912+
],
913+
note: "the command runs in $SHELL unless it starts with / — then it is a pit command. "
914+
+ "Aliases live in ~/.moshcode/aliases.json and cannot shadow a pit command, engine, or tool.",
915+
},
898916
{ name: "help", aliases: ["?", "h"], args: "[command]", pitOnly: true,
899917
description: "this, or one command in detail" },
900918
{ name: "quit", aliases: ["exit", "q"], pitOnly: true,

src/help.mjs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,8 +449,21 @@ export function renderPitCommand(name) {
449449
}
450450
}
451451
const out = [`/${entry.name}${entry.description}`];
452-
if (entry.args) out.push("", "usage:", row(`/${entry.name} ${entry.args}`, "", 44));
452+
// A pit-only verb may write its own synopsis/examples/note, the same shapes
453+
// renderCommand reads. Without them the args string is the whole usage, which
454+
// is enough for `/quit` and not enough for anything with sub-verbs.
455+
const synopsis = entry.synopsis || (entry.args ? [[`/${entry.name} ${entry.args}`, ""]] : []);
456+
if (synopsis.length) {
457+
out.push("", "usage:");
458+
for (const [line, note] of synopsis) out.push(row(line, note, 44));
459+
}
453460
if (entry.aliases?.length) out.push("", `aliases: ${entry.aliases.map((a) => `/${a}`).join(", ")}`);
461+
const examples = entry.examples || [];
462+
if (examples.length) {
463+
out.push("", "examples:");
464+
for (const [line, note] of examples) out.push(row(line, note ? `# ${note}` : "", 44));
465+
}
466+
if (entry.note) out.push("", wrap(entry.note, 0));
454467
return out.join("\n");
455468
}
456469

0 commit comments

Comments
 (0)