fix(maestro): close command-resolution gaps from #199 review
Fixes six issues in the shared command resolver (Rust display.rs and its TS mirror toolIcon.ts), all locked in by shared fixture cases: - An unescaped newline now ends resolution like `;` does, instead of being folded in as whitespace; a backslash-newline is a line continuation, removed entirely rather than embedded or splitting the word. - Assignment recognition (`FOO=bar`) now requires the `=` to come from plain, unquoted/unescaped source text. `"FOO=1"` and `FOO\=1` are executable words named `FOO=1`, not assignments — quote removal was losing that provenance. - `command -v`/`-V` (lookup only, no execution), `env -S` (embeds a command string) and `npx -c`/`--call` (same) no longer resolve through to a program they don't actually run. Restricted bash `time`'s options to `-p`, the only flag the keyword itself parses (`-o` etc. belong to external GNU time) — this corrects a previously-wrong fixture case. - `env`, `sudo` and `command`'s options may now bundle (`env -iv`, `sudo -nE`) or take an attached value (`sudo -uroot`), the way real getopt-based tools do, with missing-operand forms safely rejected. - pnpm/yarn `dlx` and `bun x`/`bunx` now consume their own trailing options (`--package foo`/`--package=foo`, `--bun`) before the executable, matching each tool's documented CLI syntax. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
370edfa763
commit
0af91296df
@@ -239,14 +239,14 @@ struct Resolution {
|
||||
/// when [`tokenize`] can't safely make sense of the line at all.
|
||||
fn resolve(command: &str) -> Option<Resolution> {
|
||||
let words = tokenize(command)?;
|
||||
let refs: Vec<&str> = words.iter().map(String::as_str).collect();
|
||||
let refs: Vec<&str> = words.iter().map(|w| w.text.as_str()).collect();
|
||||
let mut i = 0;
|
||||
while let Some(&word) = refs.get(i) {
|
||||
while let Some(word) = words.get(i) {
|
||||
if is_env_assignment(word) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let wrapper = basename(word).to_lowercase();
|
||||
let wrapper = basename(&word.text).to_lowercase();
|
||||
let Some(resolver) = wrapper_resolver(&wrapper) else {
|
||||
break;
|
||||
};
|
||||
@@ -259,7 +259,7 @@ fn resolve(command: &str) -> Option<Resolution> {
|
||||
}
|
||||
}
|
||||
Some(Resolution {
|
||||
words,
|
||||
words: words.into_iter().map(|w| w.text).collect(),
|
||||
program_index: i,
|
||||
})
|
||||
}
|
||||
@@ -274,26 +274,49 @@ fn legacy_first_word(command: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// One token from [`tokenize`]: its dequoted text, and — when a literal,
|
||||
/// unquoted/unescaped `=` occurred in the source before any quoting or
|
||||
/// escaping ever touched this word — the byte length of the name preceding
|
||||
/// it. Only a *plain* `=` counts as an assignment's: quoting or escaping it
|
||||
/// (`"FOO=1"`, `FOO\=1`) makes the word an executable command named
|
||||
/// `FOO=1`, not an assignment (#199) — quoting elsewhere in the word (a
|
||||
/// value, or even part of the name, as in `FO"O"=1`) doesn't disqualify it,
|
||||
/// matching a real shell's own assignment-word grammar.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Word {
|
||||
text: String,
|
||||
plain_eq: Option<usize>,
|
||||
}
|
||||
|
||||
/// Word-split `command` the way a shell would (honouring `'…'`, `"…"` and
|
||||
/// `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return
|
||||
/// `None` the moment it needs real shell grammar this resolver doesn't
|
||||
/// attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or
|
||||
/// substitution (`(`, `)`, `` ` ``, `$(`), or an unterminated quote/escape.
|
||||
/// Never expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal.
|
||||
fn tokenize(command: &str) -> Option<Vec<String>> {
|
||||
/// substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command
|
||||
/// separator, like `;` — #199), or an unterminated quote/escape. Never
|
||||
/// expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A
|
||||
/// backslash immediately before a newline, quoted or not, is a line
|
||||
/// continuation: removed entirely rather than embedding the newline or
|
||||
/// ending the word (#199).
|
||||
fn tokenize(command: &str) -> Option<Vec<Word>> {
|
||||
let mut words = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut has_current = false;
|
||||
let mut plain_eq: Option<usize> = None;
|
||||
let mut chars = command.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
' ' | '\t' | '\n' | '\r' => {
|
||||
' ' | '\t' | '\r' => {
|
||||
if has_current {
|
||||
words.push(std::mem::take(&mut current));
|
||||
words.push(Word {
|
||||
text: std::mem::take(&mut current),
|
||||
plain_eq: plain_eq.take(),
|
||||
});
|
||||
has_current = false;
|
||||
}
|
||||
}
|
||||
'\n' => return None,
|
||||
'|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' => return None,
|
||||
'$' if chars.peek() == Some(&'(') => return None,
|
||||
'\'' => {
|
||||
@@ -313,6 +336,7 @@ fn tokenize(command: &str) -> Option<Vec<String>> {
|
||||
Some('"') => break,
|
||||
Some('\\') => match chars.next() {
|
||||
Some(c2 @ ('"' | '\\' | '$' | '`')) => current.push(c2),
|
||||
Some('\n') => {} // line continuation, removed entirely
|
||||
Some(c2) => {
|
||||
current.push('\\');
|
||||
current.push(c2);
|
||||
@@ -324,18 +348,28 @@ fn tokenize(command: &str) -> Option<Vec<String>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
'\\' => {
|
||||
has_current = true;
|
||||
current.push(chars.next()?); // None: trailing backslash
|
||||
}
|
||||
'\\' => match chars.next() {
|
||||
Some('\n') => {} // line continuation, removed entirely
|
||||
Some(c2) => {
|
||||
has_current = true;
|
||||
current.push(c2);
|
||||
}
|
||||
None => return None, // trailing backslash
|
||||
},
|
||||
other => {
|
||||
has_current = true;
|
||||
if other == '=' && plain_eq.is_none() {
|
||||
plain_eq = Some(current.len());
|
||||
}
|
||||
current.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_current {
|
||||
words.push(current);
|
||||
words.push(Word {
|
||||
text: current,
|
||||
plain_eq,
|
||||
});
|
||||
}
|
||||
Some(words)
|
||||
}
|
||||
@@ -348,6 +382,12 @@ fn tokenize(command: &str) -> Option<Vec<String>> {
|
||||
struct OptionTable {
|
||||
flags: &'static [&'static str],
|
||||
valued: &'static [&'static str],
|
||||
/// Whether this wrapper's short options may be combined (`-iv`) or take
|
||||
/// an attached value (`-uroot`), the way real getopt-based tools do
|
||||
/// (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/
|
||||
/// `dlx`) don't get this — guessing at bundling they don't actually
|
||||
/// support would resolve through options they'd reject outright.
|
||||
bundles_short_options: bool,
|
||||
}
|
||||
|
||||
impl OptionTable {
|
||||
@@ -383,19 +423,64 @@ impl OptionTable {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if self.bundles_short_options
|
||||
&& !word.starts_with("--")
|
||||
&& word.len() > 2
|
||||
&& self.matches_short_cluster(word)
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
/// `-iv` (bundled boolean short flags) or `-uroot` (a valued short
|
||||
/// option with its value attached) — getopt's traditional short-option
|
||||
/// forms. `false` the moment a character isn't a short option this
|
||||
/// table knows, or a valued one ends the word with no attached value
|
||||
/// left to be its operand (#199: rejected rather than guessed to come
|
||||
/// from the next word instead).
|
||||
fn matches_short_cluster(&self, word: &str) -> bool {
|
||||
if !word.is_ascii() {
|
||||
return false;
|
||||
}
|
||||
let bytes = word.as_bytes();
|
||||
let mut buf = [b'-', 0];
|
||||
let mut idx = 1;
|
||||
while idx < bytes.len() {
|
||||
buf[1] = bytes[idx];
|
||||
let short = std::str::from_utf8(&buf).expect("ascii buffer");
|
||||
if self.flags.contains(&short) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if self.valued.contains(&short) {
|
||||
return idx + 1 < bytes.len();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
const NO_OPTIONS: OptionTable = OptionTable {
|
||||
flags: &[],
|
||||
valued: &[],
|
||||
bundles_short_options: false,
|
||||
};
|
||||
|
||||
const ENV_OPTS: OptionTable = OptionTable {
|
||||
flags: &["-i", "--ignore-environment", "-0", "--null"],
|
||||
valued: &["-u", "--unset", "-C", "--chdir", "-S", "--split-string"],
|
||||
flags: &[
|
||||
"-i",
|
||||
"--ignore-environment",
|
||||
"-0",
|
||||
"--null",
|
||||
"-v",
|
||||
"--debug",
|
||||
],
|
||||
valued: &["-u", "--unset", "-C", "--chdir"],
|
||||
bundles_short_options: true,
|
||||
};
|
||||
|
||||
const SUDO_OPTS: OptionTable = OptionTable {
|
||||
@@ -420,30 +505,38 @@ const SUDO_OPTS: OptionTable = OptionTable {
|
||||
"--stdin",
|
||||
],
|
||||
valued: &["-u", "--user", "-g", "--group"],
|
||||
bundles_short_options: true,
|
||||
};
|
||||
|
||||
/// `-v`/`-V` only print what would run — they never execute it — so
|
||||
/// resolving through them would claim a program runs that `command` never
|
||||
/// launches (#199). Only `-p`, which still executes normally, is safe to
|
||||
/// look through.
|
||||
const COMMAND_BUILTIN_OPTS: OptionTable = OptionTable {
|
||||
flags: &["-p", "-v", "-V"],
|
||||
flags: &["-p"],
|
||||
valued: &[],
|
||||
bundles_short_options: true,
|
||||
};
|
||||
|
||||
/// The bash *keyword* `time` only ever parses `-p` (POSIX output format);
|
||||
/// `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU
|
||||
/// `/usr/bin/time`, a different program a bare `time` word doesn't
|
||||
/// necessarily name. Assuming them here would be guessing which `time`
|
||||
/// resolves (#199).
|
||||
const TIME_OPTS: OptionTable = OptionTable {
|
||||
flags: &[
|
||||
"-p",
|
||||
"--portability",
|
||||
"-v",
|
||||
"--verbose",
|
||||
"-a",
|
||||
"--append",
|
||||
"-q",
|
||||
"--quiet",
|
||||
],
|
||||
valued: &["-o", "--output", "-f", "--format"],
|
||||
flags: &["-p"],
|
||||
valued: &[],
|
||||
bundles_short_options: true,
|
||||
};
|
||||
|
||||
/// `-c`/`--call` embeds a whole shell command string rather than naming the
|
||||
/// program positionally — resolving past it would mean guessing at a
|
||||
/// program buried inside that string, so it's left unrecognised and
|
||||
/// resolution stops at `npx` (#199).
|
||||
const NPX_OPTS: OptionTable = OptionTable {
|
||||
flags: &["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"],
|
||||
valued: &["-p", "--package", "-c", "--call", "--shell"],
|
||||
valued: &["-p", "--package", "--shell"],
|
||||
bundles_short_options: false,
|
||||
};
|
||||
|
||||
const PNPM_OPTS: OptionTable = OptionTable {
|
||||
@@ -454,12 +547,34 @@ const PNPM_OPTS: OptionTable = OptionTable {
|
||||
"--aggregate-output",
|
||||
"--no-progress",
|
||||
],
|
||||
valued: &["-C", "--dir", "--filter"],
|
||||
// `--package`/`--package=value` is documented in this pre-`dlx`
|
||||
// position (`pnpm --package=x dlx y`), alongside the identical flag
|
||||
// `dlx` itself also accepts (`DLX_TRAILING_OPTS`).
|
||||
valued: &["-C", "--dir", "--filter", "--package"],
|
||||
bundles_short_options: false,
|
||||
};
|
||||
|
||||
const YARN_OPTS: OptionTable = OptionTable {
|
||||
flags: &["--silent", "-s", "--no-progress", "--non-interactive"],
|
||||
valued: &["--cwd"],
|
||||
bundles_short_options: false,
|
||||
};
|
||||
|
||||
/// `--package`/`--package=value` right after `dlx`: both pnpm's and yarn's
|
||||
/// `dlx` document it there, to run a binary whose name differs from the
|
||||
/// package that provides it (#199).
|
||||
const DLX_TRAILING_OPTS: OptionTable = OptionTable {
|
||||
flags: &[],
|
||||
valued: &["--package"],
|
||||
bundles_short_options: false,
|
||||
};
|
||||
|
||||
/// `--bun`, forcing bun's own runtime instead of Node — bun's docs place it
|
||||
/// right after the `x`/`bunx` trigger, before the executable (#199).
|
||||
const BUN_X_OPTS: OptionTable = OptionTable {
|
||||
flags: &["--bun"],
|
||||
valued: &[],
|
||||
bundles_short_options: false,
|
||||
};
|
||||
|
||||
/// A resolver for one wrapper form: given the tokenized command and the
|
||||
@@ -479,38 +594,51 @@ fn wrapper_resolver(name: &str) -> Option<WrapperResolver> {
|
||||
"pnpm" => Some(|words, i| resolve_dlx(&PNPM_OPTS, words, i)),
|
||||
"yarn" => Some(|words, i| resolve_dlx(&YARN_OPTS, words, i)),
|
||||
"bun" => Some(resolve_bun),
|
||||
"bunx" | "uvx" => Some(|_words, i| Some(i + 1)),
|
||||
"bunx" => Some(|words, i| BUN_X_OPTS.consume(words, i + 1)),
|
||||
"uvx" => Some(|_words, i| Some(i + 1)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
|
||||
/// — without it, the next word is a `package.json` script name, not a
|
||||
/// package to run (#199: `pnpm claude` is not `pnpm dlx claude`).
|
||||
/// package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
|
||||
/// itself takes options too (`DLX_TRAILING_OPTS`), consumed before the
|
||||
/// executable.
|
||||
fn resolve_dlx(opts: &OptionTable, words: &[&str], i: usize) -> Option<usize> {
|
||||
let j = opts.consume(words, i + 1)?;
|
||||
(words.get(j) == Some(&"dlx")).then_some(j + 1)
|
||||
if words.get(j) != Some(&"dlx") {
|
||||
return None;
|
||||
}
|
||||
DLX_TRAILING_OPTS.consume(words, j + 1)
|
||||
}
|
||||
|
||||
/// `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
|
||||
/// (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
|
||||
/// subcommand is looked through.
|
||||
/// subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
|
||||
/// the executable.
|
||||
fn resolve_bun(words: &[&str], i: usize) -> Option<usize> {
|
||||
(words.get(i + 1) == Some(&"x")).then_some(i + 2)
|
||||
if words.get(i + 1) != Some(&"x") {
|
||||
return None;
|
||||
}
|
||||
BUN_X_OPTS.consume(words, i + 2)
|
||||
}
|
||||
|
||||
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
||||
fn is_env_assignment(word: &str) -> bool {
|
||||
let mut chars = word.chars();
|
||||
/// `FOO=bar`, the shell's own prefix for one command's environment —
|
||||
/// recognised only when the `=` came from plain, unquoted/unescaped source
|
||||
/// text (#199): `"FOO=1"` and `FOO\=1` name a program called `FOO=1`, not
|
||||
/// an assignment.
|
||||
fn is_env_assignment(word: &Word) -> bool {
|
||||
let Some(eq) = word.plain_eq else {
|
||||
return false;
|
||||
};
|
||||
let name = &word.text[..eq];
|
||||
let mut chars = name.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
let rest: String = chars.collect();
|
||||
match rest.split_once('=') {
|
||||
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||
None => false,
|
||||
}
|
||||
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
fn basename(word: &str) -> &str {
|
||||
@@ -795,32 +923,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn word(text: &str) -> Word {
|
||||
Word {
|
||||
text: text.into(),
|
||||
plain_eq: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell-aware tokenization (#199): quoting/escaping is honoured, never
|
||||
/// evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion is attempted.
|
||||
#[test]
|
||||
fn tokenize_handles_quotes_and_escapes_without_expanding_anything() {
|
||||
assert_eq!(
|
||||
tokenize("claude --resume"),
|
||||
Some(vec!["claude".into(), "--resume".into()])
|
||||
Some(vec![word("claude"), word("--resume")])
|
||||
);
|
||||
assert_eq!(
|
||||
tokenize("\"/opt/my agent/claude\" --resume"),
|
||||
Some(vec!["/opt/my agent/claude".into(), "--resume".into()])
|
||||
Some(vec![word("/opt/my agent/claude"), word("--resume")])
|
||||
);
|
||||
assert_eq!(
|
||||
tokenize("FOO=\"bar baz\" claude"),
|
||||
Some(vec!["FOO=bar baz".into(), "claude".into()])
|
||||
Some(vec![
|
||||
Word {
|
||||
text: "FOO=bar baz".into(),
|
||||
plain_eq: Some(3),
|
||||
},
|
||||
word("claude"),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
tokenize("claude --name=$HOME"),
|
||||
Some(vec!["claude".into(), "--name=$HOME".into()]),
|
||||
Some(vec![
|
||||
word("claude"),
|
||||
Word {
|
||||
text: "--name=$HOME".into(),
|
||||
plain_eq: Some(6),
|
||||
},
|
||||
]),
|
||||
"a bare $ is kept literal, never expanded"
|
||||
);
|
||||
}
|
||||
|
||||
/// Real shell grammar this resolver doesn't attempt — pipelines, list
|
||||
/// operators, subshells/substitution, an unterminated quote — is
|
||||
/// rejected outright rather than guessed through (#199).
|
||||
/// operators, subshells/substitution, an unescaped newline, an
|
||||
/// unterminated quote — is rejected outright rather than guessed
|
||||
/// through (#199).
|
||||
#[test]
|
||||
fn tokenize_rejects_ambiguous_shell_grammar() {
|
||||
for command in [
|
||||
@@ -831,8 +979,30 @@ mod tests {
|
||||
"claude \"unterminated",
|
||||
"claude 'unterminated",
|
||||
"claude \\",
|
||||
"claude\nclaude-code", // an unescaped newline separates commands, like `;`
|
||||
] {
|
||||
assert_eq!(tokenize(command), None, "{command:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A backslash immediately before a newline is a line continuation:
|
||||
/// removed entirely, joining what it split rather than embedding a
|
||||
/// literal newline or ending the command (#199).
|
||||
#[test]
|
||||
fn tokenize_treats_backslash_newline_as_a_continuation() {
|
||||
assert_eq!(
|
||||
tokenize("clau\\\nde --resume"),
|
||||
Some(vec![word("claude"), word("--resume")])
|
||||
);
|
||||
}
|
||||
|
||||
/// Only a plain, unquoted/unescaped `=` marks an assignment boundary —
|
||||
/// quoting or escaping the `=` itself disqualifies the word, even
|
||||
/// though the dequoted text looks identical (#199).
|
||||
#[test]
|
||||
fn tokenize_only_marks_plain_unquoted_equals_signs_as_assignments() {
|
||||
assert_eq!(tokenize("FOO=1 claude").unwrap()[0].plain_eq, Some(3));
|
||||
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
|
||||
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,11 @@
|
||||
{ "command": "sudo -- claude --resume", "program": "claude", "tool": "claude" },
|
||||
{ "command": "nohup claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "time claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "time -o /tmp/t.log claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "time -p claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "time -o /tmp/t.log claude", "program": "time", "tool": null },
|
||||
{ "command": "command claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "command -v claude", "program": "command", "tool": null },
|
||||
{ "command": "command -V claude", "program": "command", "tool": null },
|
||||
{ "command": "npx --yes claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "npx claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "pnpm --silent dlx claude-code", "program": "claude-code", "tool": "claude" },
|
||||
@@ -28,8 +31,29 @@
|
||||
{ "command": "bun x claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bunx claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "uvx claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bun x --bun claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bunx --bun claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bun --bun x claude-code", "program": "bun", "tool": null },
|
||||
{ "command": "pnpm dlx --package foo claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "pnpm dlx --package=foo claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "pnpm --package=typescript dlx tsc", "program": "tsc", "tool": null },
|
||||
{ "command": "pnpm dlx --unknown-flag claude-code", "program": "pnpm", "tool": null },
|
||||
{ "command": "yarn dlx --package foo claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "yarn dlx --package=foo claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "sudo env -i claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "FOO=1 sudo BAR=2 claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "env -iv claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "sudo -nE claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "sudo -uroot claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "sudo -u", "program": "sudo", "tool": null },
|
||||
{ "command": "sudo -nu claude", "program": "sudo", "tool": null },
|
||||
{ "command": "env -S 'python3 -u' script.py", "program": "env", "tool": null },
|
||||
{ "command": "npx -c \"claude --version\"", "program": "npx", "tool": null },
|
||||
{ "command": "npx --call \"claude --version\"", "program": "npx", "tool": null },
|
||||
{ "command": "\"FOO=1\" claude", "program": "FOO=1", "tool": null },
|
||||
{ "command": "FOO\\=1 claude", "program": "FOO=1", "tool": null },
|
||||
{ "command": "claude\nclaude-code", "program": "claude", "tool": null },
|
||||
{ "command": "clau\\\nde --resume", "program": "claude", "tool": "claude" },
|
||||
{ "command": "npx opencode", "program": "opencode", "tool": "opencode" },
|
||||
{ "command": "pnpm dlx opencode run", "program": "opencode", "tool": "opencode" },
|
||||
{ "command": "pnpm claude", "program": "pnpm", "tool": null },
|
||||
|
||||
+147
-26
@@ -74,7 +74,7 @@ export function splitCommand(command: string): CommandParts {
|
||||
}
|
||||
const i = programIndex(words);
|
||||
if (i >= words.length) return { tool: null, program: command.trim() };
|
||||
const program = basename(words[i]);
|
||||
const program = basename(words[i].text);
|
||||
return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
|
||||
}
|
||||
|
||||
@@ -86,20 +86,41 @@ function legacyFirstWord(command: string): string {
|
||||
return first === undefined ? command.trim() : basename(first);
|
||||
}
|
||||
|
||||
/** One token from {@link tokenize}: its dequoted text, and — when a
|
||||
* literal, unquoted/unescaped `=` occurred in the source before any
|
||||
* quoting or escaping ever touched this word — the index of the name
|
||||
* preceding it. Only a *plain* `=` counts as an assignment's: quoting or
|
||||
* escaping it (`"FOO=1"`, `FOO\=1`) makes the word an executable command
|
||||
* named `FOO=1`, not an assignment (#199) — quoting elsewhere in the word
|
||||
* (a value, or even part of the name, as in `FO"O"=1`) doesn't disqualify
|
||||
* it, matching a real shell's own assignment-word grammar. */
|
||||
interface Word {
|
||||
text: string;
|
||||
plainEq: number | null;
|
||||
}
|
||||
|
||||
/** `FOO=bar`, the shell's own prefix for one command's environment —
|
||||
* recognised only when the `=` came from plain, unquoted/unescaped source
|
||||
* text (#199). */
|
||||
function isEnvAssignment(word: Word): boolean {
|
||||
if (word.plainEq === null) return false;
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(word.text.slice(0, word.plainEq));
|
||||
}
|
||||
|
||||
/** Walk `words` past leading `FOO=bar` assignments and known wrapper forms
|
||||
* (interleaved in any order/count) to the index of the actual program. */
|
||||
function programIndex(words: string[]): number {
|
||||
function programIndex(words: Word[]): number {
|
||||
const texts = words.map((w) => w.text);
|
||||
let i = 0;
|
||||
while (i < words.length) {
|
||||
const word = words[i];
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
||||
if (isEnvAssignment(words[i])) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const wrapper = basename(word).toLowerCase();
|
||||
const wrapper = basename(texts[i]).toLowerCase();
|
||||
const resolver = WRAPPER_RESOLVERS[wrapper];
|
||||
if (!resolver) break;
|
||||
const next = resolver(words, i);
|
||||
const next = resolver(texts, i);
|
||||
// Not a form of the wrapper we recognise (an unknown option, or — for
|
||||
// `pnpm`/`yarn`/`bun` — no `dlx`/`x` subcommand behind it): the wrapper
|
||||
// word itself is the program, not a launcher.
|
||||
@@ -118,24 +139,32 @@ function basename(word: string): string {
|
||||
* `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return
|
||||
* `null` the moment it needs real shell grammar this resolver doesn't
|
||||
* attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or
|
||||
* substitution (`(`, `)`, `` ` ``, `$(`), or an unterminated quote/escape.
|
||||
* Never expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. */
|
||||
function tokenize(command: string): string[] | null {
|
||||
const words: string[] = [];
|
||||
* substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command
|
||||
* separator, like `;` — #199), or an unterminated quote/escape. Never
|
||||
* expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A
|
||||
* backslash immediately before a newline, quoted or not, is a line
|
||||
* continuation: removed entirely rather than embedding the newline or
|
||||
* ending the word (#199). */
|
||||
function tokenize(command: string): Word[] | null {
|
||||
const words: Word[] = [];
|
||||
let current = "";
|
||||
let hasCurrent = false;
|
||||
let plainEq: number | null = null;
|
||||
let i = 0;
|
||||
const n = command.length;
|
||||
|
||||
while (i < n) {
|
||||
const c = command[i];
|
||||
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
|
||||
if (c === " " || c === "\t" || c === "\r") {
|
||||
if (hasCurrent) {
|
||||
words.push(current);
|
||||
words.push({ text: current, plainEq });
|
||||
current = "";
|
||||
hasCurrent = false;
|
||||
plainEq = null;
|
||||
}
|
||||
i += 1;
|
||||
} else if (c === "\n") {
|
||||
return null;
|
||||
} else if ("|&;<>()`".includes(c)) {
|
||||
return null;
|
||||
} else if (c === "$" && command[i + 1] === "(") {
|
||||
@@ -160,6 +189,10 @@ function tokenize(command: string): string[] | null {
|
||||
if (c2 === "\\") {
|
||||
const escaped = command[i + 1];
|
||||
if (escaped === undefined) return null; // trailing backslash
|
||||
if (escaped === "\n") {
|
||||
i += 2; // line continuation, removed entirely
|
||||
continue;
|
||||
}
|
||||
current += '"\\$`'.includes(escaped) ? escaped : "\\" + escaped;
|
||||
i += 2;
|
||||
} else {
|
||||
@@ -170,16 +203,23 @@ function tokenize(command: string): string[] | null {
|
||||
} else if (c === "\\") {
|
||||
const escaped = command[i + 1];
|
||||
if (escaped === undefined) return null; // trailing backslash
|
||||
if (escaped === "\n") {
|
||||
i += 2; // line continuation, removed entirely
|
||||
continue;
|
||||
}
|
||||
hasCurrent = true;
|
||||
current += escaped;
|
||||
i += 2;
|
||||
} else {
|
||||
hasCurrent = true;
|
||||
if (c === "=" && plainEq === null) {
|
||||
plainEq = current.length;
|
||||
}
|
||||
current += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if (hasCurrent) words.push(current);
|
||||
if (hasCurrent) words.push({ text: current, plainEq });
|
||||
return words;
|
||||
}
|
||||
|
||||
@@ -191,6 +231,33 @@ function tokenize(command: string): string[] | null {
|
||||
interface OptionTable {
|
||||
flags: Set<string>;
|
||||
valued: Set<string>;
|
||||
/** Whether this wrapper's short options may be combined (`-iv`) or take
|
||||
* an attached value (`-uroot`), the way real getopt-based tools do
|
||||
* (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/`dlx`)
|
||||
* don't get this — guessing at bundling they don't actually support
|
||||
* would resolve through options they'd reject outright. */
|
||||
bundlesShortOptions: boolean;
|
||||
}
|
||||
|
||||
/** `-iv` (bundled boolean short flags) or `-uroot` (a valued short option
|
||||
* with its value attached) — getopt's traditional short-option forms.
|
||||
* `false` the moment a character isn't a short option this table knows, or
|
||||
* a valued one ends the word with no attached value left to be its operand
|
||||
* (#199: rejected rather than guessed to come from the next word instead). */
|
||||
function matchesShortCluster(opts: OptionTable, word: string): boolean {
|
||||
let idx = 1;
|
||||
while (idx < word.length) {
|
||||
const short = "-" + word[idx];
|
||||
if (opts.flags.has(short)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (opts.valued.has(short)) {
|
||||
return idx + 1 < word.length;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Consume `opts`'s options starting at `words[i]`, returning the index just
|
||||
@@ -220,15 +287,25 @@ function consumeOptions(opts: OptionTable, words: string[], i: number): number |
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
opts.bundlesShortOptions &&
|
||||
!word.startsWith("--") &&
|
||||
word.length > 2 &&
|
||||
matchesShortCluster(opts, word)
|
||||
) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const NO_OPTIONS: OptionTable = { flags: new Set(), valued: new Set() };
|
||||
const NO_OPTIONS: OptionTable = { flags: new Set(), valued: new Set(), bundlesShortOptions: false };
|
||||
|
||||
const ENV_OPTS: OptionTable = {
|
||||
flags: new Set(["-i", "--ignore-environment", "-0", "--null"]),
|
||||
valued: new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string"]),
|
||||
flags: new Set(["-i", "--ignore-environment", "-0", "--null", "-v", "--debug"]),
|
||||
valued: new Set(["-u", "--unset", "-C", "--chdir"]),
|
||||
bundlesShortOptions: true,
|
||||
};
|
||||
|
||||
const SUDO_OPTS: OptionTable = {
|
||||
@@ -253,47 +330,91 @@ const SUDO_OPTS: OptionTable = {
|
||||
"--stdin",
|
||||
]),
|
||||
valued: new Set(["-u", "--user", "-g", "--group"]),
|
||||
bundlesShortOptions: true,
|
||||
};
|
||||
|
||||
/** `-v`/`-V` only print what would run — they never execute it — so
|
||||
* resolving through them would claim a program runs that `command` never
|
||||
* launches (#199). Only `-p`, which still executes normally, is safe to
|
||||
* look through. */
|
||||
const COMMAND_BUILTIN_OPTS: OptionTable = {
|
||||
flags: new Set(["-p", "-v", "-V"]),
|
||||
flags: new Set(["-p"]),
|
||||
valued: new Set(),
|
||||
bundlesShortOptions: true,
|
||||
};
|
||||
|
||||
/** The bash *keyword* `time` only ever parses `-p` (POSIX output format);
|
||||
* `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU
|
||||
* `/usr/bin/time`, a different program a bare `time` word doesn't
|
||||
* necessarily name. Assuming them here would be guessing which `time`
|
||||
* resolves (#199). */
|
||||
const TIME_OPTS: OptionTable = {
|
||||
flags: new Set(["-p", "--portability", "-v", "--verbose", "-a", "--append", "-q", "--quiet"]),
|
||||
valued: new Set(["-o", "--output", "-f", "--format"]),
|
||||
flags: new Set(["-p"]),
|
||||
valued: new Set(),
|
||||
bundlesShortOptions: true,
|
||||
};
|
||||
|
||||
/** `-c`/`--call` embeds a whole shell command string rather than naming the
|
||||
* program positionally — resolving past it would mean guessing at a
|
||||
* program buried inside that string, so it's left unrecognised and
|
||||
* resolution stops at `npx` (#199). */
|
||||
const NPX_OPTS: OptionTable = {
|
||||
flags: new Set(["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"]),
|
||||
valued: new Set(["-p", "--package", "-c", "--call", "--shell"]),
|
||||
valued: new Set(["-p", "--package", "--shell"]),
|
||||
bundlesShortOptions: false,
|
||||
};
|
||||
|
||||
const PNPM_OPTS: OptionTable = {
|
||||
flags: new Set(["--silent", "-s", "--stream", "--aggregate-output", "--no-progress"]),
|
||||
valued: new Set(["-C", "--dir", "--filter"]),
|
||||
// `--package`/`--package=value` is documented in this pre-`dlx` position
|
||||
// (`pnpm --package=x dlx y`), alongside the identical flag `dlx` itself
|
||||
// also accepts (`DLX_TRAILING_OPTS`).
|
||||
valued: new Set(["-C", "--dir", "--filter", "--package"]),
|
||||
bundlesShortOptions: false,
|
||||
};
|
||||
|
||||
const YARN_OPTS: OptionTable = {
|
||||
flags: new Set(["--silent", "-s", "--no-progress", "--non-interactive"]),
|
||||
valued: new Set(["--cwd"]),
|
||||
bundlesShortOptions: false,
|
||||
};
|
||||
|
||||
/** `--package`/`--package=value` right after `dlx`: both pnpm's and yarn's
|
||||
* `dlx` document it there, to run a binary whose name differs from the
|
||||
* package that provides it (#199). */
|
||||
const DLX_TRAILING_OPTS: OptionTable = {
|
||||
flags: new Set(),
|
||||
valued: new Set(["--package"]),
|
||||
bundlesShortOptions: false,
|
||||
};
|
||||
|
||||
/** `--bun`, forcing bun's own runtime instead of Node — bun's docs place it
|
||||
* right after the `x`/`bunx` trigger, before the executable (#199). */
|
||||
const BUN_X_OPTS: OptionTable = {
|
||||
flags: new Set(["--bun"]),
|
||||
valued: new Set(),
|
||||
bundlesShortOptions: false,
|
||||
};
|
||||
|
||||
/** `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
|
||||
* — without it, the next word is a `package.json` script name, not a
|
||||
* package to run (#199: `pnpm claude` is not `pnpm dlx claude`). */
|
||||
* package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
|
||||
* itself takes options too (`DLX_TRAILING_OPTS`), consumed before the
|
||||
* executable. */
|
||||
function resolveDlx(opts: OptionTable, words: string[], i: number): number | null {
|
||||
const j = consumeOptions(opts, words, i + 1);
|
||||
if (j === null) return null;
|
||||
return words[j] === "dlx" ? j + 1 : null;
|
||||
if (words[j] !== "dlx") return null;
|
||||
return consumeOptions(DLX_TRAILING_OPTS, words, j + 1);
|
||||
}
|
||||
|
||||
/** `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
|
||||
* (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
|
||||
* subcommand is looked through. */
|
||||
* subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
|
||||
* the executable. */
|
||||
function resolveBun(words: string[], i: number): number | null {
|
||||
return words[i + 1] === "x" ? i + 2 : null;
|
||||
if (words[i + 1] !== "x") return null;
|
||||
return consumeOptions(BUN_X_OPTS, words, i + 2);
|
||||
}
|
||||
|
||||
/** A resolver for one wrapper form: given the tokenized command and the
|
||||
@@ -312,7 +433,7 @@ const WRAPPER_RESOLVERS: Record<string, WrapperResolver> = {
|
||||
pnpm: (words, i) => resolveDlx(PNPM_OPTS, words, i),
|
||||
yarn: (words, i) => resolveDlx(YARN_OPTS, words, i),
|
||||
bun: resolveBun,
|
||||
bunx: (_words, i) => i + 1,
|
||||
bunx: (words, i) => consumeOptions(BUN_X_OPTS, words, i + 1),
|
||||
uvx: (_words, i) => i + 1,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user