use async_trait::async_trait;
use car_engine::ToolExecutor;
use serde_json::{json, Value};
pub struct AutomationTools;
impl Default for AutomationTools {
fn default() -> Self {
Self::new()
}
}
impl AutomationTools {
pub fn new() -> Self {
Self
}
pub fn tool_defs(&self) -> Vec<Value> {
if !cfg!(target_os = "macos") {
return Vec::new();
}
vec![json!({
"name": "run_applescript",
"description": "Run an AppleScript or JavaScript-for-Automation (JXA) script to \
control macOS and its apps — Finder, System Events, Notes, Calendar, Mail, \
Reminders, notifications, window/app control, clipboard, and anything \
scriptable. Returns the script's stdout/stderr. This drives the real host \
desktop (it cannot be sandboxed), so it requires full-access approval — a \
capability no sandboxed or text-only agent has. Prefer JXA (`language: \
\"javascript\"`), which models generate more cleanly than AppleScript.",
"parameters": {
"type": "object",
"properties": {
"script": {
"type": "string",
"description": "The AppleScript or JXA source to run."
},
"language": {
"type": "string",
"enum": ["applescript", "javascript"],
"description": "Script language (default applescript)."
}
},
"required": ["script"]
},
"mutating": true,
"tier": "full_access"
})]
}
async fn run_applescript(&self, params: &Value) -> Result<Value, String> {
let script = params
.get("script")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or("run_applescript requires a non-empty `script`")?;
let lang = match params.get("language").and_then(|v| v.as_str()) {
Some(l)
if l.eq_ignore_ascii_case("javascript")
|| l.eq_ignore_ascii_case("jxa")
|| l.eq_ignore_ascii_case("js") =>
{
car_automation::applescript::Language::JavaScript
}
_ => car_automation::applescript::Language::AppleScript,
};
let out = car_automation::applescript::run(
script,
lang,
Some(std::time::Duration::from_secs(60)),
)
.await
.map_err(|e| format!("automation failed: {e}"))?;
Ok(json!({
"stdout": out.stdout,
"stderr": out.stderr,
"exit_code": out.exit_code,
}))
}
}
#[async_trait]
impl ToolExecutor for AutomationTools {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
match tool {
"run_applescript" => self.run_applescript(params).await,
other => Err(format!("unknown tool: '{other}'")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn unknown_tool_falls_through() {
let err = AutomationTools::new()
.execute("nope", &json!({}))
.await
.unwrap_err();
assert!(err.starts_with("unknown tool"), "{err}");
}
#[tokio::test]
async fn rejects_empty_script() {
let err = AutomationTools::new()
.execute("run_applescript", &json!({ "script": " " }))
.await
.unwrap_err();
assert!(err.contains("non-empty"), "{err}");
}
#[test]
fn declares_full_access_tier_on_macos() {
let defs = AutomationTools::new().tool_defs();
if cfg!(target_os = "macos") {
assert_eq!(defs.len(), 1);
assert_eq!(defs[0]["tier"], "full_access");
assert_eq!(defs[0]["mutating"], true);
} else {
assert!(defs.is_empty());
}
}
}