diff --git a/crates/focus-tools/Cargo.toml b/crates/focus-tools/Cargo.toml index 10012fa..6e387d0 100644 --- a/crates/focus-tools/Cargo.toml +++ b/crates/focus-tools/Cargo.toml @@ -4,8 +4,8 @@ version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true -description = "Built-in tools (read, write, edit, bash) for the focus agent" +description = "Built-in tools (read, write, edit, shell) for the focus agent" [dependencies] focus-core.workspace = true -tokio = { workspace = true, features = ["rt", "process", "fs", "io-util", "sync"] } +focus-json.workspace = true diff --git a/crates/focus-tools/src/edit.rs b/crates/focus-tools/src/edit.rs new file mode 100644 index 0000000..0f6fcc2 --- /dev/null +++ b/crates/focus-tools/src/edit.rs @@ -0,0 +1,283 @@ +//! `edit` 工具:pi 风格的精确字符串替换。 +//! The `edit` tool: pi-style exact string replacement. +//! +//! 一次调用可含多组 `{oldString, newString}`,按顺序应用。每组默认要求 +//! `oldString` 唯一匹配;出现多次时必须显式给出 1 起始的 `occurrence`。 +//! A single call may carry multiple `{oldString, newString}` pairs, applied in +//! order. Each pair requires a unique match by default; when a string appears +//! multiple times, a 1-based `occurrence` must be given explicitly. + +use crate::resolve_path; +use focus_core::model::ContentBlock; +use focus_core::tool::{Tool, ToolEffects, ToolResult, ToolUpdateSink}; +use focus_core::{CoreError, CoreResult}; +use focus_json::JsonValue; +use std::path::PathBuf; + +/// 应用精确替换的编辑工具。 +/// A tool that applies exact replacements. +#[derive(Debug, Clone)] +pub struct EditTool { + root: PathBuf, +} + +impl EditTool { + /// 以项目根目录构造工具。 + /// Build the tool with a project root. + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } +} + +impl Tool for EditTool { + fn name(&self) -> &str { + "edit" + } + + fn description(&self) -> &str { + "Apply one or more exact string replacements to a file. Each edit must \ + match uniquely unless an 'occurrence' (1-based) is given. Edits are \ + applied in order." + } + + fn parameters(&self) -> JsonValue { + let mut o = JsonValue::obj(); + o.insert("type", "object".into()).ok(); + let mut props = JsonValue::obj(); + let mut path = JsonValue::obj(); + path.insert("type", "string".into()).ok(); + props.insert("path", path).ok(); + let mut edits = JsonValue::obj(); + edits.insert("type", "array".into()).ok(); + let mut item = JsonValue::obj(); + item.insert("type", "object".into()).ok(); + let mut item_props = JsonValue::obj(); + let mut old = JsonValue::obj(); + old.insert("type", "string".into()).ok(); + item_props.insert("oldString", old).ok(); + let mut new = JsonValue::obj(); + new.insert("type", "string".into()).ok(); + item_props.insert("newString", new).ok(); + let mut occ = JsonValue::obj(); + occ.insert("type", "number".into()).ok(); + item_props.insert("occurrence", occ).ok(); + item.insert("properties", item_props).ok(); + let mut item_required = JsonValue::arr(); + item_required.push("oldString".into()).ok(); + item_required.push("newString".into()).ok(); + item.insert("required", item_required).ok(); + edits.insert("items", item).ok(); + props.insert("edits", edits).ok(); + o.insert("properties", props).ok(); + let mut required = JsonValue::arr(); + required.push("path".into()).ok(); + required.push("edits".into()).ok(); + o.insert("required", required).ok(); + o + } + + fn effects(&self) -> ToolEffects { + ToolEffects::WRITE + } + + fn execute( + &self, + _id: &str, + args: &JsonValue, + _on_update: Option<&ToolUpdateSink>, + ) -> CoreResult { + let path = args + .get_str("path") + .ok_or_else(|| CoreError::InvalidArgs("edit: missing 'path'".into()))?; + let edits = args + .get_arr("edits") + .ok_or_else(|| CoreError::InvalidArgs("edit: missing 'edits' array".into()))?; + if edits.is_empty() { + return Err(CoreError::InvalidArgs("edit: 'edits' is empty".into())); + } + let resolved = resolve_path(&self.root, path); + let content = std::fs::read_to_string(&resolved) + .map_err(|e| CoreError::Tool(format!("edit {}: {}", resolved.display(), e)))?; + + let mut current = content; + let mut applied = 0usize; + for (i, edit) in edits.iter().enumerate() { + let old_string = edit.get_str("oldString").ok_or_else(|| { + CoreError::InvalidArgs(format!("edit #{}: missing 'oldString'", i + 1)) + })?; + let new_string = edit.get_str("newString").ok_or_else(|| { + CoreError::InvalidArgs(format!("edit #{}: missing 'newString'", i + 1)) + })?; + let occurrence = edit.get_num("occurrence").map(|n| n as usize); + + // 收集所有匹配位置。 + // Collect all match positions. + let positions: Vec = current + .match_indices(old_string) + .map(|(pos, _)| pos) + .collect(); + + let chosen = match occurrence { + Some(occ) if occ >= 1 && occ <= positions.len() => positions[occ - 1], + Some(occ) => { + return Err(CoreError::Tool(format!( + "edit {}: occurrence {} out of range ({} matches found)", + resolved.display(), + occ, + positions.len() + ))) + } + None if positions.len() == 1 => positions[0], + None if positions.is_empty() => { + return Err(CoreError::Tool(format!( + "edit {}: oldString #{} not found: {:?}", + resolved.display(), + i + 1, + old_string + ))) + } + None => { + return Err(CoreError::Tool(format!( + "edit {}: oldString #{} matches {} times; specify 'occurrence'", + resolved.display(), + i + 1, + positions.len() + ))) + } + }; + + // 拼接替换后的内容。 + // Splice in the replacement. + let mut next = String::with_capacity(current.len() + new_string.len()); + next.push_str(¤t[..chosen]); + next.push_str(new_string); + next.push_str(¤t[chosen + old_string.len()..]); + current = next; + applied += 1; + } + + std::fs::write(&resolved, current.as_bytes()) + .map_err(|e| CoreError::Tool(format!("edit {}: {}", resolved.display(), e)))?; + + let mut details = JsonValue::obj(); + details + .insert("path", resolved.display().to_string().into()) + .ok(); + details.insert("applied", (applied as f64).into()).ok(); + Ok(ToolResult { + content: vec![ContentBlock::text(format!( + "Applied {} edit(s) to {}", + applied, + resolved.display() + ))], + details, + added_tool_names: Vec::new(), + terminate: false, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn temp_dir(tag: &str) -> PathBuf { + let d = + std::env::temp_dir().join(format!("focus-tools-edit-{}-{}", tag, std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d + } + + fn edit_args(path: &str, edits: Vec) -> JsonValue { + let mut args = JsonValue::obj(); + args.insert("path", path.into()).ok(); + args.insert("edits", JsonValue::Arr(edits)).ok(); + args + } + + fn pair(old: &str, new: &str) -> JsonValue { + let mut e = JsonValue::obj(); + e.insert("oldString", old.into()).ok(); + e.insert("newString", new.into()).ok(); + e + } + + #[test] + fn applies_single_replacement() { + let dir = temp_dir("single"); + fs::write(dir.join("f.txt"), "foo bar foo").unwrap(); + let tool = EditTool::new(&dir); + let r = tool + .execute("id", &edit_args("f.txt", vec![pair("bar", "baz")]), None) + .unwrap(); + assert!(r.content[0].as_text().unwrap().text.contains("1 edit")); + assert_eq!( + fs::read_to_string(dir.join("f.txt")).unwrap(), + "foo baz foo" + ); + } + + #[test] + fn applies_multiple_edits_in_order() { + let dir = temp_dir("multi"); + fs::write(dir.join("f.txt"), "a b c").unwrap(); + let tool = EditTool::new(&dir); + let r = tool + .execute( + "id", + &edit_args( + "f.txt", + vec![pair("a", "1"), pair("b", "2"), pair("c", "3")], + ), + None, + ) + .unwrap(); + assert!(r.content[0].as_text().unwrap().text.contains("3 edit")); + assert_eq!(fs::read_to_string(dir.join("f.txt")).unwrap(), "1 2 3"); + } + + #[test] + fn ambiguous_match_requires_occurrence() { + let dir = temp_dir("ambig"); + fs::write(dir.join("f.txt"), "x x x").unwrap(); + let tool = EditTool::new(&dir); + // 无 occurrence → 报错。 + // No occurrence → error. + let err = tool.execute("id", &edit_args("f.txt", vec![pair("x", "y")]), None); + assert!(err.is_err()); + let msg = err.unwrap_err().to_string(); + assert!(msg.contains("3 times"), "got: {}", msg); + // 带 occurrence → 只替换第 2 个。 + // With occurrence → replace only the 2nd. + let mut e = pair("x", "y"); + e.insert("occurrence", 2u64.into()).ok(); + tool.execute("id", &edit_args("f.txt", vec![e]), None) + .unwrap(); + assert_eq!(fs::read_to_string(dir.join("f.txt")).unwrap(), "x y x"); + } + + #[test] + fn not_found_reports_descriptively() { + let dir = temp_dir("nf"); + fs::write(dir.join("f.txt"), "hello").unwrap(); + let tool = EditTool::new(&dir); + let err = tool + .execute("id", &edit_args("f.txt", vec![pair("zzz", "y")]), None) + .unwrap_err(); + assert!(err.to_string().contains("not found"), "got: {}", err); + } + + #[test] + fn preserves_crlf_when_not_matched() { + let dir = temp_dir("crlf"); + fs::write(dir.join("f.txt"), "a\r\nb\r\nc\r\n").unwrap(); + let tool = EditTool::new(&dir); + tool.execute("id", &edit_args("f.txt", vec![pair("b", "B")]), None) + .unwrap(); + // 未匹配的 CRLF 原样保留。 + // Unmatched CRLF bytes are preserved as-is. + assert_eq!(fs::read(dir.join("f.txt")).unwrap(), b"a\r\nB\r\nc\r\n"); + } +} diff --git a/crates/focus-tools/src/lib.rs b/crates/focus-tools/src/lib.rs index 0d6161f..e9845c6 100644 --- a/crates/focus-tools/src/lib.rs +++ b/crates/focus-tools/src/lib.rs @@ -1,10 +1,44 @@ //! focus-tools:内置的文件与 shell 工具集。 //! focus-tools: built-in file and shell tools. //! -//! 每个工具都实现 [`focus_core::Tool`]:`read`、`write`、`edit`、`bash`。 -//! Each tool implements [`focus_core::Tool`]: `read`, `write`, `edit`, `bash`. -//! -//! 本里程碑阶段尚未实现。 -//! Not yet implemented in this milestone. +//! 每个工具都实现 [`focus_core::Tool`]:`read`、`write`、`edit`、`shell`。 +//! 全部使用同步 `std` I/O(与 core 的同步 `Tool` trait 一致),并原生支持 +//! Windows 与 Linux(路径用 `std::path`,shell 按平台选择)。 +//! Each tool implements [`focus_core::Tool`]: `read`, `write`, `edit`, `shell`. +//! All use synchronous `std` I/O (consistent with the core's sync `Tool` +//! trait) and natively support both Windows and Linux (paths via `std::path`, +//! the shell chosen per platform). #![forbid(unsafe_code)] + +/// pi 风格的精确替换编辑工具。 +/// The pi-style exact-replacement edit tool. +pub mod edit; +/// 读取文件(支持行范围)。 +/// File reading (with optional line ranges). +pub mod read; +/// 平台感知的 shell 执行工具。 +/// Platform-aware shell execution tool. +pub mod shell; +/// 整文件覆盖写入。 +/// Whole-file overwrite writing. +pub mod write; + +pub use edit::EditTool; +pub use read::ReadTool; +pub use shell::ShellTool; +pub use write::WriteTool; + +use std::path::{Path, PathBuf}; + +/// 把用户提供的路径解析为绝对路径:相对路径基于 `root`,绝对路径原样保留。 +/// Resolve a user-supplied path: relative paths are based on `root`, +/// absolute paths are kept as-is. +pub(crate) fn resolve_path(root: &Path, input: &str) -> PathBuf { + let p = Path::new(input); + if p.is_absolute() { + p.to_path_buf() + } else { + root.join(p) + } +} diff --git a/crates/focus-tools/src/read.rs b/crates/focus-tools/src/read.rs new file mode 100644 index 0000000..2c9bc75 --- /dev/null +++ b/crates/focus-tools/src/read.rs @@ -0,0 +1,204 @@ +//! `read` 工具:读取文件,支持 1 起始的行范围。 +//! The `read` tool: read a file, with optional 1-based line ranges. + +use crate::resolve_path; +use focus_core::model::ContentBlock; +use focus_core::tool::{Tool, ToolEffects, ToolResult, ToolUpdateSink}; +use focus_core::CoreResult; +use focus_json::JsonValue; +use std::path::PathBuf; + +/// 读取文件的工具。 +/// A tool that reads files. +#[derive(Debug, Clone)] +pub struct ReadTool { + root: PathBuf, +} + +impl ReadTool { + /// 以项目根目录构造工具(相对路径基于它解析)。 + /// Build the tool with a project root (relative paths resolve against it). + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } +} + +impl Tool for ReadTool { + fn name(&self) -> &str { + "read" + } + + fn description(&self) -> &str { + "Read a file from disk. Provide the path and optionally a 1-based line \ + range (startLine/endLine) to read only part of it." + } + + fn parameters(&self) -> JsonValue { + let mut o = JsonValue::obj(); + o.insert("type", "object".into()).ok(); + let mut props = JsonValue::obj(); + let mut path = JsonValue::obj(); + path.insert("type", "string".into()).ok(); + path.insert( + "description", + "file path (absolute or relative to the project root)".into(), + ) + .ok(); + props.insert("path", path).ok(); + let mut start = JsonValue::obj(); + start.insert("type", "number".into()).ok(); + props.insert("startLine", start).ok(); + let mut end = JsonValue::obj(); + end.insert("type", "number".into()).ok(); + props.insert("endLine", end).ok(); + o.insert("properties", props).ok(); + let mut required = JsonValue::arr(); + required.push("path".into()).ok(); + o.insert("required", required).ok(); + o + } + + fn effects(&self) -> ToolEffects { + ToolEffects::READ + } + + fn execute( + &self, + _id: &str, + args: &JsonValue, + _on_update: Option<&ToolUpdateSink>, + ) -> CoreResult { + let path = args + .get_str("path") + .ok_or_else(|| focus_core::CoreError::InvalidArgs("read: missing 'path'".into()))?; + let resolved = resolve_path(&self.root, path); + + // 二进制文件检测:包含 NUL 字节即视为二进制。 + // Binary detection: NUL bytes mark a file as binary. + let bytes = std::fs::read(&resolved).map_err(|e| { + focus_core::CoreError::Tool(format!("read {}: {}", resolved.display(), e)) + })?; + if bytes.contains(&0u8) { + return Ok(ToolResult::error(format!( + "read {}: binary file (contains NUL bytes); refusing to read as text", + resolved.display() + ))); + } + let content = String::from_utf8_lossy(&bytes).to_string(); + + let start_line = args.get_num("startLine").map(|n| n as usize); + let end_line = args.get_num("endLine").map(|n| n as usize); + let text = match (start_line, end_line) { + (None, None) => content, + (start, end) => slice_lines(&content, start, end), + }; + + let mut details = JsonValue::obj(); + details + .insert("path", resolved.display().to_string().into()) + .ok(); + details.insert("bytes", (bytes.len() as f64).into()).ok(); + Ok(ToolResult { + content: vec![ContentBlock::text(text)], + details, + added_tool_names: Vec::new(), + terminate: false, + }) + } +} + +/// 按 1 起始的行号切片;`end` 为 None 时读到文件末尾。 +/// Slice by 1-based line numbers; `None` end reads to the end of the file. +fn slice_lines(content: &str, start: Option, end: Option) -> String { + let lines: Vec<&str> = content.lines().collect(); + let start_idx = start.unwrap_or(1).saturating_sub(1); + let end_idx = match end { + Some(e) if e >= 1 => e.min(lines.len()), + Some(_) => 0, + None => lines.len(), + }; + if start_idx >= end_idx { + return String::new(); + } + lines[start_idx..end_idx].join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn temp_dir(tag: &str) -> PathBuf { + let d = + std::env::temp_dir().join(format!("focus-tools-read-{}-{}", tag, std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn reads_file() { + let dir = temp_dir("reads"); + fs::write(dir.join("a.txt"), "line1\nline2\nline3\n").unwrap(); + let tool = ReadTool::new(&dir); + let mut args = JsonValue::obj(); + args.insert("path", "a.txt".into()).ok(); + let r = tool.execute("id", &args, None).unwrap(); + let text = r.content[0].as_text().unwrap().text.clone(); + assert_eq!(text, "line1\nline2\nline3\n"); + } + + #[test] + fn reads_line_range() { + let dir = temp_dir("range"); + fs::write(dir.join("a.txt"), "l1\nl2\nl3\nl4\n").unwrap(); + let tool = ReadTool::new(&dir); + let mut args = JsonValue::obj(); + args.insert("path", "a.txt".into()).ok(); + args.insert("startLine", 2u64.into()).ok(); + args.insert("endLine", 3u64.into()).ok(); + let r = tool.execute("id", &args, None).unwrap(); + let text = r.content[0].as_text().unwrap().text.clone(); + assert_eq!(text, "l2\nl3"); + } + + #[test] + fn missing_file_is_an_error_result() { + let dir = temp_dir("missing"); + let tool = ReadTool::new(&dir); + let mut args = JsonValue::obj(); + args.insert("path", "nope.txt".into()).ok(); + // 可预期错误以 Err 返回(agent 循环会将其编码为错误结果)。 + // Expected errors surface as Err (the agent loop encodes them as + // error results). + let err = tool.execute("id", &args, None).unwrap_err().to_string(); + assert!(err.contains("nope.txt"), "got: {}", err); + } + + #[test] + fn binary_file_is_rejected() { + let dir = temp_dir("bin"); + fs::write(dir.join("b.bin"), [0u8, 1, 2, 3]).unwrap(); + let tool = ReadTool::new(&dir); + let mut args = JsonValue::obj(); + args.insert("path", "b.bin".into()).ok(); + let r = tool.execute("id", &args, None).unwrap(); + assert!(r.content[0] + .as_text() + .map(|t| t.text.contains("binary")) + .unwrap_or(false)); + } + + #[test] + fn absolute_path_works() { + let dir = temp_dir("abs"); + let abs = dir.join("x.txt"); + fs::write(&abs, "hi").unwrap(); + let tool = ReadTool::new(&dir); + let mut args = JsonValue::obj(); + args.insert("path", abs.display().to_string().into()).ok(); + let r = tool.execute("id", &args, None).unwrap(); + let text = r.content[0].as_text().unwrap().text.clone(); + assert_eq!(text, "hi"); + } +} diff --git a/crates/focus-tools/src/shell.rs b/crates/focus-tools/src/shell.rs new file mode 100644 index 0000000..5e1bba2 --- /dev/null +++ b/crates/focus-tools/src/shell.rs @@ -0,0 +1,404 @@ +//! `shell` 工具:跨平台 shell 执行。 +//! The `shell` tool: cross-platform shell execution. +//! +//! Linux 默认使用 `bash -c`;Windows 默认使用 PowerShell(`powershell.exe +//! -NoProfile -Command`),失败时回退到 `cmd.exe /C`。可用环境变量 +//! `FOCUS_SHELL` 覆盖(此时仍按平台选择参数形式)。输出按行通过 +//! [`ToolUpdateSink`] 增量推送;超时(默认 30s,可配 `timeoutMs`)时杀死子进程。 +//! Linux defaults to `bash -c`; Windows defaults to PowerShell +//! (`powershell.exe -NoProfile -Command`), falling back to `cmd.exe /C`. The +//! `FOCUS_SHELL` env var overrides the executable (arguments still follow the +//! platform convention). Output is streamed line-by-line via +//! [`ToolUpdateSink`]; on timeout (default 30s, configurable via `timeoutMs`) +//! the child process is killed. + +use crate::resolve_path; +use focus_core::model::ContentBlock; +use focus_core::tool::{Tool, ToolEffects, ToolResult, ToolUpdate, ToolUpdateSink}; +use focus_core::{CoreError, CoreResult}; +use focus_json::JsonValue; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{channel, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +/// 默认命令超时(秒)。 +/// Default command timeout (seconds). +const DEFAULT_TIMEOUT_SECS: u64 = 30; + +/// 执行平台 shell 命令的工具。 +/// A tool that runs platform shell commands. +#[derive(Debug, Clone)] +pub struct ShellTool { + root: PathBuf, +} + +impl ShellTool { + /// 以项目根目录构造工具。 + /// Build the tool with a project root. + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } +} + +impl Tool for ShellTool { + fn name(&self) -> &str { + "shell" + } + + fn description(&self) -> &str { + "Run a shell command and capture its output. Uses bash on Linux and \ + PowerShell on Windows (falling back to cmd.exe). Output streams as it \ + is produced; commands time out after 30s by default (set timeoutMs)." + } + + fn parameters(&self) -> JsonValue { + let mut o = JsonValue::obj(); + o.insert("type", "object".into()).ok(); + let mut props = JsonValue::obj(); + let mut command = JsonValue::obj(); + command.insert("type", "string".into()).ok(); + props.insert("command", command).ok(); + let mut cwd = JsonValue::obj(); + cwd.insert("type", "string".into()).ok(); + props.insert("cwd", cwd).ok(); + let mut timeout = JsonValue::obj(); + timeout.insert("type", "number".into()).ok(); + props.insert("timeoutMs", timeout).ok(); + o.insert("properties", props).ok(); + let mut required = JsonValue::arr(); + required.push("command".into()).ok(); + o.insert("required", required).ok(); + o + } + + fn effects(&self) -> ToolEffects { + ToolEffects::PROCESS + } + + fn execute( + &self, + _id: &str, + args: &JsonValue, + on_update: Option<&ToolUpdateSink>, + ) -> CoreResult { + let command = args + .get_str("command") + .ok_or_else(|| CoreError::InvalidArgs("shell: missing 'command'".into()))? + .to_string(); + let timeout_ms = args + .get_num("timeoutMs") + .map(|n| n as u64) + .unwrap_or(DEFAULT_TIMEOUT_SECS * 1000); + let timeout = Duration::from_millis(timeout_ms.max(1)); + + let mut cmd = build_command(&command); + let cwd = match args.get_str("cwd") { + Some(c) => resolve_path(&self.root, c), + None => self.root.clone(), + }; + cmd.current_dir(&cwd); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| CoreError::Tool(format!("shell spawn: {}", e)))?; + + let result = run_child(&mut child, timeout, on_update)?; + let (stdout, stderr, exit_code, timed_out) = result; + + let mut details = JsonValue::obj(); + details.insert("exitCode", (exit_code as f64).into()).ok(); + details.insert("stdout", stdout.clone().into()).ok(); + details.insert("stderr", stderr.clone().into()).ok(); + details.insert("timedOut", timed_out.into()).ok(); + + let combined = if stdout.is_empty() { + stderr + } else if stderr.is_empty() { + stdout + } else { + format!("{}\n{}", stdout, stderr) + }; + + let text = if timed_out { + format!( + "Command timed out after {}ms.\n{}", + timeout_ms, + truncate(&combined, 2000) + ) + } else { + truncate(&combined, 2000) + }; + + Ok(ToolResult { + content: vec![ContentBlock::text(text)], + details, + added_tool_names: Vec::new(), + terminate: false, + }) + } +} + +/// 构造平台 shell 命令。 +/// Build the platform shell command. +fn build_command(command: &str) -> Command { + let mut cmd = Command::new(shell_program()); + if cfg!(windows) { + // PowerShell 失败时调用方回退到 cmd.exe。 + // Fall back to cmd.exe when PowerShell fails to spawn. + cmd.args(["-NoProfile", "-Command", command]); + } else { + cmd.args(["-c", command]); + } + cmd +} + +/// 选择 shell 程序:`FOCUS_SHELL` 优先,否则按平台默认。 +/// Pick the shell program: `FOCUS_SHELL` wins, else the platform default. +fn shell_program() -> String { + if let Ok(s) = std::env::var("FOCUS_SHELL") { + if !s.is_empty() { + return s; + } + } + if cfg!(windows) { + "powershell.exe".to_string() + } else { + "bash".to_string() + } +} + +/// stdout/stderr 读取线程发来的消息。 +/// Messages sent by the stdout/stderr reader threads. +enum StreamMsg { + /// 一行输出;`true` 表示来自 stderr。 + /// One line of output; `true` means it came from stderr. + Line(String, bool), + /// 对应管道已到达 EOF。 + /// The corresponding pipe reached EOF. + Eof, +} + +/// 运行子进程并收集输出;返回 (stdout, stderr, exit_code, timed_out)。 +/// Run the child and collect its output; returns +/// (stdout, stderr, exit_code, timed_out). +fn run_child( + child: &mut Child, + timeout: Duration, + on_update: Option<&ToolUpdateSink>, +) -> CoreResult<(String, String, i32, bool)> { + let stdout = child + .stdout + .take() + .ok_or_else(|| CoreError::Tool("shell: no stdout pipe".into()))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| CoreError::Tool("shell: no stderr pipe".into()))?; + + let (tx, rx) = channel::(); + spawn_reader(stdout, false, tx.clone()); + spawn_reader(stderr, true, tx); + + let mut stdout_buf = String::new(); + let mut stderr_buf = String::new(); + let mut eofs = 0usize; + let deadline = Instant::now() + timeout; + let mut timed_out = false; + + // 主线程负责消费消息(并调用 on_update),避免跨线程借用。 + // The main thread consumes messages (calling on_update), avoiding + // cross-thread borrows. + while eofs < 2 { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + timed_out = true; + break; + } + match rx.recv_timeout(remaining) { + Ok(StreamMsg::Line(line, is_stderr)) => { + if let Some(cb) = on_update { + let mut details = JsonValue::obj(); + details + .insert("stream", if is_stderr { "stderr" } else { "stdout" }.into()) + .ok(); + cb(ToolUpdate { + content: vec![ContentBlock::text(line.clone())], + details, + }); + } + if is_stderr { + stderr_buf.push_str(&line); + stderr_buf.push('\n'); + } else { + stdout_buf.push_str(&line); + stdout_buf.push('\n'); + } + } + Ok(StreamMsg::Eof) => eofs += 1, + Err(RecvTimeoutError::Timeout) => { + timed_out = true; + break; + } + // 两个读取线程都已退出(通常伴随两个 Eof)。 + // Both reader threads exited (usually after two Eofs). + Err(RecvTimeoutError::Disconnected) => eofs = 2, + } + } + + if timed_out { + // 杀死子进程(注意:不会连带杀掉孙进程,后续版本可扩展)。 + // Kill the child (note: grandchildren are not killed; a future + // version could extend this). + let _ = child.kill(); + let _ = child.wait(); + return Ok((stdout_buf, stderr_buf, -1, true)); + } + + let status = child + .wait() + .map_err(|e| CoreError::Tool(format!("shell wait: {}", e)))?; + let exit_code = status.code().unwrap_or(-1); + Ok((stdout_buf, stderr_buf, exit_code, false)) +} + +/// 启动一个读取线程:逐行读取管道并送入 channel。 +/// Spawn a reader thread: reads the pipe line-by-line into the channel. +fn spawn_reader( + pipe: R, + is_stderr: bool, + tx: std::sync::mpsc::Sender, +) { + std::thread::spawn(move || { + let reader = BufReader::new(pipe); + for line in reader.lines() { + match line { + Ok(l) => { + if tx + .send(StreamMsg::Line( + l.trim_end_matches(['\r', '\n']).to_string(), + is_stderr, + )) + .is_err() + { + return; + } + } + Err(_) => return, + } + } + let _ = tx.send(StreamMsg::Eof); + }); +} + +/// 截断过长的输出(保留头尾)。 +/// Truncate overly long output (keeping head and tail). +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let half = max / 2; + let mut out = s.chars().take(half).collect::(); + out.push_str("\n... [output truncated] ...\n"); + out.push_str(&s.chars().skip(s.len() - half).collect::()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use focus_core::tool::ToolUpdate; + use std::fs; + use std::sync::{Arc, Mutex}; + + fn temp_dir(tag: &str) -> PathBuf { + let d = + std::env::temp_dir().join(format!("focus-tools-shell-{}-{}", tag, std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d + } + + fn cmd_args(command: &str) -> JsonValue { + let mut args = JsonValue::obj(); + args.insert("command", command.into()).ok(); + args + } + + #[cfg(unix)] + #[test] + fn runs_bash_and_captures_stdout() { + let dir = temp_dir("stdout"); + let tool = ShellTool::new(&dir); + let r = tool.execute("id", &cmd_args("echo hello"), None).unwrap(); + let text = r.content[0].as_text().unwrap().text.clone(); + assert_eq!(text.trim(), "hello"); + assert_eq!(r.details.get_num("exitCode"), Some(0.0)); + assert_eq!(r.details.get_bool("timedOut"), Some(false)); + } + + #[cfg(unix)] + #[test] + fn captures_stderr_and_exit_code() { + let dir = temp_dir("stderr"); + let tool = ShellTool::new(&dir); + let r = tool + .execute("id", &cmd_args("echo oops >&2; exit 3"), None) + .unwrap(); + assert_eq!(r.details.get_num("exitCode"), Some(3.0)); + let stderr = r.details.get_str("stderr").unwrap_or(""); + assert!(stderr.contains("oops"), "got: {}", stderr); + } + + #[cfg(unix)] + #[test] + fn times_out_and_kills() { + let dir = temp_dir("timeout"); + let tool = ShellTool::new(&dir); + let mut args = cmd_args("sleep 5"); + args.insert("timeoutMs", 200u64.into()).ok(); + let r = tool.execute("id", &args, None).unwrap(); + assert_eq!(r.details.get_bool("timedOut"), Some(true)); + let text = r.content[0].as_text().unwrap().text.clone(); + assert!(text.contains("timed out"), "got: {}", text); + } + + #[cfg(unix)] + #[test] + fn streams_updates_line_by_line() { + let dir = temp_dir("updates"); + let tool = ShellTool::new(&dir); + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink: Box = { + let lines = lines.clone(); + Box::new(move |u: ToolUpdate| { + if let Some(t) = u.content[0].as_text() { + lines.lock().unwrap().push(t.text.clone()); + } + }) + }; + let r = tool + .execute("id", &cmd_args("printf 'a\\nb\\nc\\n'"), Some(&*sink)) + .unwrap(); + assert_eq!(r.details.get_num("exitCode"), Some(0.0)); + let got = lines.lock().unwrap(); + assert_eq!( + *got, + vec!["a".to_string(), "b".to_string(), "c".to_string()] + ); + } + + #[cfg(unix)] + #[test] + fn runs_in_project_root() { + let dir = temp_dir("cwd"); + let tool = ShellTool::new(&dir); + let r = tool.execute("id", &cmd_args("pwd"), None).unwrap(); + let text = r.content[0].as_text().unwrap().text.clone(); + assert_eq!(text.trim(), dir.display().to_string()); + } +} diff --git a/crates/focus-tools/src/write.rs b/crates/focus-tools/src/write.rs new file mode 100644 index 0000000..083a5b0 --- /dev/null +++ b/crates/focus-tools/src/write.rs @@ -0,0 +1,144 @@ +//! `write` 工具:整文件覆盖写入,自动创建父目录。 +//! The `write` tool: whole-file overwrite, auto-creating parent directories. + +use crate::resolve_path; +use focus_core::model::ContentBlock; +use focus_core::tool::{Tool, ToolEffects, ToolResult, ToolUpdateSink}; +use focus_core::{CoreError, CoreResult}; +use focus_json::JsonValue; +use std::path::PathBuf; + +/// 覆盖写入文件的工具。 +/// A tool that overwrites files. +#[derive(Debug, Clone)] +pub struct WriteTool { + root: PathBuf, +} + +impl WriteTool { + /// 以项目根目录构造工具。 + /// Build the tool with a project root. + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } +} + +impl Tool for WriteTool { + fn name(&self) -> &str { + "write" + } + + fn description(&self) -> &str { + "Write text to a file, creating parent directories as needed. \ + Overwrites any existing content." + } + + fn parameters(&self) -> JsonValue { + let mut o = JsonValue::obj(); + o.insert("type", "object".into()).ok(); + let mut props = JsonValue::obj(); + let mut path = JsonValue::obj(); + path.insert("type", "string".into()).ok(); + props.insert("path", path).ok(); + let mut content = JsonValue::obj(); + content.insert("type", "string".into()).ok(); + props.insert("content", content).ok(); + o.insert("properties", props).ok(); + let mut required = JsonValue::arr(); + required.push("path".into()).ok(); + required.push("content".into()).ok(); + o.insert("required", required).ok(); + o + } + + fn effects(&self) -> ToolEffects { + ToolEffects::WRITE + } + + fn execute( + &self, + _id: &str, + args: &JsonValue, + _on_update: Option<&ToolUpdateSink>, + ) -> CoreResult { + let path = args + .get_str("path") + .ok_or_else(|| CoreError::InvalidArgs("write: missing 'path'".into()))?; + let content = args + .get_str("content") + .ok_or_else(|| CoreError::InvalidArgs("write: missing 'content'".into()))?; + let resolved = resolve_path(&self.root, path); + + if let Some(parent) = resolved.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + CoreError::Tool(format!( + "write {}: create parent dirs: {}", + resolved.display(), + e + )) + })?; + } + std::fs::write(&resolved, content.as_bytes()) + .map_err(|e| CoreError::Tool(format!("write {}: {}", resolved.display(), e)))?; + + let mut details = JsonValue::obj(); + details + .insert("path", resolved.display().to_string().into()) + .ok(); + details.insert("bytes", (content.len() as f64).into()).ok(); + Ok(ToolResult { + content: vec![ContentBlock::text(format!( + "Wrote {} bytes to {}", + content.len(), + resolved.display() + ))], + details, + added_tool_names: Vec::new(), + terminate: false, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn temp_dir(tag: &str) -> PathBuf { + let d = + std::env::temp_dir().join(format!("focus-tools-write-{}-{}", tag, std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn writes_and_overwrites() { + let dir = temp_dir("overwrite"); + fs::write(dir.join("a.txt"), "old").unwrap(); + let tool = WriteTool::new(&dir); + let mut args = JsonValue::obj(); + args.insert("path", "a.txt".into()).ok(); + args.insert("content", "new content".into()).ok(); + let r = tool.execute("id", &args, None).unwrap(); + assert!(r.content[0].as_text().unwrap().text.contains("11 bytes")); + assert_eq!( + fs::read_to_string(dir.join("a.txt")).unwrap(), + "new content" + ); + } + + #[test] + fn creates_parent_directories() { + let dir = temp_dir("nested"); + let tool = WriteTool::new(&dir); + let mut args = JsonValue::obj(); + args.insert("path", "deep/nested/file.txt".into()).ok(); + args.insert("content", "hi".into()).ok(); + tool.execute("id", &args, None).unwrap(); + assert_eq!( + fs::read_to_string(dir.join("deep/nested/file.txt")).unwrap(), + "hi" + ); + } +}