use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use locode_host::{ExecRequest, FrontBackSpec, Host, ShellSpec};
use locode_tools::{Tool, ToolCtx, ToolError, ToolKind, ToolOutput};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
const MAX_TIMEOUT_MS: u64 = 600_000;
const MAX_RESULT_CHARS: usize = 30_000;
const TRUNCATION_SEPARATOR: &str = "\n\n... [output truncated] ...\n\n";
pub(crate) struct ClaudeBash {
host: Arc<Host>,
}
impl ClaudeBash {
pub(crate) fn new(host: Arc<Host>) -> Self {
Self { host }
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct BashArgs {
#[schemars(description = "The command to execute")]
command: String,
#[schemars(description = "Optional timeout in milliseconds (max 600000)")]
#[serde(default)]
timeout: Option<u64>,
#[schemars(
description = r#"Clear, concise description of what this command does in active voice. Never use words like "complex" or "risk" in the description - just describe what it does.
For simple commands (git, npm, standard CLI tools), keep it brief (5-10 words):
- ls → "List files in current directory"
- git status → "Show working tree status"
- npm install → "Install package dependencies"
For commands that are harder to parse at a glance (piped commands, obscure flags, etc.), add enough context to clarify what it does:
- find . -name "*.tmp" -exec rm {} \; → "Find and delete all .tmp files recursively"
- git reset --hard origin/main → "Discard all local changes and match remote main"
- curl -s url | jq '.data[]' → "Fetch JSON from URL and extract data array elements""#
)]
#[serde(default)]
#[allow(dead_code)] description: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct BashOutput {
exit_code: Option<i32>,
interrupted: bool,
truncated: bool,
#[serde(skip)]
body: String,
}
impl ToolOutput for BashOutput {
fn to_prompt_text(&self) -> String {
self.body.clone()
}
}
fn process_output(combined: &str) -> String {
let bytes = combined.as_bytes();
let mut cut = 0;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'\n' {
i += 1;
cut = i;
} else if c.is_ascii_whitespace() {
i += 1;
} else {
break;
}
}
combined[cut..].trim_end().to_string()
}
#[async_trait]
impl Tool for ClaudeBash {
type Args = BashArgs;
type Output = BashOutput;
fn kind(&self) -> ToolKind {
ToolKind::Shell
}
#[allow(clippy::unnecessary_literal_bound)] fn description(&self) -> &str {
include_str!("descriptions/bash.md")
}
async fn run(&self, ctx: &ToolCtx, args: BashArgs) -> Result<Self::Output, ToolError> {
let timeout_ms = args
.timeout
.filter(|&t| t > 0)
.unwrap_or(DEFAULT_TIMEOUT_MS)
.min(MAX_TIMEOUT_MS);
let request = ExecRequest {
command: args.command,
cwd: ctx.cwd.clone(),
timeout: Some(Duration::from_millis(timeout_ms)),
env: Vec::new(),
shell: Some(ShellSpec {
detect_program: true,
login_arg: false,
login_path_probe: true,
}),
front_back: Some(FrontBackSpec {
char_budget: MAX_RESULT_CHARS,
spill: false,
}),
};
let out = self
.host
.exec(request, &ctx.cancel)
.await
.map_err(|e| ToolError::Respond(e.to_string()))?;
let interrupted = out.timed_out || out.cancelled;
let capture = out
.front_back
.ok_or_else(|| ToolError::Respond("internal: combined capture missing".to_string()))?;
let truncated = capture.back.is_some();
let raw = match &capture.back {
Some(back) => format!("{}{TRUNCATION_SEPARATOR}{back}", capture.front),
None => capture.front.clone(),
};
let mut body = process_output(&raw);
if !interrupted && matches!(out.exit_code, Some(code) if code != 0) {
let code = out.exit_code.unwrap_or_default();
if body.is_empty() {
body = format!("Exit code {code}");
} else {
body = format!("{body}\nExit code {code}");
}
}
if interrupted {
if !body.is_empty() {
body.push('\n');
}
body.push_str("<error>Command was aborted before completion</error>");
}
let output = BashOutput {
exit_code: out.exit_code,
interrupted,
truncated,
body,
};
if interrupted {
return Err(ToolError::Respond(output.body));
}
Ok(output)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn process_output_strips_leading_blanks_and_trailing() {
assert_eq!(process_output("\n\n \nhello\nworld\n\n"), "hello\nworld");
assert_eq!(process_output("plain"), "plain");
assert_eq!(process_output(""), "");
assert_eq!(process_output("\n\n hello"), " hello");
assert_eq!(process_output("a\r\nb\r\n"), "a\r\nb");
}
#[test]
fn description_is_the_pinned_bash_md() {
let desc = include_str!("descriptions/bash.md");
assert_eq!(desc.len(), 9451, "bash.md byte length changed");
assert!(
desc.starts_with("Executes a given bash command and returns its output."),
"bash.md opening line changed"
);
assert!(
desc.trim_end()
.ends_with("gh api repos/foo/bar/pulls/123/comments"),
"bash.md closing line changed"
);
assert!(
!desc.contains("Co-Authored-By"),
"attribution must be absent"
);
assert!(
!desc.contains("Generated with [Claude Code]"),
"PR attribution must be absent"
);
assert!(desc.contains("- Create the commit with a message."));
}
}