Skip to content

Commit 05cfd7f

Browse files
fix(tui): stop /shell dropping quotes from the command it runs (#44)
/shell <cmd> 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. Co-authored-by: clawedassistant26 <307253840+clawedassistant26@users.noreply.github.com>
1 parent 171f7bd commit 05cfd7f

2 files changed

Lines changed: 36 additions & 1 deletion

File tree

src/tui.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ export function splitCommandLine(line) {
105105
return parts;
106106
}
107107

108+
// Everything after the first word of a command line, exactly as typed. `/shell`
109+
// hands this straight to `$SHELL -c`, the same way `!cmd` does: the shell does
110+
// its own parsing, so re-joining the tokenized parts would strip the user's
111+
// quotes and escapes and silently split `-m "two words"` into two arguments.
112+
function commandRemainder(line) {
113+
const firstWord = /^\s*\S+\s*/.exec(String(line));
114+
return firstWord ? String(line).slice(firstWord[0].length).trim() : "";
115+
}
116+
108117
function printEngines() {
109118
console.log(bone(" engines") + ash(" — autonomous ") + acid("/agents <name>") + ash(" · raw ") + acid("/start <name>"));
110119
for (const e of engineStatus()) {
@@ -361,8 +370,9 @@ export async function tui() {
361370
continue;
362371
}
363372
if (cmd === "shell" || cmd === "sh") {
373+
const rawCmd = commandRemainder(line);
364374
rl.close();
365-
await openShell(rest.length ? rest.join(" ") : null);
375+
await openShell(rawCmd || null);
366376
rl = mkrl();
367377
continue;
368378
}

test/tui.test.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,28 @@ test("TUI /run passes positional args through to moshscript argv", () => {
8080
assert.match(result.stdout, /alpha/);
8181
assert.match(result.stdout, /two words/);
8282
});
83+
84+
// `/shell <cmd>` and `!<cmd>` are the same feature — both hand the command to
85+
// `$SHELL -c`, which does its own parsing. Re-joining the tokenized parts drops
86+
// the user's quotes, so `-m "two words"` reaches the shell as two arguments.
87+
const posixShell = process.platform === "win32" ? { skip: "needs a POSIX shell" } : {};
88+
89+
test("TUI /shell hands the raw command line to the shell, quoting intact", posixShell, async () => {
90+
const result = await runTui('/shell printf "[%s]\\n" "two words"\n/quit\n');
91+
92+
assert.equal(result.status, 0);
93+
assert.match(result.stdout, /\[two words\]/);
94+
assert.doesNotMatch(result.stdout, /\[two\]/);
95+
});
96+
97+
test("TUI /shell and !cmd run an identical command identically", posixShell, async () => {
98+
const command = 'printf "[%s]\\n" "two words"';
99+
const bracketed = (out) => out.match(/\[[^\]\n]*\]/g) || [];
100+
101+
const viaSlash = await runTui(`/shell ${command}\n/quit\n`);
102+
const viaBang = await runTui(`!${command}\n/quit\n`);
103+
104+
assert.equal(viaSlash.status, 0);
105+
assert.equal(viaBang.status, 0);
106+
assert.deepEqual(bracketed(viaSlash.stdout), bracketed(viaBang.stdout));
107+
});

0 commit comments

Comments
 (0)