feat(users): UserManager with per-user SQLCipher, and extract skald-core crate
Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.
## UserManager + per-user encryption (§9/§11)
New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.
New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).
- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
<0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
`create_owner_tables` (one owner's content, identical in every file). No FK in
the owner bucket may reach the registry — enforced by a standalone test.
Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
so a crash leaves an orphan file, never a user without a database. `open_db`
never creates: a missing file is an error, not a silent empty database.
Not consumed yet: no login, call sites still use the shared system.db pool.
## Extract crates/skald-core
The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:
- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
(sibling of `http_router`), so the core no longer downcasts to
`MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
teardown-and-respawn; the core defaults to the supervisor exit code. The core
loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
only emits tracing events.
All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
|
||||
use super::{read_to_string, write_string};
|
||||
|
||||
fn normalize_ws(s: &str) -> String {
|
||||
s.lines()
|
||||
.map(|l| {
|
||||
let mut out = String::with_capacity(l.len());
|
||||
let mut last_space = true;
|
||||
for ch in l.chars() {
|
||||
if ch.is_whitespace() {
|
||||
if !last_space { out.push(' '); }
|
||||
last_space = true;
|
||||
} else {
|
||||
out.push(ch);
|
||||
last_space = false;
|
||||
}
|
||||
}
|
||||
out.trim_end().to_owned()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn find_normalized(haystack: &str, normalized_needle: &str) -> Option<(usize, usize)> {
|
||||
let needle_lines: Vec<&str> = normalized_needle.lines().collect();
|
||||
let n = needle_lines.len();
|
||||
if n == 0 { return None; }
|
||||
|
||||
let hay_lines: Vec<&str> = haystack.lines().collect();
|
||||
let hay_count = hay_lines.len();
|
||||
|
||||
let mut offsets = Vec::with_capacity(hay_count + 1);
|
||||
offsets.push(0usize);
|
||||
for line in &hay_lines {
|
||||
let prev = *offsets.last().unwrap();
|
||||
offsets.push(prev + line.len() + 1);
|
||||
}
|
||||
|
||||
for start_idx in 0..=(hay_count.saturating_sub(n)) {
|
||||
let matches = (0..n).all(|i| {
|
||||
normalize_ws(hay_lines[start_idx + i]).as_str() == needle_lines[i]
|
||||
});
|
||||
if matches {
|
||||
let byte_start = offsets[start_idx];
|
||||
let byte_end = if start_idx + n < hay_count {
|
||||
offsets[start_idx + n]
|
||||
} else {
|
||||
haystack.len()
|
||||
};
|
||||
return Some((byte_start, byte_end));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub struct EditFile;
|
||||
|
||||
impl EditFile {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for EditFile {
|
||||
fn name(&self) -> &str { "edit_file" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Replace a substring in a file with new text. \
|
||||
Use instead of sed/awk in the terminal. \
|
||||
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
|
||||
By default `old` must be unique — include enough surrounding context to make it so. \
|
||||
Always call read_file first and copy text exactly as shown after '| ' (the ' N | ' prefix is NOT part of the file). \
|
||||
Set replace_all=true to replace every occurrence instead of requiring uniqueness."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
|
||||
"old": { "type": "string", "description": "Text to find and replace. Must be unique in the file unless replace_all=true." },
|
||||
"new": { "type": "string", "description": "Replacement text. Pass empty string to delete the matched text." },
|
||||
"replace_all": {
|
||||
"type": "boolean",
|
||||
"description": "Replace every occurrence of old instead of requiring a unique match (default: false).",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["path", "old", "new"]
|
||||
})
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
super::path_arg(args)
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
let _ = length;
|
||||
truncate_label(&format!("edit_file `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let old = args["old"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: old"))?;
|
||||
let new = args["new"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: new"))?;
|
||||
|
||||
let replace_all = args["replace_all"].as_bool().unwrap_or(false);
|
||||
let content = read_to_string(user_path)?;
|
||||
|
||||
let updated = if replace_all {
|
||||
if !content.contains(old) {
|
||||
anyhow::bail!(
|
||||
"Text not found in {user_path}. \
|
||||
Call read_file first and copy the text exactly as shown after the '| ' prefix."
|
||||
);
|
||||
}
|
||||
content.replace(old, new)
|
||||
} else {
|
||||
let exact_count = content.matches(old).count();
|
||||
if exact_count > 1 {
|
||||
anyhow::bail!(
|
||||
"Text found {exact_count} times in {user_path}. \
|
||||
Include more surrounding context in `old` to make it unique, or set replace_all=true."
|
||||
);
|
||||
}
|
||||
if exact_count == 1 {
|
||||
content.replacen(old, new, 1)
|
||||
} else {
|
||||
let normalized_old = normalize_ws(old);
|
||||
let (start, end) = find_normalized(&content, &normalized_old)
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"Text not found in {user_path}. \
|
||||
Call read_file first and copy the text exactly as shown after the '| ' prefix."
|
||||
))?;
|
||||
format!("{}{}{}", &content[..start], new, &content[end..])
|
||||
}
|
||||
};
|
||||
|
||||
write_string(user_path, &updated)?;
|
||||
Ok(format!("Edited {user_path}."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
use anyhow::Result;
|
||||
use regex::Regex;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use super::resolve;
|
||||
|
||||
pub struct GrepFiles;
|
||||
|
||||
impl GrepFiles {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for GrepFiles {
|
||||
fn name(&self) -> &str { "grep_files" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Search for a regex pattern across files in a directory or a single file. \
|
||||
Use instead of grep/rg in the terminal. \
|
||||
Binary files and common build/cache directories (target/, .git/, node_modules/, .venv/) are skipped. \
|
||||
Use output_mode='files_only' to get just file paths (faster, lower token cost). \
|
||||
Use output_mode='count' for match counts per file. \
|
||||
Use context_lines to show surrounding lines around each match (like grep -C)."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory or file to search. Relative to project root, or absolute."
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Regex pattern to search for (case-insensitive by default)."
|
||||
},
|
||||
"case_sensitive": {
|
||||
"type": "boolean",
|
||||
"description": "If true, match is case-sensitive. Default: false.",
|
||||
"default": false
|
||||
},
|
||||
"include_glob": {
|
||||
"type": "string",
|
||||
"description": "Restrict search to files matching this glob pattern, e.g. '*.rs' or '*.py'."
|
||||
},
|
||||
"output_mode": {
|
||||
"type": "string",
|
||||
"enum": ["content", "files_only", "count"],
|
||||
"description": "'content' (default): matching lines with file path and line number. 'files_only': only the paths of files containing at least one match — use when you need to know which files match without reading content. 'count': number of matches per file.",
|
||||
"default": "content"
|
||||
},
|
||||
"context_lines": {
|
||||
"type": "integer",
|
||||
"description": "Lines of context to show before and after each match in content mode (default 0, max 10). Like grep -C.",
|
||||
"default": 0
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Stop after this many results (default 100).",
|
||||
"default": 100
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "Skip the first N results for pagination (default 0).",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"required": ["path", "pattern"]
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let pattern = args["pattern"].as_str().unwrap_or("?");
|
||||
match length {
|
||||
ToolDescriptionLength::Short => {
|
||||
truncate_label(&format!("grep_files `{pattern}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
ToolDescriptionLength::Full => {
|
||||
let path = args["path"].as_str().unwrap_or(".");
|
||||
truncate_label(&format!("grep_files `{pattern}` in {path}"), MAX_LABEL_FULL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("Missing: path"))?;
|
||||
let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("Missing: pattern"))?;
|
||||
let case_sensitive = args["case_sensitive"].as_bool().unwrap_or(false);
|
||||
let include_glob = args["include_glob"].as_str();
|
||||
let output_mode = args["output_mode"].as_str().unwrap_or("content");
|
||||
let context_lines = args["context_lines"].as_u64().unwrap_or(0).min(10) as usize;
|
||||
let max_results = args["max_results"].as_u64().unwrap_or(100) as usize;
|
||||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||||
|
||||
let re = {
|
||||
let pat = if case_sensitive { pattern.to_string() } else { format!("(?i){pattern}") };
|
||||
Regex::new(&pat).map_err(|e| anyhow::anyhow!("Invalid regex: {e}"))?
|
||||
};
|
||||
let glob_pattern = include_glob.and_then(|g| glob::Pattern::new(g).ok());
|
||||
let root = resolve(user_path)?;
|
||||
if !root.exists() {
|
||||
anyhow::bail!("Path not found: {user_path}");
|
||||
}
|
||||
|
||||
// Walkers emit absolute paths (the `path` arg is resolved to an absolute working
|
||||
// directory upstream). Strip the queried root so results are shown relative to it,
|
||||
// consistent with `list_files` — keeps the model from echoing absolute paths back.
|
||||
let root_prefix = format!("{}/", root.display());
|
||||
let rel = |s: String| s.strip_prefix(&root_prefix).map(str::to_string).unwrap_or(s);
|
||||
|
||||
match output_mode {
|
||||
"files_only" => {
|
||||
let mut files: Vec<String> = Vec::new();
|
||||
collect_matching_files(&root, &re, &glob_pattern, max_results + offset, &mut files)?;
|
||||
let files: Vec<String> = files.into_iter().skip(offset).take(max_results).map(rel).collect();
|
||||
if files.is_empty() {
|
||||
return Ok(format!("No files match {:?} in {user_path}.", pattern));
|
||||
}
|
||||
Ok(format!("{} file(s):\n{}", files.len(), files.join("\n")))
|
||||
}
|
||||
"count" => {
|
||||
let mut counts: Vec<(String, usize)> = Vec::new();
|
||||
collect_match_counts(&root, &re, &glob_pattern, max_results + offset, &mut counts)?;
|
||||
let counts: Vec<(String, usize)> = counts.into_iter().skip(offset).take(max_results).collect();
|
||||
if counts.is_empty() {
|
||||
return Ok(format!("No matches for {:?} in {user_path}.", pattern));
|
||||
}
|
||||
let lines: Vec<String> = counts.into_iter().map(|(f, n)| format!("{}: {n}", rel(f))).collect();
|
||||
Ok(format!("{} file(s):\n{}", lines.len(), lines.join("\n")))
|
||||
}
|
||||
_ => {
|
||||
let mut matches: Vec<String> = Vec::new();
|
||||
let mut output_bytes: usize = 0;
|
||||
let mut truncated = false;
|
||||
search_path(&root, &re, &glob_pattern, max_results + offset, context_lines, &mut matches, &mut output_bytes, &mut truncated)?;
|
||||
|
||||
let matches: Vec<String> = matches.into_iter().skip(offset).take(max_results).map(rel).collect();
|
||||
if matches.is_empty() {
|
||||
return Ok(format!("No matches for {:?} in {user_path}.", pattern));
|
||||
}
|
||||
let mut out = format!("{} match(es):\n", matches.len());
|
||||
out.push_str(&matches.join("\n"));
|
||||
if truncated {
|
||||
out.push_str(&format!(
|
||||
"\n\n[Output truncated at {MAX_OUTPUT_BYTES} bytes. Narrow your search with a more specific pattern, path, or include_glob.]"
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `secrets` is skipped so a recursive grep rooted at a parent (e.g. the auto-read
|
||||
// working directory) never descends into and leaks secret values.
|
||||
const SKIP_DIRS: &[&str] = &["target", ".git", "node_modules", ".venv", "__pycache__", "secrets"];
|
||||
const MAX_FILE_BYTES: u64 = 200_000;
|
||||
const MAX_OUTPUT_BYTES: usize = 60_000;
|
||||
const MAX_LINE_BYTES: usize = 500;
|
||||
|
||||
// ── files_only mode ───────────────────────────────────────────────────────────
|
||||
|
||||
fn collect_matching_files(
|
||||
path: &std::path::Path,
|
||||
re: &Regex,
|
||||
glob: &Option<glob::Pattern>,
|
||||
max: usize,
|
||||
out: &mut Vec<String>,
|
||||
) -> Result<()> {
|
||||
if out.len() >= max { return Ok(()); }
|
||||
if path.is_dir() {
|
||||
let mut entries: Vec<_> = std::fs::read_dir(path)?.filter_map(|e| e.ok()).collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
if out.len() >= max { break; }
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if SKIP_DIRS.contains(&name) { continue; }
|
||||
collect_matching_files(&p, re, glob, max, out)?;
|
||||
} else if file_has_match(&p, re, glob)? {
|
||||
out.push(p.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
} else if file_has_match(path, re, glob)? {
|
||||
out.push(path.to_string_lossy().into_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn file_has_match(path: &std::path::Path, re: &Regex, glob: &Option<glob::Pattern>) -> Result<bool> {
|
||||
if let Some(pat) = glob {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if !pat.matches(name) { return Ok(false); }
|
||||
}
|
||||
if let Ok(meta) = path.metadata() {
|
||||
if meta.len() > MAX_FILE_BYTES { return Ok(false); }
|
||||
}
|
||||
let text = match std::fs::read(path) { Ok(b) => b, Err(_) => return Ok(false) };
|
||||
if text.iter().take(8000).any(|&b| b == 0) { return Ok(false); }
|
||||
let content = match std::str::from_utf8(&text) { Ok(s) => s, Err(_) => return Ok(false) };
|
||||
Ok(content.lines().any(|l| re.is_match(l)))
|
||||
}
|
||||
|
||||
// ── count mode ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn collect_match_counts(
|
||||
path: &std::path::Path,
|
||||
re: &Regex,
|
||||
glob: &Option<glob::Pattern>,
|
||||
max: usize,
|
||||
out: &mut Vec<(String, usize)>,
|
||||
) -> Result<()> {
|
||||
if out.len() >= max { return Ok(()); }
|
||||
if path.is_dir() {
|
||||
let mut entries: Vec<_> = std::fs::read_dir(path)?.filter_map(|e| e.ok()).collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
if out.len() >= max { break; }
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if SKIP_DIRS.contains(&name) { continue; }
|
||||
collect_match_counts(&p, re, glob, max, out)?;
|
||||
} else if let Some(n) = count_file_matches(&p, re, glob)? {
|
||||
if n > 0 { out.push((p.to_string_lossy().into_owned(), n)); }
|
||||
}
|
||||
}
|
||||
} else if let Some(n) = count_file_matches(path, re, glob)? {
|
||||
if n > 0 { out.push((path.to_string_lossy().into_owned(), n)); }
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn count_file_matches(path: &std::path::Path, re: &Regex, glob: &Option<glob::Pattern>) -> Result<Option<usize>> {
|
||||
if let Some(pat) = glob {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if !pat.matches(name) { return Ok(None); }
|
||||
}
|
||||
if let Ok(meta) = path.metadata() {
|
||||
if meta.len() > MAX_FILE_BYTES { return Ok(None); }
|
||||
}
|
||||
let text = match std::fs::read(path) { Ok(b) => b, Err(_) => return Ok(None) };
|
||||
if text.iter().take(8000).any(|&b| b == 0) { return Ok(None); }
|
||||
let content = match std::str::from_utf8(&text) { Ok(s) => s, Err(_) => return Ok(None) };
|
||||
Ok(Some(content.lines().filter(|l| re.is_match(l)).count()))
|
||||
}
|
||||
|
||||
// ── content mode ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn search_path(
|
||||
path: &std::path::Path,
|
||||
re: &Regex,
|
||||
glob: &Option<glob::Pattern>,
|
||||
max_results: usize,
|
||||
context_lines: usize,
|
||||
matches: &mut Vec<String>,
|
||||
output_bytes: &mut usize,
|
||||
truncated: &mut bool,
|
||||
) -> Result<()> {
|
||||
if matches.len() >= max_results || *truncated { return Ok(()); }
|
||||
if path.is_dir() {
|
||||
let mut entries: Vec<_> = std::fs::read_dir(path)?.filter_map(|e| e.ok()).collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
if matches.len() >= max_results || *truncated { break; }
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if SKIP_DIRS.contains(&name) { continue; }
|
||||
search_path(&p, re, glob, max_results, context_lines, matches, output_bytes, truncated)?;
|
||||
} else {
|
||||
grep_file(&p, re, glob, max_results, context_lines, matches, output_bytes, truncated)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
grep_file(path, re, glob, max_results, context_lines, matches, output_bytes, truncated)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn grep_file(
|
||||
path: &std::path::Path,
|
||||
re: &Regex,
|
||||
glob: &Option<glob::Pattern>,
|
||||
max_results: usize,
|
||||
context_lines: usize,
|
||||
matches: &mut Vec<String>,
|
||||
output_bytes: &mut usize,
|
||||
truncated: &mut bool,
|
||||
) -> Result<()> {
|
||||
if let Some(pat) = glob {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if !pat.matches(name) { return Ok(()); }
|
||||
}
|
||||
if let Ok(meta) = path.metadata() {
|
||||
if meta.len() > MAX_FILE_BYTES { return Ok(()); }
|
||||
}
|
||||
let text = match std::fs::read(path) { Ok(b) => b, Err(_) => return Ok(()) };
|
||||
if text.iter().take(8000).any(|&b| b == 0) { return Ok(()); }
|
||||
let content = match std::str::from_utf8(&text) { Ok(s) => s, Err(_) => return Ok(()) };
|
||||
let display = path.to_string_lossy();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
|
||||
if context_lines == 0 {
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if matches.len() >= max_results || *truncated { break; }
|
||||
if re.is_match(line) {
|
||||
let snippet = if line.len() > MAX_LINE_BYTES { format!("{}…", &line[..MAX_LINE_BYTES]) } else { line.to_string() };
|
||||
let entry = format!("{}:{}: {}", display, i + 1, snippet);
|
||||
*output_bytes += entry.len();
|
||||
if *output_bytes > MAX_OUTPUT_BYTES { *truncated = true; break; }
|
||||
matches.push(entry);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let match_indices: Vec<usize> = lines.iter().enumerate()
|
||||
.filter(|(_, l)| re.is_match(l))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if match_indices.is_empty() { return Ok(()); }
|
||||
|
||||
let mut windows: Vec<(usize, usize)> = Vec::new();
|
||||
for &m in &match_indices {
|
||||
let start = m.saturating_sub(context_lines);
|
||||
let end = (m + context_lines).min(lines.len().saturating_sub(1));
|
||||
if let Some(last) = windows.last_mut() {
|
||||
if start <= last.1 + 1 { last.1 = last.1.max(end); continue; }
|
||||
}
|
||||
windows.push((start, end));
|
||||
}
|
||||
|
||||
let match_set: std::collections::HashSet<usize> = match_indices.into_iter().collect();
|
||||
for (wi, (start, end)) in windows.iter().enumerate() {
|
||||
if matches.len() >= max_results || *truncated { break; }
|
||||
if wi > 0 {
|
||||
let sep = format!("{}:---", display);
|
||||
*output_bytes += sep.len();
|
||||
matches.push(sep);
|
||||
}
|
||||
for idx in *start..=*end {
|
||||
if matches.len() >= max_results || *truncated { break; }
|
||||
let marker = if match_set.contains(&idx) { ">" } else { " " };
|
||||
let line = lines[idx];
|
||||
let snippet = if line.len() > MAX_LINE_BYTES { format!("{}…", &line[..MAX_LINE_BYTES]) } else { line.to_string() };
|
||||
let entry = format!("{}{}: {}: {}", marker, display, idx + 1, snippet);
|
||||
*output_bytes += entry.len();
|
||||
if *output_bytes > MAX_OUTPUT_BYTES { *truncated = true; break; }
|
||||
matches.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use super::{read_to_string, write_string};
|
||||
|
||||
pub struct InsertAtLine;
|
||||
|
||||
impl InsertAtLine {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for InsertAtLine {
|
||||
fn name(&self) -> &str { "insert_at_line" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Insert new text immediately before or after a specific line number in a file. \
|
||||
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
|
||||
"line": { "type": "integer", "minimum": 1, "description": "1-based line number." },
|
||||
"content": { "type": "string", "description": "Text to insert. May span multiple lines." },
|
||||
"placement": {
|
||||
"type": "string",
|
||||
"enum": ["before", "after"],
|
||||
"description": "Whether to insert before or after the target line. Default: \"after\"."
|
||||
}
|
||||
},
|
||||
"required": ["path", "line", "content"]
|
||||
})
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
super::path_arg(args)
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
match length {
|
||||
ToolDescriptionLength::Short => {
|
||||
truncate_label(&format!("insert_at_line `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
ToolDescriptionLength::Full => {
|
||||
let line = args["line"].as_u64().map(|n| format!(" line {n}")).unwrap_or_default();
|
||||
truncate_label(&format!("insert_at_line `{path}`{line}"), MAX_LABEL_FULL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let line_num = args["line"].as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: line"))? as usize;
|
||||
let new_text = args["content"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: content"))?;
|
||||
let placement = args["placement"].as_str().unwrap_or("after");
|
||||
|
||||
anyhow::ensure!(line_num >= 1, "line must be >= 1");
|
||||
|
||||
let text = read_to_string(user_path)?;
|
||||
let mut lines: Vec<&str> = text.split('\n').collect();
|
||||
let idx = (line_num - 1).min(lines.len().saturating_sub(1));
|
||||
let insert_idx = if placement == "before" { idx } else { idx + 1 };
|
||||
let new_lines: Vec<&str> = new_text.split('\n').collect();
|
||||
for (i, l) in new_lines.iter().enumerate() {
|
||||
lines.insert(insert_idx + i, l);
|
||||
}
|
||||
let updated = lines.join("\n");
|
||||
|
||||
write_string(user_path, &updated)?;
|
||||
|
||||
Ok(format!(
|
||||
"Inserted {} line(s) {} line {} in {user_path}.",
|
||||
new_lines.len(), placement, line_num
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
|
||||
use super::resolve;
|
||||
|
||||
/// Directories to skip unconditionally when walking.
|
||||
/// `secrets` is skipped so a recursive listing rooted at a parent (e.g. the auto-read
|
||||
/// working directory) never reveals the contents of the secrets store.
|
||||
const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".cache", "secrets"];
|
||||
|
||||
pub struct ListFiles;
|
||||
|
||||
impl ListFiles {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for ListFiles {
|
||||
fn name(&self) -> &str { "list_files" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"List files and directories under a path. \
|
||||
Use instead of ls/find in the terminal. \
|
||||
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
|
||||
Skips .git, target, node_modules, .cache. \
|
||||
Returns a JSON array of paths relative to the requested directory. \
|
||||
Use depth=1 for immediate contents only, depth=2-3 for moderate exploration."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory to list. Defaults to project root if omitted."
|
||||
},
|
||||
"depth": {
|
||||
"type": "integer",
|
||||
"description": "Maximum recursion depth (default 3). Use 1 for immediate contents only."
|
||||
},
|
||||
"dirs_only": {
|
||||
"type": "boolean",
|
||||
"description": "If true, return only directories and omit files (default false)."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or(".");
|
||||
let _ = length;
|
||||
truncate_label(&format!("list_files `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str().unwrap_or(".");
|
||||
let max_depth = args["depth"].as_u64().unwrap_or(3) as usize;
|
||||
let dirs_only = args["dirs_only"].as_bool().unwrap_or(false);
|
||||
let dir = resolve(user_path)?;
|
||||
|
||||
let mut paths: Vec<String> = Vec::new();
|
||||
walk(&dir, &dir, 0, max_depth, dirs_only, &mut paths)?;
|
||||
paths.sort();
|
||||
Ok(serde_json::to_string(&paths)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn walk(root: &Path, dir: &Path, depth: usize, max_depth: usize, dirs_only: bool, out: &mut Vec<String>) -> Result<()> {
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
|
||||
if path.is_dir() {
|
||||
if SKIP_DIRS.contains(&name) { continue; }
|
||||
if dirs_only {
|
||||
let rel = path.strip_prefix(root)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| path.to_string_lossy().to_string());
|
||||
out.push(rel);
|
||||
}
|
||||
if depth + 1 < max_depth {
|
||||
walk(root, &path, depth + 1, max_depth, dirs_only, out)?;
|
||||
}
|
||||
} else if path.is_file() && !dirs_only {
|
||||
let rel = path.strip_prefix(root)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| path.to_string_lossy().to_string());
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
mod edit_file;
|
||||
mod grep_files;
|
||||
mod insert_at_line;
|
||||
mod list_files;
|
||||
mod read_file;
|
||||
mod replace_lines;
|
||||
mod search_file;
|
||||
mod write_file;
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
/// Extracts the `path` argument as an owned string, if present. Single-file
|
||||
/// tools use this to advertise their target to the UI via `Tool::target_path`,
|
||||
/// keeping the argument name in one place.
|
||||
pub(crate) fn path_arg(args: &Value) -> Option<String> {
|
||||
args.get("path").and_then(Value::as_str).map(str::to_string)
|
||||
}
|
||||
|
||||
pub use edit_file::EditFile;
|
||||
pub use grep_files::GrepFiles;
|
||||
pub use insert_at_line::InsertAtLine;
|
||||
pub use list_files::ListFiles;
|
||||
pub use read_file::ReadFile;
|
||||
pub use replace_lines::ReplaceLines;
|
||||
pub use search_file::SearchFile;
|
||||
pub use write_file::WriteFile;
|
||||
|
||||
/// Resolve a user-supplied path:
|
||||
/// - starts with `/` → absolute path, used as-is
|
||||
/// - otherwise → relative to the process working directory (project root)
|
||||
pub fn resolve(user_path: &str) -> Result<PathBuf> {
|
||||
let p = PathBuf::from(user_path);
|
||||
if p.is_absolute() {
|
||||
Ok(p)
|
||||
} else {
|
||||
let cwd = std::env::current_dir()
|
||||
.context("Failed to read current working directory")?;
|
||||
Ok(cwd.join(p))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves `path` (relative entries against `base`) to an absolute, canonical form
|
||||
/// suitable for security prefix-matching. `.`/`..` are resolved and symlinks in the
|
||||
/// existing portion of the path are followed: the longest existing ancestor is
|
||||
/// canonicalized via the OS, and any not-yet-existing tail (e.g. a write target that
|
||||
/// does not exist yet) is appended lexically. Falls back to a pure lexical normalization
|
||||
/// when nothing along the path can be canonicalized.
|
||||
///
|
||||
/// This closes `docs/../secrets/x` traversal and symlink escapes for both the allow
|
||||
/// fast-paths (`RunContext`) and the deny rules (`approval::normalize_path`).
|
||||
pub fn canonicalize_for_policy(path: &str, base: &Path) -> PathBuf {
|
||||
let raw = {
|
||||
let p = Path::new(path);
|
||||
if p.is_absolute() { p.to_path_buf() } else { base.join(p) }
|
||||
};
|
||||
let cleaned = lexical_normalize(&raw);
|
||||
|
||||
// Longest existing ancestor first (ancestors() yields self, then parents).
|
||||
for ancestor in cleaned.ancestors() {
|
||||
if let Ok(canon) = std::fs::canonicalize(ancestor) {
|
||||
// `canon.join("")` appends a trailing separator, so skip the join
|
||||
// when the tail is empty (the common case: the file itself is its
|
||||
// first canonicalizable ancestor). Otherwise the canonical path
|
||||
// ends in '/', leaking into display strings and /api/file requests.
|
||||
return match cleaned.strip_prefix(ancestor) {
|
||||
Ok(tail) if !tail.as_os_str().is_empty() => canon.join(tail),
|
||||
_ => canon,
|
||||
};
|
||||
}
|
||||
}
|
||||
cleaned
|
||||
}
|
||||
|
||||
/// Pure lexical normalization: resolves `.` and `..` components without touching the
|
||||
/// filesystem. Used as the base for `canonicalize_for_policy` and as its fallback.
|
||||
fn lexical_normalize(p: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for comp in p.components() {
|
||||
match comp {
|
||||
Component::ParentDir => { out.pop(); }
|
||||
Component::CurDir => {}
|
||||
other => out.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True if `child` is `base` itself or lies inside it. Both should already be canonical
|
||||
/// (e.g. produced by `canonicalize_for_policy`). Comparison is component-wise, so
|
||||
/// `/a/bc` is not considered to be under `/a/b`.
|
||||
pub fn path_under(child: &Path, base: &Path) -> bool {
|
||||
child.starts_with(base)
|
||||
}
|
||||
|
||||
/// Normalize a user path for display in the UI: relative to the project root when the
|
||||
/// file lives inside it, absolute otherwise. Resolves `.`/`..` and symlinks via
|
||||
/// `canonicalize_for_policy` so the same file always yields the same string — keeping
|
||||
/// the file viewer's "already loaded" check and its watcher subscription consistent.
|
||||
pub fn relativize_for_display(user_path: &str) -> String {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let abs = canonicalize_for_policy(user_path, &cwd);
|
||||
let cwd_canon = std::fs::canonicalize(&cwd).unwrap_or(cwd);
|
||||
match abs.strip_prefix(&cwd_canon) {
|
||||
Ok(rel) => rel.to_string_lossy().into_owned(),
|
||||
Err(_) => abs.to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn read_to_string(user_path: &str) -> Result<String> {
|
||||
let abs = resolve(user_path)?;
|
||||
std::fs::read_to_string(&abs)
|
||||
.with_context(|| format!("Cannot read file: {user_path}"))
|
||||
}
|
||||
|
||||
pub(super) fn write_string(user_path: &str, content: &str) -> Result<()> {
|
||||
let abs = resolve(user_path)?;
|
||||
if let Some(parent) = abs.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(&abs, content)
|
||||
.with_context(|| format!("Failed to write: {}", abs.display()))
|
||||
}
|
||||
|
||||
pub fn register_all(registry: &mut ToolRegistry) {
|
||||
registry.register(EditFile::new());
|
||||
registry.register(GrepFiles::new());
|
||||
registry.register(InsertAtLine::new());
|
||||
registry.register(ListFiles::new());
|
||||
registry.register(ReadFile::new());
|
||||
registry.register(ReplaceLines::new());
|
||||
registry.register(SearchFile::new());
|
||||
registry.register(WriteFile::new());
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use super::read_to_string;
|
||||
|
||||
pub struct ReadFile;
|
||||
|
||||
impl ReadFile {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for ReadFile {
|
||||
fn name(&self) -> &str { "read_file" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Read the content of a file with 1-based line numbers. \
|
||||
Use instead of cat/head/tail in the terminal. \
|
||||
Returns text prefixed as ' N | line'. When calling edit_file, copy the text after '| ' exactly. \
|
||||
For large files use start_line/end_line to read in chunks — files over ~2000 lines should never be read whole. \
|
||||
Use limit to cap output when end_line is unknown."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path. Relative to project root, or absolute (e.g. /etc/hosts)."
|
||||
},
|
||||
"start_line": {
|
||||
"type": "integer",
|
||||
"description": "First line to read (1-based, inclusive). Omit to start from the beginning."
|
||||
},
|
||||
"end_line": {
|
||||
"type": "integer",
|
||||
"description": "Last line to read (1-based, inclusive). Omit to read to the end of the file."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of lines to return (max 2000). Applied after start_line when end_line is omitted.",
|
||||
"maximum": 2000
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
super::path_arg(args)
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
match length {
|
||||
ToolDescriptionLength::Short => {
|
||||
truncate_label(&format!("read_file `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
ToolDescriptionLength::Full => {
|
||||
let range = match (args["start_line"].as_u64(), args["end_line"].as_u64()) {
|
||||
(Some(s), Some(e)) => format!(" lines {s}-{e}"),
|
||||
(Some(s), None) => format!(" from line {s}"),
|
||||
(None, Some(e)) => format!(" to line {e}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
truncate_label(&format!("read_file `{path}`{range}"), MAX_LABEL_FULL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let content = read_to_string(user_path)?;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let total = lines.len();
|
||||
|
||||
let limit = args["limit"].as_u64().map(|n| n.min(2000) as usize);
|
||||
let start = args["start_line"].as_u64()
|
||||
.map(|n| (n as usize).saturating_sub(1))
|
||||
.unwrap_or(0);
|
||||
let end = match (args["end_line"].as_u64(), limit) {
|
||||
(Some(e), _) => (e as usize).min(total),
|
||||
(None, Some(l)) => (start + l).min(total),
|
||||
(None, None) => total,
|
||||
};
|
||||
|
||||
if start >= total && total > 0 {
|
||||
return Ok(format!("(file has only {total} lines; start_line {start_line} is out of range)",
|
||||
start_line = start + 1));
|
||||
}
|
||||
|
||||
let end = end.max(start);
|
||||
|
||||
let width = total.to_string().len().max(3);
|
||||
let numbered = lines[start..end]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, line)| format!("{:>width$} | {line}", start + i + 1))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Ok(numbered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use super::{read_to_string, write_string};
|
||||
|
||||
pub struct ReplaceLines;
|
||||
|
||||
impl ReplaceLines {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for ReplaceLines {
|
||||
fn name(&self) -> &str { "replace_lines" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Replace a range of lines in a file with new text. \
|
||||
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
|
||||
Use the 1-based line numbers shown by read_file. `from_line` and `to_line` are inclusive."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
|
||||
"from_line": { "type": "integer", "description": "First line to replace (1-based, inclusive)." },
|
||||
"to_line": { "type": "integer", "description": "Last line to replace (1-based, inclusive)." },
|
||||
"new": { "type": "string", "description": "Replacement text." }
|
||||
},
|
||||
"required": ["path", "from_line", "to_line", "new"]
|
||||
})
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
super::path_arg(args)
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
match length {
|
||||
ToolDescriptionLength::Short => {
|
||||
truncate_label(&format!("replace_lines `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
ToolDescriptionLength::Full => {
|
||||
let from = args["from_line"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "?".into());
|
||||
let to = args["to_line"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "?".into());
|
||||
truncate_label(&format!("replace_lines `{path}` lines {from}-{to}"), MAX_LABEL_FULL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let from_line = args["from_line"].as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: from_line"))? as usize;
|
||||
let to_line = args["to_line"].as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: to_line"))? as usize;
|
||||
let new = args["new"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: new"))?;
|
||||
|
||||
if from_line == 0 { anyhow::bail!("from_line must be >= 1"); }
|
||||
if to_line < from_line { anyhow::bail!("to_line must be >= from_line"); }
|
||||
|
||||
let content = read_to_string(user_path)?;
|
||||
let mut lines: Vec<&str> = content.lines().collect();
|
||||
let total = lines.len();
|
||||
if from_line > total {
|
||||
anyhow::bail!("from_line {from_line} exceeds file length ({total} lines)");
|
||||
}
|
||||
let to_clamped = to_line.min(total);
|
||||
let new_lines: Vec<&str> = new.lines().collect();
|
||||
lines.splice((from_line - 1)..to_clamped, new_lines);
|
||||
|
||||
let has_trailing = content.ends_with('\n');
|
||||
let mut updated = lines.join("\n");
|
||||
if has_trailing { updated.push('\n'); }
|
||||
|
||||
write_string(user_path, &updated)?;
|
||||
|
||||
Ok(format!(
|
||||
"Replaced lines {from_line}–{to_clamped} in {user_path} with {} new lines.",
|
||||
new.lines().count()
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use super::read_to_string;
|
||||
|
||||
pub struct SearchFile;
|
||||
|
||||
impl SearchFile {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for SearchFile {
|
||||
fn name(&self) -> &str { "search_file" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Search for lines containing a substring in a file. \
|
||||
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
|
||||
Returns each matching line with context, prefixed with 1-based line numbers in ' N | ' format."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
|
||||
"query": { "type": "string", "description": "Substring to search for (case-insensitive)." },
|
||||
"context_lines": {
|
||||
"type": "integer",
|
||||
"description": "Lines of context above and below each match (default 3, max 10).",
|
||||
"default": 3
|
||||
}
|
||||
},
|
||||
"required": ["path", "query"]
|
||||
})
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
super::path_arg(args)
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
match length {
|
||||
ToolDescriptionLength::Short => {
|
||||
truncate_label(&format!("search_file `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
ToolDescriptionLength::Full => {
|
||||
let query = args["query"].as_str().unwrap_or("?");
|
||||
truncate_label(&format!("search_file `{path}` for \"{query}\""), MAX_LABEL_FULL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let query = args["query"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: query"))?;
|
||||
let context = args["context_lines"].as_u64().unwrap_or(3).min(10) as usize;
|
||||
|
||||
let text = read_to_string(user_path)?;
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let lower_query = query.to_lowercase();
|
||||
let width = lines.len().to_string().len().max(3);
|
||||
|
||||
let matches: Vec<usize> = lines.iter().enumerate()
|
||||
.filter(|(_, l)| l.to_lowercase().contains(&lower_query))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
if matches.is_empty() {
|
||||
return Ok(format!("No matches found for {:?} in {user_path}.", query));
|
||||
}
|
||||
|
||||
let mut chunks: Vec<(usize, usize)> = Vec::new();
|
||||
for &m in &matches {
|
||||
let start = m.saturating_sub(context);
|
||||
let end = (m + context).min(lines.len() - 1);
|
||||
if let Some(last) = chunks.last_mut() {
|
||||
if start <= last.1 + 1 { last.1 = last.1.max(end); continue; }
|
||||
}
|
||||
chunks.push((start, end));
|
||||
}
|
||||
|
||||
let match_set: std::collections::HashSet<usize> = matches.into_iter().collect();
|
||||
let mut out = format!("{} match(es) in {user_path}:\n", match_set.len());
|
||||
|
||||
for (ci, (start, end)) in chunks.iter().enumerate() {
|
||||
if ci > 0 { out.push_str(" ···\n"); }
|
||||
for idx in *start..=*end {
|
||||
let marker = if match_set.contains(&idx) { ">" } else { " " };
|
||||
out.push_str(&format!("{marker}{:>width$} | {}\n", idx + 1, lines[idx]));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
|
||||
use super::{resolve, write_string};
|
||||
|
||||
pub struct WriteFile;
|
||||
|
||||
impl WriteFile {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for WriteFile {
|
||||
fn name(&self) -> &str { "write_file" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Create a new file or fully overwrite an existing one. \
|
||||
Use instead of echo/cat heredoc in the terminal. \
|
||||
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
|
||||
OVERWRITES the entire file — for targeted edits to an existing file use edit_file instead."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path. Relative to project root, or absolute."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Full content to write to the file."
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
})
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
super::path_arg(args)
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
let _ = length;
|
||||
truncate_label(&format!("write_file `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let content = args["content"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: content"))?;
|
||||
|
||||
let abs = resolve(user_path)?;
|
||||
let existed = abs.exists();
|
||||
write_string(user_path, content)?;
|
||||
|
||||
if existed {
|
||||
Ok(format!("Overwrote {user_path} ({} bytes).", content.len()))
|
||||
} else {
|
||||
Ok(format!("Created {user_path} ({} bytes).", content.len()))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user