mod grep;
mod search_replace;
use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::path::Path;
use crate::types::{EventSink, FunctionDef, ToolDefinition};
use super::{
filter_sensitive_content_in_text, get_path_arg, is_key_write_path, is_sensitive_read_path,
is_sensitive_write_path, resolve_within_workspace, resolve_within_workspace_or_output,
};
use crate::high_risk;
pub(super) fn tool_definitions() -> Vec<ToolDefinition> {
vec![
ToolDefinition {
tool_type: "function".to_string(),
function: FunctionDef {
name: "read_file".to_string(),
description: "Read the contents of a file. Returns UTF-8 text with line numbers (N|line). Use start_line/end_line for partial reads to save context. Blocks .env, .key, .git/config. Other files have sensitive values (API_KEY, password, etc.) redacted.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path (relative to workspace or absolute)"
},
"start_line": {
"type": "integer",
"description": "Start line number (1-based, inclusive). Omit to read from beginning."
},
"end_line": {
"type": "integer",
"description": "End line number (1-based, inclusive). Omit to read to end."
}
},
"required": ["path"]
}),
},
},
ToolDefinition {
tool_type: "function".to_string(),
function: FunctionDef {
name: "write_file".to_string(),
description: "Write content to a file. Creates parent directories if needed. Blocks writes to sensitive files (.env, .key, .git/config). Use append: true to append to existing file instead of overwriting.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path (relative to workspace or absolute)"
},
"content": {
"type": "string",
"description": "Content to write"
},
"append": {
"type": "boolean",
"description": "If true, append content to end of file. Default: false (overwrite)."
}
},
"required": ["path", "content"]
}),
},
},
ToolDefinition {
tool_type: "function".to_string(),
function: FunctionDef {
name: "search_replace".to_string(),
description: "Replace text in a file with automatic fuzzy matching. Tries exact match first, then falls back to whitespace-insensitive and similarity-based matching. Use dry_run: true to preview without writing.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path (relative to workspace or absolute)"
},
"old_string": {
"type": "string",
"description": "Text to find (fuzzy matching handles minor whitespace differences automatically)"
},
"new_string": {
"type": "string",
"description": "Text to replace old_string with"
},
"replace_all": {
"type": "boolean",
"description": "If true, replace all occurrences. Default: false (replace first only)."
},
"dry_run": {
"type": "boolean",
"description": "If true, preview the edit without writing to disk. Default: false."
}
},
"required": ["path", "old_string", "new_string"]
}),
},
},
ToolDefinition {
tool_type: "function".to_string(),
function: FunctionDef {
name: "insert_lines".to_string(),
description: "Insert content after a specific line number. Use line=0 to insert at the beginning of the file.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path (relative to workspace or absolute)"
},
"line": {
"type": "integer",
"description": "Insert after this line number (0 = beginning of file, 1 = after first line)"
},
"content": {
"type": "string",
"description": "Content to insert"
}
},
"required": ["path", "line", "content"]
}),
},
},
ToolDefinition {
tool_type: "function".to_string(),
function: FunctionDef {
name: "grep_files".to_string(),
description: "Search file contents using regex. Returns file:line:content matches. Auto-skips .git, node_modules, target, and binary files.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regex pattern to search for"
},
"path": {
"type": "string",
"description": "Directory to search in (relative to workspace). Default: workspace root."
},
"include": {
"type": "string",
"description": "File type filter (e.g. '*.rs', '*.py'). Default: all text files."
}
},
"required": ["pattern"]
}),
},
},
ToolDefinition {
tool_type: "function".to_string(),
function: FunctionDef {
name: "list_directory".to_string(),
description: "List files and directories in a given path. Supports recursive listing.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory path (relative to workspace or absolute). Defaults to workspace root."
},
"recursive": {
"type": "boolean",
"description": "If true, list recursively. Default: false."
}
},
"required": []
}),
},
},
ToolDefinition {
tool_type: "function".to_string(),
function: FunctionDef {
name: "file_exists".to_string(),
description: "Check if a file or directory exists. Returns type (file/directory) and size.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to check"
}
},
"required": ["path"]
}),
},
},
]
}
pub(super) fn execute_read_file(args: &Value, workspace: &Path) -> Result<String> {
let path_str = get_path_arg(args, false)
.ok_or_else(|| anyhow::anyhow!("'path' or 'file_path' is required"))?;
let resolved = resolve_within_workspace_or_output(&path_str, workspace)?;
if !resolved.exists() {
anyhow::bail!("File not found: {}", path_str);
}
if resolved.is_dir() {
anyhow::bail!("Path is a directory, not a file: {}", path_str);
}
if is_sensitive_read_path(&path_str) {
anyhow::bail!(
"Blocked: reading sensitive file '{}' (.env, .key, .git/config, etc.) is not allowed",
path_str
);
}
let start_line = args
.get("start_line")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
let end_line = args
.get("end_line")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
match skilllite_fs::read_file(&resolved) {
Ok(content) => {
let (content, was_redacted) = filter_sensitive_content_in_text(&content);
let lines: Vec<&str> = content.lines().collect();
let total = lines.len();
let start = start_line.unwrap_or(1).max(1);
let end = end_line.unwrap_or(total).min(total);
if start > total {
return Ok(format!(
"[File has {} lines, requested start_line={}]",
total, start
));
}
if start > end {
return Ok(format!(
"[Invalid range: start_line={} > end_line={}]",
start, end
));
}
let mut output = String::new();
for (i, line) in lines.iter().enumerate().take(end).skip(start - 1) {
output.push_str(&format!("{:>6}|{}\n", i + 1, line));
}
if start_line.is_some() || end_line.is_some() {
output.push_str(&format!(
"\n[Showing lines {}-{} of {} total]",
start, end, total
));
}
if was_redacted {
output.push_str(
"\n\n[⚠️ Sensitive values (API_KEY, PASSWORD, etc.) have been redacted]",
);
}
Ok(output)
}
Err(e) => {
if e.downcast_ref::<std::io::Error>()
.map(|ie| ie.kind() == std::io::ErrorKind::InvalidData)
== Some(true)
{
let size = match skilllite_fs::file_exists(&resolved)? {
skilllite_fs::PathKind::File(len) => len,
_ => 0,
};
Ok(format!(
"[Binary file, {} bytes. Cannot display as text.]",
size
))
} else {
Err(e)
}
}
}
}
pub(super) fn execute_write_file(
args: &Value,
workspace: &Path,
event_sink: Option<&mut dyn EventSink>,
) -> Result<String> {
let path_str = get_path_arg(args, false)
.ok_or_else(|| anyhow::anyhow!("'path' or 'file_path' is required"))?;
let content = args
.get("content")
.and_then(|v| v.as_str())
.context("'content' is required")?;
let append = args
.get("append")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_sensitive_write_path(&path_str) {
anyhow::bail!(
"Blocked: writing to sensitive file '{}' is not allowed",
path_str
);
}
if high_risk::confirm_write_key_path() && is_key_write_path(&path_str) {
if let Some(sink) = event_sink {
let preview = content.chars().take(200).collect::<String>();
let suffix = if content.len() > 200 { "..." } else { "" };
let msg = format!(
"⚠️ 关键路径写入确认\n\n路径: {}\n内容预览 (前200字符):\n{}\n{}\n\n确认写入?",
path_str, preview, suffix
);
if !sink.on_confirmation_request(&msg) {
return Ok("User cancelled: write to key path not confirmed".to_string());
}
}
}
let resolved = resolve_within_workspace(&path_str, workspace)?;
if append {
skilllite_fs::append_file(&resolved, content)
.with_context(|| format!("Failed to append to file: {}", path_str))?;
} else {
skilllite_fs::write_file(&resolved, content)
.with_context(|| format!("Failed to write file: {}", path_str))?;
}
Ok(format!(
"Successfully {} {} bytes to {}",
if append { "appended" } else { "wrote" },
content.len(),
path_str
))
}
pub(super) fn execute_search_replace(
args: &Value,
workspace: &Path,
event_sink: Option<&mut dyn EventSink>,
) -> Result<String> {
search_replace::execute_search_replace(args, workspace, event_sink)
}
pub(super) fn execute_preview_edit(args: &Value, workspace: &Path) -> Result<String> {
search_replace::execute_preview_edit(args, workspace)
}
pub(super) fn execute_insert_lines(
args: &Value,
workspace: &Path,
event_sink: Option<&mut dyn EventSink>,
) -> Result<String> {
search_replace::execute_insert_lines(args, workspace, event_sink)
}
pub(super) fn execute_grep_files(args: &Value, workspace: &Path) -> Result<String> {
grep::execute_grep_files(args, workspace)
}
pub(super) fn execute_list_directory(args: &Value, workspace: &Path) -> Result<String> {
let path_str = get_path_arg(args, true).unwrap_or_else(|| ".".to_string());
let recursive = args
.get("recursive")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let resolved = resolve_within_workspace_or_output(&path_str, workspace)?;
let entries = skilllite_fs::list_directory(&resolved, recursive)?;
Ok(entries.join("\n"))
}
pub(super) fn execute_file_exists(args: &Value, workspace: &Path) -> Result<String> {
let path_str = get_path_arg(args, false)
.ok_or_else(|| anyhow::anyhow!("'path' or 'file_path' is required"))?;
let resolved = resolve_within_workspace_or_output(&path_str, workspace)?;
match skilllite_fs::file_exists(&resolved)? {
skilllite_fs::PathKind::NotFound => Ok(format!("{}: does not exist", path_str)),
skilllite_fs::PathKind::Dir => Ok(format!("{}: directory", path_str)),
skilllite_fs::PathKind::File(size) => Ok(format!("{}: file ({} bytes)", path_str, size)),
}
}