Skip to content

Commit 80a84df

Browse files
authored
feat(pi-integration): add Pi coding agent extension with bashkit VFS (everruns#638)
## Summary - Add Pi coding agent extension (`examples/bashkit-pi/`) that replaces built-in bash, read, write, and edit tools with bashkit virtual implementations using NAPI-RS bindings - Expose VFS APIs (readFile, writeFile, mkdir, exists, remove) via NAPI on the `Bash` class for direct filesystem access without shell command overhead - Inject bashkit system prompt into LLM context via `before_agent_start` hook, with environment identity section to prevent host path leakage - Update implementation status spec with JavaScript bindings documentation ## What changed **`crates/bashkit-js/src/lib.rs`** — Add 5 VFS NAPI methods on `Bash`: `read_file`, `write_file`, `mkdir`, `exists`, `remove`. These delegate directly to `bash.fs()` for zero-overhead VFS access from JS. **`crates/bashkit-js/wrapper.ts`** — Replace shell-based VFS helpers (`cat`, `test -e`, heredoc writes) on `Bash` class with native NAPI delegations. Keep `ls`/`glob` as shell-based convenience wrappers. **`examples/bashkit-pi/bashkit-extension.ts`** — Pi extension registering 4 tools (bash, read, write, edit) backed by a single bashkit `Bash` instance. VFS and shell state stay in sync. **`specs/009-implementation-status.md`** — Add Language Bindings section documenting JS/Node.js API surface and platform matrix. ## Tests added/verified - `crates/bashkit-js/__test__/vfs.spec.ts` — 18 tests covering readFile/writeFile roundtrip, mkdir, exists, remove, VFS↔bash interop, reset - All Rust tests pass (`cargo test --all-features`) - Clippy clean, fmt clean - Smoke tested VFS APIs end-to-end from Node.js ## Security review - VFS methods delegate to sandboxed filesystem — no host access possible - Native NAPI VFS methods eliminate shell metacharacter injection vectors present in old shell-based helpers - Path handling via `Path::new()` — no shell interpolation
1 parent a4d0ec0 commit 80a84df

9 files changed

Lines changed: 625 additions & 21 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import test from "ava";
2+
import { Bash } from "../wrapper.js";
3+
4+
// ============================================================================
5+
// VFS — readFile / writeFile
6+
// ============================================================================
7+
8+
test("writeFile + readFile roundtrip", (t) => {
9+
const bash = new Bash();
10+
bash.writeFile("/tmp/hello.txt", "Hello, VFS!");
11+
t.is(bash.readFile("/tmp/hello.txt"), "Hello, VFS!");
12+
});
13+
14+
test("writeFile overwrites existing content", (t) => {
15+
const bash = new Bash();
16+
bash.writeFile("/tmp/over.txt", "first");
17+
bash.writeFile("/tmp/over.txt", "second");
18+
t.is(bash.readFile("/tmp/over.txt"), "second");
19+
});
20+
21+
test("readFile throws on missing file", (t) => {
22+
const bash = new Bash();
23+
t.throws(() => bash.readFile("/nonexistent/file.txt"));
24+
});
25+
26+
test("writeFile preserves binary-like content", (t) => {
27+
const bash = new Bash();
28+
const content = "line1\nline2\n\ttabbed\n";
29+
bash.writeFile("/tmp/multi.txt", content);
30+
t.is(bash.readFile("/tmp/multi.txt"), content);
31+
});
32+
33+
test("writeFile with empty content", (t) => {
34+
const bash = new Bash();
35+
bash.writeFile("/tmp/empty.txt", "");
36+
t.is(bash.readFile("/tmp/empty.txt"), "");
37+
});
38+
39+
// ============================================================================
40+
// VFS — mkdir
41+
// ============================================================================
42+
43+
test("mkdir creates directory", (t) => {
44+
const bash = new Bash();
45+
bash.mkdir("/tmp/newdir");
46+
t.true(bash.exists("/tmp/newdir"));
47+
});
48+
49+
test("mkdir recursive creates parent chain", (t) => {
50+
const bash = new Bash();
51+
bash.mkdir("/a/b/c/d", true);
52+
t.true(bash.exists("/a/b/c/d"));
53+
t.true(bash.exists("/a/b/c"));
54+
t.true(bash.exists("/a/b"));
55+
});
56+
57+
test("mkdir non-recursive fails without parent", (t) => {
58+
const bash = new Bash();
59+
t.throws(() => bash.mkdir("/x/y/z"));
60+
});
61+
62+
// ============================================================================
63+
// VFS — exists
64+
// ============================================================================
65+
66+
test("exists returns false for missing path", (t) => {
67+
const bash = new Bash();
68+
t.false(bash.exists("/does/not/exist"));
69+
});
70+
71+
test("exists returns true for file", (t) => {
72+
const bash = new Bash();
73+
bash.writeFile("/tmp/e.txt", "x");
74+
t.true(bash.exists("/tmp/e.txt"));
75+
});
76+
77+
test("exists returns true for directory", (t) => {
78+
const bash = new Bash();
79+
bash.mkdir("/tmp/edir");
80+
t.true(bash.exists("/tmp/edir"));
81+
});
82+
83+
// ============================================================================
84+
// VFS — remove
85+
// ============================================================================
86+
87+
test("remove deletes a file", (t) => {
88+
const bash = new Bash();
89+
bash.writeFile("/tmp/rm.txt", "bye");
90+
t.true(bash.exists("/tmp/rm.txt"));
91+
bash.remove("/tmp/rm.txt");
92+
t.false(bash.exists("/tmp/rm.txt"));
93+
});
94+
95+
test("remove recursive deletes directory tree", (t) => {
96+
const bash = new Bash();
97+
bash.mkdir("/tmp/tree/sub", true);
98+
bash.writeFile("/tmp/tree/sub/f.txt", "data");
99+
bash.remove("/tmp/tree", true);
100+
t.false(bash.exists("/tmp/tree"));
101+
});
102+
103+
test("remove throws on missing path", (t) => {
104+
const bash = new Bash();
105+
t.throws(() => bash.remove("/no/such/file"));
106+
});
107+
108+
// ============================================================================
109+
// VFS ↔ bash interop
110+
// ============================================================================
111+
112+
test("bash executeSync sees VFS-written files", (t) => {
113+
const bash = new Bash();
114+
bash.writeFile("/tmp/from-vfs.txt", "vfs-content");
115+
const r = bash.executeSync("cat /tmp/from-vfs.txt");
116+
t.is(r.stdout, "vfs-content");
117+
});
118+
119+
test("readFile sees bash-created files", (t) => {
120+
const bash = new Bash();
121+
bash.executeSync("echo bash-content > /tmp/from-bash.txt");
122+
t.is(bash.readFile("/tmp/from-bash.txt"), "bash-content\n");
123+
});
124+
125+
test("VFS mkdir makes directory visible to bash ls", (t) => {
126+
const bash = new Bash();
127+
bash.mkdir("/project/src/lib", true);
128+
bash.writeFile("/project/src/lib/mod.rs", "// rust");
129+
const r = bash.executeSync("ls /project/src/lib/");
130+
t.is(r.stdout.trim(), "mod.rs");
131+
});
132+
133+
test("bash mkdir makes directory visible to VFS exists", (t) => {
134+
const bash = new Bash();
135+
bash.executeSync("mkdir -p /project/pkg");
136+
t.true(bash.exists("/project/pkg"));
137+
});
138+
139+
test("reset clears VFS state", (t) => {
140+
const bash = new Bash();
141+
bash.writeFile("/tmp/persist.txt", "data");
142+
t.true(bash.exists("/tmp/persist.txt"));
143+
bash.reset();
144+
t.false(bash.exists("/tmp/persist.txt"));
145+
});

crates/bashkit-js/src/lib.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use bashkit::tool::VERSION;
1010
use bashkit::{Bash as RustBash, BashTool as RustBashTool, ExecutionLimits, Tool};
1111
use napi_derive::napi;
1212
use std::collections::HashMap;
13+
use std::path::Path;
1314
use std::sync::Arc;
1415
use std::sync::atomic::{AtomicBool, Ordering};
1516
use tokio::sync::Mutex;
@@ -186,6 +187,79 @@ impl Bash {
186187
Ok(())
187188
})
188189
}
190+
191+
// ========================================================================
192+
// VFS — direct filesystem access
193+
// ========================================================================
194+
195+
/// Read a file from the virtual filesystem. Returns contents as a UTF-8 string.
196+
#[napi]
197+
pub fn read_file(&self, path: String) -> napi::Result<String> {
198+
let inner = self.inner.clone();
199+
self.rt.block_on(async move {
200+
let bash = inner.lock().await;
201+
let bytes = bash
202+
.fs()
203+
.read_file(Path::new(&path))
204+
.await
205+
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
206+
String::from_utf8(bytes)
207+
.map_err(|e| napi::Error::from_reason(format!("Invalid UTF-8: {e}")))
208+
})
209+
}
210+
211+
/// Write a string to a file in the virtual filesystem.
212+
/// Creates the file if it doesn't exist, replaces contents if it does.
213+
#[napi]
214+
pub fn write_file(&self, path: String, content: String) -> napi::Result<()> {
215+
let inner = self.inner.clone();
216+
self.rt.block_on(async move {
217+
let bash = inner.lock().await;
218+
bash.fs()
219+
.write_file(Path::new(&path), content.as_bytes())
220+
.await
221+
.map_err(|e| napi::Error::from_reason(e.to_string()))
222+
})
223+
}
224+
225+
/// Create a directory. If recursive is true, creates parent directories as needed.
226+
#[napi]
227+
pub fn mkdir(&self, path: String, recursive: Option<bool>) -> napi::Result<()> {
228+
let inner = self.inner.clone();
229+
self.rt.block_on(async move {
230+
let bash = inner.lock().await;
231+
bash.fs()
232+
.mkdir(Path::new(&path), recursive.unwrap_or(false))
233+
.await
234+
.map_err(|e| napi::Error::from_reason(e.to_string()))
235+
})
236+
}
237+
238+
/// Check if a path exists in the virtual filesystem.
239+
#[napi]
240+
pub fn exists(&self, path: String) -> napi::Result<bool> {
241+
let inner = self.inner.clone();
242+
self.rt.block_on(async move {
243+
let bash = inner.lock().await;
244+
bash.fs()
245+
.exists(Path::new(&path))
246+
.await
247+
.map_err(|e| napi::Error::from_reason(e.to_string()))
248+
})
249+
}
250+
251+
/// Remove a file or directory. If recursive is true, removes directory contents.
252+
#[napi]
253+
pub fn remove(&self, path: String, recursive: Option<bool>) -> napi::Result<()> {
254+
let inner = self.inner.clone();
255+
self.rt.block_on(async move {
256+
let bash = inner.lock().await;
257+
bash.fs()
258+
.remove(Path::new(&path), recursive.unwrap_or(false))
259+
.await
260+
.map_err(|e| napi::Error::from_reason(e.to_string()))
261+
})
262+
}
189263
}
190264

191265
// ============================================================================

crates/bashkit-js/wrapper.ts

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -261,32 +261,31 @@ export class Bash {
261261
this.native.reset();
262262
}
263263

264-
// ==========================================================================
265-
// VFS file helpers
266-
// ==========================================================================
267-
268-
/**
269-
* Check whether a path exists in the virtual filesystem.
270-
*/
271-
exists(path: string): boolean {
272-
return this.executeSync(`test -e '${path.replace(/'/g, "'\\''")}'`).exitCode === 0;
273-
}
264+
// VFS — direct filesystem access
274265

275-
/**
276-
* Read file contents from the virtual filesystem.
277-
* Throws `BashError` if the file does not exist.
278-
*/
266+
/** Read a file from the virtual filesystem as a UTF-8 string. */
279267
readFile(path: string): string {
280-
const result = this.executeSyncOrThrow(`cat '${path.replace(/'/g, "'\\''")}'`);
281-
return result.stdout;
268+
return this.native.readFile(path);
282269
}
283270

284-
/**
285-
* Write content to a file in the virtual filesystem.
286-
* Creates parent directories as needed.
287-
*/
271+
/** Write a string to a file in the virtual filesystem. */
288272
writeFile(path: string, content: string): void {
289-
this.executeSyncOrThrow(buildWriteCmd(path, content));
273+
this.native.writeFile(path, content);
274+
}
275+
276+
/** Create a directory. If recursive is true, creates parents as needed. */
277+
mkdir(path: string, recursive?: boolean): void {
278+
this.native.mkdir(path, recursive);
279+
}
280+
281+
/** Check if a path exists in the virtual filesystem. */
282+
exists(path: string): boolean {
283+
return this.native.exists(path);
284+
}
285+
286+
/** Remove a file or directory. If recursive is true, removes contents. */
287+
remove(path: string, recursive?: boolean): void {
288+
this.native.remove(path, recursive);
290289
}
291290

292291
/**

examples/bashkit-pi/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules/

examples/bashkit-pi/README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Pi + Bashkit Integration
2+
3+
Run [pi](https://pi.dev/) (terminal coding agent) with bashkit's virtual bash interpreter and virtual filesystem instead of real shell/filesystem access.
4+
5+
## What This Does
6+
7+
Replaces all four of pi's core tools (bash, read, write, edit) with bashkit-backed virtual implementations:
8+
9+
- **bash** — commands execute in bashkit's sandboxed virtual bash (100+ builtins)
10+
- **read** — reads files from bashkit's in-memory VFS
11+
- **write** — writes files to bashkit's in-memory VFS
12+
- **edit** — edits files in bashkit's in-memory VFS (find-and-replace)
13+
14+
No real filesystem access. No subprocess. Uses `@everruns/bashkit` Node.js native bindings (NAPI-RS) loaded directly in pi's process.
15+
16+
## Setup
17+
18+
```bash
19+
# 1. Build the Node.js bindings
20+
cd crates/bashkit-js && npm install && npm run build && cd -
21+
22+
# 2. Install this example's dependencies
23+
cd examples/bashkit-pi && npm install && cd -
24+
25+
# 3. Install pi
26+
npm install -g @mariozechner/pi-coding-agent
27+
```
28+
29+
## Run
30+
31+
```bash
32+
# With OpenAI
33+
pi --provider openai --model gpt-5.4 \
34+
-e examples/bashkit-pi/bashkit-extension.ts \
35+
--api-key "$OPENAI_API_KEY"
36+
37+
# With Anthropic
38+
pi --provider anthropic --model claude-sonnet-4-20250514 \
39+
-e examples/bashkit-pi/bashkit-extension.ts \
40+
--api-key "$ANTHROPIC_API_KEY"
41+
42+
# Non-interactive
43+
pi --provider openai --model gpt-5.4 \
44+
-e examples/bashkit-pi/bashkit-extension.ts \
45+
-p "Create a project structure, write some code, and grep for patterns" \
46+
--no-session
47+
```
48+
49+
## Architecture
50+
51+
```
52+
pi (LLM agent)
53+
├── bash tool ──→ Bash.executeSync() ──→ bashkit virtual bash
54+
├── read tool ──→ Bash.readFile() ──→ bashkit VFS (direct)
55+
├── write tool ──→ Bash.writeFile() ──→ bashkit VFS (direct)
56+
└── edit tool ──→ Bash.readFile() + writeFile() ──→ bashkit VFS (direct)
57+
```
58+
59+
Single `Bash` instance shared across all tools. read/write/edit use direct VFS APIs (no shell quoting). bash tool uses `executeSync()`. Both share the same VFS — files created by any tool are visible to all others.
60+
61+
## How It Works
62+
63+
1. Extension creates a single `Bash` instance on load
64+
2. All four tools (bash, read, write, edit) operate on the same virtual filesystem
65+
3. Files created by `write` are visible to `bash`, `read`, `edit` — and vice versa
66+
4. Shell state (variables, cwd, functions) persists across `bash` calls

0 commit comments

Comments
 (0)