Files
claw-code/rust/crates/tools/src/lib.rs
Yeachan-Heo 4bae5ee132 Improve CLI visibility into runtime usage and compaction
This adds token and estimated cost reporting to runtime usage tracking and surfaces it in the CLI status and turn output. It also upgrades compaction summaries so users see a clearer resumable summary and token savings after /compact.

The verification path required cleaning existing workspace clippy and test friction in adjacent crates so cargo fmt, cargo clippy -D warnings, and cargo test succeed from the Rust workspace root in this repo state.

Constraint: Keep the change incremental and user-visible without a large CLI rewrite

Constraint: Verification must pass with cargo fmt, cargo clippy --all-targets --all-features -- -D warnings, and cargo test

Rejected: Implement a full model-pricing table now | would add more surface area than needed for this first UX slice

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: If pricing becomes model-specific later, keep the current estimate labeling explicit rather than implying exact billing

Tested: cargo fmt; cargo clippy --all-targets --all-features -- -D warnings; cargo test -q

Not-tested: Live Anthropic API interaction and real streaming terminal sessions
2026-03-31 19:18:56 +00:00

257 lines
8.0 KiB
Rust

use runtime::{
edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
GrepSearchInput,
};
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolManifestEntry {
pub name: String,
pub source: ToolSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolSource {
Base,
Conditional,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ToolRegistry {
entries: Vec<ToolManifestEntry>,
}
impl ToolRegistry {
#[must_use]
pub fn new(entries: Vec<ToolManifestEntry>) -> Self {
Self { entries }
}
#[must_use]
pub fn entries(&self) -> &[ToolManifestEntry] {
&self.entries
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolSpec {
pub name: &'static str,
pub description: &'static str,
pub input_schema: Value,
}
#[must_use]
pub fn mvp_tool_specs() -> Vec<ToolSpec> {
vec![
ToolSpec {
name: "bash",
description: "Execute a shell command in the current workspace.",
input_schema: json!({
"type": "object",
"properties": {
"command": { "type": "string" },
"timeout": { "type": "integer", "minimum": 1 },
"description": { "type": "string" },
"run_in_background": { "type": "boolean" },
"dangerouslyDisableSandbox": { "type": "boolean" }
},
"required": ["command"],
"additionalProperties": false
}),
},
ToolSpec {
name: "read_file",
description: "Read a text file from the workspace.",
input_schema: json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"offset": { "type": "integer", "minimum": 0 },
"limit": { "type": "integer", "minimum": 1 }
},
"required": ["path"],
"additionalProperties": false
}),
},
ToolSpec {
name: "write_file",
description: "Write a text file in the workspace.",
input_schema: json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"content": { "type": "string" }
},
"required": ["path", "content"],
"additionalProperties": false
}),
},
ToolSpec {
name: "edit_file",
description: "Replace text in a workspace file.",
input_schema: json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"old_string": { "type": "string" },
"new_string": { "type": "string" },
"replace_all": { "type": "boolean" }
},
"required": ["path", "old_string", "new_string"],
"additionalProperties": false
}),
},
ToolSpec {
name: "glob_search",
description: "Find files by glob pattern.",
input_schema: json!({
"type": "object",
"properties": {
"pattern": { "type": "string" },
"path": { "type": "string" }
},
"required": ["pattern"],
"additionalProperties": false
}),
},
ToolSpec {
name: "grep_search",
description: "Search file contents with a regex pattern.",
input_schema: json!({
"type": "object",
"properties": {
"pattern": { "type": "string" },
"path": { "type": "string" },
"glob": { "type": "string" },
"output_mode": { "type": "string" },
"-B": { "type": "integer", "minimum": 0 },
"-A": { "type": "integer", "minimum": 0 },
"-C": { "type": "integer", "minimum": 0 },
"context": { "type": "integer", "minimum": 0 },
"-n": { "type": "boolean" },
"-i": { "type": "boolean" },
"type": { "type": "string" },
"head_limit": { "type": "integer", "minimum": 1 },
"offset": { "type": "integer", "minimum": 0 },
"multiline": { "type": "boolean" }
},
"required": ["pattern"],
"additionalProperties": false
}),
},
]
}
pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
match name {
"bash" => from_value::<BashCommandInput>(input).and_then(run_bash),
"read_file" => from_value::<ReadFileInput>(input).and_then(|input| run_read_file(&input)),
"write_file" => {
from_value::<WriteFileInput>(input).and_then(|input| run_write_file(&input))
}
"edit_file" => from_value::<EditFileInput>(input).and_then(|input| run_edit_file(&input)),
"glob_search" => {
from_value::<GlobSearchInputValue>(input).and_then(|input| run_glob_search(&input))
}
"grep_search" => {
from_value::<GrepSearchInput>(input).and_then(|input| run_grep_search(&input))
}
_ => Err(format!("unsupported tool: {name}")),
}
}
fn from_value<T: for<'de> Deserialize<'de>>(input: &Value) -> Result<T, String> {
serde_json::from_value(input.clone()).map_err(|error| error.to_string())
}
fn run_bash(input: BashCommandInput) -> Result<String, String> {
serde_json::to_string_pretty(&execute_bash(input).map_err(|error| error.to_string())?)
.map_err(|error| error.to_string())
}
fn run_read_file(input: &ReadFileInput) -> Result<String, String> {
to_pretty_json(
read_file(&input.path, input.offset, input.limit).map_err(|error| error.to_string())?,
)
}
fn run_write_file(input: &WriteFileInput) -> Result<String, String> {
to_pretty_json(write_file(&input.path, &input.content).map_err(|error| error.to_string())?)
}
fn run_edit_file(input: &EditFileInput) -> Result<String, String> {
to_pretty_json(
edit_file(
&input.path,
&input.old_string,
&input.new_string,
input.replace_all.unwrap_or(false),
)
.map_err(|error| error.to_string())?,
)
}
fn run_glob_search(input: &GlobSearchInputValue) -> Result<String, String> {
to_pretty_json(
glob_search(&input.pattern, input.path.as_deref()).map_err(|error| error.to_string())?,
)
}
fn run_grep_search(input: &GrepSearchInput) -> Result<String, String> {
to_pretty_json(grep_search(input).map_err(|error| error.to_string())?)
}
fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
}
#[derive(Debug, Deserialize)]
struct ReadFileInput {
path: String,
offset: Option<usize>,
limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct WriteFileInput {
path: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct EditFileInput {
path: String,
old_string: String,
new_string: String,
replace_all: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct GlobSearchInputValue {
pattern: String,
path: Option<String>,
}
#[cfg(test)]
mod tests {
use super::{execute_tool, mvp_tool_specs};
use serde_json::json;
#[test]
fn exposes_mvp_tools() {
let names = mvp_tool_specs()
.into_iter()
.map(|spec| spec.name)
.collect::<Vec<_>>();
assert!(names.contains(&"bash"));
assert!(names.contains(&"read_file"));
}
#[test]
fn rejects_unknown_tool_names() {
let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");
assert!(error.contains("unsupported tool"));
}
}