Close the Claude Code tools parity gap

Implement the remaining long-tail tool surfaces needed for Claude Code parity in the Rust tools crate: SendUserMessage/Brief, Config, StructuredOutput, and REPL, plus tests that lock down their current schemas and basic behavior. A small runtime clippy cleanup in file_ops was required so the requested verification lane could pass without suppressing workspace warnings.

Constraint: Match Claude Code tool names and input schemas closely enough for parity-oriented callers
Constraint: No new dependencies for schema validation or REPL orchestration
Rejected: Split runtime clippy fixes into a separate commit | would block the required cargo clippy verification step for this delivery
Rejected: Implement a stateful persistent REPL session manager | unnecessary for current parity scope and would widen risk substantially
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: If upstream Claude Code exposes a concrete REPL tool schema later, reconcile this implementation against that source before expanding behavior
Tested: cargo fmt --all; cargo clippy -p tools --all-targets --all-features -- -D warnings; cargo test -p tools
Not-tested: End-to-end integration with non-Rust consumers; schema-level validation against upstream generated tool payloads
This commit is contained in:
Yeachan-Heo
2026-03-31 22:53:20 +00:00
parent 99b78d6ea4
commit 46581fe442
2 changed files with 722 additions and 21 deletions

View File

@@ -138,9 +138,9 @@ pub fn read_file(
let content = fs::read_to_string(&absolute_path)?; let content = fs::read_to_string(&absolute_path)?;
let lines: Vec<&str> = content.lines().collect(); let lines: Vec<&str> = content.lines().collect();
let start_index = offset.unwrap_or(0).min(lines.len()); let start_index = offset.unwrap_or(0).min(lines.len());
let end_index = limit let end_index = limit.map_or(lines.len(), |limit| {
.map(|limit| start_index.saturating_add(limit).min(lines.len())) start_index.saturating_add(limit).min(lines.len())
.unwrap_or(lines.len()); });
let selected = lines[start_index..end_index].join("\n"); let selected = lines[start_index..end_index].join("\n");
Ok(ReadFileOutput { Ok(ReadFileOutput {
@@ -296,12 +296,12 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
continue; continue;
} }
let Ok(content) = fs::read_to_string(&file_path) else { let Ok(file_contents) = fs::read_to_string(&file_path) else {
continue; continue;
}; };
if output_mode == "count" { if output_mode == "count" {
let count = regex.find_iter(&content).count(); let count = regex.find_iter(&file_contents).count();
if count > 0 { if count > 0 {
filenames.push(file_path.to_string_lossy().into_owned()); filenames.push(file_path.to_string_lossy().into_owned());
total_matches += count; total_matches += count;
@@ -309,7 +309,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
continue; continue;
} }
let lines: Vec<&str> = content.lines().collect(); let lines: Vec<&str> = file_contents.lines().collect();
let mut matched_lines = Vec::new(); let mut matched_lines = Vec::new();
for (index, line) in lines.iter().enumerate() { for (index, line) in lines.iter().enumerate() {
if regex.is_match(line) { if regex.is_match(line) {
@@ -327,13 +327,13 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
for index in matched_lines { for index in matched_lines {
let start = index.saturating_sub(input.before.unwrap_or(context)); let start = index.saturating_sub(input.before.unwrap_or(context));
let end = (index + input.after.unwrap_or(context) + 1).min(lines.len()); let end = (index + input.after.unwrap_or(context) + 1).min(lines.len());
for current in start..end { for (current, line) in lines.iter().enumerate().take(end).skip(start) {
let prefix = if input.line_numbers.unwrap_or(true) { let prefix = if input.line_numbers.unwrap_or(true) {
format!("{}:{}:", file_path.to_string_lossy(), current + 1) format!("{}:{}:", file_path.to_string_lossy(), current + 1)
} else { } else {
format!("{}:", file_path.to_string_lossy()) format!("{}:", file_path.to_string_lossy())
}; };
content_lines.push(format!("{prefix}{}", lines[current])); content_lines.push(format!("{prefix}{line}"));
} }
} }
} }
@@ -341,7 +341,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
let (filenames, applied_limit, applied_offset) = let (filenames, applied_limit, applied_offset) =
apply_limit(filenames, input.head_limit, input.offset); apply_limit(filenames, input.head_limit, input.offset);
let 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); let (lines, limit, offset) = apply_limit(content_lines, input.head_limit, input.offset);
return Ok(GrepSearchOutput { return Ok(GrepSearchOutput {
mode: Some(output_mode), mode: Some(output_mode),
@@ -361,7 +361,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
mode: Some(output_mode.clone()), mode: Some(output_mode.clone()),
num_files: filenames.len(), num_files: filenames.len(),
filenames, filenames,
content, content: content_output,
num_lines: None, num_lines: None,
num_matches: (output_mode == "count").then_some(total_matches), num_matches: (output_mode == "count").then_some(total_matches),
applied_limit, applied_limit,
@@ -376,8 +376,7 @@ fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
let mut files = Vec::new(); let mut files = Vec::new();
for entry in WalkDir::new(base_path) { for entry in WalkDir::new(base_path) {
let entry = let entry = entry.map_err(|error| io::Error::other(error.to_string()))?;
entry.map_err(|error| io::Error::new(io::ErrorKind::Other, error.to_string()))?;
if entry.file_type().is_file() { if entry.file_type().is_file() {
files.push(entry.path().to_path_buf()); files.push(entry.path().to_path_buf());
} }

View File

@@ -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 std::time::{Duration, Instant};
use reqwest::blocking::Client; use reqwest::blocking::Client;
@@ -46,6 +48,7 @@ pub struct ToolSpec {
} }
#[must_use] #[must_use]
#[allow(clippy::too_many_lines)]
pub fn mvp_tool_specs() -> Vec<ToolSpec> { pub fn mvp_tool_specs() -> Vec<ToolSpec> {
vec![ vec![
ToolSpec { ToolSpec {
@@ -275,6 +278,63 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"additionalProperties": false "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 { ToolSpec {
name: "PowerShell", name: "PowerShell",
description: "Execute a PowerShell command with optional timeout.", description: "Execute a PowerShell command with optional timeout.",
@@ -309,6 +369,12 @@ pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
"ToolSearch" => from_value::<ToolSearchInput>(input).and_then(run_tool_search), "ToolSearch" => from_value::<ToolSearchInput>(input).and_then(run_tool_search),
"NotebookEdit" => from_value::<NotebookEditInput>(input).and_then(run_notebook_edit), "NotebookEdit" => from_value::<NotebookEditInput>(input).and_then(run_notebook_edit),
"Sleep" => from_value::<SleepInput>(input).and_then(run_sleep), "Sleep" => from_value::<SleepInput>(input).and_then(run_sleep),
"SendUserMessage" | "Brief" => from_value::<BriefInput>(input).and_then(run_brief),
"Config" => from_value::<ConfigInput>(input).and_then(run_config),
"StructuredOutput" => {
from_value::<StructuredOutputInput>(input).and_then(run_structured_output)
}
"REPL" => from_value::<ReplInput>(input).and_then(run_repl),
"PowerShell" => from_value::<PowerShellInput>(input).and_then(run_powershell), "PowerShell" => from_value::<PowerShellInput>(input).and_then(run_powershell),
_ => Err(format!("unsupported tool: {name}")), _ => Err(format!("unsupported tool: {name}")),
} }
@@ -323,14 +389,17 @@ fn run_bash(input: BashCommandInput) -> Result<String, String> {
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
} }
#[allow(clippy::needless_pass_by_value)]
fn run_read_file(input: ReadFileInput) -> Result<String, String> { fn run_read_file(input: ReadFileInput) -> Result<String, String> {
to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?) 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<String, String> { fn run_write_file(input: WriteFileInput) -> Result<String, String> {
to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?) 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<String, String> { fn run_edit_file(input: EditFileInput) -> Result<String, String> {
to_pretty_json( to_pretty_json(
edit_file( edit_file(
@@ -343,18 +412,22 @@ fn run_edit_file(input: EditFileInput) -> Result<String, String> {
) )
} }
#[allow(clippy::needless_pass_by_value)]
fn run_glob_search(input: GlobSearchInputValue) -> Result<String, String> { fn run_glob_search(input: GlobSearchInputValue) -> Result<String, String> {
to_pretty_json(glob_search(&input.pattern, input.path.as_deref()).map_err(io_to_string)?) 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<String, String> { fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
to_pretty_json(grep_search(&input).map_err(io_to_string)?) to_pretty_json(grep_search(&input).map_err(io_to_string)?)
} }
#[allow(clippy::needless_pass_by_value)]
fn run_web_fetch(input: WebFetchInput) -> Result<String, String> { fn run_web_fetch(input: WebFetchInput) -> Result<String, String> {
to_pretty_json(execute_web_fetch(&input)?) to_pretty_json(execute_web_fetch(&input)?)
} }
#[allow(clippy::needless_pass_by_value)]
fn run_web_search(input: WebSearchInput) -> Result<String, String> { fn run_web_search(input: WebSearchInput) -> Result<String, String> {
to_pretty_json(execute_web_search(&input)?) to_pretty_json(execute_web_search(&input)?)
} }
@@ -383,6 +456,22 @@ fn run_sleep(input: SleepInput) -> Result<String, String> {
to_pretty_json(execute_sleep(input)) to_pretty_json(execute_sleep(input))
} }
fn run_brief(input: BriefInput) -> Result<String, String> {
to_pretty_json(execute_brief(input)?)
}
fn run_config(input: ConfigInput) -> Result<String, String> {
to_pretty_json(execute_config(input)?)
}
fn run_structured_output(input: StructuredOutputInput) -> Result<String, String> {
to_pretty_json(execute_structured_output(input))
}
fn run_repl(input: ReplInput) -> Result<String, String> {
to_pretty_json(execute_repl(input)?)
}
fn run_powershell(input: PowerShellInput) -> Result<String, String> { fn run_powershell(input: PowerShellInput) -> Result<String, String> {
to_pretty_json(execute_powershell(input).map_err(|error| error.to_string())?) to_pretty_json(execute_powershell(input).map_err(|error| error.to_string())?)
} }
@@ -391,6 +480,7 @@ fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
serde_json::to_string_pretty(&value).map_err(|error| error.to_string()) 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 { fn io_to_string(error: std::io::Error) -> String {
error.to_string() error.to_string()
} }
@@ -506,6 +596,45 @@ struct SleepInput {
duration_ms: u64, duration_ms: u64,
} }
#[derive(Debug, Deserialize)]
struct BriefInput {
message: String,
attachments: Option<Vec<String>>,
status: BriefStatus,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
enum BriefStatus {
Normal,
Proactive,
}
#[derive(Debug, Deserialize)]
struct ConfigInput {
setting: String,
value: Option<ConfigValue>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ConfigValue {
String(String),
Bool(bool),
Number(f64),
}
#[derive(Debug, Deserialize)]
#[serde(transparent)]
struct StructuredOutputInput(BTreeMap<String, Value>);
#[derive(Debug, Deserialize)]
struct ReplInput {
code: String,
language: String,
timeout_ms: Option<u64>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct PowerShellInput { struct PowerShellInput {
command: String, command: String,
@@ -601,6 +730,52 @@ struct SleepOutput {
message: String, message: String,
} }
#[derive(Debug, Serialize)]
struct BriefOutput {
message: String,
attachments: Option<Vec<ResolvedAttachment>>,
#[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<String>,
setting: Option<String>,
value: Option<Value>,
#[serde(rename = "previousValue")]
previous_value: Option<Value>,
#[serde(rename = "newValue")]
new_value: Option<Value>,
error: Option<String>,
}
#[derive(Debug, Serialize)]
struct StructuredOutputResult {
data: String,
structured_output: BTreeMap<String, Value>,
}
#[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)] #[derive(Debug, Serialize)]
#[serde(untagged)] #[serde(untagged)]
enum WebSearchResultItem { enum WebSearchResultItem {
@@ -722,7 +897,7 @@ fn normalize_fetch_url(url: &str) -> Result<String, String> {
let mut upgraded = parsed; let mut upgraded = parsed;
upgraded upgraded
.set_scheme("https") .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()); return Ok(upgraded.to_string());
} }
} }
@@ -761,9 +936,10 @@ fn summarize_web_fetch(
let compact = collapse_whitespace(content); let compact = collapse_whitespace(content);
let detail = if lower_prompt.contains("title") { let detail = if lower_prompt.contains("title") {
extract_title(content, raw_body, content_type) extract_title(content, raw_body, content_type).map_or_else(
.map(|title| format!("Title: {title}")) || preview_text(&compact, 600),
.unwrap_or_else(|| preview_text(&compact, 600)) |title| format!("Title: {title}"),
)
} else if lower_prompt.contains("summary") || lower_prompt.contains("summarize") { } else if lower_prompt.contains("summary") || lower_prompt.contains("summarize") {
preview_text(&compact, 900) preview_text(&compact, 900)
} else { } else {
@@ -1186,6 +1362,7 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
Ok(manifest) Ok(manifest)
} }
#[allow(clippy::needless_pass_by_value)]
fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput { fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
let deferred = deferred_tool_specs(); let deferred = deferred_tool_specs();
let max_results = input.max_results.unwrap_or(5).max(1); 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 { fn canonical_tool_token(value: &str) -> String {
let mut canonical = value let mut canonical = value
.chars() .chars()
.filter(|ch| ch.is_ascii_alphanumeric()) .filter(char::is_ascii_alphanumeric)
.flat_map(char::to_lowercase) .flat_map(char::to_lowercase)
.collect::<String>(); .collect::<String>();
if let Some(stripped) = canonical.strip_suffix("tool") { if let Some(stripped) = canonical.strip_suffix("tool") {
@@ -1384,6 +1561,7 @@ fn iso8601_now() -> String {
.to_string() .to_string()
} }
#[allow(clippy::too_many_lines)]
fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput, String> { fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput, String> {
let path = std::path::PathBuf::from(&input.notebook_path); let path = std::path::PathBuf::from(&input.notebook_path);
if path.extension().and_then(|ext| ext.to_str()) != Some("ipynb") { if path.extension().and_then(|ext| ext.to_str()) != Some("ipynb") {
@@ -1466,7 +1644,7 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
if !cell.get("outputs").is_some_and(serde_json::Value::is_array) { if !cell.get("outputs").is_some_and(serde_json::Value::is_array) {
cell["outputs"] = json!([]); cell["outputs"] = json!([]);
} }
if !cell.get("execution_count").is_some() { if cell.get("execution_count").is_none() {
cell["execution_count"] = serde_json::Value::Null; cell["execution_count"] = serde_json::Value::Null;
} }
} }
@@ -1545,6 +1723,7 @@ fn cell_kind(cell: &serde_json::Value) -> Option<NotebookCellType> {
}) })
} }
#[allow(clippy::needless_pass_by_value)]
fn execute_sleep(input: SleepInput) -> SleepOutput { fn execute_sleep(input: SleepInput) -> SleepOutput {
std::thread::sleep(Duration::from_millis(input.duration_ms)); std::thread::sleep(Duration::from_millis(input.duration_ms));
SleepOutput { SleepOutput {
@@ -1553,6 +1732,403 @@ fn execute_sleep(input: SleepInput) -> SleepOutput {
} }
} }
fn execute_brief(input: BriefInput) -> Result<BriefOutput, String> {
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::<Result<Vec<_>, 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<ResolvedAttachment, String> {
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<ConfigOutput, String> {
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<ReplOutput, String> {
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<ReplRuntime, String> {
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<ConfigSettingSpec> {
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<Value, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<serde_json::Map<String, Value>, 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::<Value>(&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<String, Value>) -> 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<String, Value>,
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<String, Value>, 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<runtime::BashCommandOutput> { fn execute_powershell(input: PowerShellInput) -> std::io::Result<runtime::BashCommandOutput> {
let _ = &input.description; let _ = &input.description;
let shell = detect_powershell_shell()?; let shell = detect_powershell_shell()?;
@@ -1586,6 +2162,7 @@ fn command_exists(command: &str) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
#[allow(clippy::too_many_lines)]
fn execute_shell_command( fn execute_shell_command(
shell: &str, shell: &str,
command: &str, command: &str,
@@ -1802,6 +2379,10 @@ mod tests {
assert!(names.contains(&"ToolSearch")); assert!(names.contains(&"ToolSearch"));
assert!(names.contains(&"NotebookEdit")); assert!(names.contains(&"NotebookEdit"));
assert!(names.contains(&"Sleep")); assert!(names.contains(&"Sleep"));
assert!(names.contains(&"SendUserMessage"));
assert!(names.contains(&"Config"));
assert!(names.contains(&"StructuredOutput"));
assert!(names.contains(&"REPL"));
assert!(names.contains(&"PowerShell")); assert!(names.contains(&"PowerShell"));
} }
@@ -2181,9 +2762,128 @@ mod tests {
assert!(elapsed >= Duration::from_millis(15)); 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] #[test]
fn powershell_runs_via_stub_shell() { 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!( let dir = std::env::temp_dir().join(format!(
"clawd-pwsh-bin-{}", "clawd-pwsh-bin-{}",
std::time::SystemTime::now() std::time::SystemTime::now()
@@ -2237,7 +2937,9 @@ printf 'pwsh:%s' "$1"
#[test] #[test]
fn powershell_errors_when_shell_is_missing() { 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 original_path = std::env::var("PATH").unwrap_or_default();
let empty_dir = std::env::temp_dir().join(format!( let empty_dir = std::env::temp_dir().join(format!(
"clawd-empty-bin-{}", "clawd-empty-bin-{}",