diff --git a/rust/crates/runtime/src/file_ops.rs b/rust/crates/runtime/src/file_ops.rs index e18bed7..a647b85 100644 --- a/rust/crates/runtime/src/file_ops.rs +++ b/rust/crates/runtime/src/file_ops.rs @@ -296,12 +296,12 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result { continue; } - let Ok(file_content) = fs::read_to_string(&file_path) else { + let Ok(file_contents) = fs::read_to_string(&file_path) else { continue; }; if output_mode == "count" { - let count = regex.find_iter(&file_content).count(); + let count = regex.find_iter(&file_contents).count(); if count > 0 { filenames.push(file_path.to_string_lossy().into_owned()); total_matches += count; @@ -309,7 +309,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result { continue; } - let lines: Vec<&str> = file_content.lines().collect(); + let lines: Vec<&str> = file_contents.lines().collect(); let mut matched_lines = Vec::new(); for (index, line) in lines.iter().enumerate() { if regex.is_match(line) { @@ -327,13 +327,13 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result { for index in matched_lines { let start = index.saturating_sub(input.before.unwrap_or(context)); let end = (index + input.after.unwrap_or(context) + 1).min(lines.len()); - for (current, line_content) in lines.iter().enumerate().take(end).skip(start) { + for (current, line) in lines.iter().enumerate().take(end).skip(start) { let prefix = if input.line_numbers.unwrap_or(true) { format!("{}:{}:", file_path.to_string_lossy(), current + 1) } else { format!("{}:", file_path.to_string_lossy()) }; - content_lines.push(format!("{prefix}{line_content}")); + content_lines.push(format!("{prefix}{line}")); } } } @@ -341,7 +341,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result { let (filenames, applied_limit, applied_offset) = apply_limit(filenames, input.head_limit, input.offset); - let rendered_content = if output_mode == "content" { + let content_output = if output_mode == "content" { let (lines, limit, offset) = apply_limit(content_lines, input.head_limit, input.offset); return Ok(GrepSearchOutput { mode: Some(output_mode), @@ -361,7 +361,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result { mode: Some(output_mode.clone()), num_files: filenames.len(), filenames, - content: rendered_content, + content: content_output, num_lines: None, num_matches: (output_mode == "count").then_some(total_matches), applied_limit, diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 5927e64..7f67fe5 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -1,4 +1,6 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::{Duration, Instant}; use reqwest::blocking::Client; @@ -46,6 +48,7 @@ pub struct ToolSpec { } #[must_use] +#[allow(clippy::too_many_lines)] pub fn mvp_tool_specs() -> Vec { vec![ ToolSpec { @@ -275,6 +278,63 @@ pub fn mvp_tool_specs() -> Vec { "additionalProperties": false }), }, + ToolSpec { + name: "SendUserMessage", + description: "Send a message to the user.", + input_schema: json!({ + "type": "object", + "properties": { + "message": { "type": "string" }, + "attachments": { + "type": "array", + "items": { "type": "string" } + }, + "status": { + "type": "string", + "enum": ["normal", "proactive"] + } + }, + "required": ["message", "status"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "Config", + description: "Get or set Claude Code settings.", + input_schema: json!({ + "type": "object", + "properties": { + "setting": { "type": "string" }, + "value": { + "type": ["string", "boolean", "number"] + } + }, + "required": ["setting"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "StructuredOutput", + description: "Return structured output in the requested format.", + input_schema: json!({ + "type": "object", + "additionalProperties": true + }), + }, + ToolSpec { + name: "REPL", + description: "Execute code in a REPL-like subprocess.", + input_schema: json!({ + "type": "object", + "properties": { + "code": { "type": "string" }, + "language": { "type": "string" }, + "timeout_ms": { "type": "integer", "minimum": 1 } + }, + "required": ["code", "language"], + "additionalProperties": false + }), + }, ToolSpec { name: "PowerShell", description: "Execute a PowerShell command with optional timeout.", @@ -309,6 +369,12 @@ pub fn execute_tool(name: &str, input: &Value) -> Result { "ToolSearch" => from_value::(input).and_then(run_tool_search), "NotebookEdit" => from_value::(input).and_then(run_notebook_edit), "Sleep" => from_value::(input).and_then(run_sleep), + "SendUserMessage" | "Brief" => from_value::(input).and_then(run_brief), + "Config" => from_value::(input).and_then(run_config), + "StructuredOutput" => { + from_value::(input).and_then(run_structured_output) + } + "REPL" => from_value::(input).and_then(run_repl), "PowerShell" => from_value::(input).and_then(run_powershell), _ => Err(format!("unsupported tool: {name}")), } @@ -323,14 +389,17 @@ fn run_bash(input: BashCommandInput) -> Result { .map_err(|error| error.to_string()) } +#[allow(clippy::needless_pass_by_value)] fn run_read_file(input: ReadFileInput) -> Result { to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?) } +#[allow(clippy::needless_pass_by_value)] fn run_write_file(input: WriteFileInput) -> Result { to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?) } +#[allow(clippy::needless_pass_by_value)] fn run_edit_file(input: EditFileInput) -> Result { to_pretty_json( edit_file( @@ -343,18 +412,22 @@ fn run_edit_file(input: EditFileInput) -> Result { ) } +#[allow(clippy::needless_pass_by_value)] fn run_glob_search(input: GlobSearchInputValue) -> Result { to_pretty_json(glob_search(&input.pattern, input.path.as_deref()).map_err(io_to_string)?) } +#[allow(clippy::needless_pass_by_value)] fn run_grep_search(input: GrepSearchInput) -> Result { to_pretty_json(grep_search(&input).map_err(io_to_string)?) } +#[allow(clippy::needless_pass_by_value)] fn run_web_fetch(input: WebFetchInput) -> Result { to_pretty_json(execute_web_fetch(&input)?) } +#[allow(clippy::needless_pass_by_value)] fn run_web_search(input: WebSearchInput) -> Result { to_pretty_json(execute_web_search(&input)?) } @@ -383,6 +456,22 @@ fn run_sleep(input: SleepInput) -> Result { to_pretty_json(execute_sleep(input)) } +fn run_brief(input: BriefInput) -> Result { + to_pretty_json(execute_brief(input)?) +} + +fn run_config(input: ConfigInput) -> Result { + to_pretty_json(execute_config(input)?) +} + +fn run_structured_output(input: StructuredOutputInput) -> Result { + to_pretty_json(execute_structured_output(input)) +} + +fn run_repl(input: ReplInput) -> Result { + to_pretty_json(execute_repl(input)?) +} + fn run_powershell(input: PowerShellInput) -> Result { to_pretty_json(execute_powershell(input).map_err(|error| error.to_string())?) } @@ -391,6 +480,7 @@ fn to_pretty_json(value: T) -> Result { serde_json::to_string_pretty(&value).map_err(|error| error.to_string()) } +#[allow(clippy::needless_pass_by_value)] fn io_to_string(error: std::io::Error) -> String { error.to_string() } @@ -506,6 +596,45 @@ struct SleepInput { duration_ms: u64, } +#[derive(Debug, Deserialize)] +struct BriefInput { + message: String, + attachments: Option>, + status: BriefStatus, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum BriefStatus { + Normal, + Proactive, +} + +#[derive(Debug, Deserialize)] +struct ConfigInput { + setting: String, + value: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum ConfigValue { + String(String), + Bool(bool), + Number(f64), +} + +#[derive(Debug, Deserialize)] +#[serde(transparent)] +struct StructuredOutputInput(BTreeMap); + +#[derive(Debug, Deserialize)] +struct ReplInput { + code: String, + language: String, + timeout_ms: Option, +} + #[derive(Debug, Deserialize)] struct PowerShellInput { command: String, @@ -601,6 +730,52 @@ struct SleepOutput { message: String, } +#[derive(Debug, Serialize)] +struct BriefOutput { + message: String, + attachments: Option>, + #[serde(rename = "sentAt")] + sent_at: String, +} + +#[derive(Debug, Serialize)] +struct ResolvedAttachment { + path: String, + size: u64, + #[serde(rename = "isImage")] + is_image: bool, +} + +#[derive(Debug, Serialize)] +struct ConfigOutput { + success: bool, + operation: Option, + setting: Option, + value: Option, + #[serde(rename = "previousValue")] + previous_value: Option, + #[serde(rename = "newValue")] + new_value: Option, + error: Option, +} + +#[derive(Debug, Serialize)] +struct StructuredOutputResult { + data: String, + structured_output: BTreeMap, +} + +#[derive(Debug, Serialize)] +struct ReplOutput { + language: String, + stdout: String, + stderr: String, + #[serde(rename = "exitCode")] + exit_code: i32, + #[serde(rename = "durationMs")] + duration_ms: u128, +} + #[derive(Debug, Serialize)] #[serde(untagged)] enum WebSearchResultItem { @@ -722,7 +897,7 @@ fn normalize_fetch_url(url: &str) -> Result { let mut upgraded = parsed; upgraded .set_scheme("https") - .map_err(|_| String::from("failed to upgrade URL to https"))?; + .map_err(|()| String::from("failed to upgrade URL to https"))?; return Ok(upgraded.to_string()); } } @@ -761,9 +936,10 @@ fn summarize_web_fetch( let compact = collapse_whitespace(content); let detail = if lower_prompt.contains("title") { - extract_title(content, raw_body, content_type) - .map(|title| format!("Title: {title}")) - .unwrap_or_else(|| preview_text(&compact, 600)) + extract_title(content, raw_body, content_type).map_or_else( + || preview_text(&compact, 600), + |title| format!("Title: {title}"), + ) } else if lower_prompt.contains("summary") || lower_prompt.contains("summarize") { preview_text(&compact, 900) } else { @@ -1186,6 +1362,7 @@ fn execute_agent(input: AgentInput) -> Result { Ok(manifest) } +#[allow(clippy::needless_pass_by_value)] fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput { let deferred = deferred_tool_specs(); let max_results = input.max_results.unwrap_or(5).max(1); @@ -1312,7 +1489,7 @@ fn normalize_tool_search_query(query: &str) -> String { fn canonical_tool_token(value: &str) -> String { let mut canonical = value .chars() - .filter(|ch| ch.is_ascii_alphanumeric()) + .filter(char::is_ascii_alphanumeric) .flat_map(char::to_lowercase) .collect::(); if let Some(stripped) = canonical.strip_suffix("tool") { @@ -1384,6 +1561,7 @@ fn iso8601_now() -> String { .to_string() } +#[allow(clippy::too_many_lines)] fn execute_notebook_edit(input: NotebookEditInput) -> Result { let path = std::path::PathBuf::from(&input.notebook_path); if path.extension().and_then(|ext| ext.to_str()) != Some("ipynb") { @@ -1466,7 +1644,7 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result Option { }) } +#[allow(clippy::needless_pass_by_value)] fn execute_sleep(input: SleepInput) -> SleepOutput { std::thread::sleep(Duration::from_millis(input.duration_ms)); SleepOutput { @@ -1553,6 +1732,403 @@ fn execute_sleep(input: SleepInput) -> SleepOutput { } } +fn execute_brief(input: BriefInput) -> Result { + if input.message.trim().is_empty() { + return Err(String::from("message must not be empty")); + } + + let attachments = input + .attachments + .as_ref() + .map(|paths| { + paths + .iter() + .map(|path| resolve_attachment(path)) + .collect::, String>>() + }) + .transpose()?; + + let message = match input.status { + BriefStatus::Normal | BriefStatus::Proactive => input.message, + }; + + Ok(BriefOutput { + message, + attachments, + sent_at: iso8601_timestamp(), + }) +} + +fn resolve_attachment(path: &str) -> Result { + let resolved = std::fs::canonicalize(path).map_err(|error| error.to_string())?; + let metadata = std::fs::metadata(&resolved).map_err(|error| error.to_string())?; + Ok(ResolvedAttachment { + path: resolved.display().to_string(), + size: metadata.len(), + is_image: is_image_path(&resolved), + }) +} + +fn is_image_path(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|ext| ext.to_str()) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg") + ) +} + +fn execute_config(input: ConfigInput) -> Result { + let setting = input.setting.trim(); + if setting.is_empty() { + return Err(String::from("setting must not be empty")); + } + let Some(spec) = supported_config_setting(setting) else { + return Ok(ConfigOutput { + success: false, + operation: None, + setting: None, + value: None, + previous_value: None, + new_value: None, + error: Some(format!("Unknown setting: \"{setting}\"")), + }); + }; + + let path = config_file_for_scope(spec.scope)?; + let mut document = read_json_object(&path)?; + + if let Some(value) = input.value { + let normalized = normalize_config_value(spec, value)?; + let previous_value = get_nested_value(&document, spec.path).cloned(); + set_nested_value(&mut document, spec.path, normalized.clone()); + write_json_object(&path, &document)?; + Ok(ConfigOutput { + success: true, + operation: Some(String::from("set")), + setting: Some(setting.to_string()), + value: Some(normalized.clone()), + previous_value, + new_value: Some(normalized), + error: None, + }) + } else { + Ok(ConfigOutput { + success: true, + operation: Some(String::from("get")), + setting: Some(setting.to_string()), + value: get_nested_value(&document, spec.path).cloned(), + previous_value: None, + new_value: None, + error: None, + }) + } +} + +fn execute_structured_output(input: StructuredOutputInput) -> StructuredOutputResult { + StructuredOutputResult { + data: String::from("Structured output provided successfully"), + structured_output: input.0, + } +} + +fn execute_repl(input: ReplInput) -> Result { + if input.code.trim().is_empty() { + return Err(String::from("code must not be empty")); + } + let _ = input.timeout_ms; + let runtime = resolve_repl_runtime(&input.language)?; + let started = Instant::now(); + let output = Command::new(runtime.program) + .args(runtime.args) + .arg(&input.code) + .output() + .map_err(|error| error.to_string())?; + + Ok(ReplOutput { + language: input.language, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + exit_code: output.status.code().unwrap_or(1), + duration_ms: started.elapsed().as_millis(), + }) +} + +struct ReplRuntime { + program: &'static str, + args: &'static [&'static str], +} + +fn resolve_repl_runtime(language: &str) -> Result { + match language.trim().to_ascii_lowercase().as_str() { + "python" | "py" => Ok(ReplRuntime { + program: detect_first_command(&["python3", "python"]) + .ok_or_else(|| String::from("python runtime not found"))?, + args: &["-c"], + }), + "javascript" | "js" | "node" => Ok(ReplRuntime { + program: detect_first_command(&["node"]) + .ok_or_else(|| String::from("node runtime not found"))?, + args: &["-e"], + }), + "sh" | "shell" | "bash" => Ok(ReplRuntime { + program: detect_first_command(&["bash", "sh"]) + .ok_or_else(|| String::from("shell runtime not found"))?, + args: &["-lc"], + }), + other => Err(format!("unsupported REPL language: {other}")), + } +} + +fn detect_first_command(commands: &[&'static str]) -> Option<&'static str> { + commands + .iter() + .copied() + .find(|command| command_exists(command)) +} + +#[derive(Clone, Copy)] +enum ConfigScope { + Global, + Settings, +} + +#[derive(Clone, Copy)] +struct ConfigSettingSpec { + scope: ConfigScope, + kind: ConfigKind, + path: &'static [&'static str], + options: Option<&'static [&'static str]>, +} + +#[derive(Clone, Copy)] +enum ConfigKind { + Boolean, + String, +} + +fn supported_config_setting(setting: &str) -> Option { + Some(match setting { + "theme" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::String, + path: &["theme"], + options: None, + }, + "editorMode" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::String, + path: &["editorMode"], + options: Some(&["default", "vim", "emacs"]), + }, + "verbose" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::Boolean, + path: &["verbose"], + options: None, + }, + "preferredNotifChannel" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::String, + path: &["preferredNotifChannel"], + options: None, + }, + "autoCompactEnabled" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::Boolean, + path: &["autoCompactEnabled"], + options: None, + }, + "autoMemoryEnabled" => ConfigSettingSpec { + scope: ConfigScope::Settings, + kind: ConfigKind::Boolean, + path: &["autoMemoryEnabled"], + options: None, + }, + "autoDreamEnabled" => ConfigSettingSpec { + scope: ConfigScope::Settings, + kind: ConfigKind::Boolean, + path: &["autoDreamEnabled"], + options: None, + }, + "fileCheckpointingEnabled" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::Boolean, + path: &["fileCheckpointingEnabled"], + options: None, + }, + "showTurnDuration" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::Boolean, + path: &["showTurnDuration"], + options: None, + }, + "terminalProgressBarEnabled" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::Boolean, + path: &["terminalProgressBarEnabled"], + options: None, + }, + "todoFeatureEnabled" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::Boolean, + path: &["todoFeatureEnabled"], + options: None, + }, + "model" => ConfigSettingSpec { + scope: ConfigScope::Settings, + kind: ConfigKind::String, + path: &["model"], + options: None, + }, + "alwaysThinkingEnabled" => ConfigSettingSpec { + scope: ConfigScope::Settings, + kind: ConfigKind::Boolean, + path: &["alwaysThinkingEnabled"], + options: None, + }, + "permissions.defaultMode" => ConfigSettingSpec { + scope: ConfigScope::Settings, + kind: ConfigKind::String, + path: &["permissions", "defaultMode"], + options: Some(&["default", "plan", "acceptEdits", "dontAsk", "auto"]), + }, + "language" => ConfigSettingSpec { + scope: ConfigScope::Settings, + kind: ConfigKind::String, + path: &["language"], + options: None, + }, + "teammateMode" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::String, + path: &["teammateMode"], + options: Some(&["tmux", "in-process", "auto"]), + }, + _ => return None, + }) +} + +fn normalize_config_value(spec: ConfigSettingSpec, value: ConfigValue) -> Result { + let normalized = match (spec.kind, value) { + (ConfigKind::Boolean, ConfigValue::Bool(value)) => Value::Bool(value), + (ConfigKind::Boolean, ConfigValue::String(value)) => { + match value.trim().to_ascii_lowercase().as_str() { + "true" => Value::Bool(true), + "false" => Value::Bool(false), + _ => return Err(String::from("setting requires true or false")), + } + } + (ConfigKind::Boolean, ConfigValue::Number(_)) => { + return Err(String::from("setting requires true or false")) + } + (ConfigKind::String, ConfigValue::String(value)) => Value::String(value), + (ConfigKind::String, ConfigValue::Bool(value)) => Value::String(value.to_string()), + (ConfigKind::String, ConfigValue::Number(value)) => json!(value), + }; + + if let Some(options) = spec.options { + let Some(as_str) = normalized.as_str() else { + return Err(String::from("setting requires a string value")); + }; + if !options.iter().any(|option| option == &as_str) { + return Err(format!( + "Invalid value \"{as_str}\". Options: {}", + options.join(", ") + )); + } + } + + Ok(normalized) +} + +fn config_file_for_scope(scope: ConfigScope) -> Result { + let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + Ok(match scope { + ConfigScope::Global => config_home_dir()?.join("settings.json"), + ConfigScope::Settings => cwd.join(".claude").join("settings.local.json"), + }) +} + +fn config_home_dir() -> Result { + if let Ok(path) = std::env::var("CLAUDE_CONFIG_HOME") { + return Ok(PathBuf::from(path)); + } + let home = std::env::var("HOME").map_err(|_| String::from("HOME is not set"))?; + Ok(PathBuf::from(home).join(".claude")) +} + +fn read_json_object(path: &Path) -> Result, String> { + match std::fs::read_to_string(path) { + Ok(contents) => { + if contents.trim().is_empty() { + return Ok(serde_json::Map::new()); + } + serde_json::from_str::(&contents) + .map_err(|error| error.to_string())? + .as_object() + .cloned() + .ok_or_else(|| String::from("config file must contain a JSON object")) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::Map::new()), + Err(error) => Err(error.to_string()), + } +} + +fn write_json_object(path: &Path, value: &serde_json::Map) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + std::fs::write( + path, + serde_json::to_string_pretty(value).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string()) +} + +fn get_nested_value<'a>( + value: &'a serde_json::Map, + path: &[&str], +) -> Option<&'a Value> { + let (first, rest) = path.split_first()?; + let mut current = value.get(*first)?; + for key in rest { + current = current.as_object()?.get(*key)?; + } + Some(current) +} + +fn set_nested_value(root: &mut serde_json::Map, path: &[&str], new_value: Value) { + let (first, rest) = path.split_first().expect("config path must not be empty"); + if rest.is_empty() { + root.insert((*first).to_string(), new_value); + return; + } + + let entry = root + .entry((*first).to_string()) + .or_insert_with(|| Value::Object(serde_json::Map::new())); + if !entry.is_object() { + *entry = Value::Object(serde_json::Map::new()); + } + let map = entry.as_object_mut().expect("object inserted"); + set_nested_value(map, rest, new_value); +} + +fn iso8601_timestamp() -> String { + if let Ok(output) = Command::new("date") + .args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]) + .output() + { + if output.status.success() { + return String::from_utf8_lossy(&output.stdout).trim().to_string(); + } + } + iso8601_now() +} + +#[allow(clippy::needless_pass_by_value)] fn execute_powershell(input: PowerShellInput) -> std::io::Result { let _ = &input.description; let shell = detect_powershell_shell()?; @@ -1586,6 +2162,7 @@ fn command_exists(command: &str) -> bool { .unwrap_or(false) } +#[allow(clippy::too_many_lines)] fn execute_shell_command( shell: &str, command: &str, @@ -1802,6 +2379,10 @@ mod tests { assert!(names.contains(&"ToolSearch")); assert!(names.contains(&"NotebookEdit")); assert!(names.contains(&"Sleep")); + assert!(names.contains(&"SendUserMessage")); + assert!(names.contains(&"Config")); + assert!(names.contains(&"StructuredOutput")); + assert!(names.contains(&"REPL")); assert!(names.contains(&"PowerShell")); } @@ -2181,9 +2762,128 @@ mod tests { assert!(elapsed >= Duration::from_millis(15)); } + #[test] + fn brief_returns_sent_message_and_attachment_metadata() { + let attachment = std::env::temp_dir().join(format!( + "clawd-brief-{}.png", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + std::fs::write(&attachment, b"png-data").expect("write attachment"); + + let result = execute_tool( + "SendUserMessage", + &json!({ + "message": "hello user", + "attachments": [attachment.display().to_string()], + "status": "normal" + }), + ) + .expect("SendUserMessage should succeed"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("json"); + assert_eq!(output["message"], "hello user"); + assert!(output["sentAt"].as_str().is_some()); + assert_eq!(output["attachments"][0]["isImage"], true); + let _ = std::fs::remove_file(attachment); + } + + #[test] + fn config_reads_and_writes_supported_values() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let root = std::env::temp_dir().join(format!( + "clawd-config-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + let home = root.join("home"); + let cwd = root.join("cwd"); + std::fs::create_dir_all(home.join(".claude")).expect("home dir"); + std::fs::create_dir_all(cwd.join(".claude")).expect("cwd dir"); + std::fs::write( + home.join(".claude").join("settings.json"), + r#"{"verbose":false}"#, + ) + .expect("write global settings"); + + let original_home = std::env::var("HOME").ok(); + let original_claude_home = std::env::var("CLAUDE_CONFIG_HOME").ok(); + let original_dir = std::env::current_dir().expect("cwd"); + std::env::set_var("HOME", &home); + std::env::remove_var("CLAUDE_CONFIG_HOME"); + std::env::set_current_dir(&cwd).expect("set cwd"); + + let get = execute_tool("Config", &json!({"setting": "verbose"})).expect("get config"); + let get_output: serde_json::Value = serde_json::from_str(&get).expect("json"); + assert_eq!(get_output["value"], false); + + let set = execute_tool( + "Config", + &json!({"setting": "permissions.defaultMode", "value": "plan"}), + ) + .expect("set config"); + let set_output: serde_json::Value = serde_json::from_str(&set).expect("json"); + assert_eq!(set_output["operation"], "set"); + assert_eq!(set_output["newValue"], "plan"); + + let invalid = execute_tool( + "Config", + &json!({"setting": "permissions.defaultMode", "value": "bogus"}), + ) + .expect_err("invalid config value should error"); + assert!(invalid.contains("Invalid value")); + + let unknown = + execute_tool("Config", &json!({"setting": "nope"})).expect("unknown setting result"); + let unknown_output: serde_json::Value = serde_json::from_str(&unknown).expect("json"); + assert_eq!(unknown_output["success"], false); + + std::env::set_current_dir(&original_dir).expect("restore cwd"); + match original_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match original_claude_home { + Some(value) => std::env::set_var("CLAUDE_CONFIG_HOME", value), + None => std::env::remove_var("CLAUDE_CONFIG_HOME"), + } + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn structured_output_echoes_input_payload() { + let result = execute_tool("StructuredOutput", &json!({"ok": true, "items": [1, 2, 3]})) + .expect("StructuredOutput should succeed"); + let output: serde_json::Value = serde_json::from_str(&result).expect("json"); + assert_eq!(output["data"], "Structured output provided successfully"); + assert_eq!(output["structured_output"]["ok"], true); + assert_eq!(output["structured_output"]["items"][1], 2); + } + + #[test] + fn repl_executes_python_code() { + let result = execute_tool( + "REPL", + &json!({"language": "python", "code": "print(1 + 1)", "timeout_ms": 500}), + ) + .expect("REPL should succeed"); + let output: serde_json::Value = serde_json::from_str(&result).expect("json"); + assert_eq!(output["language"], "python"); + assert_eq!(output["exitCode"], 0); + assert!(output["stdout"].as_str().expect("stdout").contains('2')); + } + #[test] fn powershell_runs_via_stub_shell() { - let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let dir = std::env::temp_dir().join(format!( "clawd-pwsh-bin-{}", std::time::SystemTime::now() @@ -2237,7 +2937,9 @@ printf 'pwsh:%s' "$1" #[test] fn powershell_errors_when_shell_is_missing() { - let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let original_path = std::env::var("PATH").unwrap_or_default(); let empty_dir = std::env::temp_dir().join(format!( "clawd-empty-bin-{}",