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

View File

@@ -50,7 +50,7 @@ When the user asks for a feature, follow this 4-step loop strictly:
* **User Story:** "As a user, I want..." * **User Story:** "As a user, I want..."
* **Acceptance Criteria:** Bullet points of observable success. * **Acceptance Criteria:** Bullet points of observable success.
* **Out of scope:** Things that are out of scope so that the LLM doesn't go crazy * **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) ### Step 2: The Spec (Digest)
* **Action:** Update the files in `specs/`. * **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) ### Step 4: Verification (Close)
* **Action:** Write a test case that maps directly to the Acceptance Criteria in the Story. * **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:** 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.
--- ---

View File

@@ -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<String, AppError>`
* **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<Vec<FileEntry>, 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<String>`
* **Output:** `Result<Vec<Match>, 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<String>`
* **Output:** `Result<CommandOutput, AppError>`
* **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").

View File

@@ -75,6 +75,7 @@ To support both Remote and Local models, the system implements a `ModelProvider`
* **Rust:** * **Rust:**
* `serde`, `serde_json`: Serialization. * `serde`, `serde_json`: Serialization.
* `ignore`: Fast recursive directory iteration respecting gitignore. * `ignore`: Fast recursive directory iteration respecting gitignore.
* `walkdir`: Simple directory traversal.
* `tokio`: Async runtime. * `tokio`: Async runtime.
* `reqwest`: For LLM API calls (if backend-initiated). * `reqwest`: For LLM API calls (if backend-initiated).
* `tauri-plugin-dialog`: Native system dialogs. * `tauri-plugin-dialog`: Native system dialogs.

View File

@@ -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).

View File

@@ -10,17 +10,18 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"@tauri-apps/api": "^2", "@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": { "devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.0.4", "vite": "^7.0.4"
"@tauri-apps/cli": "^2"
} }
} }

10
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2 specifier: ^2
version: 2.9.1 version: 2.9.1
'@tauri-apps/plugin-dialog':
specifier: ^2.4.2
version: 2.4.2
'@tauri-apps/plugin-opener': '@tauri-apps/plugin-opener':
specifier: ^2 specifier: ^2
version: 2.5.2 version: 2.5.2
@@ -484,6 +487,9 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
'@tauri-apps/plugin-dialog@2.4.2':
resolution: {integrity: sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ==}
'@tauri-apps/plugin-opener@2.5.2': '@tauri-apps/plugin-opener@2.5.2':
resolution: {integrity: sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew==} 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-ia32-msvc': 2.9.6
'@tauri-apps/cli-win32-x64-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': '@tauri-apps/plugin-opener@2.5.2':
dependencies: dependencies:
'@tauri-apps/api': 2.9.1 '@tauri-apps/api': 2.9.1

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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 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]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -292,6 +313,16 @@ dependencies = [
"alloc-stdlib", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.1" version = "3.19.1"
@@ -549,6 +580,25 @@ dependencies = [
"crossbeam-utils", "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]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@@ -704,6 +754,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"block2",
"libc",
"objc2", "objc2",
] ]
@@ -718,6 +770,15 @@ dependencies = [
"syn 2.0.111", "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]] [[package]]
name = "dlopen2" name = "dlopen2"
version = "0.8.2" version = "0.8.2"
@@ -741,6 +802,12 @@ dependencies = [
"syn 2.0.111", "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]] [[package]]
name = "dpi" name = "dpi"
version = "0.1.2" version = "0.1.2"
@@ -1287,6 +1354,19 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" 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]] [[package]]
name = "gobject-sys" name = "gobject-sys"
version = "0.18.0" version = "0.18.0"
@@ -1624,6 +1704,22 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -1869,11 +1965,14 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
name = "living-spec-standalone" name = "living-spec-standalone"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ignore",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener", "tauri-plugin-opener",
"walkdir",
] ]
[[package]] [[package]]
@@ -2566,7 +2665,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.12.1", "indexmap 2.12.1",
"quick-xml", "quick-xml 0.38.4",
"serde", "serde",
"time", "time",
] ]
@@ -2696,6 +2795,15 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -2745,6 +2853,16 @@ dependencies = [
"rand_core 0.6.4", "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]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.2.2" version = "0.2.2"
@@ -2765,6 +2883,16 @@ dependencies = [
"rand_core 0.6.4", "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]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.5.1" version = "0.5.1"
@@ -2783,6 +2911,15 @@ dependencies = [
"getrandom 0.2.16", "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]] [[package]]
name = "rand_hc" name = "rand_hc"
version = "0.2.0" version = "0.2.0"
@@ -2911,6 +3048,31 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -3005,6 +3167,12 @@ dependencies = [
"syn 2.0.111", "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]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -3620,6 +3788,46 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.2" version = "2.5.2"
@@ -3858,7 +4066,9 @@ dependencies = [
"libc", "libc",
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"socket2", "socket2",
"tracing",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -4342,6 +4552,66 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.83" version = "0.3.83"
@@ -5010,6 +5280,7 @@ dependencies = [
"ordered-stream", "ordered-stream",
"serde", "serde",
"serde_repr", "serde_repr",
"tokio",
"tracing", "tracing",
"uds_windows", "uds_windows",
"uuid", "uuid",
@@ -5136,6 +5407,7 @@ dependencies = [
"endi", "endi",
"enumflags2", "enumflags2",
"serde", "serde",
"url",
"winnow 0.7.14", "winnow 0.7.14",
"zvariant_derive", "zvariant_derive",
"zvariant_utils", "zvariant_utils",

View File

@@ -22,4 +22,7 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" 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", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": ["core:default", "opener:default", "dialog:default"]
"core:default",
"opener: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/ mod commands;
#[tauri::command] mod state;
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name) use state::SessionState;
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .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>>,
}

View File

@@ -1,49 +1,77 @@
import { useState } from "react"; import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { ToolTester } from "./components/ToolTester";
import "./App.css"; import "./App.css";
function App() { function App() {
const [greetMsg, setGreetMsg] = useState(""); const [projectPath, setProjectPath] = useState<string | null>(null);
const [name, setName] = useState(""); const [errorMsg, setErrorMsg] = useState<string | null>(null);
async function greet() { async function selectProject() {
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ try {
setGreetMsg(await invoke("greet", { name })); 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<string>("open_project", {
path: selected,
});
setProjectPath(confirmedPath);
} catch (e) {
console.error(e);
setErrorMsg(
typeof e === "string" ? e : "An error occurred opening the project.",
);
}
} }
return ( return (
<main className="container"> <main className="container">
<h1>Welcome to Tauri + React</h1> <h1>AI Code Assistant</h1>
<div className="row"> {!projectPath ? (
<a href="https://vite.dev" target="_blank"> <div className="selection-screen">
<img src="/vite.svg" className="logo vite" alt="Vite logo" /> <p>
</a> Please select a project folder to start the Story-Driven Spec
<a href="https://tauri.app" target="_blank"> Workflow.
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" /> </p>
</a> <button onClick={selectProject}>Open Project Directory</button>
<a href="https://react.dev" target="_blank"> </div>
<img src={reactLogo} className="logo react" alt="React logo" /> ) : (
</a> <div className="workspace">
</div> <div
<p>Click on the Tauri, Vite, and React logos to learn more.</p> className="toolbar"
style={{
padding: "10px",
background: "#f0f0f0",
borderRadius: "4px",
color: "#333",
}}
>
<strong>Active Project:</strong> {projectPath}
</div>
<hr style={{ margin: "20px 0" }} />
<p>Project loaded successfully.</p>
<ToolTester />
</div>
)}
<form {errorMsg && (
className="row" <div className="error-message" style={{ marginTop: "20px" }}>
onSubmit={(e) => { <p style={{ color: "red" }}>Error: {errorMsg}</p>
e.preventDefault(); </div>
greet(); )}
}}
>
<input
id="greet-input"
onChange={(e) => setName(e.currentTarget.value)}
placeholder="Enter a name..."
/>
<button type="submit">Greet</button>
</form>
<p>{greetMsg}</p>
</main> </main>
); );
} }

View File

@@ -0,0 +1,69 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
export function ToolTester() {
const [output, setOutput] = useState<string>("Ready.");
const runCommand = async (name: string, args: Record<string, unknown>) => {
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 (
<div
style={{ marginTop: "20px", border: "1px solid #ccc", padding: "10px" }}
>
<h3>Tool Tester</h3>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
<button onClick={() => runCommand("list_directory", { path: "." })}>
List Root
</button>
<button
onClick={() =>
runCommand("read_file", { path: ".living_spec/README.md" })
}
>
Read Spec
</button>
<button onClick={() => runCommand("search_files", { query: "Story" })}>
Search "Story"
</button>
<button
onClick={() =>
runCommand("exec_shell", { command: "ls", args: ["-F"] })
}
>
Shell: ls -F
</button>
<button
onClick={() =>
runCommand("exec_shell", { command: "git", args: ["status"] })
}
>
Shell: git status
</button>
</div>
<pre
style={{
marginTop: "10px",
background: "#333",
color: "#fff",
padding: "10px",
borderRadius: "5px",
overflowX: "auto",
textAlign: "left",
fontSize: "12px",
}}
>
{output}
</pre>
</div>
);
}

15
src/types.ts Normal file
View File

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