diff --git a/Cargo.lock b/Cargo.lock index 86974afc..04178cf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", + "getrandom 0.2.15", "once_cell", "version_check", "zerocopy", @@ -167,6 +168,9 @@ name = "arbitrary" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "arg_enum_proc_macro" @@ -897,6 +901,21 @@ dependencies = [ "which", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit_field" version = "0.10.2" @@ -1097,6 +1116,17 @@ dependencies = [ "nom", ] +[[package]] +name = "cfb" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a4f8e55be323b378facfcf1f06aa97f6ec17cf4ac84fb17325093aaf62da41" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfg-expr" version = "0.15.8" @@ -1642,6 +1672,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.99", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -1746,6 +1787,12 @@ dependencies = [ "const-random", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "document-features" version = "0.2.11" @@ -1755,6 +1802,21 @@ dependencies = [ "litrs", ] +[[package]] +name = "docx-rs" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e593b51d4fe95d69d70fd40da4b314b029736302c986c3c760826e842fd27dc3" +dependencies = [ + "base64 0.13.1", + "image 0.24.9", + "serde", + "serde_json", + "thiserror 1.0.69", + "xml-rs", + "zip 0.6.6", +] + [[package]] name = "dotenv" version = "0.15.0" @@ -1873,6 +1935,17 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -2310,10 +2383,12 @@ dependencies = [ "async-trait", "base64 0.21.7", "chrono", + "docx-rs", "etcetera", "google-drive3", "http-body-util", "ignore", + "image 0.24.9", "include_dir", "indoc", "kill_tree", @@ -2329,13 +2404,13 @@ dependencies = [ "serial_test", "shellexpand", "sysinfo 0.32.1", - "temp-env", "tempfile", "thiserror 1.0.69", "tokio", "tracing", "tracing-appender", "tracing-subscriber", + "umya-spreadsheet", "url", "urlencoding", "webbrowser", @@ -2512,6 +2587,21 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "html_parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f56db07b6612644f6f7719f8ef944f75fff9d6378fdf3d316fd32194184abd" +dependencies = [ + "doc-comment", + "pest", + "pest_derive", + "serde", + "serde_derive", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "http" version = "0.2.12" @@ -2875,6 +2965,24 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "exr", + "gif", + "jpeg-decoder", + "num-traits", + "png", + "qoi", + "tiff", +] + [[package]] name = "image" version = "0.25.5" @@ -3096,6 +3204,9 @@ name = "jpeg-decoder" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" +dependencies = [ + "rayon", +] [[package]] name = "js-sys" @@ -3280,6 +3391,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + [[package]] name = "log" version = "0.4.26" @@ -4220,6 +4337,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.6" @@ -5459,6 +5586,12 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "thin-vec" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a38c90d48152c236a3ab59271da4f4ae63d678c5d7ad6b7714d7cb9760be5e4b" + [[package]] name = "thiserror" version = "1.0.69" @@ -5499,6 +5632,12 @@ dependencies = [ "syn 2.0.99", ] +[[package]] +name = "thousands" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" + [[package]] name = "thread_local" version = "1.1.8" @@ -5942,6 +6081,35 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "umya-spreadsheet" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ec15f1f191ba42ba0ed0f788999eec910c201cbbd4ae5de7cf0eb0a94b3d1a" +dependencies = [ + "aes", + "ahash", + "base64 0.22.1", + "byteorder", + "cbc", + "cfb", + "chrono", + "encoding_rs", + "fancy-regex", + "getrandom 0.2.15", + "hmac", + "html_parser", + "image 0.25.5", + "lazy_static", + "md-5", + "quick-xml 0.37.2", + "regex", + "sha2", + "thin-vec", + "thousands", + "zip 2.2.3", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -6783,7 +6951,7 @@ dependencies = [ "core-foundation 0.10.0", "core-graphics", "dbus", - "image", + "image 0.25.5", "log", "percent-encoding", "sysinfo 0.32.1", @@ -6803,6 +6971,12 @@ dependencies = [ "quick-xml 0.30.0", ] +[[package]] +name = "xml-rs" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b940ebc25896e71dd073bad2dbaa2abfe97b0a391415e22ad1326d9c54e3c4" + [[package]] name = "xmlparser" version = "0.13.6" @@ -6964,6 +7138,49 @@ dependencies = [ "syn 2.0.99", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + +[[package]] +name = "zip" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b280484c454e74e5fff658bbf7df8fdbe7a07c6b2de4a53def232c15ef138f3a" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.7.1", + "memchr", + "thiserror 2.0.12", + "zopfli", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index a81b51fe..c5a59d42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,4 @@ version = "1.0.12" authors = ["Block "] license = "Apache-2.0" repository = "https://github.com/block/goose" -description = "An AI agent" +description = "An AI agent" \ No newline at end of file diff --git a/crates/goose-mcp/Cargo.toml b/crates/goose-mcp/Cargo.toml index 7286774c..be12dc2b 100644 --- a/crates/goose-mcp/Cargo.toml +++ b/crates/goose-mcp/Cargo.toml @@ -38,9 +38,11 @@ http-body-util = "0.1.2" regex = "1.11.1" once_cell = "1.20.2" ignore = "0.4" -temp-env = "0.3" lopdf = "0.35.0" +docx-rs = "0.4.7" +image = "0.24.9" +umya-spreadsheet = "2.2.3" [dev-dependencies] serial_test = "3.0.0" -sysinfo = "0.32.1" +sysinfo = "0.32.1" \ No newline at end of file diff --git a/crates/goose-mcp/src/computercontroller/docx_tool.rs b/crates/goose-mcp/src/computercontroller/docx_tool.rs new file mode 100644 index 00000000..5e349d1e --- /dev/null +++ b/crates/goose-mcp/src/computercontroller/docx_tool.rs @@ -0,0 +1,871 @@ +use docx_rs::*; +use image::{self, ImageFormat}; +use mcp_core::{Content, ToolError}; +use std::{fs, io::Cursor}; + +#[derive(Debug)] +enum UpdateMode { + Append, + Replace { + old_text: String, + }, + InsertStructured { + level: Option, // e.g., "Heading1", "Heading2", etc. + style: Option, + }, + AddImage { + image_path: String, + width: Option, + height: Option, + }, +} + +#[derive(Debug, Clone, Default)] +struct DocxStyle { + bold: bool, + italic: bool, + underline: bool, + size: Option, + color: Option, + alignment: Option, +} + +impl DocxStyle { + fn from_json(value: &serde_json::Value) -> Option { + let obj = value.as_object()?; + Some(Self { + bold: obj.get("bold").and_then(|v| v.as_bool()).unwrap_or(false), + italic: obj.get("italic").and_then(|v| v.as_bool()).unwrap_or(false), + underline: obj + .get("underline") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + size: obj.get("size").and_then(|v| v.as_u64()).map(|s| s as usize), + color: obj.get("color").and_then(|v| v.as_str()).map(String::from), + alignment: obj + .get("alignment") + .and_then(|v| v.as_str()) + .and_then(|a| match a { + "left" => Some(AlignmentType::Left), + "center" => Some(AlignmentType::Center), + "right" => Some(AlignmentType::Right), + "justified" => Some(AlignmentType::Both), + _ => None, + }), + }) + } + + fn apply_to_run(&self, run: Run) -> Run { + let mut run = run; + if self.bold { + run = run.bold(); + } + if self.italic { + run = run.italic(); + } + if self.underline { + run = run.underline("single"); + } + if let Some(size) = self.size { + run = run.size(size); + } + if let Some(color) = &self.color { + run = run.color(color); + } + run + } + + fn apply_to_paragraph(&self, para: Paragraph) -> Paragraph { + let mut para = para; + if let Some(alignment) = self.alignment { + para = para.align(alignment); + } + para + } +} + +pub async fn docx_tool( + path: &str, + operation: &str, + content: Option<&str>, + params: Option<&serde_json::Value>, +) -> Result, ToolError> { + match operation { + "extract_text" => { + let file = fs::read(path).map_err(|e| { + ToolError::ExecutionError(format!("Failed to read DOCX file: {}", e)) + })?; + + let docx = read_docx(&file).map_err(|e| { + ToolError::ExecutionError(format!("Failed to parse DOCX file: {}", e)) + })?; + + let mut text = String::new(); + let mut structure = Vec::new(); + let mut current_level = None; + + // Extract document structure and text + for element in docx.document.children.iter() { + if let DocumentChild::Paragraph(p) = element { + // Check for heading style + if let Some(style) = p.property.style.as_ref() { + if style.val.starts_with("Heading") { + current_level = Some(style.val.clone()); + structure.push(format!("{}: ", style.val)); + } + } + + // Extract text from runs + let para_text: String = p + .children + .iter() + .filter_map(|child| { + if let ParagraphChild::Run(run) = child { + Some( + run.children + .iter() + .filter_map(|rc| { + if let RunChild::Text(t) = rc { + Some(t.text.clone()) + } else { + None + } + }) + .collect::>() + .join(""), + ) + } else { + None + } + }) + .collect::>() + .join(""); + + if !para_text.trim().is_empty() { + if current_level.is_some() { + if let Some(s) = structure.last_mut() { + s.push_str(¶_text); + } + current_level = None; + } + text.push_str(¶_text); + text.push('\n'); + } + } + } + + let result = if !structure.is_empty() { + format!( + "Document Structure:\n{}\n\nFull Text:\n{}", + structure.join("\n"), + text + ) + } else { + format!("Extracted Text:\n{}", text) + }; + + Ok(vec![Content::text(result)]) + } + + "update_doc" => { + let content = content.ok_or_else(|| { + ToolError::InvalidParameters( + "Content parameter required for update_doc".to_string(), + ) + })?; + + // Parse update mode and style from params + let (mode, style) = if let Some(params) = params { + let mode = params + .get("mode") + .and_then(|v| v.as_str()) + .unwrap_or("append"); + let style = params.get("style").and_then(DocxStyle::from_json); + + let mode = match mode { + "append" => UpdateMode::Append, + "replace" => { + let old_text = + params + .get("old_text") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters( + "old_text parameter required for replace mode".to_string(), + ) + })?; + UpdateMode::Replace { + old_text: old_text.to_string(), + } + } + "structured" => { + let level = params + .get("level") + .and_then(|v| v.as_str()) + .map(String::from); + UpdateMode::InsertStructured { + level, + style: style.clone(), + } + } + "add_image" => { + let image_path = params + .get("image_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters( + "image_path parameter required for add_image mode".to_string(), + ) + })? + .to_string(); + + let width = params + .get("width") + .and_then(|v| v.as_u64()) + .map(|w| w as u32); + + let height = params + .get("height") + .and_then(|v| v.as_u64()) + .map(|h| h as u32); + + UpdateMode::AddImage { + image_path, + width, + height, + } + } + _ => return Err(ToolError::InvalidParameters( + "Invalid mode. Must be 'append', 'replace', 'structured', or 'add_image'" + .to_string(), + )), + }; + (mode, style) + } else { + (UpdateMode::Append, None) + }; + + match mode { + UpdateMode::Append => { + // Read existing document if it exists, or create new one + let mut doc = if std::path::Path::new(path).exists() { + let file = fs::read(path).map_err(|e| { + ToolError::ExecutionError(format!("Failed to read DOCX file: {}", e)) + })?; + read_docx(&file).map_err(|e| { + ToolError::ExecutionError(format!("Failed to parse DOCX file: {}", e)) + })? + } else { + Docx::new() + }; + + // Split content into paragraphs and add them + for para in content.split('\n') { + if !para.trim().is_empty() { + let mut run = Run::new().add_text(para); + let mut paragraph = Paragraph::new(); + + if let Some(style) = &style { + run = style.apply_to_run(run); + paragraph = style.apply_to_paragraph(paragraph); + } + + doc = doc.add_paragraph(paragraph.add_run(run)); + } + } + + let mut buf = Vec::new(); + { + let mut cursor = Cursor::new(&mut buf); + doc.build().pack(&mut cursor).map_err(|e| { + ToolError::ExecutionError(format!("Failed to build DOCX: {}", e)) + })?; + } + + fs::write(path, &buf).map_err(|e| { + ToolError::ExecutionError(format!("Failed to write DOCX file: {}", e)) + })?; + + Ok(vec![Content::text(format!( + "Successfully wrote content to {}", + path + ))]) + } + + UpdateMode::Replace { old_text } => { + // Read existing document + let file = fs::read(path).map_err(|e| { + ToolError::ExecutionError(format!("Failed to read DOCX file: {}", e)) + })?; + + let docx = read_docx(&file).map_err(|e| { + ToolError::ExecutionError(format!("Failed to parse DOCX file: {}", e)) + })?; + + let mut new_doc = Docx::new(); + let mut found_text = false; + + // Process each paragraph + for element in docx.document.children.iter() { + if let DocumentChild::Paragraph(p) = element { + let para_text: String = p + .children + .iter() + .filter_map(|child| { + if let ParagraphChild::Run(run) = child { + Some( + run.children + .iter() + .filter_map(|rc| { + if let RunChild::Text(t) = rc { + Some(t.text.clone()) + } else { + None + } + }) + .collect::>() + .join(""), + ) + } else { + None + } + }) + .collect::>() + .join(""); + + if para_text.contains(&old_text) { + // Replace this paragraph with new content + found_text = true; + for para in content.split('\n') { + if !para.trim().is_empty() { + let mut run = Run::new().add_text(para); + let mut paragraph = Paragraph::new(); + + if let Some(style) = &style { + run = style.apply_to_run(run); + paragraph = style.apply_to_paragraph(paragraph); + } + + new_doc = new_doc.add_paragraph(paragraph.add_run(run)); + } + } + } else { + // Create a new paragraph with the same content and style + let mut para = Paragraph::new(); + if let Some(style) = &p.property.style { + para = para.style(&style.val); + } + for child in p.children.iter() { + if let ParagraphChild::Run(run) = child { + for rc in run.children.iter() { + if let RunChild::Text(t) = rc { + para = para.add_run(Run::new().add_text(&t.text)); + } + } + } + } + new_doc = new_doc.add_paragraph(para); + } + } + } + + if !found_text { + return Err(ToolError::ExecutionError(format!( + "Could not find text to replace: {}", + old_text + ))); + } + + let mut buf = Vec::new(); + { + let mut cursor = Cursor::new(&mut buf); + new_doc.build().pack(&mut cursor).map_err(|e| { + ToolError::ExecutionError(format!("Failed to build DOCX: {}", e)) + })?; + } + + fs::write(path, &buf).map_err(|e| { + ToolError::ExecutionError(format!("Failed to write DOCX file: {}", e)) + })?; + + Ok(vec![Content::text(format!( + "Successfully replaced content in {}", + path + ))]) + } + + UpdateMode::InsertStructured { level, style } => { + let mut doc = if std::path::Path::new(path).exists() { + let file = fs::read(path).map_err(|e| { + ToolError::ExecutionError(format!("Failed to read DOCX file: {}", e)) + })?; + read_docx(&file).map_err(|e| { + ToolError::ExecutionError(format!("Failed to parse DOCX file: {}", e)) + })? + } else { + Docx::new() + }; + + // Create the paragraph with heading style if specified + for para in content.split('\n') { + if !para.trim().is_empty() { + let mut run = Run::new().add_text(para); + let mut paragraph = Paragraph::new(); + + // Apply heading style if specified + if let Some(level) = &level { + paragraph = paragraph.style(level); + } + + // Apply custom style if specified + if let Some(style) = &style { + run = style.apply_to_run(run); + paragraph = style.apply_to_paragraph(paragraph); + } + + doc = doc.add_paragraph(paragraph.add_run(run)); + } + } + + let mut buf = Vec::new(); + { + let mut cursor = Cursor::new(&mut buf); + doc.build().pack(&mut cursor).map_err(|e| { + ToolError::ExecutionError(format!("Failed to build DOCX: {}", e)) + })?; + } + + fs::write(path, &buf).map_err(|e| { + ToolError::ExecutionError(format!("Failed to write DOCX file: {}", e)) + })?; + + Ok(vec![Content::text(format!( + "Successfully added structured content to {}", + path + ))]) + } + + UpdateMode::AddImage { + image_path, + width, + height, + } => { + let mut doc = if std::path::Path::new(path).exists() { + let file = fs::read(path).map_err(|e| { + ToolError::ExecutionError(format!("Failed to read DOCX file: {}", e)) + })?; + read_docx(&file).map_err(|e| { + ToolError::ExecutionError(format!("Failed to parse DOCX file: {}", e)) + })? + } else { + Docx::new() + }; + + // Read the image file + let image_data = fs::read(&image_path).map_err(|e| { + ToolError::ExecutionError(format!("Failed to read image file: {}", e)) + })?; + + // Get image format and extension + let extension = std::path::Path::new(&image_path) + .extension() + .and_then(|e| e.to_str()) + .ok_or_else(|| { + ToolError::ExecutionError("Invalid image file extension".to_string()) + })? + .to_lowercase(); + + // Convert to PNG if not already PNG + let image_data = if extension != "png" { + // Try to convert to PNG using the image crate + let img = image::load_from_memory(&image_data).map_err(|e| { + ToolError::ExecutionError(format!("Failed to load image: {}", e)) + })?; + let mut png_data = Vec::new(); + img.write_to(&mut Cursor::new(&mut png_data), ImageFormat::Png) + .map_err(|e| { + ToolError::ExecutionError(format!( + "Failed to convert image to PNG: {}", + e + )) + })?; + png_data + } else { + image_data + }; + + // Add optional caption if provided + if !content.trim().is_empty() { + let mut caption = Paragraph::new(); + if let Some(style) = &style { + caption = style.apply_to_paragraph(caption); + caption = + caption.add_run(style.apply_to_run(Run::new().add_text(content))); + } else { + caption = caption.add_run(Run::new().add_text(content)); + } + doc = doc.add_paragraph(caption); + } + + // Create a paragraph with the image + let mut paragraph = Paragraph::new(); + if let Some(style) = &style { + paragraph = style.apply_to_paragraph(paragraph); + } + + // Create and add the image + let mut pic = Pic::new(&image_data); + if let (Some(w), Some(h)) = (width, height) { + pic = pic.size(w, h); + } + + paragraph = paragraph.add_run(Run::new().add_image(pic)); + doc = doc.add_paragraph(paragraph); + + let mut buf = Vec::new(); + { + let mut cursor = Cursor::new(&mut buf); + doc.build().pack(&mut cursor).map_err(|e| { + ToolError::ExecutionError(format!("Failed to build DOCX: {}", e)) + })?; + } + + fs::write(path, &buf).map_err(|e| { + ToolError::ExecutionError(format!("Failed to write DOCX file: {}", e)) + })?; + + Ok(vec![Content::text(format!( + "Successfully added image to {}", + path + ))]) + } + } + } + + _ => Err(ToolError::InvalidParameters(format!( + "Invalid operation: {}. Valid operations are: 'extract_text', 'update_doc'", + operation + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::PathBuf; + + #[tokio::test] + async fn test_docx_text_extraction() { + let test_docx_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src/computercontroller/tests/data/sample.docx"); + + println!("Testing text extraction from: {}", test_docx_path.display()); + + let result = docx_tool(test_docx_path.to_str().unwrap(), "extract_text", None, None).await; + + assert!(result.is_ok(), "DOCX text extraction should succeed"); + let content = result.unwrap(); + assert!(!content.is_empty(), "Extracted text should not be empty"); + let text = content[0].as_text().unwrap(); + println!("Extracted text:\n{}", text); + assert!( + !text.trim().is_empty(), + "Extracted text should not be empty" + ); + } + + #[tokio::test] + async fn test_docx_update_append() { + let test_output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src/computercontroller/tests/data/test_output.docx"); + + let test_content = + "Test Heading\nThis is a test paragraph.\n\nAnother paragraph with some content."; + + let result = docx_tool( + test_output_path.to_str().unwrap(), + "update_doc", + Some(test_content), + None, + ) + .await; + + assert!(result.is_ok(), "DOCX update should succeed"); + assert!(test_output_path.exists(), "Output file should exist"); + + // Now try to read it back + let result = docx_tool( + test_output_path.to_str().unwrap(), + "extract_text", + None, + None, + ) + .await; + assert!( + result.is_ok(), + "Should be able to read back the written file" + ); + let content = result.unwrap(); + let text = content[0].as_text().unwrap(); + assert!( + text.contains("Test Heading"), + "Should contain written content" + ); + assert!( + text.contains("test paragraph"), + "Should contain written content" + ); + + // Clean up + fs::remove_file(test_output_path).unwrap(); + } + + #[tokio::test] + async fn test_docx_update_styled() { + let test_output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src/computercontroller/tests/data/test_styled.docx"); + + let test_content = "Styled Heading\nThis is a styled paragraph."; + let params = json!({ + "mode": "structured", + "level": "Heading1", + "style": { + "bold": true, + "color": "FF0000", + "size": 24, + "alignment": "center" + } + }); + + let result = docx_tool( + test_output_path.to_str().unwrap(), + "update_doc", + Some(test_content), + Some(¶ms), + ) + .await; + + assert!(result.is_ok(), "DOCX styled update should succeed"); + assert!(test_output_path.exists(), "Output file should exist"); + + // Clean up + fs::remove_file(test_output_path).unwrap(); + } + + #[tokio::test] + async fn test_docx_update_replace() { + let test_output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src/computercontroller/tests/data/test_replace.docx"); + + // First create a document + let initial_content = "Original content\nThis should be replaced.\nKeep this text."; + let _ = docx_tool( + test_output_path.to_str().unwrap(), + "update_doc", + Some(initial_content), + None, + ) + .await; + + // Now replace part of it + let replacement = "New content here"; + let params = json!({ + "mode": "replace", + "old_text": "This should be replaced", + "style": { + "italic": true + } + }); + + let result = docx_tool( + test_output_path.to_str().unwrap(), + "update_doc", + Some(replacement), + Some(¶ms), + ) + .await; + + assert!(result.is_ok(), "DOCX replace should succeed"); + + // Verify the content + let result = docx_tool( + test_output_path.to_str().unwrap(), + "extract_text", + None, + None, + ) + .await; + assert!(result.is_ok()); + let content = result.unwrap(); + let text = content[0].as_text().unwrap(); + assert!( + text.contains("New content here"), + "Should contain new content" + ); + assert!( + text.contains("Keep this text"), + "Should keep unmodified content" + ); + assert!( + !text.contains("This should be replaced"), + "Should not contain replaced text" + ); + + // Clean up + fs::remove_file(test_output_path).unwrap(); + } + + #[tokio::test] + async fn test_docx_add_image() { + let test_output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src/computercontroller/tests/data/test_image.docx"); + + // Create a test image file + let test_image_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src/computercontroller/tests/data/test_image.png"); + + // Create a simple test PNG image using the image crate + let imgbuf = image::ImageBuffer::from_fn(32, 32, |x, y| { + let dx = x as f32 - 16.0; + let dy = y as f32 - 16.0; + if dx * dx + dy * dy < 16.0 * 16.0 { + image::Rgb([0u8, 0u8, 255u8]) // Blue circle + } else { + image::Rgb([255u8, 255u8, 255u8]) // White background + } + }); + imgbuf + .save(&test_image_path) + .expect("Failed to create test image"); + + let params = json!({ + "mode": "add_image", + "image_path": test_image_path.to_str().unwrap(), + "width": 100, + "height": 100, + "style": { + "alignment": "center" + } + }); + + let result = docx_tool( + test_output_path.to_str().unwrap(), + "update_doc", + Some("Image Caption"), + Some(¶ms), + ) + .await; + + assert!(result.is_ok(), "DOCX image addition should succeed"); + assert!(test_output_path.exists(), "Output file should exist"); + + // Clean up + fs::remove_file(test_output_path).unwrap(); + fs::remove_file(test_image_path).unwrap(); + } + + #[tokio::test] + async fn test_docx_invalid_path() { + let result = docx_tool("nonexistent.docx", "extract_text", None, None).await; + assert!(result.is_err(), "Should fail with invalid path"); + } + + #[tokio::test] + async fn test_docx_invalid_operation() { + let test_docx_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src/computercontroller/tests/data/sample.docx"); + + let result = docx_tool( + test_docx_path.to_str().unwrap(), + "invalid_operation", + None, + None, + ) + .await; + + assert!(result.is_err(), "Should fail with invalid operation"); + } + + #[tokio::test] + async fn test_docx_update_without_content() { + let test_output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src/computercontroller/tests/data/test_output.docx"); + + let result = docx_tool(test_output_path.to_str().unwrap(), "update_doc", None, None).await; + + assert!(result.is_err(), "Should fail without content"); + } + + #[tokio::test] + async fn test_docx_update_preserve_content() { + let test_output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src/computercontroller/tests/data/test_preserve.docx"); + + // First create a document with initial content + let initial_content = + "Initial content\nThis is the first paragraph.\nThis should stay in the document."; + let result = docx_tool( + test_output_path.to_str().unwrap(), + "update_doc", + Some(initial_content), + None, + ) + .await; + assert!(result.is_ok(), "Initial document creation should succeed"); + + // Now append new content + let new_content = "New content\nThis is an additional paragraph."; + let params = json!({ + "mode": "append", + "style": { + "bold": true + } + }); + + let result = docx_tool( + test_output_path.to_str().unwrap(), + "update_doc", + Some(new_content), + Some(¶ms), + ) + .await; + assert!(result.is_ok(), "Content append should succeed"); + + // Verify both old and new content exists + let result = docx_tool( + test_output_path.to_str().unwrap(), + "extract_text", + None, + None, + ) + .await; + assert!(result.is_ok()); + let content = result.unwrap(); + let text = content[0].as_text().unwrap(); + + // Check for initial content + assert!( + text.contains("Initial content"), + "Should contain initial content" + ); + assert!( + text.contains("first paragraph"), + "Should contain first paragraph" + ); + assert!( + text.contains("should stay in the document"), + "Should preserve existing content" + ); + + // Check for new content + assert!(text.contains("New content"), "Should contain new content"); + assert!( + text.contains("additional paragraph"), + "Should contain appended paragraph" + ); + + // Clean up + fs::remove_file(test_output_path).unwrap(); + } +} diff --git a/crates/goose-mcp/src/computercontroller/mod.rs b/crates/goose-mcp/src/computercontroller/mod.rs index 6a1688df..692605cb 100644 --- a/crates/goose-mcp/src/computercontroller/mod.rs +++ b/crates/goose-mcp/src/computercontroller/mod.rs @@ -19,7 +19,10 @@ use mcp_core::{ use mcp_server::router::CapabilitiesBuilder; use mcp_server::Router; +mod docx_tool; mod pdf_tool; +mod presentation_tool; +mod xlsx_tool; mod platform; use platform::{create_system_automation, SystemAutomation}; @@ -261,6 +264,205 @@ impl ComputerControllerRouter { }), ); + let docx_tool = Tool::new( + "docx_tool", + indoc! {r#" + Process DOCX files to extract text and create/update documents. + Supports operations: + - extract_text: Extract all text content and structure (headings, TOC) from the DOCX + - update_doc: Create a new DOCX or update existing one with provided content + Modes: + - append: Add content to end of document (default) + - replace: Replace specific text with new content + - structured: Add content with specific heading level and styling + - add_image: Add an image to the document (with optional caption) + + Use this when there is a .docx file that needs to be processed or created. + "#}, + json!({ + "type": "object", + "required": ["path", "operation"], + "properties": { + "path": { + "type": "string", + "description": "Path to the DOCX file" + }, + "operation": { + "type": "string", + "enum": ["extract_text", "update_doc"], + "description": "Operation to perform on the DOCX" + }, + "content": { + "type": "string", + "description": "Content to write (required for update_doc operation)" + }, + "params": { + "type": "object", + "description": "Additional parameters for update_doc operation", + "properties": { + "mode": { + "type": "string", + "enum": ["append", "replace", "structured", "add_image"], + "description": "Update mode (default: append)" + }, + "old_text": { + "type": "string", + "description": "Text to replace (required for replace mode)" + }, + "level": { + "type": "string", + "description": "Heading level for structured mode (e.g., 'Heading1', 'Heading2')" + }, + "image_path": { + "type": "string", + "description": "Path to the image file (required for add_image mode)" + }, + "width": { + "type": "integer", + "description": "Image width in pixels (optional)" + }, + "height": { + "type": "integer", + "description": "Image height in pixels (optional)" + }, + "style": { + "type": "object", + "description": "Styling options for the text", + "properties": { + "bold": { + "type": "boolean", + "description": "Make text bold" + }, + "italic": { + "type": "boolean", + "description": "Make text italic" + }, + "underline": { + "type": "boolean", + "description": "Make text underlined" + }, + "size": { + "type": "integer", + "description": "Font size in points" + }, + "color": { + "type": "string", + "description": "Text color in hex format (e.g., 'FF0000' for red)" + }, + "alignment": { + "type": "string", + "enum": ["left", "center", "right", "justified"], + "description": "Text alignment" + } + } + } + } + } + } + }), + ); + + let make_presentation_tool = Tool::new( + "make_presentation", + indoc! {r#" + Create and manage HTML presentations with a simple, modern design. + Operations: + - create: Create new presentation with template + - add_slide: Add a new slide with content + + Open in a browser (using a command) to show the user: open + + For advanced edits, use developer tools to modify the HTML directly. + A template slide is included in comments for reference. + "#}, + json!({ + "type": "object", + "required": ["path", "operation"], + "properties": { + "path": { + "type": "string", + "description": "Path to the presentation file" + }, + "operation": { + "type": "string", + "enum": ["create", "add_slide"], + "description": "Operation to perform" + }, + "params": { + "type": "object", + "description": "Parameters for add_slide operation", + "properties": { + "content": { + "type": "string", + "description": "Content for the new slide" + } + } + } + } + }), + ); + + let xlsx_tool = Tool::new( + "xlsx_tool", + indoc! {r#" + Process Excel (XLSX) files to read and manipulate spreadsheet data. + Supports operations: + - list_worksheets: List all worksheets in the workbook (returns name, index, column_count, row_count) + - get_columns: Get column names from a worksheet (returns values from the first row) + - get_range: Get values and formulas from a cell range (e.g., "A1:C10") (returns a 2D array organized as [row][column]) + - find_text: Search for text in a worksheet (returns a list of (row, column) coordinates) + - update_cell: Update a single cell's value (returns confirmation message) + - get_cell: Get value and formula from a specific cell (returns both value and formula if present) + - save: Save changes back to the file (returns confirmation message) + + Use this when working with Excel spreadsheets to analyze or modify data. + "#}, + json!({ + "type": "object", + "required": ["path", "operation"], + "properties": { + "path": { + "type": "string", + "description": "Path to the XLSX file" + }, + "operation": { + "type": "string", + "enum": ["list_worksheets", "get_columns", "get_range", "find_text", "update_cell", "get_cell", "save"], + "description": "Operation to perform on the XLSX file" + }, + "worksheet": { + "type": "string", + "description": "Worksheet name (if not provided, uses first worksheet)" + }, + "range": { + "type": "string", + "description": "Cell range in A1 notation (e.g., 'A1:C10') for get_range operation" + }, + "search_text": { + "type": "string", + "description": "Text to search for in find_text operation" + }, + "case_sensitive": { + "type": "boolean", + "default": false, + "description": "Whether search should be case-sensitive" + }, + "row": { + "type": "integer", + "description": "Row number for update_cell and get_cell operations" + }, + "col": { + "type": "integer", + "description": "Column number for update_cell and get_cell operations" + }, + "value": { + "type": "string", + "description": "New value for update_cell operation" + } + } + }), + ); + // choose_app_strategy().cache_dir() // - macOS/Linux: ~/.cache/goose/computer_controller/ // - Windows: ~\AppData\Local\Block\goose\cache\computer_controller\ @@ -389,6 +591,9 @@ impl ComputerControllerRouter { computer_control_tool, cache_tool, pdf_tool, + docx_tool, + xlsx_tool, + make_presentation_tool, ], cache_dir, active_resources: Arc::new(Mutex::new(HashMap::new())), @@ -682,7 +887,187 @@ impl ComputerControllerRouter { Ok(vec![Content::text(result)]) } + async fn xlsx_tool(&self, params: Value) -> Result, ToolError> { + let path = params + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("Missing 'path' parameter".into()))?; + + let operation = params + .get("operation") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("Missing 'operation' parameter".into()))?; + + match operation { + "list_worksheets" => { + let xlsx = xlsx_tool::XlsxTool::new(path) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + let worksheets = xlsx + .list_worksheets() + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + Ok(vec![Content::text(format!("{:#?}", worksheets))]) + } + "get_columns" => { + let xlsx = xlsx_tool::XlsxTool::new(path) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + let worksheet = if let Some(name) = params.get("worksheet").and_then(|v| v.as_str()) + { + xlsx.get_worksheet_by_name(name) + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + } else { + xlsx.get_worksheet_by_index(0) + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + }; + let columns = xlsx + .get_column_names(worksheet) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + Ok(vec![Content::text(format!("{:#?}", columns))]) + } + "get_range" => { + let range = params + .get("range") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("Missing 'range' parameter".into()) + })?; + + let xlsx = xlsx_tool::XlsxTool::new(path) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + let worksheet = if let Some(name) = params.get("worksheet").and_then(|v| v.as_str()) + { + xlsx.get_worksheet_by_name(name) + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + } else { + xlsx.get_worksheet_by_index(0) + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + }; + let range_data = xlsx + .get_range(worksheet, range) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + Ok(vec![Content::text(format!("{:#?}", range_data))]) + } + "find_text" => { + let search_text = params + .get("search_text") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("Missing 'search_text' parameter".into()) + })?; + + let case_sensitive = params + .get("case_sensitive") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let xlsx = xlsx_tool::XlsxTool::new(path) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + let worksheet = if let Some(name) = params.get("worksheet").and_then(|v| v.as_str()) + { + xlsx.get_worksheet_by_name(name) + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + } else { + xlsx.get_worksheet_by_index(0) + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + }; + let matches = xlsx + .find_in_worksheet(worksheet, search_text, case_sensitive) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + Ok(vec![Content::text(format!( + "Found matches at: {:#?}", + matches + ))]) + } + "update_cell" => { + let row = params.get("row").and_then(|v| v.as_u64()).ok_or_else(|| { + ToolError::InvalidParameters("Missing 'row' parameter".into()) + })?; + + let col = params.get("col").and_then(|v| v.as_u64()).ok_or_else(|| { + ToolError::InvalidParameters("Missing 'col' parameter".into()) + })?; + + let value = params + .get("value") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("Missing 'value' parameter".into()) + })?; + + let worksheet_name = params + .get("worksheet") + .and_then(|v| v.as_str()) + .unwrap_or("Sheet1"); + + let mut xlsx = xlsx_tool::XlsxTool::new(path) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + xlsx.update_cell(worksheet_name, row as u32, col as u32, value) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + xlsx.save(path) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + Ok(vec![Content::text(format!( + "Updated cell ({}, {}) to '{}' in worksheet '{}'", + row, col, value, worksheet_name + ))]) + } + "save" => { + let xlsx = xlsx_tool::XlsxTool::new(path) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + xlsx.save(path) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + Ok(vec![Content::text("File saved successfully.")]) + } + "get_cell" => { + let row = params.get("row").and_then(|v| v.as_u64()).ok_or_else(|| { + ToolError::InvalidParameters("Missing 'row' parameter".into()) + })?; + + let col = params.get("col").and_then(|v| v.as_u64()).ok_or_else(|| { + ToolError::InvalidParameters("Missing 'col' parameter".into()) + })?; + + let xlsx = xlsx_tool::XlsxTool::new(path) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + let worksheet = if let Some(name) = params.get("worksheet").and_then(|v| v.as_str()) + { + xlsx.get_worksheet_by_name(name) + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + } else { + xlsx.get_worksheet_by_index(0) + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + }; + let cell_value = xlsx + .get_cell_value(worksheet, row as u32, col as u32) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + Ok(vec![Content::text(format!("{:#?}", cell_value))]) + } + _ => Err(ToolError::InvalidParameters(format!( + "Invalid operation: {}", + operation + ))), + } + } + // Implement cache tool functionality + async fn docx_tool(&self, params: Value) -> Result, ToolError> { + let path = params + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("Missing 'path' parameter".into()))?; + + let operation = params + .get("operation") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("Missing 'operation' parameter".into()))?; + + crate::computercontroller::docx_tool::docx_tool( + path, + operation, + params.get("content").and_then(|v| v.as_str()), + params.get("params"), + ) + .await + } + async fn pdf_tool(&self, params: Value) -> Result, ToolError> { let path = params .get("path") @@ -809,6 +1194,26 @@ impl Router for ComputerControllerRouter { "computer_control" => this.computer_control(arguments).await, "cache" => this.cache(arguments).await, "pdf_tool" => this.pdf_tool(arguments).await, + "docx_tool" => this.docx_tool(arguments).await, + "xlsx_tool" => this.xlsx_tool(arguments).await, + "make_presentation" => { + let path = arguments + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("Missing 'path' parameter".into()) + })?; + + let operation = arguments + .get("operation") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("Missing 'operation' parameter".into()) + })?; + + presentation_tool::make_presentation(path, operation, arguments.get("params")) + .await + } _ => Err(ToolError::NotFound(format!("Tool {} not found", tool_name))), } }) diff --git a/crates/goose-mcp/src/computercontroller/presentation_tool.rs b/crates/goose-mcp/src/computercontroller/presentation_tool.rs new file mode 100644 index 00000000..26e5e550 --- /dev/null +++ b/crates/goose-mcp/src/computercontroller/presentation_tool.rs @@ -0,0 +1,398 @@ +use mcp_core::{Content, ToolError}; +use serde_json::Value; +use std::fs; + +const TEMPLATE: &str = r#" + + HTML and CSS Slideshow + + + + +
+
+ +
+

Your Presentation

+

Use arrow keys to navigate

+
+ + + + +
+
+ + + + + +"#; + +pub async fn make_presentation( + path: &str, + operation: &str, + params: Option<&Value>, +) -> Result, ToolError> { + match operation { + "create" => { + // Get title from params or use default + let title = params + .and_then(|p| p.get("title")) + .and_then(|v| v.as_str()) + .unwrap_or("Your Presentation"); + + // Replace title in template + let content = TEMPLATE.replace("Your Presentation", title); + + // Create a new presentation with the template + fs::write(path, content).map_err(|e| { + ToolError::ExecutionError(format!("Failed to create presentation file: {}", e)) + })?; + + Ok(vec![Content::text(format!( + "Created new presentation with title '{}' at: {}\nYou can open it with the command: `open {}` to show user. You should look at the html and consider if you want to ask user if they need to adjust it, colours, typeface and so on.", + title, path, path + ))]) + } + "add_slide" => { + let content = params + .and_then(|p| p.get("content")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("Missing 'content' parameter for slide".into()) + })?; + + // Read existing file + let mut html = fs::read_to_string(path).map_err(|e| { + ToolError::ExecutionError(format!("Failed to read presentation file: {}", e)) + })?; + + // Find the marker comment + let marker = "