From f9a49f39928833f0df3b149cd644bc8facc15aed Mon Sep 17 00:00:00 2001 From: clawedassistant26 <307253840+clawedassistant26@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:19:36 +0000 Subject: [PATCH] fix(tui): stop /shell dropping quotes from the command it runs /shell re-joined the tokenized command line with spaces before handing it to $SHELL -c, so every quote and escape the user typed was lost and the shell re-split the result. `/shell git commit -m "two words"` ran with two separate arguments; `/shell printf "[%s]\n" "two words"` printed `[two]n[words]n`. The !cmd escape already takes the raw remainder of the line for exactly this reason. Do the same for /shell so both paths behave identically. Adds two regression tests: one asserting a quoted argument survives, one asserting /shell and !cmd produce identical output for the same command. --- src/tui.mjs | 12 +++++++++++- test/tui.test.mjs | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/tui.mjs b/src/tui.mjs index 1e80582..e5374af 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -105,6 +105,15 @@ export function splitCommandLine(line) { return parts; } +// Everything after the first word of a command line, exactly as typed. `/shell` +// hands this straight to `$SHELL -c`, the same way `!cmd` does: the shell does +// its own parsing, so re-joining the tokenized parts would strip the user's +// quotes and escapes and silently split `-m "two words"` into two arguments. +function commandRemainder(line) { + const firstWord = /^\s*\S+\s*/.exec(String(line)); + return firstWord ? String(line).slice(firstWord[0].length).trim() : ""; +} + function printEngines() { console.log(bone(" engines") + ash(" — autonomous ") + acid("/agents ") + ash(" · raw ") + acid("/start ")); for (const e of engineStatus()) { @@ -361,8 +370,9 @@ export async function tui() { continue; } if (cmd === "shell" || cmd === "sh") { + const rawCmd = commandRemainder(line); rl.close(); - await openShell(rest.length ? rest.join(" ") : null); + await openShell(rawCmd || null); rl = mkrl(); continue; } diff --git a/test/tui.test.mjs b/test/tui.test.mjs index bb09a13..a3a4de9 100644 --- a/test/tui.test.mjs +++ b/test/tui.test.mjs @@ -80,3 +80,28 @@ test("TUI /run passes positional args through to moshscript argv", () => { assert.match(result.stdout, /alpha/); assert.match(result.stdout, /two words/); }); + +// `/shell ` and `!` are the same feature — both hand the command to +// `$SHELL -c`, which does its own parsing. Re-joining the tokenized parts drops +// the user's quotes, so `-m "two words"` reaches the shell as two arguments. +const posixShell = process.platform === "win32" ? { skip: "needs a POSIX shell" } : {}; + +test("TUI /shell hands the raw command line to the shell, quoting intact", posixShell, async () => { + const result = await runTui('/shell printf "[%s]\\n" "two words"\n/quit\n'); + + assert.equal(result.status, 0); + assert.match(result.stdout, /\[two words\]/); + assert.doesNotMatch(result.stdout, /\[two\]/); +}); + +test("TUI /shell and !cmd run an identical command identically", posixShell, async () => { + const command = 'printf "[%s]\\n" "two words"'; + const bracketed = (out) => out.match(/\[[^\]\n]*\]/g) || []; + + const viaSlash = await runTui(`/shell ${command}\n/quit\n`); + const viaBang = await runTui(`!${command}\n/quit\n`); + + assert.equal(viaSlash.status, 0); + assert.equal(viaBang.status, 0); + assert.deepEqual(bracketed(viaSlash.stdout), bracketed(viaBang.stdout)); +});