skilllite-agent 0.1.15

SkillLite Agent: LLM-powered tool loop, extensions, chat
Documentation
//! File operations: read_file, write_file, search_replace, insert_lines, grep_files, list_directory, file_exists.
//!
//! Split into submodules:
//! - `search_replace`: search_replace, preview_edit, insert_lines + fuzzy matching + backup + validation
//! - `grep`: grep_files

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);
    }

    // A11: .env、.key、.git/config 等配置和密码文件直接拒绝
    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
        );
    }

    // A11: 关键路径确认
    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)),
    }
}