1669 lines
64 KiB
Rust
1669 lines
64 KiB
Rust
#[cfg(not(windows))]
|
|
use crate::subprocess::merged_path;
|
|
use crate::subprocess::SubprocessExt;
|
|
#[cfg(target_os = "macos")]
|
|
use base64::Engine;
|
|
use etcetera::{choose_app_strategy, AppStrategy};
|
|
use indoc::{formatdoc, indoc};
|
|
use reqwest::{Client, Url};
|
|
use rmcp::{
|
|
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
|
model::{
|
|
AnnotateAble, CallToolResult, Content, ErrorCode, ErrorData, Implementation,
|
|
InitializeResult, ListResourcesResult, PaginatedRequestParams, RawResource,
|
|
ReadResourceRequestParams, ReadResourceResult, Resource, ResourceContents,
|
|
ServerCapabilities, ServerInfo,
|
|
},
|
|
schemars::JsonSchema,
|
|
service::RequestContext,
|
|
tool, tool_handler, tool_router, RoleServer, ServerHandler,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{collections::HashMap, fs, path::PathBuf, sync::Arc, sync::Mutex};
|
|
use tokio::process::Command;
|
|
|
|
#[cfg(target_os = "macos")]
|
|
use rmcp::model::Role;
|
|
#[cfg(target_os = "macos")]
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
mod docx_tool;
|
|
mod pdf_tool;
|
|
mod xlsx_tool;
|
|
|
|
mod platform;
|
|
use platform::{create_system_automation, SystemAutomation};
|
|
|
|
/// Enum for save_as parameter in web_scrape tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, Default)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum SaveAsFormat {
|
|
/// Save as text (for HTML pages)
|
|
#[default]
|
|
Text,
|
|
/// Save as JSON (for API responses)
|
|
Json,
|
|
/// Save as binary (for images and other files)
|
|
Binary,
|
|
}
|
|
|
|
/// Parameters for the web_scrape tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct WebScrapeParams {
|
|
/// The URL to fetch content from
|
|
pub url: String,
|
|
/// Format of the response.
|
|
#[serde(default)]
|
|
pub save_as: SaveAsFormat,
|
|
}
|
|
|
|
/// Enum for language parameter in automation_script tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum ScriptLanguage {
|
|
/// Shell/Bash script
|
|
Shell,
|
|
/// Batch script (Windows)
|
|
Batch,
|
|
/// Ruby script
|
|
Ruby,
|
|
/// PowerShell script
|
|
Powershell,
|
|
}
|
|
|
|
/// Enum for command parameter in cache tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum CacheCommand {
|
|
/// List all cached files
|
|
List,
|
|
/// View content of a cached file
|
|
View,
|
|
/// Delete a cached file
|
|
Delete,
|
|
/// Clear all cached files
|
|
Clear,
|
|
}
|
|
|
|
/// Parameters for the automation_script tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct AutomationScriptParams {
|
|
/// The scripting language to use
|
|
#[serde(rename = "language")]
|
|
pub language: ScriptLanguage,
|
|
/// The script content
|
|
pub script: String,
|
|
/// Whether to save the script output to a file
|
|
#[serde(default)]
|
|
pub save_output: bool,
|
|
}
|
|
|
|
/// Parameters for the computer_control tool (Windows, Linux)
|
|
#[cfg(not(target_os = "macos"))]
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct ComputerControlParams {
|
|
/// The automation script content (PowerShell for Windows, shell for Linux)
|
|
pub script: String,
|
|
/// Whether to save the script output to a file
|
|
#[serde(default)]
|
|
pub save_output: bool,
|
|
}
|
|
|
|
/// Parameters for the computer_control tool (macOS — Peekaboo CLI passthrough)
|
|
#[cfg(target_os = "macos")]
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct ComputerControlParams {
|
|
/// The peekaboo subcommand and arguments as a single string.
|
|
/// Examples:
|
|
/// "see --app Safari --annotate"
|
|
/// "click --on B1"
|
|
/// "type --text \"hello\" --return"
|
|
/// "hotkey --keys cmd,c"
|
|
/// "app launch Safari --open https://example.com"
|
|
/// "window list --app Safari --json"
|
|
/// "press tab --count 3"
|
|
/// "clipboard --action get"
|
|
pub command: String,
|
|
/// Whether to capture and return a screenshot as part of the result.
|
|
/// Useful after click/type actions to see the updated UI state.
|
|
#[serde(default)]
|
|
pub capture_screenshot: bool,
|
|
}
|
|
|
|
/// Parameters for the cache tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct CacheParams {
|
|
/// The command to perform
|
|
pub command: CacheCommand,
|
|
/// Path to the cached file for view/delete commands
|
|
pub path: Option<String>,
|
|
}
|
|
|
|
/// Parameters for the pdf_tool
|
|
/// Enum for operation parameter in pdf_tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum PdfOperation {
|
|
/// Extract all text content from the PDF
|
|
ExtractText,
|
|
/// Extract and save embedded images to PNG files
|
|
ExtractImages,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct PdfToolParams {
|
|
/// Path to the PDF file
|
|
pub path: String,
|
|
/// Operation to perform on the PDF
|
|
pub operation: PdfOperation,
|
|
}
|
|
|
|
/// Enum for operation parameter in docx_tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DocxOperation {
|
|
/// Extract all text content and structure from the DOCX
|
|
ExtractText,
|
|
/// Create a new DOCX or update existing one with provided content
|
|
UpdateDoc,
|
|
}
|
|
|
|
/// Enum for update mode in docx_tool params
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DocxUpdateMode {
|
|
/// Add content to end of document (default)
|
|
#[default]
|
|
Append,
|
|
/// Replace specific text with new content
|
|
Replace,
|
|
/// Add content with specific heading level and styling
|
|
Structured,
|
|
/// Add an image to the document (with optional caption)
|
|
AddImage,
|
|
}
|
|
|
|
/// Enum for text alignment in docx_tool params
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum TextAlignment {
|
|
/// Left alignment
|
|
Left,
|
|
/// Center alignment
|
|
Center,
|
|
/// Right alignment
|
|
Right,
|
|
/// Justified alignment
|
|
Justified,
|
|
}
|
|
|
|
/// Styling options for text in docx_tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, Default)]
|
|
pub struct DocxTextStyle {
|
|
/// Make text bold
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub bold: Option<bool>,
|
|
/// Make text italic
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub italic: Option<bool>,
|
|
/// Make text underlined
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub underline: Option<bool>,
|
|
/// Font size in points
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub size: Option<u32>,
|
|
/// Text color in hex format (e.g., 'FF0000' for red)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub color: Option<String>,
|
|
/// Text alignment
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub alignment: Option<TextAlignment>,
|
|
}
|
|
|
|
/// Additional parameters for update_doc operation
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, Default)]
|
|
pub struct DocxUpdateParams {
|
|
/// Update mode (default: append)
|
|
#[serde(default)]
|
|
pub mode: DocxUpdateMode,
|
|
/// Text to replace (required for replace mode)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub old_text: Option<String>,
|
|
/// Heading level for structured mode (e.g., 'Heading1', 'Heading2')
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub level: Option<String>,
|
|
/// Path to the image file (required for add_image mode)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub image_path: Option<String>,
|
|
/// Image width in pixels (optional)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub width: Option<u32>,
|
|
/// Image height in pixels (optional)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub height: Option<u32>,
|
|
/// Styling options for the text
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub style: Option<DocxTextStyle>,
|
|
}
|
|
|
|
/// Parameters for the docx_tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct DocxToolParams {
|
|
/// Path to the DOCX file
|
|
pub path: String,
|
|
/// Operation to perform on the DOCX
|
|
pub operation: DocxOperation,
|
|
/// Content to write (required for update_doc operation)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub content: Option<String>,
|
|
/// Additional parameters for update_doc operation
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub params: Option<DocxUpdateParams>,
|
|
}
|
|
|
|
/// Parameters for the xlsx_tool
|
|
/// Enum for operation parameter in xlsx_tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum XlsxOperation {
|
|
/// List all worksheets in the workbook
|
|
ListWorksheets,
|
|
/// Get column names from a worksheet
|
|
GetColumns,
|
|
/// Get values and formulas from a cell range
|
|
GetRange,
|
|
/// Search for text in a worksheet
|
|
FindText,
|
|
/// Update a single cell's value
|
|
UpdateCell,
|
|
/// Get value and formula from a specific cell
|
|
GetCell,
|
|
/// Save changes back to the file
|
|
Save,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct XlsxToolParams {
|
|
/// Path to the XLSX file
|
|
pub path: String,
|
|
/// Operation to perform on the XLSX file
|
|
pub operation: XlsxOperation,
|
|
/// Worksheet name (if not provided, uses first worksheet)
|
|
pub worksheet: Option<String>,
|
|
/// Cell range in A1 notation (e.g., 'A1:C10') for get_range operation
|
|
pub range: Option<String>,
|
|
/// Text to search for in find_text operation
|
|
pub search_text: Option<String>,
|
|
/// Whether search should be case-sensitive
|
|
#[serde(default)]
|
|
pub case_sensitive: bool,
|
|
/// Row number for update_cell and get_cell operations
|
|
pub row: Option<u64>,
|
|
/// Column number for update_cell and get_cell operations
|
|
pub col: Option<u64>,
|
|
/// New value for update_cell operation
|
|
pub value: Option<String>,
|
|
}
|
|
|
|
/// ComputerController MCP Server using official RMCP SDK
|
|
#[derive(Clone)]
|
|
pub struct ComputerControllerServer {
|
|
tool_router: ToolRouter<Self>,
|
|
cache_dir: PathBuf,
|
|
active_resources: Arc<Mutex<HashMap<String, ResourceContents>>>,
|
|
http_client: Client,
|
|
instructions: String,
|
|
system_automation: Arc<Box<dyn SystemAutomation + Send + Sync>>,
|
|
#[cfg(target_os = "macos")]
|
|
peekaboo_installed: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl Default for ComputerControllerServer {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[tool_router(router = tool_router)]
|
|
impl ComputerControllerServer {
|
|
pub fn new() -> Self {
|
|
// choose_app_strategy().cache_dir()
|
|
// - macOS/Linux: ~/.cache/goose/computer_controller/
|
|
// - Windows: ~\AppData\Local\Block\goose\cache\computer_controller\
|
|
// keep previous behavior of defaulting to /tmp/
|
|
let cache_dir = choose_app_strategy(crate::APP_STRATEGY.clone())
|
|
.map(|strategy| strategy.in_cache_dir("computer_controller"))
|
|
.unwrap_or_else(|_| create_system_automation().get_temp_path());
|
|
|
|
fs::create_dir_all(&cache_dir).unwrap_or_else(|_| {
|
|
println!(
|
|
"Warning: Failed to create cache directory at {:?}",
|
|
cache_dir
|
|
)
|
|
});
|
|
|
|
let system_automation: Arc<Box<dyn SystemAutomation + Send + Sync>> =
|
|
Arc::new(create_system_automation());
|
|
|
|
let has_display = system_automation.has_display();
|
|
|
|
let os_specific_instructions = match (std::env::consts::OS, has_display) {
|
|
("windows", _) => indoc! {r#"
|
|
Here are some extra tools:
|
|
automation_script
|
|
- Create and run PowerShell or Batch scripts
|
|
- PowerShell is recommended for most tasks
|
|
- Scripts can save their output to files
|
|
- Windows-specific features:
|
|
- PowerShell for system automation and UI control
|
|
- Windows Management Instrumentation (WMI)
|
|
- Registry access and system settings
|
|
- Use the screenshot tool if needed to help with tasks
|
|
|
|
computer_control
|
|
- System automation using PowerShell
|
|
- Consider the screenshot tool to work out what is on screen and what to do to help with the control task.
|
|
"#},
|
|
("macos", _) => indoc! {r#"
|
|
Here are some extra tools:
|
|
automation_script
|
|
- Create and run Shell, Ruby, or AppleScript scripts
|
|
- Scripts can save their output to files
|
|
|
|
computer_control — Peekaboo CLI for macOS UI automation (auto-installed via Homebrew).
|
|
Peekaboo captures/inspects screens, targets UI elements, drives input, and manages
|
|
apps/windows/menus. Pass a peekaboo subcommand string as the `command` parameter.
|
|
Set `capture_screenshot: true` to capture the screen after actions (click, type, etc.).
|
|
Commands support `--json`/`-j` for structured output. Run `peekaboo <cmd> --help` for
|
|
full flags if needed.
|
|
|
|
Quickstart (most reliable flow):
|
|
1. command: "see --app Safari --annotate" — get annotated screenshot with element IDs
|
|
2. command: "click --on B3 --app Safari" — click element B3
|
|
3. command: "type \"user@example.com\" --app Safari" — type text
|
|
4. command: "press tab --count 1 --app Safari" — press tab
|
|
5. command: "type \"password\" --app Safari --return" — type and press enter
|
|
|
|
Vision:
|
|
- see — annotated UI map with element IDs and optional AI analysis
|
|
`see --app Safari --annotate`, `see --mode screen --screen-index 0`
|
|
`see --app Notes --analyze "describe what's on screen"`
|
|
- image — capture screenshots without annotation
|
|
`image --mode frontmost`, `image --mode screen --screen-index 1 --retina`
|
|
`image --app Safari --window-title "Dashboard" --analyze "Summarize KPIs"`
|
|
- capture — live motion-aware capture
|
|
`capture live --mode region --region 100,100,800,600 --duration 30`
|
|
|
|
Interaction:
|
|
- click — by element ID, query, or coordinates with smart waits
|
|
`click --on B1`, `click --coords 100,200`, `click --on B1 --double`, `click --on B1 --right`
|
|
- type — text input with optional control keys
|
|
`type "hello" --return`, `type "text" --clear --app Notes`, `type "slow" --wpm 80`
|
|
- press — special key sequences with repeats
|
|
`press tab --count 3`, `press escape`, `press return`, `press space`
|
|
- hotkey — modifier key combos (comma-separated)
|
|
`hotkey --keys cmd,c`, `hotkey --keys cmd,shift,t`, `hotkey --keys cmd,a`
|
|
- paste — set clipboard then paste (more reliable than type for long text)
|
|
`paste --text "long multi-line content"`
|
|
- scroll — directional scrolling with optional targeting
|
|
`scroll --direction down --amount 5 --smooth`, `scroll --direction up --amount 3`
|
|
- drag — drag between elements or coordinates
|
|
`drag --from B1 --to T2`, `drag --from-coords 100,100 --to-coords 500,300`
|
|
- swipe — gesture-style drags
|
|
`swipe --from-coords 100,500 --to-coords 100,200 --duration 800`
|
|
- move — cursor positioning
|
|
`move 500,300 --smooth`
|
|
|
|
Apps & Windows:
|
|
- app — launch, quit, switch, list applications
|
|
`app launch Safari --open https://example.com`, `app quit Safari`
|
|
`app switch Safari`, `app list`, `app hide Safari`, `app unhide Safari`
|
|
- window — manage window position, size, focus, list
|
|
`window list --app Safari --json`, `window focus --app Safari`
|
|
`window set-bounds --app Safari --x 50 --y 50 --width 1200 --height 800`
|
|
`window close --app Safari`, `window minimize --app Safari`
|
|
- list — enumerate apps, windows, screens
|
|
`list apps --json`, `list windows --json`, `list screens --json`
|
|
- space — macOS Spaces (virtual desktops)
|
|
`space list`, `space switch --index 2`
|
|
|
|
Menus & System:
|
|
- menu — click application menu items
|
|
`menu click --app Safari --item "New Window"`
|
|
`menu click --app TextEdit --path "Format > Font > Show Fonts"`
|
|
- menubar — status bar / menu extras
|
|
`menubar list --json`, `menubar click --title "WiFi"`
|
|
- dock — Dock items
|
|
`dock launch Safari`, `dock list --json`
|
|
- dialog — system dialogs and alerts
|
|
`dialog click --button "OK"`, `dialog list`
|
|
- clipboard — read/write clipboard
|
|
`clipboard --action get`, `clipboard --action set --text "content"`
|
|
- open — open URLs or files with app targeting
|
|
`open https://example.com --app Safari`
|
|
- permissions — check Screen Recording / Accessibility status
|
|
`permissions status`
|
|
|
|
Common targeting parameters (work across most commands):
|
|
- App/window: `--app Name`, `--pid 1234`, `--window-title "title"`, `--window-id 5678`, `--window-index 0`
|
|
- Elements: `--on B1` (element ID from see), `--coords 100,200`
|
|
- Snapshot reuse: `--snapshot <id>` (reuse a previous see result without re-capturing)
|
|
- Focus: `--no-auto-focus`, `--space-switch`, `--bring-to-current-space`
|
|
|
|
Tips:
|
|
- Always `see --annotate` first to identify element IDs before clicking
|
|
- Use `--json` for structured output on list/query commands
|
|
- Use `paste` over `type` for long or multi-line text
|
|
- Use `--screen-index` for multi-monitor setups
|
|
- If something fails, check `permissions status` for missing permissions
|
|
- Use `capture_screenshot: true` on click/type/press actions to verify the result
|
|
"#},
|
|
(_, true) => indoc! {r#"
|
|
Here are some extra tools:
|
|
automation_script
|
|
- Create and run Shell scripts
|
|
- Shell (bash) is recommended for most tasks
|
|
- Scripts can save their output to files
|
|
- Linux-specific features:
|
|
- System automation through shell scripting
|
|
- X11/Wayland window management
|
|
- D-Bus system services integration
|
|
- Desktop environment control
|
|
- Use the screenshot tool if needed to help with tasks
|
|
|
|
computer_control
|
|
- System automation using shell commands and system tools
|
|
- Desktop environment automation (GNOME, KDE, etc.)
|
|
- Consider the screenshot tool to work out what is on screen and what to do to help with the control task.
|
|
|
|
When you need to interact with websites or web applications, consider using tools like xdotool or wmctrl for:
|
|
- Window management
|
|
- Simulating keyboard/mouse input
|
|
- Automating UI interactions
|
|
- Desktop environment control
|
|
"#},
|
|
(_, false) => indoc! {r#"
|
|
Here are some extra tools:
|
|
automation_script
|
|
- Create and run Shell scripts
|
|
- Shell (bash) is recommended for most tasks
|
|
- Scripts can save their output to files
|
|
- Linux-specific features:
|
|
- System automation through shell scripting
|
|
- D-Bus system services integration
|
|
|
|
Note: No display server detected (headless mode). The computer_control tool
|
|
is not available in this environment. Use automation_script for shell-based tasks.
|
|
"#},
|
|
};
|
|
|
|
let instructions = formatdoc! {r#"
|
|
You are a helpful assistant to a power user who is not a professional developer, but you may use development tools to help assist them.
|
|
The user may not know how to break down tasks, so you will need to ensure that you do, and run things in batches as needed.
|
|
The ComputerControllerExtension helps you with common tasks like web scraping,
|
|
data processing, and automation without requiring programming expertise.
|
|
|
|
You can use scripting as needed to work with text files of data, such as csvs, json, or text files etc.
|
|
Using the developer extension is allowed for more sophisticated tasks or instructed to (js or py can be helpful for more complex tasks if tools are available).
|
|
|
|
Accessing web sites, even apis, may be common (you can use scripting to do this) without troubling them too much (they won't know what limits are).
|
|
Try to do your best to find ways to complete a task without too many questions or offering options unless it is really unclear, find a way if you can.
|
|
You can also guide them steps if they can help out as you go along.
|
|
|
|
There is already a screenshot tool available you can use if needed to see what is on screen.
|
|
|
|
{os_instructions}
|
|
|
|
web_scrape
|
|
- Fetch content from html websites and APIs
|
|
- Save as text, JSON, or binary files
|
|
- Content is cached locally for later use
|
|
- This is not optimised for complex websites, so don't use this as the first tool.
|
|
cache
|
|
- Manage your cached files
|
|
- List, view, delete files
|
|
- Clear all cached data
|
|
The extension automatically manages:
|
|
- Cache directory: {cache_dir}
|
|
- File organization and cleanup
|
|
"#,
|
|
os_instructions = os_specific_instructions,
|
|
cache_dir = cache_dir.display()
|
|
};
|
|
|
|
let mut tool_router = Self::tool_router();
|
|
if !has_display {
|
|
tool_router.remove_route("computer_control");
|
|
}
|
|
|
|
Self {
|
|
tool_router,
|
|
cache_dir,
|
|
active_resources: Arc::new(Mutex::new(HashMap::new())),
|
|
http_client: Client::builder().user_agent("goose/1.0").build().unwrap(),
|
|
instructions,
|
|
system_automation,
|
|
#[cfg(target_os = "macos")]
|
|
peekaboo_installed: Arc::new(AtomicBool::new(crate::peekaboo::is_peekaboo_installed())),
|
|
}
|
|
}
|
|
|
|
// Helper function to generate a cache file path
|
|
fn get_cache_path(&self, prefix: &str, extension: &str) -> PathBuf {
|
|
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
|
self.cache_dir
|
|
.join(format!("{}_{}.{}", prefix, timestamp, extension))
|
|
}
|
|
|
|
// Helper function to save content to cache
|
|
async fn save_to_cache(
|
|
&self,
|
|
content: &[u8],
|
|
prefix: &str,
|
|
extension: &str,
|
|
) -> Result<PathBuf, ErrorData> {
|
|
let cache_path = self.get_cache_path(prefix, extension);
|
|
fs::write(&cache_path, content).map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to write to cache: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
Ok(cache_path)
|
|
}
|
|
|
|
// Helper function to register a file as a resource
|
|
fn register_as_resource(&self, cache_path: &PathBuf, mime_type: &str) -> Result<(), ErrorData> {
|
|
let uri = Url::from_file_path(cache_path)
|
|
.map_err(|_| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
"Invalid cache path".to_string(),
|
|
None,
|
|
)
|
|
})?
|
|
.to_string();
|
|
|
|
let resource = ResourceContents::TextResourceContents {
|
|
uri: uri.clone(),
|
|
text: String::new(), // We'll read it when needed
|
|
mime_type: Some(mime_type.to_string()),
|
|
meta: None,
|
|
};
|
|
|
|
self.active_resources.lock().unwrap().insert(uri, resource);
|
|
Ok(())
|
|
}
|
|
|
|
/// Fetch and save content from a web page
|
|
#[tool(
|
|
name = "web_scrape",
|
|
description = "
|
|
Fetch and save content from a web page. The content can be saved as:
|
|
- text (for HTML pages)
|
|
- json (for API responses)
|
|
- binary (for images and other files)
|
|
Returns 'Content saved to: <path>'. Use cache to read the content.
|
|
"
|
|
)]
|
|
pub async fn web_scrape(
|
|
&self,
|
|
params: Parameters<WebScrapeParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
let url = ¶ms.url;
|
|
let save_as = params.save_as;
|
|
|
|
// Fetch the content
|
|
let response = self
|
|
.http_client
|
|
.get(url)
|
|
.header("Accept", "text/markdown, */*")
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to fetch URL: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
return Err(ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("HTTP request failed with status: {}", status),
|
|
None,
|
|
));
|
|
}
|
|
|
|
// Process based on save_as parameter
|
|
let (content, extension, mime_type) = match save_as {
|
|
SaveAsFormat::Text => {
|
|
let text = response.text().await.map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to get text: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
(text.into_bytes(), "txt", "text/plain")
|
|
}
|
|
SaveAsFormat::Json => {
|
|
let text = response.text().await.map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to get text: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
// Verify it's valid JSON
|
|
serde_json::from_str::<serde_json::Value>(&text).map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Invalid JSON response: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
(text.into_bytes(), "json", "application/json")
|
|
}
|
|
SaveAsFormat::Binary => {
|
|
let bytes = response.bytes().await.map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to get bytes: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
(bytes.to_vec(), "bin", "application/octet-stream")
|
|
}
|
|
};
|
|
|
|
// Save to cache
|
|
let cache_path = self.save_to_cache(&content, "web", extension).await?;
|
|
|
|
// Register as a resource
|
|
self.register_as_resource(&cache_path, mime_type)?;
|
|
|
|
Ok(CallToolResult::success(vec![Content::text(format!(
|
|
"Content saved to: {}",
|
|
cache_path.display()
|
|
))]))
|
|
}
|
|
|
|
/// Create and run small scripts for automation tasks
|
|
#[cfg(target_os = "windows")]
|
|
#[tool(
|
|
name = "automation_script",
|
|
description = "
|
|
Create and run small PowerShell or Batch scripts for automation tasks.
|
|
PowerShell is recommended for most tasks.
|
|
|
|
The script is saved to a temporary file and executed.
|
|
Some examples:
|
|
- Sort unique lines: Get-Content file.txt | Sort-Object -Unique
|
|
- Extract CSV column: Import-Csv file.csv | Select-Object -ExpandProperty Column2
|
|
- Find text: Select-String -Pattern 'pattern' -Path file.txt
|
|
"
|
|
)]
|
|
pub async fn automation_script(
|
|
&self,
|
|
params: Parameters<AutomationScriptParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
self.automation_script_impl(params).await
|
|
}
|
|
|
|
/// Create and run small scripts for automation tasks
|
|
#[cfg(target_os = "macos")]
|
|
#[tool(
|
|
name = "automation_script",
|
|
description = "
|
|
Create and run Shell, Ruby, or AppleScript (via osascript) scripts.
|
|
Use shell (bash) for most tasks. AppleScript for app scripting and system settings.
|
|
Examples:
|
|
- sort file.txt | uniq
|
|
- awk -F ',' '{ print $2}' file.csv
|
|
- osascript -e 'tell app \"Finder\" to get name of every window'
|
|
"
|
|
)]
|
|
pub async fn automation_script(
|
|
&self,
|
|
params: Parameters<AutomationScriptParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
self.automation_script_impl(params).await
|
|
}
|
|
|
|
/// Create and run small scripts for automation tasks
|
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
|
#[tool(
|
|
name = "automation_script",
|
|
description = "
|
|
Create and run Shell scripts for automation tasks.
|
|
Consider using shell script (bash) for most simple tasks first.
|
|
Examples:
|
|
- sort file.txt | uniq
|
|
- awk -F ',' '{ print $2}' file.csv
|
|
- grep pattern file.txt
|
|
"
|
|
)]
|
|
pub async fn automation_script(
|
|
&self,
|
|
params: Parameters<AutomationScriptParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
self.automation_script_impl(params).await
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn automation_script_impl(
|
|
&self,
|
|
params: Parameters<AutomationScriptParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
let language = params.language;
|
|
let script = ¶ms.script;
|
|
let save_output = params.save_output;
|
|
|
|
// Create a temporary directory for the script
|
|
let script_dir = tempfile::tempdir().map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to create temporary directory: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
let (shell, shell_arg) = self.system_automation.get_shell_command();
|
|
|
|
let command = match language {
|
|
ScriptLanguage::Shell | ScriptLanguage::Batch => {
|
|
let script_path = script_dir.path().join(format!(
|
|
"script.{}",
|
|
if cfg!(windows) { "bat" } else { "sh" }
|
|
));
|
|
fs::write(&script_path, script).map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to write script: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
// Set execute permissions on Unix systems
|
|
#[cfg(unix)]
|
|
{
|
|
let mut perms = fs::metadata(&script_path)
|
|
.map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to get file metadata: {}", e),
|
|
None,
|
|
)
|
|
})?
|
|
.permissions();
|
|
perms.set_mode(0o755); // rwxr-xr-x
|
|
fs::set_permissions(&script_path, perms).map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to set execute permissions: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
}
|
|
|
|
script_path.display().to_string()
|
|
}
|
|
ScriptLanguage::Ruby => {
|
|
let script_path = script_dir.path().join("script.rb");
|
|
fs::write(&script_path, script).map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to write script: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
format!("ruby {}", script_path.display())
|
|
}
|
|
ScriptLanguage::Powershell => {
|
|
let script_path = script_dir.path().join("script.ps1");
|
|
fs::write(&script_path, script).map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to write script: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
script_path.display().to_string()
|
|
}
|
|
};
|
|
|
|
// Run the script
|
|
let output = match language {
|
|
ScriptLanguage::Powershell => {
|
|
// For PowerShell, we need to use -File instead of -Command
|
|
Command::new("powershell")
|
|
.arg("-NoProfile")
|
|
.arg("-NonInteractive")
|
|
.arg("-File")
|
|
.arg(&command)
|
|
.env("GOOSE_TERMINAL", "1")
|
|
.env("AGENT", "goose")
|
|
.set_no_window()
|
|
.output()
|
|
.await
|
|
.map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to run script: {}", e),
|
|
None,
|
|
)
|
|
})?
|
|
}
|
|
_ => {
|
|
let mut cmd = Command::new(shell);
|
|
cmd.arg(shell_arg)
|
|
.arg(&command)
|
|
.env("GOOSE_TERMINAL", "1")
|
|
.env("AGENT", "goose");
|
|
#[cfg(not(windows))]
|
|
if let Some(path) = merged_path() {
|
|
cmd.env("PATH", path);
|
|
}
|
|
cmd.set_no_window().output().await.map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to run script: {}", e),
|
|
None,
|
|
)
|
|
})?
|
|
}
|
|
};
|
|
|
|
let output_str = String::from_utf8_lossy(&output.stdout).into_owned();
|
|
let error_str = String::from_utf8_lossy(&output.stderr).into_owned();
|
|
|
|
let mut result = if output.status.success() {
|
|
format!("Script completed successfully.\n\nOutput:\n{}", output_str)
|
|
} else {
|
|
format!(
|
|
"Script failed with error code {}.\n\nError:\n{}\nOutput:\n{}",
|
|
output.status, error_str, output_str
|
|
)
|
|
};
|
|
|
|
// Save output if requested
|
|
if save_output && !output_str.is_empty() {
|
|
let cache_path = self
|
|
.save_to_cache(output_str.as_bytes(), "script_output", "txt")
|
|
.await?;
|
|
result.push_str(&format!("\n\nOutput saved to: {}", cache_path.display()));
|
|
|
|
// Register as a resource
|
|
self.register_as_resource(&cache_path, "text")?;
|
|
}
|
|
|
|
Ok(CallToolResult::success(vec![Content::text(result)]))
|
|
}
|
|
|
|
/// Control the computer using system automation
|
|
#[cfg(target_os = "windows")]
|
|
#[tool(
|
|
name = "computer_control",
|
|
description = "
|
|
Control the computer using Windows system automation.
|
|
|
|
Features available:
|
|
- PowerShell automation for system control
|
|
- UI automation through PowerShell
|
|
- File and system management
|
|
- Windows-specific features and settings
|
|
|
|
Can be combined with screenshot tool for visual task assistance.
|
|
"
|
|
)]
|
|
pub async fn computer_control(
|
|
&self,
|
|
params: Parameters<ComputerControlParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
self.computer_control_impl(params).await
|
|
}
|
|
|
|
/// Control the computer using Peekaboo CLI for macOS GUI automation.
|
|
/// Auto-installs via Homebrew on first use.
|
|
#[cfg(target_os = "macos")]
|
|
#[tool(
|
|
name = "computer_control",
|
|
description = "
|
|
macOS UI automation via Peekaboo CLI. Pass a subcommand string as `command`.
|
|
|
|
Core workflow: see → click → type
|
|
1. see --app Safari --annotate (get annotated screenshot with element IDs)
|
|
2. click --on B3 (click element by ID)
|
|
3. type \"hello\" --return (type text, press enter)
|
|
|
|
Key commands: see, image, click, type, press, hotkey, paste, scroll, drag,
|
|
swipe, move, app, window, list, menu, menubar, dock, dialog, clipboard,
|
|
space, open, permissions.
|
|
|
|
Targeting: --app Name, --window-title, --window-id, --on ID, --coords x,y
|
|
Set capture_screenshot=true to verify UI state after actions.
|
|
See extension instructions for full command reference and examples.
|
|
"
|
|
)]
|
|
pub async fn computer_control(
|
|
&self,
|
|
params: Parameters<ComputerControlParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
self.peekaboo_impl(params).await
|
|
}
|
|
|
|
/// Control the computer using system automation
|
|
#[cfg(target_os = "linux")]
|
|
#[tool(
|
|
name = "computer_control",
|
|
description = "
|
|
Control the computer using Linux system automation.
|
|
|
|
Features available:
|
|
- Shell scripting for system control
|
|
- X11/Wayland window management
|
|
- D-Bus for system services
|
|
- File and system management
|
|
- Desktop environment control (GNOME, KDE, etc.)
|
|
- Process management and monitoring
|
|
- System settings and configurations
|
|
|
|
Can be combined with screenshot tool for visual task assistance.
|
|
"
|
|
)]
|
|
pub async fn computer_control(
|
|
&self,
|
|
params: Parameters<ComputerControlParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
self.computer_control_impl(params).await
|
|
}
|
|
|
|
/// Control the computer using system automation (fallback for other OS)
|
|
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
|
#[tool(
|
|
name = "computer_control",
|
|
description = "Control the computer using system automation. Features available depend on your operating system. Can be combined with screenshot tool for visual task assistance."
|
|
)]
|
|
pub async fn computer_control(
|
|
&self,
|
|
params: Parameters<ComputerControlParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
self.computer_control_impl(params).await
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
async fn computer_control_impl(
|
|
&self,
|
|
params: Parameters<ComputerControlParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
let script = ¶ms.script;
|
|
let save_output = params.save_output;
|
|
|
|
// Use platform-specific automation
|
|
let output = self
|
|
.system_automation
|
|
.execute_system_script(script)
|
|
.map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to execute script: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
let mut result = format!("Script completed successfully.\n\nOutput:\n{}", output);
|
|
|
|
// Save output if requested
|
|
if save_output && !output.is_empty() {
|
|
let cache_path = self
|
|
.save_to_cache(output.as_bytes(), "automation_output", "txt")
|
|
.await?;
|
|
result.push_str(&format!("\n\nOutput saved to: {}", cache_path.display()));
|
|
|
|
// Register as a resource
|
|
self.register_as_resource(&cache_path, "text")?;
|
|
}
|
|
|
|
Ok(CallToolResult::success(vec![Content::text(result)]))
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn ensure_peekaboo(&self) -> Result<(), ErrorData> {
|
|
if self.peekaboo_installed.load(Ordering::Relaxed) {
|
|
return Ok(());
|
|
}
|
|
if crate::peekaboo::is_peekaboo_installed() {
|
|
self.peekaboo_installed.store(true, Ordering::Relaxed);
|
|
return Ok(());
|
|
}
|
|
tracing::info!("Peekaboo not found, attempting auto-install via brew");
|
|
match crate::peekaboo::auto_install_peekaboo() {
|
|
Ok(()) => {
|
|
self.peekaboo_installed.store(true, Ordering::Relaxed);
|
|
tracing::info!("Peekaboo installed successfully");
|
|
Ok(())
|
|
}
|
|
Err(msg) => Err(ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!(
|
|
"Peekaboo is not installed and auto-install failed: {}\n\
|
|
Install manually with: brew install steipete/tap/peekaboo\n\
|
|
Peekaboo requires macOS 15+ (Sequoia) with Screen Recording and Accessibility permissions.",
|
|
msg
|
|
),
|
|
None,
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn run_peekaboo_cmd(&self, args: &[&str]) -> Result<String, ErrorData> {
|
|
let mut cmd = std::process::Command::new("peekaboo");
|
|
cmd.args(args);
|
|
if let Some(path) = merged_path() {
|
|
cmd.env("PATH", path);
|
|
}
|
|
let output = cmd.output().map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to run peekaboo: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
|
|
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
|
if !output.status.success() {
|
|
return Err(ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!(
|
|
"peekaboo {} failed (exit {}):\n{}\n{}",
|
|
args.first().unwrap_or(&""),
|
|
output.status,
|
|
stderr.trim(),
|
|
stdout.trim()
|
|
),
|
|
None,
|
|
));
|
|
}
|
|
Ok(stdout)
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
async fn peekaboo_impl(
|
|
&self,
|
|
params: Parameters<ComputerControlParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
self.ensure_peekaboo()?;
|
|
let params = params.0;
|
|
|
|
let args = shell_words::split(¶ms.command).map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
format!("Failed to parse command: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
if args.is_empty() {
|
|
return Err(ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Command cannot be empty".to_string(),
|
|
None,
|
|
));
|
|
}
|
|
|
|
let is_see = args[0] == "see";
|
|
let is_image = args[0] == "image";
|
|
let screenshot_path = if is_see || is_image {
|
|
Some(self.get_cache_path(&args[0], "png"))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mut full_args: Vec<String> = args.clone();
|
|
|
|
if let Some(ref path) = screenshot_path {
|
|
if !full_args.iter().any(|a| a == "--path") {
|
|
full_args.push("--path".to_string());
|
|
full_args.push(path.to_string_lossy().to_string());
|
|
}
|
|
}
|
|
if is_see && !full_args.iter().any(|a| a == "--json-output") {
|
|
full_args.push("--json-output".to_string());
|
|
}
|
|
|
|
let wants_json = matches!(
|
|
args[0].as_str(),
|
|
"list" | "window" | "menubar" | "permissions" | "clipboard"
|
|
);
|
|
if wants_json
|
|
&& !full_args.iter().any(|a| a == "--json" || a == "-j")
|
|
&& !full_args.iter().any(|a| a == "--json-output")
|
|
{
|
|
full_args.push("--json".to_string());
|
|
}
|
|
|
|
let arg_refs: Vec<&str> = full_args.iter().map(|s| s.as_str()).collect();
|
|
let stdout = self.run_peekaboo_cmd(&arg_refs)?;
|
|
|
|
let mut contents = Vec::new();
|
|
|
|
if let Some(ref path) = screenshot_path {
|
|
let annotated = path.to_string_lossy().replace(".png", "_annotated.png");
|
|
let image_path = if is_see && std::path::Path::new(&annotated).exists() {
|
|
PathBuf::from(&annotated)
|
|
} else {
|
|
path.clone()
|
|
};
|
|
if image_path.exists() {
|
|
if let Ok(bytes) = fs::read(&image_path) {
|
|
let data = base64::prelude::BASE64_STANDARD.encode(&bytes);
|
|
contents.push(Content::image(data, "image/png").with_priority(0.0));
|
|
}
|
|
}
|
|
}
|
|
|
|
if params.capture_screenshot && screenshot_path.is_none() {
|
|
let cap_path = self.get_cache_path("peekaboo_capture", "png");
|
|
let cap_path_str = cap_path.to_string_lossy().to_string();
|
|
if self
|
|
.run_peekaboo_cmd(&["image", "--mode", "frontmost", "--path", &cap_path_str])
|
|
.is_ok()
|
|
&& cap_path.exists()
|
|
{
|
|
if let Ok(bytes) = fs::read(&cap_path) {
|
|
let data = base64::prelude::BASE64_STANDARD.encode(&bytes);
|
|
contents.push(Content::image(data, "image/png").with_priority(0.0));
|
|
}
|
|
}
|
|
}
|
|
|
|
let text = if stdout.len() > 12000 {
|
|
let truncated: String = stdout.chars().take(12000).collect();
|
|
format!(
|
|
"{}\n\n[Output truncated. {} total chars.]",
|
|
truncated,
|
|
stdout.len()
|
|
)
|
|
} else {
|
|
stdout
|
|
};
|
|
|
|
contents.insert(0, Content::text(&text).with_audience(vec![Role::Assistant]));
|
|
|
|
Ok(CallToolResult::success(contents))
|
|
}
|
|
|
|
/// Process Excel (XLSX) files to read and manipulate spreadsheet data
|
|
#[tool(
|
|
name = "xlsx_tool",
|
|
description = "
|
|
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.
|
|
"
|
|
)]
|
|
pub async fn xlsx_tool(
|
|
&self,
|
|
params: Parameters<XlsxToolParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
let path = ¶ms.path;
|
|
let operation = params.operation;
|
|
|
|
match operation {
|
|
XlsxOperation::ListWorksheets => {
|
|
let xlsx = xlsx_tool::XlsxTool::new(path)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
let worksheets = xlsx
|
|
.list_worksheets()
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
Ok(CallToolResult::success(vec![Content::text(format!(
|
|
"{:#?}",
|
|
worksheets
|
|
))]))
|
|
}
|
|
XlsxOperation::GetColumns => {
|
|
let xlsx = xlsx_tool::XlsxTool::new(path)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
let worksheet = if let Some(name) = ¶ms.worksheet {
|
|
xlsx.get_worksheet_by_name(name).map_err(|e| {
|
|
ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None)
|
|
})?
|
|
} else {
|
|
xlsx.get_worksheet_by_index(0).map_err(|e| {
|
|
ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None)
|
|
})?
|
|
};
|
|
let columns = xlsx
|
|
.get_column_names(worksheet)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
Ok(CallToolResult::success(vec![Content::text(format!(
|
|
"{:#?}",
|
|
columns
|
|
))]))
|
|
}
|
|
XlsxOperation::GetRange => {
|
|
let range = params.range.as_ref().ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Missing 'range' parameter".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
let xlsx = xlsx_tool::XlsxTool::new(path)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
let worksheet = if let Some(name) = ¶ms.worksheet {
|
|
xlsx.get_worksheet_by_name(name).map_err(|e| {
|
|
ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None)
|
|
})?
|
|
} else {
|
|
xlsx.get_worksheet_by_index(0).map_err(|e| {
|
|
ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None)
|
|
})?
|
|
};
|
|
let range_data = xlsx
|
|
.get_range(worksheet, range)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
Ok(CallToolResult::success(vec![Content::text(format!(
|
|
"{:#?}",
|
|
range_data
|
|
))]))
|
|
}
|
|
XlsxOperation::FindText => {
|
|
let search_text = params.search_text.as_ref().ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Missing 'search_text' parameter".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
let case_sensitive = params.case_sensitive;
|
|
|
|
let xlsx = xlsx_tool::XlsxTool::new(path)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
let worksheet = if let Some(name) = ¶ms.worksheet {
|
|
xlsx.get_worksheet_by_name(name).map_err(|e| {
|
|
ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None)
|
|
})?
|
|
} else {
|
|
xlsx.get_worksheet_by_index(0).map_err(|e| {
|
|
ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None)
|
|
})?
|
|
};
|
|
let matches = xlsx
|
|
.find_in_worksheet(worksheet, search_text, case_sensitive)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
Ok(CallToolResult::success(vec![Content::text(format!(
|
|
"Found matches at: {:#?}",
|
|
matches
|
|
))]))
|
|
}
|
|
XlsxOperation::UpdateCell => {
|
|
let row = params.row.ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Missing 'row' parameter".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
let col = params.col.ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Missing 'col' parameter".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
let value = params.value.as_ref().ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Missing 'value' parameter".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
let worksheet_name = params.worksheet.as_deref().unwrap_or("Sheet1");
|
|
|
|
let mut xlsx = xlsx_tool::XlsxTool::new(path)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
xlsx.update_cell(worksheet_name, row as u32, col as u32, value)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
xlsx.save(path)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
Ok(CallToolResult::success(vec![Content::text(format!(
|
|
"Updated cell ({}, {}) to '{}' in worksheet '{}'",
|
|
row, col, value, worksheet_name
|
|
))]))
|
|
}
|
|
XlsxOperation::Save => {
|
|
let xlsx = xlsx_tool::XlsxTool::new(path)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
xlsx.save(path)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
Ok(CallToolResult::success(vec![Content::text(
|
|
"File saved successfully.",
|
|
)]))
|
|
}
|
|
XlsxOperation::GetCell => {
|
|
let row = params.row.ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Missing 'row' parameter".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
let col = params.col.ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Missing 'col' parameter".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
let xlsx = xlsx_tool::XlsxTool::new(path)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
let worksheet = if let Some(name) = ¶ms.worksheet {
|
|
xlsx.get_worksheet_by_name(name).map_err(|e| {
|
|
ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None)
|
|
})?
|
|
} else {
|
|
xlsx.get_worksheet_by_index(0).map_err(|e| {
|
|
ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None)
|
|
})?
|
|
};
|
|
let cell_value = xlsx
|
|
.get_cell_value(worksheet, row as u32, col as u32)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
Ok(CallToolResult::success(vec![Content::text(format!(
|
|
"{:#?}",
|
|
cell_value
|
|
))]))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Process DOCX files to extract text and create/update documents
|
|
#[tool(
|
|
name = "docx_tool",
|
|
description = "
|
|
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.
|
|
"
|
|
)]
|
|
pub async fn docx_tool(
|
|
&self,
|
|
params: Parameters<DocxToolParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
let path = ¶ms.path;
|
|
let operation = params.operation;
|
|
|
|
// Convert enum to string for the existing implementation
|
|
let operation_str = match operation {
|
|
DocxOperation::ExtractText => "extract_text",
|
|
DocxOperation::UpdateDoc => "update_doc",
|
|
};
|
|
|
|
// Convert typed params back to JSON for the internal docx_tool impl
|
|
let json_params = params
|
|
.params
|
|
.as_ref()
|
|
.map(|p| serde_json::to_value(p).unwrap_or(serde_json::Value::Null));
|
|
|
|
let result = crate::computercontroller::docx_tool::docx_tool(
|
|
path,
|
|
operation_str,
|
|
params.content.as_deref(),
|
|
json_params.as_ref(),
|
|
)
|
|
.await
|
|
.map_err(|e| ErrorData::new(e.code, e.message, e.data))?;
|
|
|
|
Ok(CallToolResult::success(result))
|
|
}
|
|
|
|
/// Process PDF files to extract text and images
|
|
#[tool(
|
|
name = "pdf_tool",
|
|
description = "
|
|
Process PDF files to extract text and images.
|
|
Supports operations:
|
|
- extract_text: Extract all text content from the PDF
|
|
- extract_images: Extract and save embedded images to PNG files
|
|
|
|
Use this when there is a .pdf file or files that need to be processed.
|
|
"
|
|
)]
|
|
pub async fn pdf_tool(
|
|
&self,
|
|
params: Parameters<PdfToolParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
let path = ¶ms.path;
|
|
let operation = params.operation;
|
|
|
|
// Convert enum to string for the existing implementation
|
|
let operation_str = match operation {
|
|
PdfOperation::ExtractText => "extract_text",
|
|
PdfOperation::ExtractImages => "extract_images",
|
|
};
|
|
|
|
let result =
|
|
crate::computercontroller::pdf_tool::pdf_tool(path, operation_str, &self.cache_dir)
|
|
.await
|
|
.map_err(|e| ErrorData::new(e.code, e.message, e.data))?;
|
|
|
|
Ok(CallToolResult::success(result))
|
|
}
|
|
|
|
/// Manage cached files and data
|
|
#[tool(
|
|
name = "cache",
|
|
description = "
|
|
Manage cached files and data:
|
|
- list: List all cached files
|
|
- view: View content of a cached file
|
|
- delete: Delete a cached file
|
|
- clear: Clear all cached files
|
|
"
|
|
)]
|
|
pub async fn cache(
|
|
&self,
|
|
params: Parameters<CacheParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let command = params.0.command;
|
|
let path = params.0.path.as_deref();
|
|
|
|
match command {
|
|
CacheCommand::List => {
|
|
let mut files = Vec::new();
|
|
for entry in fs::read_dir(&self.cache_dir).map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to read cache directory: {}", e),
|
|
None,
|
|
)
|
|
})? {
|
|
let entry = entry.map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to read directory entry: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
files.push(format!("{}", entry.path().display()));
|
|
}
|
|
files.sort();
|
|
Ok(CallToolResult::success(vec![Content::text(format!(
|
|
"Cached files:\n{}",
|
|
files.join("\n")
|
|
))]))
|
|
}
|
|
CacheCommand::View => {
|
|
let path = path.ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Missing 'path' parameter for view".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
let content = fs::read_to_string(path).map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to read file: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
Ok(CallToolResult::success(vec![Content::text(format!(
|
|
"Content of {}:\n\n{}",
|
|
path, content
|
|
))]))
|
|
}
|
|
CacheCommand::Delete => {
|
|
let path = path.ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Missing 'path' parameter for delete".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
fs::remove_file(path).map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to delete file: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
// Remove from active resources if present
|
|
if let Ok(url) = Url::from_file_path(path) {
|
|
self.active_resources
|
|
.lock()
|
|
.unwrap()
|
|
.remove(&url.to_string());
|
|
}
|
|
|
|
Ok(CallToolResult::success(vec![Content::text(format!(
|
|
"Deleted file: {}",
|
|
path
|
|
))]))
|
|
}
|
|
CacheCommand::Clear => {
|
|
fs::remove_dir_all(&self.cache_dir).map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to clear cache directory: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
fs::create_dir_all(&self.cache_dir).map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to recreate cache directory: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
// Clear active resources
|
|
self.active_resources.lock().unwrap().clear();
|
|
|
|
Ok(CallToolResult::success(vec![Content::text(
|
|
"Cache cleared successfully.",
|
|
)]))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tool_handler(router = self.tool_router)]
|
|
impl ServerHandler for ComputerControllerServer {
|
|
fn get_info(&self) -> ServerInfo {
|
|
InitializeResult::new(
|
|
ServerCapabilities::builder()
|
|
.enable_tools()
|
|
.enable_resources()
|
|
.build(),
|
|
)
|
|
.with_server_info(Implementation::new(
|
|
"goose-computercontroller",
|
|
env!("CARGO_PKG_VERSION"),
|
|
))
|
|
.with_instructions(self.instructions.clone())
|
|
}
|
|
|
|
async fn list_resources(
|
|
&self,
|
|
_pagination: Option<PaginatedRequestParams>,
|
|
_context: RequestContext<RoleServer>,
|
|
) -> Result<ListResourcesResult, ErrorData> {
|
|
let active_resources = self.active_resources.lock().unwrap();
|
|
let resources: Vec<Resource> = active_resources
|
|
.keys()
|
|
.map(|uri| {
|
|
RawResource::new(
|
|
uri.clone(),
|
|
uri.split('/').next_back().unwrap_or("").to_string(),
|
|
)
|
|
.no_annotation()
|
|
})
|
|
.collect();
|
|
Ok(ListResourcesResult {
|
|
resources,
|
|
next_cursor: None,
|
|
meta: None,
|
|
})
|
|
}
|
|
|
|
async fn read_resource(
|
|
&self,
|
|
params: ReadResourceRequestParams,
|
|
_context: RequestContext<RoleServer>,
|
|
) -> Result<ReadResourceResult, ErrorData> {
|
|
let active_resources = self.active_resources.lock().unwrap();
|
|
let resource = active_resources.get(¶ms.uri).ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_REQUEST,
|
|
format!("Resource not found: {}", params.uri),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
// Clone the resource to return
|
|
Ok(ReadResourceResult::new(vec![resource.clone()]))
|
|
}
|
|
}
|