moshcode 0.51.0 → 0.52.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
@@ -1035,7 +1035,7 @@ chmod +x deploy.mosh
1035
1035
  | `ask(prompt)` | blocking gate — waits for human reply at moshcode.sh |
1036
1036
  | `say("…")` | print a line |
1037
1037
  | `sleep(ms)` | pause for N milliseconds (blocking) |
1038
- | `shell(cmd)` | run a shell command (blocking, `$SHELL -c`); returns `{ ok, code }` |
1038
+ | `shell(cmd)` | run a shell command (blocking, `$SHELL -ic`, so your rc file loads); returns `{ ok, code }` |
1039
1039
  | `stop()` | end the loop (`alive = false`) |
1040
1040
  | `repeat()` | back to the top of the loop |
1041
1041
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.51.0",
3
+ "version": "0.52.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": {
package/src/aliases.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  // An alias is a name and a line. The line is a shell command unless it starts
9
9
  // with `/`, in which case it is a pit command:
10
10
  //
11
- // /alias set gs "git status" → /gs runs `$SHELL -c "git status"`
11
+ // /alias set gs "git status" → /gs runs `$SHELL -ic "git status"`
12
12
  // /alias set cc "/agents claude" → /cc opens claude autonomously
13
13
  //
14
14
  // Shell-by-default because that is what the prompt is mostly asked for, and the
@@ -148,7 +148,7 @@ export function removeAlias(name) {
148
148
  *
149
149
  * Appended rather than substituted, the way a shell alias behaves: `/gs -sb` is
150
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`.
151
+ * tokenized parts, so the user's own quoting survives into `$SHELL -ic`.
152
152
  *
153
153
  * The `!` is what routes a bare value to the shell — the pit already reads a
154
154
  * leading `!` as "run this in $SHELL", so an alias does not need a second path
package/src/commands.mjs CHANGED
@@ -20,6 +20,7 @@ import { cliVerb, aiVerb } from "./cli.mjs";
20
20
  import { ingestApproval, pollApproval } from "./notify.mjs";
21
21
  import { capture, killSession, sendPrompt } from "./herd.mjs";
22
22
  import { herdStart, roster, waitFor } from "./herd-cli.mjs";
23
+ import { shellInvocation } from "./shell.mjs";
23
24
 
24
25
  // The moshcoding pit-anthem playlist. mosh() blasts this URL and, on a desktop
25
26
  // with a GUI, tries to open it in the default browser.
@@ -190,9 +191,9 @@ const COMMANDS = [
190
191
 
191
192
  {
192
193
  name: "shell",
193
- summary: "run a shell command (blocking, cmd.exe on Windows or $SHELL -c elsewhere)",
194
+ summary: "run a shell command (blocking, cmd.exe on Windows or $SHELL elsewhere)",
194
195
  usage: "shell(cmd)",
195
- detail: "runs cmd in $SHELL; returns { ok, code, signal }",
196
+ detail: "runs cmd in $SHELL, loading your rc file where it can; returns { ok, code, signal }",
196
197
  // The moshscript system verb for arbitrary shell commands. Blocking
197
198
  // (spawnSync + inherited stdio) so it runs inline in the no-`await` style,
198
199
  // and the child owns the terminal for interactive commands. Returns
@@ -202,15 +203,16 @@ const COMMANDS = [
202
203
  const cmd = args.join(" ");
203
204
  if (!cmd) throw new Error("moshscript: shell() requires a command string");
204
205
  if (ctx.dryRun) {
205
- ctx.out(` ▶ shell(${JSON.stringify(cmd)}) → would run: $SHELL -c ${JSON.stringify(cmd)}`);
206
+ ctx.out(` ▶ shell(${JSON.stringify(cmd)}) → would run: $SHELL ${shellInvocation(cmd).flags} ${JSON.stringify(cmd)}`);
206
207
  // Same R8 contract as the comment above: `code` is always present, so a
207
208
  // script branching on the exit status behaves the same under --dry-run.
208
209
  return { ok: true, code: 0, dryRun: true };
209
210
  }
210
- const sh = process.platform === "win32"
211
- ? (process.env.COMSPEC || "cmd.exe")
212
- : (process.env.SHELL || "/bin/sh");
213
- const shArgs = process.platform === "win32" ? ["/d", "/s", "/c", cmd] : ["-c", cmd];
211
+ // Same invocation the pit's own `!cmd` uses, so a command that works when
212
+ // typed works when scripted: interactive where a terminal is attached, so
213
+ // the user's rc file — and the aliases in it — are loaded. src/shell.mjs
214
+ // has the reasoning, including why a headless run stays non-interactive.
215
+ const { shell: sh, args: shArgs } = shellInvocation(cmd);
214
216
  ctx.out(` ▶ shell: ${cmd}`);
215
217
  const res = spawnSync(sh, shArgs, { stdio: "inherit" });
216
218
  if (res.error) throw res.error;
package/src/shell.mjs ADDED
@@ -0,0 +1,96 @@
1
+ // One answer to "how does the pit run a shell command".
2
+ //
3
+ // The pit is not a shell, so everything it runs on the user's behalf goes out
4
+ // through $SHELL: `!cmd`, `/shell`, a shell-valued alias from /alias, and
5
+ // moshscript's shell(). The obvious spelling is `$SHELL -c "<cmd>"`, and it is
6
+ // wrong in a way that costs an afternoon to find. `zsh -c` and `bash -c` are
7
+ // non-interactive shells, and a non-interactive shell does not read ~/.zshrc or
8
+ // ~/.bashrc — so the aliases and functions defined there are simply not there:
9
+ //
10
+ // /alias set prs gh-prs-all → zsh -c gh-prs-all
11
+ // → zsh:1: command not found: gh-prs-all
12
+ //
13
+ // while the identical word works when typed at a prompt. That is a bug rather
14
+ // than a footnote, because naming a shell command is most of what /alias is
15
+ // for, and the shell commands people name are the ones they already named once
16
+ // in ~/.zsh_aliases. An alias that resolves at the prompt and not in the pit
17
+ // makes the pit look broken, and from the user's side it is.
18
+ //
19
+ // So we ask for an interactive shell. `-i` is the switch that makes bash and
20
+ // zsh read their rc file, and the rc file is where the user's shell actually
21
+ // lives. Anything already on PATH worked before and still works; what changes
22
+ // is that aliases and functions now resolve too.
23
+
24
+ /**
25
+ * Shells whose startup file is read only when the shell is interactive.
26
+ *
27
+ * Deliberately just bash and zsh. fish sources config.fish however it was
28
+ * started, so it needs nothing from us; plain sh/dash have no rc file to miss
29
+ * and `-i` would only buy them job-control machinery; and a shell we have not
30
+ * heard of is likelier to be harmed by an unexpected flag than helped by it.
31
+ * Being wrong here means running a command in a shell that cannot see the
32
+ * user's aliases, which is exactly where we started — so an unknown shell
33
+ * lands on the old behaviour rather than on a guess.
34
+ */
35
+ const RC_ON_INTERACTIVE = new Set(["bash", "zsh"]);
36
+
37
+ /** Set this to opt a session out of rc loading and get plain `-c` back. */
38
+ export const NO_RC_ENV = "MOSHCODE_SHELL_NO_RC";
39
+
40
+ /** Windows has no rc file in this sense; cmd.exe wants its own flag spelling. */
41
+ const CMD_FLAGS = ["/d", "/s", "/c"];
42
+
43
+ /** The shell the user runs, or the platform's fallback. */
44
+ export function shellPath(env = process.env, platform = process.platform) {
45
+ if (platform === "win32") return env.COMSPEC || "cmd.exe";
46
+ return env.SHELL || "/bin/sh";
47
+ }
48
+
49
+ /**
50
+ * `zsh` from `/usr/bin/zsh`, `bash` from `C:\...\bash.exe`.
51
+ *
52
+ * Both separators by hand rather than path.basename, which is bound to the
53
+ * platform the code is running on: it would leave a Windows path intact when
54
+ * asked on Linux, and this function is also asked about the other platform —
55
+ * shellInvocation takes `platform` as an option so the Windows branch can be
56
+ * tested from anywhere.
57
+ */
58
+ export function shellName(shell) {
59
+ const tail = String(shell || "").split(/[\\/]/).pop() || "";
60
+ return tail.replace(/\.exe$/i, "");
61
+ }
62
+
63
+ /**
64
+ * How to spawn `rawCmd`, as { shell, args, flags, interactive }.
65
+ *
66
+ * `rawCmd` empty means "a shell to sit in" — no args at all, which is already
67
+ * an interactive shell and already reads the rc file.
68
+ *
69
+ * `tty` is why this takes options rather than reading the world directly. An
70
+ * interactive bash with no terminal attached prints
71
+ *
72
+ * bash: cannot set terminal process group (…): Inappropriate ioctl for device
73
+ * bash: no job control in this shell
74
+ *
75
+ * on stderr before it runs a thing, which would turn every headless run — cron,
76
+ * CI, `moshcode run script.mosh` in a pipeline — into noise around the output
77
+ * someone is trying to read. With a terminal attached, both shells are silent.
78
+ * So the rc file is loaded where a person is watching, which is the case that
79
+ * wanted it, and a headless run keeps the old quiet behaviour. zsh alone would
80
+ * not need the guard; the guard is not worth splitting per shell for.
81
+ */
82
+ export function shellInvocation(rawCmd, {
83
+ env = process.env,
84
+ platform = process.platform,
85
+ tty = Boolean(process.stdin?.isTTY && process.stdout?.isTTY),
86
+ } = {}) {
87
+ const shell = shellPath(env, platform);
88
+ const name = shellName(shell);
89
+ if (!rawCmd) return { shell, args: [], flags: "", interactive: true, name };
90
+ if (platform === "win32") {
91
+ return { shell, args: [...CMD_FLAGS, rawCmd], flags: CMD_FLAGS.join(" "), interactive: false, name };
92
+ }
93
+ const interactive = tty && RC_ON_INTERACTIVE.has(name) && !env[NO_RC_ENV];
94
+ const flags = interactive ? "-ic" : "-c";
95
+ return { shell, args: [flags, rawCmd], flags, interactive, name };
96
+ }
package/src/tui.mjs CHANGED
@@ -25,6 +25,7 @@ import { stocksCommand } from "./advisor.mjs";
25
25
  import { cryptoCommand } from "./crypto.mjs";
26
26
  import { gamesCommand } from "./games.mjs";
27
27
  import { canOpenBrowser, openBrowser } from "./open-url.mjs";
28
+ import { shellInvocation } from "./shell.mjs";
28
29
  import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs";
29
30
  import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
30
31
  import { RENAMED_COMMANDS, findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs";
@@ -128,7 +129,7 @@ export function splitCommandLine(line) {
128
129
  }
129
130
 
130
131
  // Everything after the first word of a command line, exactly as typed. `/shell`
131
- // hands this straight to `$SHELL -c`, the same way `!cmd` does: the shell does
132
+ // hands this straight to `$SHELL -ic`, the same way `!cmd` does: the shell does
132
133
  // its own parsing, so re-joining the tokenized parts would strip the user's
133
134
  // quotes and escapes and silently split `-m "two words"` into two arguments.
134
135
  function commandRemainder(line, words = 1) {
@@ -148,7 +149,7 @@ function commandRemainder(line, words = 1) {
148
149
  * quotes there belong to the shell. Tokenizing tells the two apart: exactly one
149
150
  * token means the whole value was quoted, so use it with the quotes stripped;
150
151
  * anything else is a bare command line, and it goes through verbatim so the
151
- * user's own quoting survives into `$SHELL -c`.
152
+ * user's own quoting survives into `$SHELL -ic`.
152
153
  */
153
154
  export function aliasValue(line) {
154
155
  const raw = commandRemainder(line, 3); // past "/alias", "set", "<name>"
@@ -508,12 +509,12 @@ async function openWorkflowTool(key, tool, args) {
508
509
 
509
510
  // Spawn the user's shell with the terminal fully handed over (stdio inherit),
510
511
  // inheriting the current cwd + env. No args → an interactive shell; a raw
511
- // command string → `$SHELL -c "<cmd>"` (one-off). Resolves { ok, code, signal }.
512
+ // command string → `$SHELL -ic "<cmd>"` (one-off). Interactive so the command
513
+ // can see the aliases and functions in ~/.zshrc — see src/shell.mjs for why
514
+ // that is not optional. Resolves { ok, code, signal }.
512
515
  function runShell(rawCmd) {
513
516
  return new Promise((resolve) => {
514
- const shell = process.env.SHELL
515
- || (process.platform === "win32" ? (process.env.COMSPEC || "cmd.exe") : "/bin/sh");
516
- const args = rawCmd ? ["-c", rawCmd] : [];
517
+ const { shell, args } = shellInvocation(rawCmd);
517
518
  let child;
518
519
  try { child = spawn(shell, args, { stdio: "inherit" }); }
519
520
  catch (e) { resolve({ ok: false, error: e }); return; }
@@ -525,9 +526,12 @@ function runShell(rawCmd) {
525
526
  // vim `:sh` — drop into a shell and land back at the mosh prompt on exit, with
526
527
  // the whole TUI session (history, cwd) intact. `rawCmd` runs a one-off instead.
527
528
  async function openShell(rawCmd) {
528
- const shellName = path.basename(process.env.SHELL || "sh");
529
+ // The flags come from the same place the spawn does, so the echoed line is
530
+ // what actually ran — a `-c` printed above an `-ic` invocation is the kind of
531
+ // small lie that sends someone debugging the wrong shell.
532
+ const { flags, name: shellName } = shellInvocation(rawCmd);
529
533
  console.log(info(rawCmd
530
- ? `${bone(shellName)} ${ash("-c")} ${ash(rawCmd)}`
534
+ ? `${bone(shellName)} ${ash(flags)} ${ash(rawCmd)}`
531
535
  : `dropping to ${bone(shellName)} — ${ash("`exit` or Ctrl-D brings you back to the pit")}`));
532
536
  console.log(hr());
533
537
  const r = await runShell(rawCmd);