Compare commits

..
Author SHA1 Message Date
Miguel Palhas 4c8217385a fix(maestro): scope assignment-skipping to wrappers that accept it
`command FOO=1 claude` and `nohup FOO=1 claude` are invalid shells (they
exec `FOO=1` as the program, exit 127), but the resolver skipped the
assignment unconditionally after any wrapper. Assignment prefixes are
only real at the start of a simple command or after a wrapper that itself
interprets them as env vars (env, sudo) or a keyword that restarts the
assignment grammar (time) — not after command, nohup, or a package
manager's subcommand, which just exec their next argument as the program.

Attach an accepts_assignment flag to each wrapper transition in both the
Rust and TS resolvers and gate the assignment-skip loop on the flag from
the most recently consumed wrapper. Extends the shared fixture with the
two reported cases plus every other wrapper transition and several nested
combinations (sudo+time valid, sudo+command / command+nohup / time+command
invalid, nohup+env valid) to lock the per-wrapper policy in for both
languages.
2026-09-05 14:25:57 +01:00
Miguel Palhas 758bacf3fc fix(maestro): harden command resolution 2026-09-05 14:05:52 +01:00
Miguel PalhasandClaude Sonnet 5 0af91296df 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>
2026-09-05 13:51:43 +01:00
Miguel Palhas 370edfa763 fix(maestro): resolve command wrappers with a shell-aware tokenizer (#199)
command_program/command_tool whitespace-split and only skipped whole
wrapper words, so quoted paths broke, sudo -u/env -i/nohup/time/npx
--yes/pnpm|yarn --silent dlx/bun x were rejected, and pnpm claude /
yarn claude (a package.json script, not `dlx claude`) resolved as a
Claude launch. Replace it with a tokenizer that honours quoting and
escaping without evaluating any expansion, and per-wrapper option
tables (env, sudo, command, nohup, time, npx, pnpm, yarn, bun) that
require dlx/x before looking through a package-manager wrapper.
Ambiguous shell grammar (pipelines, list operators, redirections,
substitution, unterminated quotes) is rejected outright rather than
guessed through; command_program still degrades to the naive first
word for display, but command_tool returns None.

Mirror the same resolver in web/src/toolIcon.ts and add
fixtures/command-resolution.json as a parity fixture both test
suites assert against.
2026-09-05 13:19:28 +01:00
Miguel Palhas e7086512e7 test(maestro): lock in shared command resolution for the TUI structured gate (#199)
The TUI's structured-view gate already delegates to command_tool, the
same resolver web/spawnForm.ts uses via splitCommand, so paths, env
assignments, wrappers, and configured presets already resolve like
daemon dispatch. Add coverage for those forms plus stale-selection
clearing on both preset switches and custom-command edits, so a
regression back to a naive basename match would be caught.
2026-09-05 12:59:03 +01:00
Miguel Palhas aca8d39886 Merge issue #198: keep TUI structured viewport stable 2026-09-05 12:46:31 +01:00
5 changed files with 1302 additions and 86 deletions
+596 -63
View File
@@ -166,45 +166,32 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
cur
}
/// Command wrappers that run another program: the program is what follows.
const WRAPPERS: [&str; 8] = [
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
];
/// The program a command line runs, basenamed and stripped of its arguments —
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
/// `npx`, `pnpm dlx`, …) are looked through, and quoting/escaping is honoured
/// (never evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion). A command a
/// shell would need real grammar for (pipelines, `&&`/`;`, redirections,
/// substitution, an unterminated quote) falls back to the naive first word
/// rather than guessing through it (#199). Mirrors the web's `splitCommand`.
pub fn command_program(command: &str) -> String {
let words: Vec<&str> = command.split_whitespace().collect();
let mut i = 0;
while i < words.len() {
let word = words[i];
if is_env_assignment(word) {
i += 1;
continue;
}
let wrapper = basename(word).to_lowercase();
if WRAPPERS.contains(&wrapper.as_str()) {
i += 1;
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
i += 1;
}
continue;
}
break;
}
match words.get(i) {
Some(word) => basename(word).to_string(),
// Nothing but wrappers and assignments: say what was there rather than
// rendering an empty fragment.
None => command.trim().to_string(),
match resolve(command) {
Some(r) => match r.words.get(r.program_index) {
Some(word) => basename(word).to_string(),
// Nothing but wrappers and assignments: say what was there
// rather than rendering an empty fragment.
None => command.trim().to_string(),
},
None => legacy_first_word(command),
}
}
/// Program name (already basenamed and lowercased) -> the tool id its mark
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
/// recognition is deliberately narrow, so an unrecognised program has no tool.
/// `claude-code-acp` — the daemon's actual structured-session child — is
/// deliberately absent: the daemon launches it directly and ignores
/// `req.command`, so it is never itself a valid user-facing launch form.
const BY_PROGRAM: [(&str, &str); 17] = [
("claude", "claude"),
("claude-code", "claude"),
@@ -226,29 +213,491 @@ const BY_PROGRAM: [(&str, &str); 17] = [
];
/// The tool id a command line's icon stands for (`claude`, `codex`,
/// `opencode`, ...), or `None` when the program is not one we have a mark for.
/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name
/// the same tools the web's `splitCommand().tool` does.
/// `opencode`, ...), or `None` when the program is not one we have a mark for
/// — including a command line [`resolve`] would only guess at. The command
/// bar matches this field (`docs/COMMAND-BAR.md`), so it must name the same
/// tools the web's `splitCommand().tool` does.
pub fn command_tool(command: &str) -> Option<&'static str> {
let program = command_program(command).to_lowercase();
let r = resolve(command)?;
let program = basename(r.words.get(r.program_index)?).to_lowercase();
BY_PROGRAM
.iter()
.find(|(name, _)| *name == program)
.map(|(_, tool)| *tool)
}
/// `FOO=bar`, the shell's own prefix for one command's environment.
fn is_env_assignment(word: &str) -> bool {
let mut chars = word.chars();
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
/// of the word that is the actual program (past every assignment/wrapper it
/// resolved through).
struct Resolution {
words: Vec<String>,
program_index: usize,
}
/// Tokenize `command` and walk past leading `FOO=bar` assignments and known
/// wrapper forms (interleaved in any order/count) to find the program. An
/// assignment run is only consumed at the very start of the line or right
/// after a wrapper whose own semantics accept one there ([`Wrapper::accepts_assignment`]) —
/// `command FOO=1 claude` and `nohup FOO=1 claude` stop at `FOO=1` rather than
/// skipping to `claude`, since `command`/`nohup` just exec their next argument
/// as the program, they don't setenv from an assignment-shaped one (#199).
/// `None` 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(|w| w.text.as_str()).collect();
let mut i = 0;
let mut assignment_allowed = true;
loop {
if assignment_allowed {
while matches!(words.get(i), Some(word) if is_env_assignment(word)) {
i += 1;
}
}
let Some(word) = words.get(i) else { break };
let wrapper = basename(&word.text).to_lowercase();
let Some(w) = wrapper_resolver(&wrapper) else {
break;
};
match (w.resolve)(&refs, i) {
Some(next) if next > i => {
i = next;
assignment_allowed = w.accepts_assignment;
}
// 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.
_ => break,
}
}
Some(Resolution {
words: words.into_iter().map(|w| w.text).collect(),
program_index: i,
})
}
/// The naive "first whitespace word, basenamed" reading used only when
/// [`tokenize`] rejects the line outright — still something sane to show,
/// never a wrapper/tool match to act on.
fn legacy_first_word(command: &str) -> String {
match command.split_whitespace().next() {
Some(word) => basename(word).to_string(),
None => command.trim().to_string(),
}
}
/// One token from [`tokenize`]: its dequoted text, and — when both the name
/// and `=` were entirely unquoted/unescaped in the source — the byte length
/// of the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`)
/// makes the word an executable named `FOO=1`; quoting/escaping only the value
/// preserves the assignment, matching Bash's assignment-word grammar (#199).
#[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 (`(`, `)`, `` ` ``, `$(`), 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 assignment_name_is_plain = true;
let mut chars = command.chars().peekable();
while let Some(c) = chars.next() {
match c {
' ' | '\t' | '\r' => {
if has_current {
words.push(Word {
text: std::mem::take(&mut current),
plain_eq: plain_eq.take(),
});
has_current = false;
assignment_name_is_plain = true;
}
}
'\n' => return None,
'|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' => return None,
'$' if chars.peek() == Some(&'(') => return None,
'\'' => {
has_current = true;
if plain_eq.is_none() {
assignment_name_is_plain = false;
}
loop {
match chars.next() {
Some('\'') => break,
Some(c2) => current.push(c2),
None => return None, // unterminated single quote
}
}
}
'"' => {
has_current = true;
if plain_eq.is_none() {
assignment_name_is_plain = false;
}
loop {
match chars.next() {
Some('"') => break,
Some('\\') => match chars.next() {
Some(c2 @ ('"' | '\\' | '$' | '`')) => current.push(c2),
Some('\n') => {} // line continuation, removed entirely
Some(c2) => {
current.push('\\');
current.push(c2);
}
None => return None, // trailing backslash
},
Some(c2) => current.push(c2),
None => return None, // unterminated double quote
}
}
}
'\\' => match chars.next() {
Some('\n') => {} // line continuation, removed entirely
Some(c2) => {
has_current = true;
if plain_eq.is_none() {
assignment_name_is_plain = false;
}
current.push(c2);
}
None => return None, // trailing backslash
},
other => {
has_current = true;
if other == '=' && plain_eq.is_none() && assignment_name_is_plain {
plain_eq = Some(current.len());
}
current.push(other);
}
}
}
if has_current {
words.push(Word {
text: current,
plain_eq,
});
}
Some(words)
}
/// A wrapper's known `-`/`--` options: which take no value, and which
/// consume either a following word or a `--flag=value` as theirs. A
/// `-`-prefixed word this table doesn't know halts resolution rather than
/// guessing past it (#199) — callers read that as [`OptionTable::consume`]
/// returning `None`.
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 {
/// Consume this table's options starting at `words[i]`, returning the
/// index just past them (which may equal `i` if there are none) — or
/// `None` on an option this table doesn't recognise, or a valued option
/// missing its operand.
fn consume(&self, words: &[&str], mut i: usize) -> Option<usize> {
loop {
let Some(&word) = words.get(i) else {
return Some(i);
};
if word == "--" {
return Some(i + 1);
}
if word == "-" || !word.starts_with('-') {
return Some(i);
}
if let Some((flag, value)) = word.split_once('=') {
return if self.valued.contains(&flag) && !value.is_empty() {
i += 1;
continue;
} else {
None
};
}
if self.flags.contains(&word) {
i += 1;
continue;
}
if self.valued.contains(&word) {
let value = words.get(i + 1)?;
if value.is_empty() || value.starts_with('-') {
return None;
}
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",
"-v",
"--debug",
],
valued: &["-u", "--unset", "-C", "--chdir"],
bundles_short_options: true,
};
const SUDO_OPTS: OptionTable = OptionTable {
flags: &[
"-i",
"--login",
"-n",
"--non-interactive",
"-E",
"--preserve-env",
"-H",
"--set-home",
"-A",
"--askpass",
"-b",
"--background",
"-k",
"--reset-timestamp",
"-K",
"--remove-timestamp",
"-S",
"--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"],
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).
fn resolve_time(words: &[&str], i: usize) -> Option<usize> {
let mut next = i + 1;
if words.get(next) == Some(&"-p") {
next += 1;
}
if words.get(next) == Some(&"--") {
next += 1;
}
match words.get(next) {
Some(word) if word.starts_with('-') => None,
_ => Some(next),
}
}
/// `-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", "--shell"],
bundles_short_options: false,
};
const PNPM_OPTS: OptionTable = OptionTable {
flags: &[
"--silent",
"-s",
"--stream",
"--aggregate-output",
"--no-progress",
],
// `--package`/`--package=value` is documented in this pre-`dlx`
// position (`pnpm --package=x dlx y`), alongside the identical flag
// `dlx` itself also accepts (`PNPM_DLX_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,
};
const PNPM_DLX_OPTS: OptionTable = OptionTable {
flags: &["-s", "--silent"],
valued: &["--package"],
bundles_short_options: false,
};
const YARN_DLX_OPTS: OptionTable = OptionTable {
flags: &["-q", "--quiet"],
valued: &["-p", "--package"],
bundles_short_options: false,
};
const BUN_X_OPTS: OptionTable = OptionTable {
flags: &["--bun", "--no-install", "--verbose", "--silent"],
valued: &["-p", "--package"],
bundles_short_options: false,
};
/// A resolver for one wrapper form: given the tokenized command and the
/// index of the wrapper word itself, the index of the word after it (past
/// its options and any subcommand it requires), or `None` if this occurrence
/// isn't a form we resolve through.
type WrapperResolver = fn(&[&str], usize) -> Option<usize>;
/// One recognised wrapper form: how to walk past it, and whether an
/// assignment word is a valid follow-on once it's been walked past.
struct Wrapper {
resolve: WrapperResolver,
/// `true` only for wrappers that themselves interpret a following
/// `FOO=bar` as an environment assignment for the command they launch
/// (`env`, `sudo`), or a shell keyword that restarts the assignment-prefix
/// grammar for a brand new simple command (`time`). Every other wrapper
/// here is a plain executable that execs its next argument as the
/// program outright — an assignment-shaped one names a program literally
/// called `FOO=1`, not an env var (#199).
accepts_assignment: bool,
}
fn wrapper_resolver(name: &str) -> Option<Wrapper> {
let (resolve, accepts_assignment): (WrapperResolver, bool) = match name {
"env" => (|words, i| ENV_OPTS.consume(words, i + 1), true),
"sudo" => (|words, i| SUDO_OPTS.consume(words, i + 1), true),
"command" => (|words, i| COMMAND_BUILTIN_OPTS.consume(words, i + 1), false),
"nohup" => (|words, i| NO_OPTIONS.consume(words, i + 1), false),
"time" => (resolve_time, true),
"npx" => (|words, i| NPX_OPTS.consume(words, i + 1), false),
"pnpm" => (
|words, i| resolve_dlx(&PNPM_OPTS, &PNPM_DLX_OPTS, words, i),
false,
),
"yarn" => (
|words, i| resolve_dlx(&YARN_OPTS, &YARN_DLX_OPTS, words, i),
false,
),
"bun" => (resolve_bun, false),
"bunx" => (|words, i| BUN_X_OPTS.consume(words, i + 1), false),
"uvx" => (|_words, i| Some(i + 1), false),
_ => return None,
};
Some(Wrapper {
resolve,
accepts_assignment,
})
}
/// `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`). `dlx`
/// itself takes launcher-specific options too, consumed before the executable.
fn resolve_dlx(
opts: &OptionTable,
dlx_opts: &OptionTable,
words: &[&str],
i: usize,
) -> Option<usize> {
let j = opts.consume(words, i + 1)?;
if words.get(j) != Some(&"dlx") {
return None;
}
dlx_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. `--bun` (`BUN_X_OPTS`) may follow it before
/// the executable.
fn resolve_bun(words: &[&str], i: usize) -> Option<usize> {
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 —
/// 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 {
@@ -497,38 +946,122 @@ mod tests {
"claude"
);
assert_eq!(command_program(" zsh -l "), "zsh");
assert_eq!(command_program("hx src/main.rs"), "hx");
}
/// The #199 resolver contract: every case in the shared parity fixture,
/// asserted identically here and by `web/src/toolIcon.test.ts`, so a
/// resolver regression in either language fails its own suite.
#[test]
fn command_program_looks_through_paths_env_and_wrappers() {
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
assert_eq!(command_program("npx opencode"), "opencode");
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
fn resolves_the_shared_command_resolution_fixture() {
#[derive(serde::Deserialize)]
struct Case {
command: String,
program: String,
tool: Option<String>,
}
#[derive(serde::Deserialize)]
struct Fixture {
cases: Vec<Case>,
}
let raw = include_str!("../../../fixtures/command-resolution.json");
let fixture: Fixture = serde_json::from_str(raw).expect("valid fixture JSON");
assert!(!fixture.cases.is_empty());
for case in &fixture.cases {
assert_eq!(
command_program(&case.command),
case.program,
"program for {:?}",
case.command
);
assert_eq!(
command_tool(&case.command),
case.tool.as_deref(),
"tool for {:?}",
case.command
);
}
}
#[test]
fn command_program_keeps_a_line_that_is_only_wrappers() {
assert_eq!(command_program("sudo"), "sudo");
assert_eq!(command_program(""), "");
fn word(text: &str) -> Word {
Word {
text: text.into(),
plain_eq: None,
}
}
/// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same
/// tools the web's `splitCommand().tool` does.
/// Shell-aware tokenization (#199): quoting/escaping is honoured, never
/// evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion is attempted.
#[test]
fn command_tool_names_the_mark_the_card_shows() {
assert_eq!(command_tool("claude --resume"), Some("claude"));
assert_eq!(command_tool("npx opencode"), Some("opencode"));
assert_eq!(command_tool("/bin/zsh -l"), Some("shell"));
assert_eq!(command_tool("CLAUDE"), Some("claude"));
fn tokenize_handles_quotes_and_escapes_without_expanding_anything() {
assert_eq!(
tokenize("claude --resume"),
Some(vec![word("claude"), word("--resume")])
);
assert_eq!(
tokenize("\"/opt/my agent/claude\" --resume"),
Some(vec![word("/opt/my agent/claude"), word("--resume")])
);
assert_eq!(
tokenize("FOO=\"bar baz\" claude"),
Some(vec![
Word {
text: "FOO=bar baz".into(),
plain_eq: Some(3),
},
word("claude"),
])
);
assert_eq!(
tokenize("claude --name=$HOME"),
Some(vec![
word("claude"),
Word {
text: "--name=$HOME".into(),
plain_eq: Some(6),
},
]),
"a bare $ is kept literal, never expanded"
);
}
/// Narrow on purpose: an unrecognised program has no mark, so the
/// candidate has no `tool` field to match rather than a wrong one.
/// Real shell grammar this resolver doesn't attempt — pipelines, list
/// operators, subshells/substitution, an unescaped newline, an
/// unterminated quote — is rejected outright rather than guessed
/// through (#199).
#[test]
fn command_tool_is_absent_for_an_unrecognised_program() {
assert_eq!(command_tool("hx src/main.rs"), None);
assert_eq!(command_tool(""), None);
fn tokenize_rejects_ambiguous_shell_grammar() {
for command in [
"claude | tee log.txt",
"claude; echo done",
"claude && echo done",
"echo hi $(claude)",
"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);
}
}
+143
View File
@@ -1926,6 +1926,149 @@ mod tests {
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// A custom command typed into the free-text field, run through the same
/// resolver the daemon/web gate uses (`command_tool`, #199): a path, an
/// env prefix, or a wrapper still resolves to Claude, so the toggle stays
/// offered and the request carries `Structured`; an unrelated program
/// does not.
#[test]
fn structured_view_follows_command_resolution_for_custom_commands() {
let cases: &[(&str, bool)] = &[
("claude", true),
("claude --resume", true),
("CLAUDE", true),
("/usr/local/bin/claude -p hi", true),
("./bin/claude", true),
("FOO=1 BAR=2 claude", true),
("npx claude-code", true),
("npx --yes claude-code", true),
("sudo claude", true),
("sudo -u root claude", true),
("env -i claude", true),
("nohup claude", true),
("time claude", true),
("command claude", true),
("pnpm --silent dlx claude-code", true),
("yarn --silent dlx claude-code", true),
("bun x claude-code", true),
("\"/opt/my agent/claude\" --resume", true),
("bash", false),
("opencode", false),
("/usr/local/bin/bash -l", false),
// A package-manager wrapper without `dlx`/`x` runs a
// `package.json` script, not the package (#199) — must not be
// mistaken for a Claude launch.
("pnpm claude", false),
("yarn claude", false),
// The daemon's structured-session child, never a valid
// user-facing launch form (#199).
("claude-code-acp", false),
];
for &(command, supported) in cases {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
..DraftPane::default()
};
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value(command);
assert_eq!(
draft.field_order().contains(&DraftField::StructuredToggle),
supported,
"toggle visibility for {command:?}"
);
draft.toggle_structured_view();
assert_eq!(
draft.structured_view, supported,
"toggle takes effect for {command:?}"
);
let view = draft.build_request().unwrap().view;
assert_eq!(
view,
if supported {
SessionView::Structured
} else {
SessionView::Terminal
},
"request view for {command:?}"
);
}
}
/// A configured preset (the spawn-form "alias" for a command) is gated
/// the same way as free text: a preset whose command resolves to Claude
/// through a wrapper still offers structured view.
#[test]
fn structured_view_follows_command_resolution_for_presets() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
..DraftPane::default()
};
draft.set_presets(vec![
SpawnPreset {
id: "claude-wrapped".into(),
label: "Claude (npx)".into(),
command: "npx claude-code --resume".into(),
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
},
]);
assert_eq!(draft.command(), "npx claude-code --resume");
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
draft.toggle_structured_view();
assert!(draft.structured_view);
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
// Switching to a preset that does not resolve to Claude clears the
// stale selection instead of letting an invalid request through.
draft.cycle_preset(1);
assert_eq!(draft.command(), "aider");
assert!(!draft.structured_view, "stale selection must clear");
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// Editing the custom-command text away from Claude clears an armed
/// selection immediately, matching the preset-switch case above — an
/// invalid `Structured` request can never reach `build_request`.
#[test]
fn structured_view_clears_when_custom_command_edited_away_from_claude() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
focused_field: DraftField::CustomCommand,
..DraftPane::default()
};
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value("claude");
draft.toggle_structured_view();
assert!(draft.structured_view);
typed(&mut draft, " -p");
assert_eq!(draft.command(), "claude -p");
assert!(draft.structured_view, "still Claude, selection survives");
for _ in 0.."claude -p".chars().count() {
draft.on_input(&InputKey::Backspace);
}
typed(&mut draft, "bash");
assert_eq!(draft.command(), "bash");
assert!(
!draft.structured_view,
"editing the program away from claude clears the stale selection"
);
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
#[test]
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
let mut draft = DraftPane {
+120
View File
@@ -0,0 +1,120 @@
{
"_description": "the parity fixture for #199's command resolver — every case here is asserted identically by crates/maestro-protocol/src/display.rs (command_program/command_tool) and web/src/toolIcon.ts (splitCommand). `program` is the resolved, basenamed program; `tool` is the icon id it maps to, or null when unrecognised/ambiguous.",
"cases": [
{ "command": "claude", "program": "claude", "tool": "claude" },
{ "command": "claude --resume", "program": "claude", "tool": "claude" },
{ "command": "CLAUDE", "program": "CLAUDE", "tool": "claude" },
{ "command": "/usr/local/bin/claude -p hi", "program": "claude", "tool": "claude" },
{ "command": "./bin/claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=1 BAR=2 claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=\"bar baz\" claude", "program": "claude", "tool": "claude" },
{ "command": "FOO='bar baz' claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=bar\\ baz claude", "program": "claude", "tool": "claude" },
{ "command": "'/usr/local/bin/claude'", "program": "claude", "tool": "claude" },
{ "command": "\"/opt/my agent/claude\" --resume", "program": "claude", "tool": "claude" },
{ "command": "env -i claude", "program": "claude", "tool": "claude" },
{ "command": "env --ignore-environment claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -u root claude", "program": "claude", "tool": "claude" },
{ "command": "sudo --user=root claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -- claude --resume", "program": "claude", "tool": "claude" },
{ "command": "nohup claude", "program": "claude", "tool": "claude" },
{ "command": "time claude", "program": "claude", "tool": "claude" },
{ "command": "time -p claude", "program": "claude", "tool": "claude" },
{ "command": "time -- claude", "program": "claude", "tool": "claude" },
{ "command": "time -p -- claude", "program": "claude", "tool": "claude" },
{ "command": "time -pp claude", "program": "time", "tool": null },
{ "command": "time -p -p claude", "program": "time", "tool": null },
{ "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" },
{ "command": "pnpm -C ./pkg dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx -s claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx --silent claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn --silent dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx -q claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx --quiet claude-code", "program": "claude-code", "tool": "claude" },
{ "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 x -p @anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --package @anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x --package=@anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --no-install claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x --verbose claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --silent 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 dlx --package= claude-code", "program": "pnpm", "tool": null },
{ "command": "pnpm dlx --package", "program": "pnpm", "tool": null },
{ "command": "pnpm dlx -c \"claude-code\"", "program": "pnpm", "tool": null },
{ "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": "yarn dlx -p foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx --package= claude-code", "program": "yarn", "tool": null },
{ "command": "yarn dlx -p", "program": "yarn", "tool": null },
{ "command": "yarn dlx -p --quiet claude-code", "program": "yarn", "tool": null },
{ "command": "bunx --package= claude-code", "program": "bunx", "tool": null },
{ "command": "bunx --package", "program": "bunx", "tool": null },
{ "command": "bun x -p --silent claude-code", "program": "bun", "tool": null },
{ "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": "env --unset= claude", "program": "env", "tool": null },
{ "command": "sudo --user= claude", "program": "sudo", "tool": null },
{ "command": "sudo --user \"\" claude", "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": "command FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "nohup FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "npx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "pnpm dlx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "yarn dlx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "bun x FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "bunx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "uvx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "sudo time FOO=1 claude", "program": "claude", "tool": "claude" },
{ "command": "nohup env FOO=1 claude", "program": "claude", "tool": "claude" },
{ "command": "sudo command FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "command nohup FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "time command FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "\"FOO=1\" claude", "program": "FOO=1", "tool": null },
{ "command": "FOO\\=1 claude", "program": "FOO=1", "tool": null },
{ "command": "F\"O\"O=1 claude", "program": "FOO=1", "tool": null },
{ "command": "F\\OO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "\"FOO\"=1 claude", "program": "FOO=1", "tool": null },
{ "command": "F\"\"OO=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 },
{ "command": "yarn claude", "program": "yarn", "tool": null },
{ "command": "claude-code-acp", "program": "claude-code-acp", "tool": null },
{ "command": "hx src/main.rs", "program": "hx", "tool": null },
{ "command": "bash", "program": "bash", "tool": "shell" },
{ "command": "opencode", "program": "opencode", "tool": "opencode" },
{ "command": "sudo", "program": "sudo", "tool": null },
{ "command": "", "program": "", "tool": null },
{ "command": "npx --unknown-flag claude-code", "program": "npx", "tool": null },
{ "command": "claude | tee log.txt", "program": "claude", "tool": null },
{ "command": "claude; echo done", "program": "claude;", "tool": null },
{ "command": "claude && echo done", "program": "claude", "tool": null },
{ "command": "echo hi $(claude)", "program": "echo", "tool": null },
{ "command": "claude \"unterminated", "program": "claude", "tool": null }
]
}
+15
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import commandResolutionFixture from "../../fixtures/command-resolution.json";
interface CommandResolutionCase {
command: string;
program: string;
tool: string | null;
}
describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
it("recognises the agents and shells sessions are spawned with", () => {
@@ -46,6 +53,14 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
expect(splitCommand("").tool).toBeNull();
});
it("resolves every case in the shared command-resolution fixture (#199)", () => {
const cases = commandResolutionFixture.cases as CommandResolutionCase[];
expect(cases.length).toBeGreaterThan(0);
for (const { command, program, tool } of cases) {
expect(splitCommand(command), command).toEqual({ tool, program });
}
});
it("gives every recognised tool a labelled mark", () => {
for (const tool of ["claude", "codex", "opencode", "gemini", "aider", "cursor", "pi", "shell"]) {
const svg = toolIconSvg(tool);
+428 -23
View File
@@ -9,10 +9,11 @@
* as text, so the fragment never renders a mark that stands for the wrong tool.
*/
/** Command wrappers that run another program: the tool is what follows. */
const WRAPPERS = new Set(["sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command"]);
/** Program name (already basenamed and lowercased) → icon id. */
/** Program name (already basenamed and lowercased) → icon id.
*
* `claude-code-acp` — the daemon's actual structured-session child — is
* deliberately absent: the daemon launches it directly and ignores the
* session's command, so it is never itself a valid user-facing launch form. */
const BY_PROGRAM: Record<string, string> = {
claude: "claude",
"claude-code": "claude",
@@ -53,39 +54,443 @@ export interface CommandParts {
program: string;
}
/** Split a command line into the tool its icon stands for and the program name.
/** Split a command line into the tool its icon stands for and the program
* name — basenamed and stripped of its arguments. Leading `FOO=bar`
* assignments and wrapper forms (`sudo`, `env -i`, `npx`, `pnpm dlx`, …) are
* looked through, and quoting/escaping is honoured (never evaluated — no
* `$var`/`` `cmd` ``/`$(cmd)` expansion). A command a shell would need real
* grammar for (pipelines, `&&`/`;`, redirections, substitution, an
* unterminated quote) falls back to the naive first word rather than
* guessing through it (#199). Mirrors the Rust `command_program`/
* `command_tool` in `crates/maestro-protocol/src/display.rs`.
*
* `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
* `hx src/` keeps `hx` as the program with no tool. */
export function splitCommand(command: string): CommandParts {
const words = command.trim().split(/\s+/).filter(Boolean);
let i = 0;
// Leading `FOO=bar` assignments and wrappers stand in front of the real
// program; `pnpm dlx x` / `yarn dlx x` need one more word skipped.
while (i < words.length) {
const word = words[i];
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
i += 1;
continue;
}
const wrapper = basename(word).toLowerCase();
if (WRAPPERS.has(wrapper)) {
i += 1;
if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
continue;
}
break;
const words = tokenize(command);
if (words === null) {
const program = legacyFirstWord(command);
return { tool: null, program };
}
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 };
}
/** The naive "first whitespace word, basenamed" reading used only when
* {@link tokenize} rejects the line outright — still something sane to
* show, never a wrapper/tool match to act on. */
function legacyFirstWord(command: string): string {
const first = command.trim().split(/\s+/).filter(Boolean)[0];
return first === undefined ? command.trim() : basename(first);
}
/** One token from {@link tokenize}: its dequoted text, and — when both the
* name and `=` were entirely unquoted/unescaped in the source — the index of
* the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`)
* makes the word an executable named `FOO=1`; quoting/escaping only the value
* preserves the assignment, matching Bash's assignment-word grammar (#199). */
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. An
* assignment run is only consumed at the very start of the line or right
* after a wrapper whose own semantics accept one there ({@link Wrapper.acceptsAssignment}) —
* `command FOO=1 claude` and `nohup FOO=1 claude` stop at `FOO=1` rather than
* skipping to `claude`, since `command`/`nohup` just exec their next argument
* as the program, they don't setenv from an assignment-shaped one (#199). */
function programIndex(words: Word[]): number {
const texts = words.map((w) => w.text);
let i = 0;
let assignmentAllowed = true;
for (;;) {
if (assignmentAllowed) {
while (i < words.length && isEnvAssignment(words[i])) {
i += 1;
}
}
if (i >= words.length) break;
const wrapper = basename(texts[i]).toLowerCase();
const w = WRAPPER_RESOLVERS[wrapper];
if (!w) break;
const next = w.resolve(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.
if (next === null || next <= i) break;
i = next;
assignmentAllowed = w.acceptsAssignment;
}
return i;
}
function basename(word: string): string {
const cut = word.lastIndexOf("/");
return cut >= 0 ? word.slice(cut + 1) : word;
}
/** Word-split `command` the way a shell would (honouring `'…'`, `"…"` and
* `\` 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 (`(`, `)`, `` ` ``, `$(`), 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 assignmentNameIsPlain = true;
let i = 0;
const n = command.length;
while (i < n) {
const c = command[i];
if (c === " " || c === "\t" || c === "\r") {
if (hasCurrent) {
words.push({ text: current, plainEq });
current = "";
hasCurrent = false;
plainEq = null;
assignmentNameIsPlain = true;
}
i += 1;
} else if (c === "\n") {
return null;
} else if ("|&;<>()`".includes(c)) {
return null;
} else if (c === "$" && command[i + 1] === "(") {
return null;
} else if (c === "'") {
hasCurrent = true;
if (plainEq === null) assignmentNameIsPlain = false;
i += 1;
const close = command.indexOf("'", i);
if (close === -1) return null; // unterminated single quote
current += command.slice(i, close);
i = close + 1;
} else if (c === '"') {
hasCurrent = true;
if (plainEq === null) assignmentNameIsPlain = false;
i += 1;
for (;;) {
if (i >= n) return null; // unterminated double quote
const c2 = command[i];
if (c2 === '"') {
i += 1;
break;
}
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 {
current += c2;
i += 1;
}
}
} 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;
if (plainEq === null) assignmentNameIsPlain = false;
current += escaped;
i += 2;
} else {
hasCurrent = true;
if (c === "=" && plainEq === null && assignmentNameIsPlain) {
plainEq = current.length;
}
current += c;
i += 1;
}
}
if (hasCurrent) words.push({ text: current, plainEq });
return words;
}
/** A wrapper's known `-`/`--` options: which take no value, and which
* consume either a following word or a `--flag=value` as theirs. A
* `-`-prefixed word this table doesn't know halts resolution rather than
* guessing past it (#199) — callers read that as {@link consumeOptions}
* returning `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
* past them (which may equal `i` if there are none) — or `null` on an
* option this table doesn't recognise, or a valued option missing its
* operand. */
function consumeOptions(opts: OptionTable, words: string[], i: number): number | null {
for (;;) {
const word = words[i];
if (word === undefined) return i;
if (word === "--") return i + 1;
if (word === "-" || !word.startsWith("-")) return i;
const eq = word.indexOf("=");
if (eq >= 0) {
if (opts.valued.has(word.slice(0, eq)) && word.slice(eq + 1).length > 0) {
i += 1;
continue;
}
return null;
}
if (opts.flags.has(word)) {
i += 1;
continue;
}
if (opts.valued.has(word)) {
const value = words[i + 1];
if (value === undefined || value.length === 0 || value.startsWith("-")) return null;
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(), bundlesShortOptions: false };
const ENV_OPTS: OptionTable = {
flags: new Set(["-i", "--ignore-environment", "-0", "--null", "-v", "--debug"]),
valued: new Set(["-u", "--unset", "-C", "--chdir"]),
bundlesShortOptions: true,
};
const SUDO_OPTS: OptionTable = {
flags: new Set([
"-i",
"--login",
"-n",
"--non-interactive",
"-E",
"--preserve-env",
"-H",
"--set-home",
"-A",
"--askpass",
"-b",
"--background",
"-k",
"--reset-timestamp",
"-K",
"--remove-timestamp",
"-S",
"--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"]),
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). */
function resolveTime(words: string[], i: number): number | null {
let next = i + 1;
if (words[next] === "-p") next += 1;
if (words[next] === "--") next += 1;
const command = words[next];
return command?.startsWith("-") ? null : next;
}
/** `-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", "--shell"]),
bundlesShortOptions: false,
};
const PNPM_OPTS: OptionTable = {
flags: new Set(["--silent", "-s", "--stream", "--aggregate-output", "--no-progress"]),
// `--package`/`--package=value` is documented in this pre-`dlx` position
// (`pnpm --package=x dlx y`), alongside the identical flag `dlx` itself
// also accepts (`PNPM_DLX_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,
};
const PNPM_DLX_OPTS: OptionTable = {
flags: new Set(["-s", "--silent"]),
valued: new Set(["--package"]),
bundlesShortOptions: false,
};
const YARN_DLX_OPTS: OptionTable = {
flags: new Set(["-q", "--quiet"]),
valued: new Set(["-p", "--package"]),
bundlesShortOptions: false,
};
const BUN_X_OPTS: OptionTable = {
flags: new Set(["--bun", "--no-install", "--verbose", "--silent"]),
valued: new Set(["-p", "--package"]),
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`). `dlx`
* itself takes launcher-specific options too, consumed before the executable. */
function resolveDlx(
opts: OptionTable,
dlxOpts: OptionTable,
words: string[],
i: number,
): number | null {
const j = consumeOptions(opts, words, i + 1);
if (j === null) return null;
if (words[j] !== "dlx") return null;
return consumeOptions(dlxOpts, 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. `--bun` (`BUN_X_OPTS`) may follow it before
* the executable. */
function resolveBun(words: string[], i: number): number | 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
* index of the wrapper word itself, the index of the word after it (past
* its options and any subcommand it requires), or `null` if this occurrence
* isn't a form we resolve through. */
type WrapperResolver = (words: string[], i: number) => number | null;
/** One recognised wrapper form: how to walk past it, and whether an
* assignment word is a valid follow-on once it's been walked past. */
interface Wrapper {
resolve: WrapperResolver;
/** `true` only for wrappers that themselves interpret a following
* `FOO=bar` as an environment assignment for the command they launch
* (`env`, `sudo`), or a shell keyword that restarts the assignment-prefix
* grammar for a brand new simple command (`time`). Every other wrapper here
* is a plain executable that execs its next argument as the program
* outright — an assignment-shaped one names a program literally called
* `FOO=1`, not an env var (#199). */
acceptsAssignment: boolean;
}
const WRAPPER_RESOLVERS: Record<string, Wrapper> = {
env: { resolve: (words, i) => consumeOptions(ENV_OPTS, words, i + 1), acceptsAssignment: true },
sudo: {
resolve: (words, i) => consumeOptions(SUDO_OPTS, words, i + 1),
acceptsAssignment: true,
},
command: {
resolve: (words, i) => consumeOptions(COMMAND_BUILTIN_OPTS, words, i + 1),
acceptsAssignment: false,
},
nohup: {
resolve: (words, i) => consumeOptions(NO_OPTIONS, words, i + 1),
acceptsAssignment: false,
},
time: { resolve: resolveTime, acceptsAssignment: true },
npx: {
resolve: (words, i) => consumeOptions(NPX_OPTS, words, i + 1),
acceptsAssignment: false,
},
pnpm: {
resolve: (words, i) => resolveDlx(PNPM_OPTS, PNPM_DLX_OPTS, words, i),
acceptsAssignment: false,
},
yarn: {
resolve: (words, i) => resolveDlx(YARN_OPTS, YARN_DLX_OPTS, words, i),
acceptsAssignment: false,
},
bun: { resolve: resolveBun, acceptsAssignment: false },
bunx: {
resolve: (words, i) => consumeOptions(BUN_X_OPTS, words, i + 1),
acceptsAssignment: false,
},
uvx: { resolve: (_words, i) => i + 1, acceptsAssignment: false },
};
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
function burst(): string {
const rays = Array.from({ length: 8 }, (_, k) => {