From 76e03bc1a2a16b01caac5bd2fdb73c831fa1353a Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 24 Dec 2025 16:59:14 +0000 Subject: [PATCH] feat: core agent tools (fs, search, shell) --- .living_spec/README.md | 4 +- .../specs/functional/AGENT_CAPABILITIES.md | 48 +++ .living_spec/specs/tech/STACK.md | 1 + .living_spec/stories/02_core_agent_tools.md | 20 ++ .../{ => archive}/01_project_selection.md | 0 package.json | 11 +- pnpm-lock.yaml | 10 + src-tauri/Cargo.lock | 274 +++++++++++++++++- src-tauri/Cargo.toml | 3 + src-tauri/capabilities/default.json | 5 +- src-tauri/src/commands/fs.rs | 133 +++++++++ src-tauri/src/commands/mod.rs | 3 + src-tauri/src/commands/search.rs | 82 ++++++ src-tauri/src/commands/shell.rs | 76 +++++ src-tauri/src/lib.rs | 20 +- src-tauri/src/state.rs | 7 + src/App.tsx | 96 +++--- src/components/ToolTester.tsx | 69 +++++ src/types.ts | 15 + 19 files changed, 825 insertions(+), 52 deletions(-) create mode 100644 .living_spec/specs/functional/AGENT_CAPABILITIES.md create mode 100644 .living_spec/stories/02_core_agent_tools.md rename .living_spec/stories/{ => archive}/01_project_selection.md (100%) create mode 100644 src-tauri/src/commands/fs.rs create mode 100644 src-tauri/src/commands/mod.rs create mode 100644 src-tauri/src/commands/search.rs create mode 100644 src-tauri/src/commands/shell.rs create mode 100644 src-tauri/src/state.rs create mode 100644 src/components/ToolTester.tsx create mode 100644 src/types.ts diff --git a/.living_spec/README.md b/.living_spec/README.md index 70df7b0..07930e3 100644 --- a/.living_spec/README.md +++ b/.living_spec/README.md @@ -50,7 +50,7 @@ When the user asks for a feature, follow this 4-step loop strictly: * **User Story:** "As a user, I want..." * **Acceptance Criteria:** Bullet points of observable success. * **Out of scope:** Things that are out of scope so that the LLM doesn't go crazy -* **Git:** Make a local feature branch for the story. +* **Git:** The Assistant initiates a new local feature branch (e.g., `feature/story-name`) immediately. ### Step 2: The Spec (Digest) * **Action:** Update the files in `specs/`. @@ -67,7 +67,7 @@ When the user asks for a feature, follow this 4-step loop strictly: ### Step 4: Verification (Close) * **Action:** Write a test case that maps directly to the Acceptance Criteria in the Story. **Action:** Run compilation and make sure it succeeds without errors. Fix warnings if possible. Run tests and make sure they all pass before proceeding. Ask questions here if needed. -* **Action:** Ask the user to accept the story. Move to `stories/archive/`. Tell the user they should commit (this gives them the chance to exclude files via .gitignore if necessary) +* **Action:** Ask the user to accept the story. Move to `stories/archive/`. Tell the user to **Squash Merge** the feature branch (e.g. `git merge --squash feature/story-name`) and commit. This ensures the main history reflects one atomic commit per Story. --- diff --git a/.living_spec/specs/functional/AGENT_CAPABILITIES.md b/.living_spec/specs/functional/AGENT_CAPABILITIES.md new file mode 100644 index 0000000..fda2b2b --- /dev/null +++ b/.living_spec/specs/functional/AGENT_CAPABILITIES.md @@ -0,0 +1,48 @@ +# Functional Spec: Agent Capabilities + +## Overview +The Agent interacts with the Target Project through a set of deterministic Tools. These tools are exposed as Tauri Commands to the frontend, which acts as the orchestrator for the LLM. + +## 1. Filesystem Tools +All filesystem operations are **strictly scoped** to the active `SessionState.project_root`. Attempting to access paths outside this root (e.g., `../foo`) must return an error. + +### `read_file` +* **Input:** `path: String` (Relative to project root) +* **Output:** `Result` +* **Behavior:** Returns the full text content of the file. + +### `write_file` +* **Input:** `path: String`, `content: String` +* **Output:** `Result<(), AppError>` +* **Behavior:** Overwrites the file. Creates parent directories if they don't exist. + +### `list_directory` +* **Input:** `path: String` (Relative) +* **Output:** `Result, AppError>` +* **Data Structure:** `FileEntry { name: String, kind: "file" | "dir" }` + +## 2. Search Tools +High-performance text search is critical for the Agent to "read" the codebase without dumping all files into context. + +### `search_files` +* **Input:** `query: String` (Regex or Literal), `glob: Option` +* **Output:** `Result, AppError>` +* **Engine:** Rust `ignore` crate (WalkBuilder) + `grep_searcher`. +* **Constraints:** + * Must respect `.gitignore`. + * Limit results (e.g., top 100 matches) to prevent freezing. + +## 3. Shell Tools +The Agent needs to compile code, run tests, and manage git. + +### `exec_shell` +* **Input:** `command: String`, `args: Vec` +* **Output:** `Result` +* **Data Structure:** `CommandOutput { stdout: String, stderr: String, exit_code: i32 }` +* **Security Policy:** + * **Allowlist:** `git`, `cargo`, `npm`, `yarn`, `pnpm`, `node`, `bun`, `ls`, `find`, `grep`, `mkdir`, `rm`, `mv`, `cp`, `touch`. + * **cwd:** Always executed in `SessionState.project_root`. + * **Timeout:** Hard limit (e.g., 30s) to prevent hanging processes. + +## Error Handling +All tools must return a standardized JSON error object to the frontend so the LLM knows *why* a tool failed (e.g., "File not found", "Permission denied"). diff --git a/.living_spec/specs/tech/STACK.md b/.living_spec/specs/tech/STACK.md index 03c6a1c..fd26e11 100644 --- a/.living_spec/specs/tech/STACK.md +++ b/.living_spec/specs/tech/STACK.md @@ -75,6 +75,7 @@ To support both Remote and Local models, the system implements a `ModelProvider` * **Rust:** * `serde`, `serde_json`: Serialization. * `ignore`: Fast recursive directory iteration respecting gitignore. + * `walkdir`: Simple directory traversal. * `tokio`: Async runtime. * `reqwest`: For LLM API calls (if backend-initiated). * `tauri-plugin-dialog`: Native system dialogs. diff --git a/.living_spec/stories/02_core_agent_tools.md b/.living_spec/stories/02_core_agent_tools.md new file mode 100644 index 0000000..d7bd5ba --- /dev/null +++ b/.living_spec/stories/02_core_agent_tools.md @@ -0,0 +1,20 @@ +# Story: Core Agent Tools (The Hands) + +## User Story +**As an** Agent +**I want to** be able to read files, list directories, search content, and execute shell commands +**So that** I can autonomously explore and modify the target project. + +## Acceptance Criteria +* [ ] Rust Backend: Implement `read_file(path)` command (scoped to project). +* [ ] Rust Backend: Implement `write_file(path, content)` command (scoped to project). +* [ ] Rust Backend: Implement `list_directory(path)` command. +* [ ] Rust Backend: Implement `exec_shell(command, args)` command. + * [ ] Must enforce allowlist (git, cargo, npm, etc). + * [ ] Must run in project root. +* [ ] Rust Backend: Implement `search_files(query, globs)` using `ignore` crate. +* [ ] Frontend: Expose these as tools to the (future) LLM interface. + +## Out of Scope +* The LLM Chat UI itself (connecting these to a visual chat window comes later). +* Complex git merges (simple commands only). diff --git a/.living_spec/stories/01_project_selection.md b/.living_spec/stories/archive/01_project_selection.md similarity index 100% rename from .living_spec/stories/01_project_selection.md rename to .living_spec/stories/archive/01_project_selection.md diff --git a/package.json b/package.json index c1d3865..ca9c192 100644 --- a/package.json +++ b/package.json @@ -10,17 +10,18 @@ "tauri": "tauri" }, "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0", "@tauri-apps/api": "^2", - "@tauri-apps/plugin-opener": "^2" + "@tauri-apps/plugin-dialog": "^2.4.2", + "@tauri-apps/plugin-opener": "^2", + "react": "^19.1.0", + "react-dom": "^19.1.0" }, "devDependencies": { + "@tauri-apps/cli": "^2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", "typescript": "~5.8.3", - "vite": "^7.0.4", - "@tauri-apps/cli": "^2" + "vite": "^7.0.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f70397a..d49f4fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@tauri-apps/api': specifier: ^2 version: 2.9.1 + '@tauri-apps/plugin-dialog': + specifier: ^2.4.2 + version: 2.4.2 '@tauri-apps/plugin-opener': specifier: ^2 version: 2.5.2 @@ -484,6 +487,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-dialog@2.4.2': + resolution: {integrity: sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ==} + '@tauri-apps/plugin-opener@2.5.2': resolution: {integrity: sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew==} @@ -1026,6 +1032,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.9.6 '@tauri-apps/cli-win32-x64-msvc': 2.9.6 + '@tauri-apps/plugin-dialog@2.4.2': + dependencies: + '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-opener@2.5.2': dependencies: '@tauri-apps/api': 2.9.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 69feaba..bb2c246 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1a41578..1276a68 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4cdbf49..803b0a6 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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"] } diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs new file mode 100644 index 0000000..fe90ab8 --- /dev/null +++ b/src-tauri/src/commands/fs.rs @@ -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 { + 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 { + 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 { + 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, 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))? +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..71e6bf5 --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,3 @@ +pub mod fs; +pub mod search; +pub mod shell; diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs new file mode 100644 index 0000000..b1828cc --- /dev/null +++ b/src-tauri/src/commands/search.rs @@ -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 { + 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, 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) +} diff --git a/src-tauri/src/commands/shell.rs b/src-tauri/src/commands/shell.rs new file mode 100644 index 0000000..ee1abc5 --- /dev/null +++ b/src-tauri/src/commands/shell.rs @@ -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 { + 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, + state: State<'_, SessionState>, +) -> Result { + 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), + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a277ef..880d78d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs new file mode 100644 index 0000000..8ccacac --- /dev/null +++ b/src-tauri/src/state.rs @@ -0,0 +1,7 @@ +use std::path::PathBuf; +use std::sync::Mutex; + +#[derive(Default)] +pub struct SessionState { + pub project_root: Mutex>, +} diff --git a/src/App.tsx b/src/App.tsx index 8286a76..c703388 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,49 +1,77 @@ import { useState } from "react"; -import reactLogo from "./assets/react.svg"; import { invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-dialog"; +import { ToolTester } from "./components/ToolTester"; import "./App.css"; function App() { - const [greetMsg, setGreetMsg] = useState(""); - const [name, setName] = useState(""); + const [projectPath, setProjectPath] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); - async function greet() { - // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ - setGreetMsg(await invoke("greet", { name })); + async function selectProject() { + try { + setErrorMsg(null); + // Open native folder picker + const selected = await open({ + directory: true, + multiple: false, + }); + + if (selected === null) { + // User cancelled selection + return; + } + + // Invoke backend command to verify and set state + // Note: invoke argument names must match Rust function args + const confirmedPath = await invoke("open_project", { + path: selected, + }); + setProjectPath(confirmedPath); + } catch (e) { + console.error(e); + setErrorMsg( + typeof e === "string" ? e : "An error occurred opening the project.", + ); + } } return (
-

Welcome to Tauri + React

+

AI Code Assistant

- -

Click on the Tauri, Vite, and React logos to learn more.

+ {!projectPath ? ( +
+

+ Please select a project folder to start the Story-Driven Spec + Workflow. +

+ +
+ ) : ( +
+
+ Active Project: {projectPath} +
+
+

Project loaded successfully.

+ +
+ )} -
{ - e.preventDefault(); - greet(); - }} - > - setName(e.currentTarget.value)} - placeholder="Enter a name..." - /> - -
-

{greetMsg}

+ {errorMsg && ( +
+

Error: {errorMsg}

+
+ )}
); } diff --git a/src/components/ToolTester.tsx b/src/components/ToolTester.tsx new file mode 100644 index 0000000..b9cd946 --- /dev/null +++ b/src/components/ToolTester.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +export function ToolTester() { + const [output, setOutput] = useState("Ready."); + + const runCommand = async (name: string, args: Record) => { + setOutput(`Running ${name}...`); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = await invoke(name, args); + setOutput(JSON.stringify(res, null, 2)); + } catch (e) { + setOutput(`Error: ${e}`); + } + }; + + return ( +
+

Tool Tester

+
+ + + + + +
+ +
+        {output}
+      
+
+ ); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..5d60ad4 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,15 @@ +export interface FileEntry { + name: string; + kind: "file" | "dir"; +} + +export interface SearchResult { + path: string; + matches: number; +} + +export interface CommandOutput { + stdout: string; + stderr: string; + exit_code: number; +}