moshcode 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -543,6 +543,24 @@ an equity's score, and each response ships the `caveats` that say so. Prices are
543
543
  Alpaca's US venue alone and can differ materially from other exchanges. Research
544
544
  aid, not advice — and like `stocks`, nothing under `crypto` can place an order.
545
545
 
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
+
546
564
  ### Social posting from the pit
547
565
 
548
566
  The pit can hand a prepared post to Bluesky or Nostr without storing either
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "type": "module",
5
5
  "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
@@ -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
+ }
@@ -895,6 +895,24 @@ export const PIT_COMMANDS = [
895
895
  description: "show the current dir + git repo/branch/origin" },
896
896
  { name: "shell", aliases: ["sh"], args: "[cmd]", pitOnly: true,
897
897
  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
+ },
898
916
  { name: "help", aliases: ["?", "h"], args: "[command]", pitOnly: true,
899
917
  description: "this, or one command in detail" },
900
918
  { name: "quit", aliases: ["exit", "q"], pitOnly: true,
package/src/help.mjs CHANGED
@@ -449,8 +449,21 @@ export function renderPitCommand(name) {
449
449
  }
450
450
  }
451
451
  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
+ }
453
460
  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));
454
467
  return out.join("\n");
455
468
  }
456
469
 
package/src/tui.mjs CHANGED
@@ -27,6 +27,7 @@ import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion
27
27
  import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
28
28
  import { RENAMED_COMMANDS, findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs";
29
29
  import { openNewTab } from "./tabs.mjs";
30
+ import { MAX_EXPANSIONS, expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs";
30
31
  import { herdCommand, herdStart, renderRoster, roster, splitDetachArgs } from "./herd-cli.mjs";
31
32
  import { detectSubstrate, substrateNote } from "./herd.mjs";
32
33
 
@@ -128,9 +129,32 @@ export function splitCommandLine(line) {
128
129
  // hands this straight to `$SHELL -c`, the same way `!cmd` does: the shell does
129
130
  // its own parsing, so re-joining the tokenized parts would strip the user's
130
131
  // quotes and escapes and silently split `-m "two words"` into two arguments.
131
- function commandRemainder(line) {
132
- const firstWord = /^\s*\S+\s*/.exec(String(line));
133
- return firstWord ? String(line).slice(firstWord[0].length).trim() : "";
132
+ function commandRemainder(line, words = 1) {
133
+ let out = String(line);
134
+ for (let i = 0; i < words; i++) {
135
+ const firstWord = /^\s*\S+\s*/.exec(out);
136
+ out = firstWord ? out.slice(firstWord[0].length) : "";
137
+ }
138
+ return out.trim();
139
+ }
140
+
141
+ /**
142
+ * The value half of `/alias set <name> <value…>`, as the user meant it.
143
+ *
144
+ * `/alias set gs "git status"` quotes the value because that is the obvious way
145
+ * to write it, and `/alias set gc git commit -m "wip"` does not because the
146
+ * quotes there belong to the shell. Tokenizing tells the two apart: exactly one
147
+ * token means the whole value was quoted, so use it with the quotes stripped;
148
+ * anything else is a bare command line, and it goes through verbatim so the
149
+ * user's own quoting survives into `$SHELL -c`.
150
+ */
151
+ export function aliasValue(line) {
152
+ const raw = commandRemainder(line, 3); // past "/alias", "set", "<name>"
153
+ if (!raw) return "";
154
+ let parts;
155
+ try { parts = splitCommandLine(raw); }
156
+ catch { return raw; }
157
+ return parts.length === 1 ? parts[0] : raw;
134
158
  }
135
159
 
136
160
  function printEngines(json = false) {
@@ -209,6 +233,94 @@ function printSocials() {
209
233
  console.log(ash(" the browser always asks you to confirm before anything is published"));
210
234
  }
211
235
 
236
+ /**
237
+ * Is this name the pit's own?
238
+ *
239
+ * Asked by resolving it the way the dispatcher does, rather than by consulting
240
+ * a list: the dispatcher checks pit verbs, then engines, then tools, and only
241
+ * then aliases, so anything that resolves earlier would shadow an alias of the
242
+ * same name into silence.
243
+ */
244
+ function isReservedName(name) {
245
+ const key = String(name).toLowerCase();
246
+ return Boolean(findPitCommand(key) || resolveEngine(key) || resolveTool(key) || RENAMED_COMMANDS[key]);
247
+ }
248
+
249
+ function printAliases({ json = false } = {}) {
250
+ const aliases = loadAliases();
251
+ const names = Object.keys(aliases).sort();
252
+ if (json) { console.log(JSON.stringify(aliases, null, 2)); return; }
253
+ if (!names.length) {
254
+ console.log(info(`no aliases yet — ${acid('/alias set gs "git status"')} then ${acid("/gs")}.`));
255
+ return;
256
+ }
257
+ console.log(bone(" aliases") + ash(" — run one with ") + acid("/<name>") + ash(" · ") + acid("/alias rm <name>") + ash(" to forget"));
258
+ const width = Math.max(...names.map((n) => n.length));
259
+ for (const name of names) {
260
+ // A leading slash marks the ones that are pit commands rather than shell,
261
+ // which is the only thing about a value that is not already visible.
262
+ const value = aliases[name];
263
+ const kind = value.startsWith("/") ? ash("pit ") : ash("shell");
264
+ console.log(` ${acid(`/${name}`.padEnd(width + 1))} ${kind} ${bone(value)}`);
265
+ }
266
+ }
267
+
268
+ /**
269
+ * `/alias` — define, list, and forget the shortcuts (src/aliases.mjs).
270
+ *
271
+ * `line` comes in alongside the tokenized `rest` because the value is a command
272
+ * line, not an argument list: re-joining tokens would drop the quoting that the
273
+ * shell still has to read.
274
+ */
275
+ function aliasCommand(rest, line) {
276
+ const json = rest.includes("--json");
277
+ // `--json` is the listing's flag wherever it appears, so `/alias --json` is a
278
+ // listing rather than a verb nobody recognises. The value in `set` is read
279
+ // from the raw line, not from here, so an aliased command that itself passes
280
+ // --json is untouched by this.
281
+ const [verb, ...args] = rest.filter((a) => a !== "--json");
282
+ const sub = String(verb ?? "").toLowerCase();
283
+
284
+ if (!verb || sub === "list" || sub === "ls") {
285
+ printAliases({ json });
286
+ return;
287
+ }
288
+ if (sub === "set" || sub === "add") {
289
+ const name = args[0];
290
+ const value = aliasValue(line);
291
+ if (!name || !value) {
292
+ console.log(err('usage: /alias set <name> "<command>"'));
293
+ console.log(ash(" the command runs in $SHELL unless it starts with / — then it's a pit command"));
294
+ return;
295
+ }
296
+ const result = setAlias(name, value, { isReserved: isReservedName });
297
+ if (!result.ok) { console.log(err(result.error)); return; }
298
+ console.log(ok(`${acid(`/${result.name}`)} → ${bone(result.value)}`));
299
+ if (result.previous) console.log(ash(` replaced: ${result.previous}`));
300
+ return;
301
+ }
302
+ if (sub === "rm" || sub === "remove" || sub === "unset" || sub === "delete" || sub === "del") {
303
+ if (!args[0]) { console.log(err("usage: /alias rm <name>")); return; }
304
+ const result = removeAlias(args[0]);
305
+ console.log(result.ok ? ok(`forgot ${acid(`/${result.name}`)} ${ash(`(was: ${result.value})`)}`) : err(result.error));
306
+ return;
307
+ }
308
+ if (sub === "get" || sub === "show") {
309
+ if (!args[0]) { console.log(err("usage: /alias get <name>")); return; }
310
+ const value = getAlias(args[0]);
311
+ console.log(value == null
312
+ ? err(`no alias named "${String(args[0]).replace(/^\//, "")}"`)
313
+ : ` ${acid(`/${String(args[0]).toLowerCase().replace(/^\//, "")}`)} ${ash("→")} ${bone(value)}`);
314
+ return;
315
+ }
316
+ // A bare `/alias gs "git status"` is what people type once they know the
317
+ // command exists, so treat an unknown verb as the name in `set` — but only
318
+ // when there is a value after it, or `/alias gs` would silently define
319
+ // nothing.
320
+ if (args.length) { aliasCommand(["set", ...rest], `/alias set ${commandRemainder(line)}`); return; }
321
+ console.log(err(`unknown /alias verb "${verb}" — set, list, get, rm`));
322
+ }
323
+
212
324
  /**
213
325
  * The moshscript vocabulary, split the way the CLI's help splits it.
214
326
  *
@@ -526,19 +638,31 @@ export async function tui() {
526
638
  const { restoreTee, drainRemote, atPrompt } = await startMirror();
527
639
 
528
640
  let rl = mkrl();
641
+ // An alias expands into a line that is dispatched exactly as if it had been
642
+ // typed, so it goes back through the top of this loop instead of through a
643
+ // second copy of the dispatcher. `expansions` bounds a chain of aliases that
644
+ // name each other; it resets whenever a real line is read.
645
+ let pending = null;
646
+ let expansions = 0;
529
647
  for (;;) {
530
648
  let line;
531
- // Arm the prompt first, THEN release any command waiting from the web:
532
- // rl.write() only lands as input once readline is actually asking.
533
- const answer = ask(rl);
534
- atPrompt(rl);
535
- drainRemote();
536
- try { line = await answer; } catch { break; }
537
- finally { atPrompt(null); }
538
- if (line == null) break; // Ctrl-D
539
- line = line.trim();
540
- if (!line) continue;
541
- saveHistory(); // readline just recorded this line into the shared history
649
+ if (pending != null) {
650
+ line = pending;
651
+ pending = null;
652
+ } else {
653
+ // Arm the prompt first, THEN release any command waiting from the web:
654
+ // rl.write() only lands as input once readline is actually asking.
655
+ const answer = ask(rl);
656
+ atPrompt(rl);
657
+ drainRemote();
658
+ try { line = await answer; } catch { break; }
659
+ finally { atPrompt(null); }
660
+ if (line == null) break; // Ctrl-D
661
+ expansions = 0;
662
+ line = line.trim();
663
+ if (!line) continue;
664
+ saveHistory(); // readline just recorded this line into the shared history
665
+ }
542
666
 
543
667
  // vim-style shell escape: `!` drops into $SHELL, `!<cmd>` runs one-off. We
544
668
  // take the raw remainder (not the tokenized parts) so quoting is preserved.
@@ -586,6 +710,7 @@ export async function tui() {
586
710
  rl = mkrl();
587
711
  continue;
588
712
  }
713
+ if (cmd === "alias" || cmd === "aliases") { aliasCommand(rest, line); continue; }
589
714
  if (cmd === "pwd" || cmd === "where") { printPwd(); continue; }
590
715
  if (cmd === "login") {
591
716
  const device = rest.includes("--device") || rest.includes("device") || rest.includes("-d");
@@ -785,6 +910,23 @@ export async function tui() {
785
910
  rl = mkrl();
786
911
  continue;
787
912
  }
913
+ // A user-defined alias (src/aliases.mjs) — last, so it can never shadow a
914
+ // built-in, and so an alias that names one is dead rather than surprising.
915
+ // /alias set refuses those names for exactly this reason.
916
+ const aliased = getAlias(cmd);
917
+ if (aliased) {
918
+ if (expansions >= MAX_EXPANSIONS) {
919
+ console.log(err(`/${cmd} keeps expanding — ${MAX_EXPANSIONS} rounds and still not a command. check /alias list for a loop.`));
920
+ continue;
921
+ }
922
+ expansions += 1;
923
+ pending = expandAlias(aliased, commandRemainder(line));
924
+ // Echoed because the line that runs is not the line that was typed, and a
925
+ // shell command that fails is a lot easier to read when what actually ran
926
+ // is on the screen above it.
927
+ console.log(ash(` ▸ ${pending}`));
928
+ continue;
929
+ }
788
930
  // A renamed verb gets pointed at its replacement; `/ticker` was a pit
789
931
  // command for a release, so a bare "unknown command" is a dead end here.
790
932
  const renamed = RENAMED_COMMANDS[cmd];