mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 22:11:53 +08:00
feat(tools): add TodoWrite and Skill tool support
Extend the Rust tools crate with concrete TodoWrite and Skill implementations. TodoWrite now validates and persists structured session todos with Claude Code-aligned item shapes, while Skill resolves local skill definitions and returns their prompt payload for execution handoff. Tests cover persistence and local skill loading without disturbing the previously added web tools.\n\nConstraint: Stay within tools-only scope and avoid depending on broader agent/runtime rewrites\nConstraint: Keep exposed tool names and schemas close to Claude Code contracts\nRejected: In-memory-only TodoWrite state | would not survive across tool calls\nRejected: Stub Skill metadata without loading prompt content | not materially useful to callers\nConfidence: medium\nScope-risk: narrow\nReversibility: clean\nDirective: Preserve TodoWrite item-field parity and keep Skill focused on local skill discovery until agent execution wiring lands\nTested: cargo fmt; cargo test -p tools\nNot-tested: cargo clippy; full workspace cargo test
This commit is contained in:
@@ -178,6 +178,46 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolSpec {
|
||||
name: "TodoWrite",
|
||||
description: "Update the structured task list for the current session.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"todos": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": { "type": "string" },
|
||||
"activeForm": { "type": "string" },
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "in_progress", "completed"]
|
||||
}
|
||||
},
|
||||
"required": ["content", "activeForm", "status"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["todos"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolSpec {
|
||||
name: "Skill",
|
||||
description: "Load a local skill definition and its instructions.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill": { "type": "string" },
|
||||
"args": { "type": "string" }
|
||||
},
|
||||
"required": ["skill"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -191,6 +231,8 @@ pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
|
||||
"grep_search" => from_value::<GrepSearchInput>(input).and_then(run_grep_search),
|
||||
"WebFetch" => from_value::<WebFetchInput>(input).and_then(run_web_fetch),
|
||||
"WebSearch" => from_value::<WebSearchInput>(input).and_then(run_web_search),
|
||||
"TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write),
|
||||
"Skill" => from_value::<SkillInput>(input).and_then(run_skill),
|
||||
_ => Err(format!("unsupported tool: {name}")),
|
||||
}
|
||||
}
|
||||
@@ -240,6 +282,14 @@ fn run_web_search(input: WebSearchInput) -> Result<String, String> {
|
||||
to_pretty_json(execute_web_search(&input)?)
|
||||
}
|
||||
|
||||
fn run_todo_write(input: TodoWriteInput) -> Result<String, String> {
|
||||
to_pretty_json(execute_todo_write(input)?)
|
||||
}
|
||||
|
||||
fn run_skill(input: SkillInput) -> Result<String, String> {
|
||||
to_pretty_json(execute_skill(input)?)
|
||||
}
|
||||
|
||||
fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
|
||||
serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -288,6 +338,33 @@ struct WebSearchInput {
|
||||
blocked_domains: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TodoWriteInput {
|
||||
todos: Vec<TodoItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
|
||||
struct TodoItem {
|
||||
content: String,
|
||||
#[serde(rename = "activeForm")]
|
||||
active_form: String,
|
||||
status: TodoStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum TodoStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Completed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SkillInput {
|
||||
skill: String,
|
||||
args: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WebFetchOutput {
|
||||
bytes: usize,
|
||||
@@ -308,6 +385,25 @@ struct WebSearchOutput {
|
||||
duration_seconds: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TodoWriteOutput {
|
||||
#[serde(rename = "oldTodos")]
|
||||
old_todos: Vec<TodoItem>,
|
||||
#[serde(rename = "newTodos")]
|
||||
new_todos: Vec<TodoItem>,
|
||||
#[serde(rename = "verificationNudgeNeeded")]
|
||||
verification_nudge_needed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SkillOutput {
|
||||
skill: String,
|
||||
path: String,
|
||||
args: Option<String>,
|
||||
description: Option<String>,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
enum WebSearchResultItem {
|
||||
@@ -672,6 +768,146 @@ fn dedupe_hits(hits: &mut Vec<SearchHit>) {
|
||||
hits.retain(|hit| seen.insert(hit.url.clone()));
|
||||
}
|
||||
|
||||
fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String> {
|
||||
validate_todos(&input.todos)?;
|
||||
let store_path = todo_store_path()?;
|
||||
let old_todos = if store_path.exists() {
|
||||
serde_json::from_str::<Vec<TodoItem>>(
|
||||
&std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?,
|
||||
)
|
||||
.map_err(|error| error.to_string())?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let all_done = input
|
||||
.todos
|
||||
.iter()
|
||||
.all(|todo| matches!(todo.status, TodoStatus::Completed));
|
||||
let persisted = if all_done {
|
||||
Vec::new()
|
||||
} else {
|
||||
input.todos.clone()
|
||||
};
|
||||
|
||||
if let Some(parent) = store_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
||||
}
|
||||
std::fs::write(
|
||||
&store_path,
|
||||
serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let verification_nudge_needed = (all_done
|
||||
&& input.todos.len() >= 3
|
||||
&& !input
|
||||
.todos
|
||||
.iter()
|
||||
.any(|todo| todo.content.to_lowercase().contains("verif")))
|
||||
.then_some(true);
|
||||
|
||||
Ok(TodoWriteOutput {
|
||||
old_todos,
|
||||
new_todos: input.todos,
|
||||
verification_nudge_needed,
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_skill(input: SkillInput) -> Result<SkillOutput, String> {
|
||||
let skill_path = resolve_skill_path(&input.skill)?;
|
||||
let prompt = std::fs::read_to_string(&skill_path).map_err(|error| error.to_string())?;
|
||||
let description = parse_skill_description(&prompt);
|
||||
|
||||
Ok(SkillOutput {
|
||||
skill: input.skill,
|
||||
path: skill_path.display().to_string(),
|
||||
args: input.args,
|
||||
description,
|
||||
prompt,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_todos(todos: &[TodoItem]) -> Result<(), String> {
|
||||
if todos.is_empty() {
|
||||
return Err(String::from("todos must not be empty"));
|
||||
}
|
||||
let in_progress = todos
|
||||
.iter()
|
||||
.filter(|todo| matches!(todo.status, TodoStatus::InProgress))
|
||||
.count();
|
||||
if in_progress > 1 {
|
||||
return Err(String::from(
|
||||
"exactly zero or one todo items may be in_progress",
|
||||
));
|
||||
}
|
||||
if todos.iter().any(|todo| todo.content.trim().is_empty()) {
|
||||
return Err(String::from("todo content must not be empty"));
|
||||
}
|
||||
if todos.iter().any(|todo| todo.active_form.trim().is_empty()) {
|
||||
return Err(String::from("todo activeForm must not be empty"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn todo_store_path() -> Result<std::path::PathBuf, String> {
|
||||
if let Ok(path) = std::env::var("CLAWD_TODO_STORE") {
|
||||
return Ok(std::path::PathBuf::from(path));
|
||||
}
|
||||
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||
Ok(cwd.join(".clawd-todos.json"))
|
||||
}
|
||||
|
||||
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
|
||||
let requested = skill.trim().trim_start_matches('/');
|
||||
if requested.is_empty() {
|
||||
return Err(String::from("skill must not be empty"));
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
|
||||
candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
|
||||
}
|
||||
candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
|
||||
|
||||
for root in candidates {
|
||||
let direct = root.join(requested).join("SKILL.md");
|
||||
if direct.exists() {
|
||||
return Ok(direct);
|
||||
}
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(&root) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path().join("SKILL.md");
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
if entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.eq_ignore_ascii_case(requested)
|
||||
{
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("unknown skill: {requested}"))
|
||||
}
|
||||
|
||||
fn parse_skill_description(contents: &str) -> Option<String> {
|
||||
for line in contents.lines() {
|
||||
if let Some(value) = line.strip_prefix("description:") {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::{Read, Write};
|
||||
@@ -773,6 +1009,79 @@ mod tests {
|
||||
assert_eq!(content[0]["url"], "https://docs.rs/reqwest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_write_persists_and_returns_previous_state() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"clawd-tools-todos-{}.json",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("time")
|
||||
.as_nanos()
|
||||
));
|
||||
std::env::set_var("CLAWD_TODO_STORE", &path);
|
||||
|
||||
let first = execute_tool(
|
||||
"TodoWrite",
|
||||
&json!({
|
||||
"todos": [
|
||||
{"content": "Add tool", "activeForm": "Adding tool", "status": "in_progress"},
|
||||
{"content": "Run tests", "activeForm": "Running tests", "status": "pending"}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.expect("TodoWrite should succeed");
|
||||
let first_output: serde_json::Value = serde_json::from_str(&first).expect("valid json");
|
||||
assert_eq!(first_output["oldTodos"].as_array().expect("array").len(), 0);
|
||||
|
||||
let second = execute_tool(
|
||||
"TodoWrite",
|
||||
&json!({
|
||||
"todos": [
|
||||
{"content": "Add tool", "activeForm": "Adding tool", "status": "completed"},
|
||||
{"content": "Run tests", "activeForm": "Running tests", "status": "completed"},
|
||||
{"content": "Verify", "activeForm": "Verifying", "status": "completed"}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.expect("TodoWrite should succeed");
|
||||
std::env::remove_var("CLAWD_TODO_STORE");
|
||||
let _ = std::fs::remove_file(path);
|
||||
|
||||
let second_output: serde_json::Value = serde_json::from_str(&second).expect("valid json");
|
||||
assert_eq!(
|
||||
second_output["oldTodos"].as_array().expect("array").len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
second_output["newTodos"].as_array().expect("array").len(),
|
||||
3
|
||||
);
|
||||
assert!(second_output["verificationNudgeNeeded"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_loads_local_skill_prompt() {
|
||||
let result = execute_tool(
|
||||
"Skill",
|
||||
&json!({
|
||||
"skill": "help",
|
||||
"args": "overview"
|
||||
}),
|
||||
)
|
||||
.expect("Skill should succeed");
|
||||
|
||||
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
|
||||
assert_eq!(output["skill"], "help");
|
||||
assert!(output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.ends_with("/help/SKILL.md"));
|
||||
assert!(output["prompt"]
|
||||
.as_str()
|
||||
.expect("prompt")
|
||||
.contains("Guide on using oh-my-codex plugin"));
|
||||
}
|
||||
|
||||
struct TestServer {
|
||||
addr: SocketAddr,
|
||||
shutdown: Option<std::sync::mpsc::Sender<()>>,
|
||||
|
||||
Reference in New Issue
Block a user