diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index b64fbea2..d88abdeb 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -4143,6 +4143,34 @@ impl Interpreter { /// Search $PATH for an executable script and run it. /// /// Returns `Ok(None)` if no matching file found (caller emits "command not found"). + /// Resolve a command name to its full path via PATH search on VFS. + /// Returns the resolved path string if found, None otherwise. + async fn resolve_command_path(&self, name: &str) -> Option { + let path_var = self + .variables + .get("PATH") + .or_else(|| self.env.get("PATH")) + .cloned() + .unwrap_or_default(); + + for dir in path_var.split(':') { + if dir.is_empty() { + continue; + } + let candidate = PathBuf::from(dir).join(name); + if let Ok(meta) = self.fs.stat(&candidate).await { + if meta.file_type.is_dir() { + continue; + } + if meta.mode & 0o111 == 0 { + continue; + } + return Some(candidate.to_string_lossy().to_string()); + } + } + None + } + async fn try_execute_script_via_path_search( &mut self, name: &str, @@ -5107,12 +5135,17 @@ impl Interpreter { match mode { 'v' => { - // command -v: print name if it's a known command - let found = self.builtins.contains_key(cmd_name.as_str()) - || self.functions.contains_key(cmd_name.as_str()) - || is_keyword(cmd_name); - let mut result = if found { - ExecResult::ok(format!("{}\n", cmd_name)) + // command -v: print name/path if it's a known command + let output = if self.functions.contains_key(cmd_name.as_str()) + || self.builtins.contains_key(cmd_name.as_str()) + || is_keyword(cmd_name) + { + Some(cmd_name.to_string()) + } else { + self.resolve_command_path(cmd_name).await + }; + let mut result = if let Some(name) = output { + ExecResult::ok(format!("{}\n", name)) } else { ExecResult { stdout: String::new(), @@ -5133,6 +5166,8 @@ impl Interpreter { format!("{} is a shell builtin\n", cmd_name) } else if is_keyword(cmd_name) { format!("{} is a shell keyword\n", cmd_name) + } else if let Some(path) = self.resolve_command_path(cmd_name).await { + format!("{} is {}\n", cmd_name, path) } else { return Ok(ExecResult::err( format!("bash: command: {}: not found\n", cmd_name), diff --git a/crates/bashkit/tests/spec_cases/bash/command_v.test.sh b/crates/bashkit/tests/spec_cases/bash/command_v.test.sh new file mode 100644 index 00000000..4d320ebe --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/command_v.test.sh @@ -0,0 +1,62 @@ +# command -v and command -V tests +# Tests that command -v searches PATH for external scripts (issue #1120) + +### command_v_finds_builtin +# command -v finds builtins +command -v echo +echo "exit=$?" +### expect +echo +exit=0 +### end + +### command_v_finds_function +# command -v finds functions +myfunc() { true; } +command -v myfunc +echo "exit=$?" +### expect +myfunc +exit=0 +### end + +### command_v_not_found +# command -v returns 1 for unknown commands +command -v nonexistent_cmd_xyz_12345 +echo "exit=$?" +### expect +exit=1 +### end + +### command_v_searches_path +### skip: VFS-only test — real bash doesn't have /scripts on disk +# command -v finds executable scripts on PATH +mkdir -p /scripts +echo '#!/bin/bash' > /scripts/myscript +chmod +x /scripts/myscript +export PATH="/scripts:$PATH" +command -v myscript +echo "exit=$?" +### expect +/scripts/myscript +exit=0 +### end + +### command_V_builtin +# command -V describes builtins +command -V echo +### expect +echo is a shell builtin +### end + +### command_V_path_script +### skip: VFS-only test — real bash doesn't have /scripts on disk +# command -V shows full path for scripts on PATH +mkdir -p /scripts +echo '#!/bin/bash' > /scripts/pathcmd +chmod +x /scripts/pathcmd +export PATH="/scripts:$PATH" +command -V pathcmd +### expect +pathcmd is /scripts/pathcmd +### end