feat: core agent tools (fs, search, shell)

This commit is contained in:
Dave
2025-12-24 16:59:14 +00:00
parent 54810631be
commit 76e03bc1a2
19 changed files with 825 additions and 52 deletions

274
src-tauri/Cargo.lock generated
View File

@@ -47,6 +47,27 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "ashpd"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df"
dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.2",
"raw-window-handle",
"serde",
"serde_repr",
"tokio",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -292,6 +313,16 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.19.1"
@@ -549,6 +580,25 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -704,6 +754,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.10.0",
"block2",
"libc",
"objc2",
]
@@ -718,6 +770,15 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "dlib"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [
"libloading",
]
[[package]]
name = "dlopen2"
version = "0.8.2"
@@ -741,6 +802,12 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
@@ -1287,6 +1354,19 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "globset"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -1624,6 +1704,22 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "ignore"
version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -1869,11 +1965,14 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
name = "living-spec-standalone"
version = "0.1.0"
dependencies = [
"ignore",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"walkdir",
]
[[package]]
@@ -2566,7 +2665,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64 0.22.1",
"indexmap 2.12.1",
"quick-xml",
"quick-xml 0.38.4",
"serde",
"time",
]
@@ -2696,6 +2795,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -2745,6 +2853,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
@@ -2765,6 +2883,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.3",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -2783,6 +2911,15 @@ dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "rand_core"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -2911,6 +3048,31 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
dependencies = [
"ashpd",
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3005,6 +3167,12 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -3620,6 +3788,46 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.17",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.17",
"toml 0.9.10+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.2"
@@ -3858,7 +4066,9 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
@@ -4342,6 +4552,66 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wayland-backend"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"scoped-tls",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
dependencies = [
"bitflags 2.10.0",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901"
dependencies = [
"bitflags 2.10.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
dependencies = [
"proc-macro2",
"quick-xml 0.37.5",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
dependencies = [
"dlib",
"log",
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.83"
@@ -5010,6 +5280,7 @@ dependencies = [
"ordered-stream",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
@@ -5136,6 +5407,7 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 0.7.14",
"zvariant_derive",
"zvariant_utils",

View File

@@ -22,4 +22,7 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-dialog = "2.4.2"
ignore = "0.4.25"
walkdir = "2.5.0"

View File

@@ -3,8 +3,5 @@
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
"permissions": ["core:default", "opener:default", "dialog:default"]
}

View File

@@ -0,0 +1,133 @@
use crate::state::SessionState;
use serde::Serialize;
use std::fs;
use std::path::PathBuf;
use tauri::State;
// -----------------------------------------------------------------------------
// Helper Functions
// -----------------------------------------------------------------------------
/// Resolves a relative path against the active project root.
/// Returns error if no project is open or if path attempts traversal (..).
fn resolve_path(state: &State<'_, SessionState>, relative_path: &str) -> Result<PathBuf, String> {
let root_guard = state.project_root.lock().map_err(|e| e.to_string())?;
let root = root_guard
.as_ref()
.ok_or_else(|| "No project is currently open.".to_string())?;
// specific check for traversal
if relative_path.contains("..") {
return Err("Security Violation: Directory traversal ('..') is not allowed.".to_string());
}
// Join path
let full_path = root.join(relative_path);
Ok(full_path)
}
// -----------------------------------------------------------------------------
// Commands
// -----------------------------------------------------------------------------
#[tauri::command]
pub async fn open_project(path: String, state: State<'_, SessionState>) -> Result<String, String> {
let p = PathBuf::from(&path);
// Validate path existence in blocking thread
let p_clone = p.clone();
tauri::async_runtime::spawn_blocking(move || {
if !p_clone.exists() {
return Err(format!("Path does not exist: {}", p_clone.display()));
}
if !p_clone.is_dir() {
return Err(format!("Path is not a directory: {}", p_clone.display()));
}
Ok(())
})
.await
.map_err(|e| format!("Task failed: {}", e))??;
let mut root = state.project_root.lock().map_err(|e| e.to_string())?;
*root = Some(p.clone());
println!("Project opened: {:?}", p);
Ok(path)
}
#[tauri::command]
pub async fn read_file(path: String, state: State<'_, SessionState>) -> Result<String, String> {
let full_path = resolve_path(&state, &path)?;
tauri::async_runtime::spawn_blocking(move || {
fs::read_to_string(&full_path).map_err(|e| format!("Failed to read file: {}", e))
})
.await
.map_err(|e| format!("Task failed: {}", e))?
}
#[tauri::command]
pub async fn write_file(
path: String,
content: String,
state: State<'_, SessionState>,
) -> Result<(), String> {
let full_path = resolve_path(&state, &path)?;
tauri::async_runtime::spawn_blocking(move || {
// Ensure parent directory exists
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directories: {}", e))?;
}
fs::write(&full_path, content).map_err(|e| format!("Failed to write file: {}", e))
})
.await
.map_err(|e| format!("Task failed: {}", e))?
}
#[derive(Serialize)]
pub struct FileEntry {
name: String,
kind: String, // "file" | "dir"
}
#[tauri::command]
pub async fn list_directory(
path: String,
state: State<'_, SessionState>,
) -> Result<Vec<FileEntry>, String> {
let full_path = resolve_path(&state, &path)?;
tauri::async_runtime::spawn_blocking(move || {
let entries = fs::read_dir(&full_path).map_err(|e| format!("Failed to read dir: {}", e))?;
let mut result = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let ft = entry.file_type().map_err(|e| e.to_string())?;
let name = entry.file_name().to_string_lossy().to_string();
result.push(FileEntry {
name,
kind: if ft.is_dir() {
"dir".to_string()
} else {
"file".to_string()
},
});
}
// Sort: directories first, then files
result.sort_by(|a, b| match (a.kind.as_str(), b.kind.as_str()) {
("dir", "file") => std::cmp::Ordering::Less,
("file", "dir") => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
Ok(result)
})
.await
.map_err(|e| format!("Task failed: {}", e))?
}

View File

@@ -0,0 +1,3 @@
pub mod fs;
pub mod search;
pub mod shell;

View File

@@ -0,0 +1,82 @@
use crate::state::SessionState;
use ignore::WalkBuilder;
use serde::Serialize;
use std::fs;
use std::path::PathBuf;
use tauri::State;
// -----------------------------------------------------------------------------
// Helper Functions
// -----------------------------------------------------------------------------
/// Helper to get the root path (cloned) without joining
fn get_project_root(state: &State<'_, SessionState>) -> Result<PathBuf, String> {
let root_guard = state.project_root.lock().map_err(|e| e.to_string())?;
let root = root_guard
.as_ref()
.ok_or_else(|| "No project is currently open.".to_string())?;
Ok(root.clone())
}
// -----------------------------------------------------------------------------
// Commands
// -----------------------------------------------------------------------------
#[derive(Serialize)]
pub struct SearchResult {
path: String, // Relative path
matches: usize,
}
#[tauri::command]
pub async fn search_files(
query: String,
state: State<'_, SessionState>,
) -> Result<Vec<SearchResult>, String> {
let root = get_project_root(&state)?;
let root_clone = root.clone();
// Run computationally expensive search on a blocking thread
let results = tauri::async_runtime::spawn_blocking(move || {
let mut matches = Vec::new();
// default to respecting .gitignore
let walker = WalkBuilder::new(&root_clone).git_ignore(true).build();
for result in walker {
match result {
Ok(entry) => {
if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
continue;
}
let path = entry.path();
// Try to read file
// Note: This is a naive implementation reading whole files into memory.
// For production, we should stream/buffer reads or use grep-searcher.
if let Ok(content) = fs::read_to_string(path) {
// Simple substring search (case-sensitive)
if content.contains(&query) {
// Compute relative path for display
let relative = path
.strip_prefix(&root_clone)
.unwrap_or(path)
.to_string_lossy()
.to_string();
matches.push(SearchResult {
path: relative,
matches: 1, // Simplified count for now
});
}
}
}
Err(err) => eprintln!("Error walking dir: {}", err),
}
}
matches
})
.await
.map_err(|e| format!("Search task failed: {}", e))?;
Ok(results)
}

View File

@@ -0,0 +1,76 @@
use crate::state::SessionState;
use serde::Serialize;
use std::path::PathBuf;
use std::process::Command;
use tauri::State;
// -----------------------------------------------------------------------------
// Helper Functions
// -----------------------------------------------------------------------------
/// Helper to get the root path (cloned) without joining
fn get_project_root(state: &State<'_, SessionState>) -> Result<PathBuf, String> {
let root_guard = state.project_root.lock().map_err(|e| e.to_string())?;
let root = root_guard
.as_ref()
.ok_or_else(|| "No project is currently open.".to_string())?;
Ok(root.clone())
}
// -----------------------------------------------------------------------------
// Commands
// -----------------------------------------------------------------------------
#[derive(Serialize)]
pub struct CommandOutput {
stdout: String,
stderr: String,
exit_code: i32,
}
#[tauri::command]
pub async fn exec_shell(
command: String,
args: Vec<String>,
state: State<'_, SessionState>,
) -> Result<CommandOutput, String> {
let root = get_project_root(&state)?;
// Security Allowlist
let allowed_commands = [
"git", "cargo", "npm", "yarn", "pnpm", "node", "bun", "ls", "find", "grep", "mkdir", "rm",
"mv", "cp", "touch", "rustc", "rustfmt",
];
if !allowed_commands.contains(&command.as_str()) {
return Err(format!("Command '{}' is not in the allowlist.", command));
}
// Execute command asynchronously
// Note: This blocks the async runtime thread unless we use tokio::process::Command,
// but tauri::command async wrapper handles offloading reasonably well.
// However, specifically for Tauri, standard Command in an async function runs on the thread pool.
// Ideally we'd use tokio::process::Command but we need to add 'tokio' with 'process' feature.
// For now, standard Command inside tauri async command (which runs on a separate thread) is acceptable
// or we can explicitly spawn_blocking.
//
// Actually, tauri::command async functions run on the tokio runtime.
// Calling std::process::Command::output() blocks the thread.
// We should use tauri::async_runtime::spawn_blocking.
let output = tauri::async_runtime::spawn_blocking(move || {
Command::new(&command)
.args(&args)
.current_dir(root)
.output()
})
.await
.map_err(|e| format!("Task join error: {}", e))?
.map_err(|e| format!("Failed to execute command: {}", e))?;
Ok(CommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code().unwrap_or(-1),
})
}

View File

@@ -1,14 +1,22 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
mod commands;
mod state;
use state::SessionState;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.plugin(tauri_plugin_dialog::init())
.manage(SessionState::default())
.invoke_handler(tauri::generate_handler![
commands::fs::open_project,
commands::fs::read_file,
commands::fs::write_file,
commands::fs::list_directory,
commands::search::search_files,
commands::shell::exec_shell
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

7
src-tauri/src/state.rs Normal file
View File

@@ -0,0 +1,7 @@
use std::path::PathBuf;
use std::sync::Mutex;
#[derive(Default)]
pub struct SessionState {
pub project_root: Mutex<Option<PathBuf>>,
}