Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
160 changes: 160 additions & 0 deletions src/aliases.mjs
Original file line number Diff line number Diff line change
@@ -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}`;
}
18 changes: 18 additions & 0 deletions src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> "<cmd>" | list | get | rm', pitOnly: true,
description: "name a line you keep retyping; /<name> runs it",
synopsis: [
['/alias set <name> "<command>"', "define one (also: /alias <name> \"<command>\")"],
["/alias [list] [--json]", "every alias"],
["/alias get <name>", "what one expands to"],
["/alias rm <name>", "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,
Expand Down
15 changes: 14 additions & 1 deletion src/help.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand Down
Loading
Loading