Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/tui/src/tools/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ use windows::core::PCWSTR;
#[cfg(not(target_env = "ohos"))]
use portable_pty::{CommandBuilder, PtySize, native_pty_system};

mod guidance;
mod output;

use super::shell_output::{summarize_output, truncate_with_meta};
Expand Down Expand Up @@ -3405,7 +3406,7 @@ impl ToolSpec for BashTool {
if self.read_only {
"Inspect the workspace with the bounded read-only command subset. Commands run directly as argv, never through a shell; only action=run plus command, cwd, and timeout_ms are accepted."
} else {
"Execute a shell command in the workspace. Action \"run\" (default) executes a command; \"wait\" polls a background task; \"interact\" sends stdin to a background task; \"cancel\" kills a background task. Foreground mode is for bounded commands; use background=true for work expected to take >5 seconds. Commands run via the user's login shell ($SHELL); when that shell is zsh, a bare word starting with `=` undergoes `=command` PATH expansion (e.g. `echo ===` fails) — quote such arguments, e.g. `echo '==='`."
guidance::description()
}
}

Expand All @@ -3423,7 +3424,7 @@ impl ToolSpec for BashTool {
},
"command": {
"type": "string",
"description": "The shell command to execute (action=run)"
"description": guidance::runtime_command_guidance()
},
"timeout_ms": {
"type": "integer",
Expand Down
150 changes: 150 additions & 0 deletions crates/tui/src/tools/shell/guidance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//! Model-facing command syntax follows the same dispatcher as execution.

use crate::shell_dispatcher::{ShellKind, global_dispatcher};
use std::sync::OnceLock;

const POWERSHELL_GUIDANCE: &str = "Use PowerShell syntax. Bash is a legacy tool name, not a Bash interpreter. \
For JSON use Invoke-RestMethod; for text use Invoke-WebRequest -UseBasicParsing \
on Windows PowerShell to avoid dependency on the Internet Explorer engine. \
Do not assume head, sed, awk, or other Unix utilities are installed. \
Use PowerShell cmdlets or verified available programs; parse JSON and select needed fields \
instead of appending head. Bash heredocs are not PowerShell syntax. Use PowerShell \
5.1-compatible syntax (no && or ||) unless the detected executable is pwsh. Example: \
$text = 'sample'; $text.Substring(0, [Math]::Min(3, $text.Length)).";

const BASH_GUIDANCE: &str = "Use Bash syntax: pipelines, redirections, $(command), \
and && / || are supported. Quote paths and variable expansions, such as \"$path\"; \
use single quotes for literal text. For literal multiline input, use a quoted heredoc \
delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed \
programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\n' 'sample'.";

const SH_GUIDANCE: &str = "Use POSIX sh syntax: pipelines, redirections, $(command), \
and && / || are supported. Quote paths and variable expansions, such as \"$path\"; \
use single quotes for literal text. Do not use Bash-only arrays, [[ ... ]], \
process substitution, or here-strings. Use only installed programs and portable \
utility options. Example: printf '%s\\n' 'sample'.";

const ZSH_GUIDANCE: &str = "Use zsh syntax. Quote paths, literal wildcard patterns, \
and variable expansions; unmatched unquoted globs can fail before a command runs. \
A bare word starting with = undergoes =command PATH expansion (e.g. echo === fails); \
quote such arguments, e.g. echo '==='. Do not assume Bash array indexing or word \
splitting rules. Use only installed programs; do not assume GNU-specific flags on macOS/BSD.";

const CMD_GUIDANCE: &str = "Use cmd.exe syntax: %NAME% expands environment variables; use double quotes \
around paths containing spaces (single quotes are not quoting delimiters). \
Use cmd built-ins or installed programs, not Bash or PowerShell syntax. \
Do not assume Unix utilities are installed. Example: echo sample";

const FISH_GUIDANCE: &str = "Use fish syntax: set NAME value for variables, \
and begin ... end for blocks. Bash assignment NAME=value and \
heredocs are not portable fish syntax. Quote paths and use only \
installed programs. Example: printf '%s\\n' 'sample'.";

const FALLBACK_GUIDANCE: &str = "Use the detected shell's syntax and only installed programs; \
do not infer Bash syntax from the legacy tool name.";

pub(super) fn command_guidance(kind: &ShellKind) -> String {
let syntax = match kind {
// Match execution's PowerShell-family detection, including custom paths.
_ if kind.is_powershell() => POWERSHELL_GUIDANCE,
ShellKind::Cmd => CMD_GUIDANCE,
ShellKind::Sh => SH_GUIDANCE,
ShellKind::Bash => BASH_GUIDANCE,
ShellKind::Custom { binary, .. } => {
match std::path::Path::new(binary)
.file_stem()
.and_then(|name| name.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("bash") => BASH_GUIDANCE,
Some("sh" | "dash" | "ash") => SH_GUIDANCE,
Some("zsh") => ZSH_GUIDANCE,
Some("fish") => FISH_GUIDANCE,
_ => FALLBACK_GUIDANCE,
}
}
_ => FALLBACK_GUIDANCE,
};
format!(
"The command to execute (action=run). Actual execution shell: `{}`. {syntax}",
kind.binary()
)
}

pub(super) fn runtime_command_guidance() -> &'static str {
static GUIDANCE: OnceLock<String> = OnceLock::new();
GUIDANCE.get_or_init(|| command_guidance(global_dispatcher().kind()))
}

pub(super) fn description() -> &'static str {
static DESCRIPTION: OnceLock<String> = OnceLock::new();
DESCRIPTION.get_or_init(|| {
format!(
"{} Execute in the workspace. Action \"run\" (default) executes a command; \
\"wait\" polls a background task; \"interact\" sends stdin to a background task; \
\"cancel\" kills a background task. Foreground mode is for bounded commands; \
use background=true for work expected to take >5 seconds.",
runtime_command_guidance()
)
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn shell_guidance_preserves_unix_shell_contracts() {
for (binary, expected) in [
("/bin/bash", BASH_GUIDANCE),
("bash", BASH_GUIDANCE),
("/usr/local/bin/bash", BASH_GUIDANCE),
("/bin/sh", SH_GUIDANCE),
("/bin/dash", SH_GUIDANCE),
("/bin/ash", SH_GUIDANCE),
("/bin/zsh", ZSH_GUIDANCE),
] {
let text = command_guidance(&ShellKind::Custom {
binary: binary.into(),
flag: "-lc".into(),
});
assert!(text.contains(expected), "missing guidance for {binary}");
assert!(!text.contains("Use PowerShell syntax"));
}
assert!(command_guidance(&ShellKind::Bash).contains(BASH_GUIDANCE));
assert!(command_guidance(&ShellKind::Sh).contains(SH_GUIDANCE));
}

#[test]
fn shell_guidance_matches_each_interpreter() {
for kind in [
ShellKind::Pwsh,
ShellKind::WindowsPowerShell,
ShellKind::Cmd,
ShellKind::Sh,
ShellKind::Bash,
ShellKind::Custom {
binary: "/bin/zsh".into(),
flag: "-lc".into(),
},
ShellKind::Custom {
binary: "/opt/pwsh".into(),
flag: "-c".into(),
},
ShellKind::Custom {
binary: "/bin/fish".into(),
flag: "-c".into(),
},
] {
let text = command_guidance(&kind);
assert!(text.contains(kind.binary()));
assert_eq!(text.contains("Use PowerShell syntax"), kind.is_powershell());
assert_eq!(
text.contains("=command PATH expansion"),
kind.binary() == "/bin/zsh"
);
assert!(!text.contains("user's login shell"));
}
}
}
52 changes: 52 additions & 0 deletions crates/tui/src/tools/shell/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,58 @@ fn env_lock() -> &'static Mutex<()> {

const BACKGROUND_COMPLETION_WAIT_MS: u64 = 30_000;

#[test]
fn forkguard_shell_catalog_guidance_matches_execution() {
let tool = BashTool::new("Bash");
let schema = tool.input_schema();
let command = schema["properties"]["command"]["description"]
.as_str()
.unwrap();
let dispatcher = crate::shell_dispatcher::global_dispatcher();
assert!(command.contains(dispatcher.kind().binary()));
assert!(tool.description().contains(command));
assert_eq!(tool.name(), "Bash");
assert!(tool.model_visible());
assert!(tool.description().contains("background=true"));
let readonly = BashTool::read_only("Bash");
assert!(readonly.description().contains("never through a shell"));
assert!(
!readonly
.input_schema()
.to_string()
.contains("Actual execution shell")
);
let alias = BashTool::alias("exec_shell", "run");
assert_eq!(alias.description(), tool.description());
let workspace = tempdir().unwrap();
let mut registry = crate::tools::ToolRegistry::new(ToolContext::new(workspace.path()));
registry.register(std::sync::Arc::new(BashTool::new("Bash")));
let catalog = registry.to_api_tools();
assert_eq!(catalog.len(), 1);
assert_eq!(catalog[0].description, tool.description());
assert_eq!(
catalog[0].input_schema["properties"]["command"]["description"],
command
);
}

#[test]
#[ignore = "Exports model-visible shell fixtures for opt-in live model evaluation"]
fn export_shell_guidance_eval_fixture() {
let path = std::env::var_os("SHELL_GUIDANCE_FIXTURE").expect("SHELL_GUIDANCE_FIXTURE");
let tool = BashTool::new("Bash");
let mut schema = tool.input_schema();
crate::tools::schema_sanitize::sanitize(&mut schema);
crate::tools::schema_canonicalize::canonicalize_schema(&mut schema);
let fixture = json!({
"name": tool.name(),
"description": tool.description(),
"input_schema": schema,
"shell": crate::shell_dispatcher::global_dispatcher().kind().binary(),
});
std::fs::write(path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap();
}

#[test]
fn deleted_saved_workspace_reports_path_and_recovery_before_spawn() {
let workspace = tempdir().expect("workspace");
Expand Down
Loading