feat: core agent tools (fs, search, shell)
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
---
|
||||
|
||||
48
.living_spec/specs/functional/AGENT_CAPABILITIES.md
Normal file
48
.living_spec/specs/functional/AGENT_CAPABILITIES.md
Normal 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").
|
||||
@@ -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.
|
||||
|
||||
20
.living_spec/stories/02_core_agent_tools.md
Normal file
20
.living_spec/stories/02_core_agent_tools.md
Normal 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).
|
||||
11
package.json
11
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"
|
||||
}
|
||||
}
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
274
src-tauri/Cargo.lock
generated
274
src-tauri/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
133
src-tauri/src/commands/fs.rs
Normal file
133
src-tauri/src/commands/fs.rs
Normal 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))?
|
||||
}
|
||||
3
src-tauri/src/commands/mod.rs
Normal file
3
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod fs;
|
||||
pub mod search;
|
||||
pub mod shell;
|
||||
82
src-tauri/src/commands/search.rs
Normal file
82
src-tauri/src/commands/search.rs
Normal 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)
|
||||
}
|
||||
76
src-tauri/src/commands/shell.rs
Normal file
76
src-tauri/src/commands/shell.rs
Normal 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),
|
||||
})
|
||||
}
|
||||
@@ -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
7
src-tauri/src/state.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SessionState {
|
||||
pub project_root: Mutex<Option<PathBuf>>,
|
||||
}
|
||||
92
src/App.tsx
92
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<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(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<string>("open_project", {
|
||||
path: selected,
|
||||
});
|
||||
setProjectPath(confirmedPath);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setErrorMsg(
|
||||
typeof e === "string" ? e : "An error occurred opening the project.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<h1>Welcome to Tauri + React</h1>
|
||||
<h1>AI Code Assistant</h1>
|
||||
|
||||
<div className="row">
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://tauri.app" target="_blank">
|
||||
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
{!projectPath ? (
|
||||
<div className="selection-screen">
|
||||
<p>
|
||||
Please select a project folder to start the Story-Driven Spec
|
||||
Workflow.
|
||||
</p>
|
||||
<button onClick={selectProject}>Open Project Directory</button>
|
||||
</div>
|
||||
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
|
||||
|
||||
<form
|
||||
className="row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
greet();
|
||||
) : (
|
||||
<div className="workspace">
|
||||
<div
|
||||
className="toolbar"
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#f0f0f0",
|
||||
borderRadius: "4px",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id="greet-input"
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
placeholder="Enter a name..."
|
||||
/>
|
||||
<button type="submit">Greet</button>
|
||||
</form>
|
||||
<p>{greetMsg}</p>
|
||||
<strong>Active Project:</strong> {projectPath}
|
||||
</div>
|
||||
<hr style={{ margin: "20px 0" }} />
|
||||
<p>Project loaded successfully.</p>
|
||||
<ToolTester />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMsg && (
|
||||
<div className="error-message" style={{ marginTop: "20px" }}>
|
||||
<p style={{ color: "red" }}>Error: {errorMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
69
src/components/ToolTester.tsx
Normal file
69
src/components/ToolTester.tsx
Normal 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
15
src/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user