moshcode 0.38.0 → 0.40.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/src/tui.mjs CHANGED
@@ -15,6 +15,7 @@ import { runUpgrade } from "./upgrade.mjs";
15
15
  import { locate, tilde } from "./pwd.mjs";
16
16
  import { createPrd, listPrds, authoringPrompt } from "./prd.mjs";
17
17
  import { loginAuto, whoami, logout } from "./auth.mjs";
18
+ import { loadCommand, saveCommand } from "./settings-sync.mjs";
18
19
  import { createMirror, teeOutput } from "./mirror.mjs";
19
20
  import { fetchMotdAd } from "./ads.mjs";
20
21
  import { runScript } from "./runtime.mjs";
@@ -27,6 +28,7 @@ import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion
27
28
  import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
28
29
  import { RENAMED_COMMANDS, findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs";
29
30
  import { openNewTab } from "./tabs.mjs";
31
+ import { MAX_EXPANSIONS, expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs";
30
32
  import { herdCommand, herdStart, renderRoster, roster, splitDetachArgs } from "./herd-cli.mjs";
31
33
  import { detectSubstrate, substrateNote } from "./herd.mjs";
32
34
 
@@ -128,9 +130,32 @@ export function splitCommandLine(line) {
128
130
  // hands this straight to `$SHELL -c`, the same way `!cmd` does: the shell does
129
131
  // its own parsing, so re-joining the tokenized parts would strip the user's
130
132
  // 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() : "";
133
+ function commandRemainder(line, words = 1) {
134
+ let out = String(line);
135
+ for (let i = 0; i < words; i++) {
136
+ const firstWord = /^\s*\S+\s*/.exec(out);
137
+ out = firstWord ? out.slice(firstWord[0].length) : "";
138
+ }
139
+ return out.trim();
140
+ }
141
+
142
+ /**
143
+ * The value half of `/alias set <name> <value…>`, as the user meant it.
144
+ *
145
+ * `/alias set gs "git status"` quotes the value because that is the obvious way
146
+ * to write it, and `/alias set gc git commit -m "wip"` does not because the
147
+ * quotes there belong to the shell. Tokenizing tells the two apart: exactly one
148
+ * token means the whole value was quoted, so use it with the quotes stripped;
149
+ * anything else is a bare command line, and it goes through verbatim so the
150
+ * user's own quoting survives into `$SHELL -c`.
151
+ */
152
+ export function aliasValue(line) {
153
+ const raw = commandRemainder(line, 3); // past "/alias", "set", "<name>"
154
+ if (!raw) return "";
155
+ let parts;
156
+ try { parts = splitCommandLine(raw); }
157
+ catch { return raw; }
158
+ return parts.length === 1 ? parts[0] : raw;
134
159
  }
135
160
 
136
161
  function printEngines(json = false) {
@@ -209,6 +234,94 @@ function printSocials() {
209
234
  console.log(ash(" the browser always asks you to confirm before anything is published"));
210
235
  }
211
236
 
237
+ /**
238
+ * Is this name the pit's own?
239
+ *
240
+ * Asked by resolving it the way the dispatcher does, rather than by consulting
241
+ * a list: the dispatcher checks pit verbs, then engines, then tools, and only
242
+ * then aliases, so anything that resolves earlier would shadow an alias of the
243
+ * same name into silence.
244
+ */
245
+ function isReservedName(name) {
246
+ const key = String(name).toLowerCase();
247
+ return Boolean(findPitCommand(key) || resolveEngine(key) || resolveTool(key) || RENAMED_COMMANDS[key]);
248
+ }
249
+
250
+ function printAliases({ json = false } = {}) {
251
+ const aliases = loadAliases();
252
+ const names = Object.keys(aliases).sort();
253
+ if (json) { console.log(JSON.stringify(aliases, null, 2)); return; }
254
+ if (!names.length) {
255
+ console.log(info(`no aliases yet — ${acid('/alias set gs "git status"')} then ${acid("/gs")}.`));
256
+ return;
257
+ }
258
+ console.log(bone(" aliases") + ash(" — run one with ") + acid("/<name>") + ash(" · ") + acid("/alias rm <name>") + ash(" to forget"));
259
+ const width = Math.max(...names.map((n) => n.length));
260
+ for (const name of names) {
261
+ // A leading slash marks the ones that are pit commands rather than shell,
262
+ // which is the only thing about a value that is not already visible.
263
+ const value = aliases[name];
264
+ const kind = value.startsWith("/") ? ash("pit ") : ash("shell");
265
+ console.log(` ${acid(`/${name}`.padEnd(width + 1))} ${kind} ${bone(value)}`);
266
+ }
267
+ }
268
+
269
+ /**
270
+ * `/alias` — define, list, and forget the shortcuts (src/aliases.mjs).
271
+ *
272
+ * `line` comes in alongside the tokenized `rest` because the value is a command
273
+ * line, not an argument list: re-joining tokens would drop the quoting that the
274
+ * shell still has to read.
275
+ */
276
+ function aliasCommand(rest, line) {
277
+ const json = rest.includes("--json");
278
+ // `--json` is the listing's flag wherever it appears, so `/alias --json` is a
279
+ // listing rather than a verb nobody recognises. The value in `set` is read
280
+ // from the raw line, not from here, so an aliased command that itself passes
281
+ // --json is untouched by this.
282
+ const [verb, ...args] = rest.filter((a) => a !== "--json");
283
+ const sub = String(verb ?? "").toLowerCase();
284
+
285
+ if (!verb || sub === "list" || sub === "ls") {
286
+ printAliases({ json });
287
+ return;
288
+ }
289
+ if (sub === "set" || sub === "add") {
290
+ const name = args[0];
291
+ const value = aliasValue(line);
292
+ if (!name || !value) {
293
+ console.log(err('usage: /alias set <name> "<command>"'));
294
+ console.log(ash(" the command runs in $SHELL unless it starts with / — then it's a pit command"));
295
+ return;
296
+ }
297
+ const result = setAlias(name, value, { isReserved: isReservedName });
298
+ if (!result.ok) { console.log(err(result.error)); return; }
299
+ console.log(ok(`${acid(`/${result.name}`)} → ${bone(result.value)}`));
300
+ if (result.previous) console.log(ash(` replaced: ${result.previous}`));
301
+ return;
302
+ }
303
+ if (sub === "rm" || sub === "remove" || sub === "unset" || sub === "delete" || sub === "del") {
304
+ if (!args[0]) { console.log(err("usage: /alias rm <name>")); return; }
305
+ const result = removeAlias(args[0]);
306
+ console.log(result.ok ? ok(`forgot ${acid(`/${result.name}`)} ${ash(`(was: ${result.value})`)}`) : err(result.error));
307
+ return;
308
+ }
309
+ if (sub === "get" || sub === "show") {
310
+ if (!args[0]) { console.log(err("usage: /alias get <name>")); return; }
311
+ const value = getAlias(args[0]);
312
+ console.log(value == null
313
+ ? err(`no alias named "${String(args[0]).replace(/^\//, "")}"`)
314
+ : ` ${acid(`/${String(args[0]).toLowerCase().replace(/^\//, "")}`)} ${ash("→")} ${bone(value)}`);
315
+ return;
316
+ }
317
+ // A bare `/alias gs "git status"` is what people type once they know the
318
+ // command exists, so treat an unknown verb as the name in `set` — but only
319
+ // when there is a value after it, or `/alias gs` would silently define
320
+ // nothing.
321
+ if (args.length) { aliasCommand(["set", ...rest], `/alias set ${commandRemainder(line)}`); return; }
322
+ console.log(err(`unknown /alias verb "${verb}" — set, list, get, rm`));
323
+ }
324
+
212
325
  /**
213
326
  * The moshscript vocabulary, split the way the CLI's help splits it.
214
327
  *
@@ -526,19 +639,31 @@ export async function tui() {
526
639
  const { restoreTee, drainRemote, atPrompt } = await startMirror();
527
640
 
528
641
  let rl = mkrl();
642
+ // An alias expands into a line that is dispatched exactly as if it had been
643
+ // typed, so it goes back through the top of this loop instead of through a
644
+ // second copy of the dispatcher. `expansions` bounds a chain of aliases that
645
+ // name each other; it resets whenever a real line is read.
646
+ let pending = null;
647
+ let expansions = 0;
529
648
  for (;;) {
530
649
  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
650
+ if (pending != null) {
651
+ line = pending;
652
+ pending = null;
653
+ } else {
654
+ // Arm the prompt first, THEN release any command waiting from the web:
655
+ // rl.write() only lands as input once readline is actually asking.
656
+ const answer = ask(rl);
657
+ atPrompt(rl);
658
+ drainRemote();
659
+ try { line = await answer; } catch { break; }
660
+ finally { atPrompt(null); }
661
+ if (line == null) break; // Ctrl-D
662
+ expansions = 0;
663
+ line = line.trim();
664
+ if (!line) continue;
665
+ saveHistory(); // readline just recorded this line into the shared history
666
+ }
542
667
 
543
668
  // vim-style shell escape: `!` drops into $SHELL, `!<cmd>` runs one-off. We
544
669
  // take the raw remainder (not the tokenized parts) so quoting is preserved.
@@ -586,6 +711,7 @@ export async function tui() {
586
711
  rl = mkrl();
587
712
  continue;
588
713
  }
714
+ if (cmd === "alias" || cmd === "aliases") { aliasCommand(rest, line); continue; }
589
715
  if (cmd === "pwd" || cmd === "where") { printPwd(); continue; }
590
716
  if (cmd === "login") {
591
717
  const device = rest.includes("--device") || rest.includes("device") || rest.includes("-d");
@@ -603,6 +729,10 @@ export async function tui() {
603
729
  continue;
604
730
  }
605
731
  if (cmd === "logout") { logout(); continue; }
732
+ // Settings sync. Never closes readline: both are one request and some
733
+ // printing, and the prompt is where you were about to type `/load` again.
734
+ if (cmd === "save") { await saveCommand(rest, { write: (l) => console.log(` ${l}`) }); continue; }
735
+ if (cmd === "load") { await loadCommand(rest, { write: (l) => console.log(` ${l}`) }); continue; }
606
736
  if (cmd === "run") {
607
737
  await runFile(rest);
608
738
  continue;
@@ -785,6 +915,23 @@ export async function tui() {
785
915
  rl = mkrl();
786
916
  continue;
787
917
  }
918
+ // A user-defined alias (src/aliases.mjs) — last, so it can never shadow a
919
+ // built-in, and so an alias that names one is dead rather than surprising.
920
+ // /alias set refuses those names for exactly this reason.
921
+ const aliased = getAlias(cmd);
922
+ if (aliased) {
923
+ if (expansions >= MAX_EXPANSIONS) {
924
+ console.log(err(`/${cmd} keeps expanding — ${MAX_EXPANSIONS} rounds and still not a command. check /alias list for a loop.`));
925
+ continue;
926
+ }
927
+ expansions += 1;
928
+ pending = expandAlias(aliased, commandRemainder(line));
929
+ // Echoed because the line that runs is not the line that was typed, and a
930
+ // shell command that fails is a lot easier to read when what actually ran
931
+ // is on the screen above it.
932
+ console.log(ash(` ▸ ${pending}`));
933
+ continue;
934
+ }
788
935
  // A renamed verb gets pointed at its replacement; `/ticker` was a pit
789
936
  // command for a release, so a bare "unknown command" is a dead end here.
790
937
  const renamed = RENAMED_COMMANDS[cmd];