Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions crates/bashkit-js/__test__/vfs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import test from "ava";
import { Bash } from "../wrapper.js";

// ============================================================================
// VFS — readFile / writeFile
// ============================================================================

test("writeFile + readFile roundtrip", (t) => {
const bash = new Bash();
bash.writeFile("/tmp/hello.txt", "Hello, VFS!");
t.is(bash.readFile("/tmp/hello.txt"), "Hello, VFS!");
});

test("writeFile overwrites existing content", (t) => {
const bash = new Bash();
bash.writeFile("/tmp/over.txt", "first");
bash.writeFile("/tmp/over.txt", "second");
t.is(bash.readFile("/tmp/over.txt"), "second");
});

test("readFile throws on missing file", (t) => {
const bash = new Bash();
t.throws(() => bash.readFile("/nonexistent/file.txt"));
});

test("writeFile preserves binary-like content", (t) => {
const bash = new Bash();
const content = "line1\nline2\n\ttabbed\n";
bash.writeFile("/tmp/multi.txt", content);
t.is(bash.readFile("/tmp/multi.txt"), content);
});

test("writeFile with empty content", (t) => {
const bash = new Bash();
bash.writeFile("/tmp/empty.txt", "");
t.is(bash.readFile("/tmp/empty.txt"), "");
});

// ============================================================================
// VFS — mkdir
// ============================================================================

test("mkdir creates directory", (t) => {
const bash = new Bash();
bash.mkdir("/tmp/newdir");
t.true(bash.exists("/tmp/newdir"));
});

test("mkdir recursive creates parent chain", (t) => {
const bash = new Bash();
bash.mkdir("/a/b/c/d", true);
t.true(bash.exists("/a/b/c/d"));
t.true(bash.exists("/a/b/c"));
t.true(bash.exists("/a/b"));
});

test("mkdir non-recursive fails without parent", (t) => {
const bash = new Bash();
t.throws(() => bash.mkdir("/x/y/z"));
});

// ============================================================================
// VFS — exists
// ============================================================================

test("exists returns false for missing path", (t) => {
const bash = new Bash();
t.false(bash.exists("/does/not/exist"));
});

test("exists returns true for file", (t) => {
const bash = new Bash();
bash.writeFile("/tmp/e.txt", "x");
t.true(bash.exists("/tmp/e.txt"));
});

test("exists returns true for directory", (t) => {
const bash = new Bash();
bash.mkdir("/tmp/edir");
t.true(bash.exists("/tmp/edir"));
});

// ============================================================================
// VFS — remove
// ============================================================================

test("remove deletes a file", (t) => {
const bash = new Bash();
bash.writeFile("/tmp/rm.txt", "bye");
t.true(bash.exists("/tmp/rm.txt"));
bash.remove("/tmp/rm.txt");
t.false(bash.exists("/tmp/rm.txt"));
});

test("remove recursive deletes directory tree", (t) => {
const bash = new Bash();
bash.mkdir("/tmp/tree/sub", true);
bash.writeFile("/tmp/tree/sub/f.txt", "data");
bash.remove("/tmp/tree", true);
t.false(bash.exists("/tmp/tree"));
});

test("remove throws on missing path", (t) => {
const bash = new Bash();
t.throws(() => bash.remove("/no/such/file"));
});

// ============================================================================
// VFS ↔ bash interop
// ============================================================================

test("bash executeSync sees VFS-written files", (t) => {
const bash = new Bash();
bash.writeFile("/tmp/from-vfs.txt", "vfs-content");
const r = bash.executeSync("cat /tmp/from-vfs.txt");
t.is(r.stdout, "vfs-content");
});

test("readFile sees bash-created files", (t) => {
const bash = new Bash();
bash.executeSync("echo bash-content > /tmp/from-bash.txt");
t.is(bash.readFile("/tmp/from-bash.txt"), "bash-content\n");
});

test("VFS mkdir makes directory visible to bash ls", (t) => {
const bash = new Bash();
bash.mkdir("/project/src/lib", true);
bash.writeFile("/project/src/lib/mod.rs", "// rust");
const r = bash.executeSync("ls /project/src/lib/");
t.is(r.stdout.trim(), "mod.rs");
});

test("bash mkdir makes directory visible to VFS exists", (t) => {
const bash = new Bash();
bash.executeSync("mkdir -p /project/pkg");
t.true(bash.exists("/project/pkg"));
});

test("reset clears VFS state", (t) => {
const bash = new Bash();
bash.writeFile("/tmp/persist.txt", "data");
t.true(bash.exists("/tmp/persist.txt"));
bash.reset();
t.false(bash.exists("/tmp/persist.txt"));
});
74 changes: 74 additions & 0 deletions crates/bashkit-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use bashkit::tool::VERSION;
use bashkit::{Bash as RustBash, BashTool as RustBashTool, ExecutionLimits, Tool};
use napi_derive::napi;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::Mutex;
Expand Down Expand Up @@ -186,6 +187,79 @@ impl Bash {
Ok(())
})
}

// ========================================================================
// VFS — direct filesystem access
// ========================================================================

/// Read a file from the virtual filesystem. Returns contents as a UTF-8 string.
#[napi]
pub fn read_file(&self, path: String) -> napi::Result<String> {
let inner = self.inner.clone();
self.rt.block_on(async move {
let bash = inner.lock().await;
let bytes = bash
.fs()
.read_file(Path::new(&path))
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
String::from_utf8(bytes)
.map_err(|e| napi::Error::from_reason(format!("Invalid UTF-8: {e}")))
})
}

/// Write a string to a file in the virtual filesystem.
/// Creates the file if it doesn't exist, replaces contents if it does.
#[napi]
pub fn write_file(&self, path: String, content: String) -> napi::Result<()> {
let inner = self.inner.clone();
self.rt.block_on(async move {
let bash = inner.lock().await;
bash.fs()
.write_file(Path::new(&path), content.as_bytes())
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
})
}

/// Create a directory. If recursive is true, creates parent directories as needed.
#[napi]
pub fn mkdir(&self, path: String, recursive: Option<bool>) -> napi::Result<()> {
let inner = self.inner.clone();
self.rt.block_on(async move {
let bash = inner.lock().await;
bash.fs()
.mkdir(Path::new(&path), recursive.unwrap_or(false))
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
})
}

/// Check if a path exists in the virtual filesystem.
#[napi]
pub fn exists(&self, path: String) -> napi::Result<bool> {
let inner = self.inner.clone();
self.rt.block_on(async move {
let bash = inner.lock().await;
bash.fs()
.exists(Path::new(&path))
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
})
}

/// Remove a file or directory. If recursive is true, removes directory contents.
#[napi]
pub fn remove(&self, path: String, recursive: Option<bool>) -> napi::Result<()> {
let inner = self.inner.clone();
self.rt.block_on(async move {
let bash = inner.lock().await;
bash.fs()
.remove(Path::new(&path), recursive.unwrap_or(false))
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
})
}
}

// ============================================================================
Expand Down
41 changes: 20 additions & 21 deletions crates/bashkit-js/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,32 +261,31 @@ export class Bash {
this.native.reset();
}

// ==========================================================================
// VFS file helpers
// ==========================================================================

/**
* Check whether a path exists in the virtual filesystem.
*/
exists(path: string): boolean {
return this.executeSync(`test -e '${path.replace(/'/g, "'\\''")}'`).exitCode === 0;
}
// VFS — direct filesystem access

/**
* Read file contents from the virtual filesystem.
* Throws `BashError` if the file does not exist.
*/
/** Read a file from the virtual filesystem as a UTF-8 string. */
readFile(path: string): string {
const result = this.executeSyncOrThrow(`cat '${path.replace(/'/g, "'\\''")}'`);
return result.stdout;
return this.native.readFile(path);
}

/**
* Write content to a file in the virtual filesystem.
* Creates parent directories as needed.
*/
/** Write a string to a file in the virtual filesystem. */
writeFile(path: string, content: string): void {
this.executeSyncOrThrow(buildWriteCmd(path, content));
this.native.writeFile(path, content);
}

/** Create a directory. If recursive is true, creates parents as needed. */
mkdir(path: string, recursive?: boolean): void {
this.native.mkdir(path, recursive);
}

/** Check if a path exists in the virtual filesystem. */
exists(path: string): boolean {
return this.native.exists(path);
}

/** Remove a file or directory. If recursive is true, removes contents. */
remove(path: string, recursive?: boolean): void {
this.native.remove(path, recursive);
}

/**
Expand Down
1 change: 1 addition & 0 deletions examples/bashkit-pi/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
66 changes: 66 additions & 0 deletions examples/bashkit-pi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Pi + Bashkit Integration

Run [pi](https://pi.dev/) (terminal coding agent) with bashkit's virtual bash interpreter and virtual filesystem instead of real shell/filesystem access.

## What This Does

Replaces all four of pi's core tools (bash, read, write, edit) with bashkit-backed virtual implementations:

- **bash** — commands execute in bashkit's sandboxed virtual bash (100+ builtins)
- **read** — reads files from bashkit's in-memory VFS
- **write** — writes files to bashkit's in-memory VFS
- **edit** — edits files in bashkit's in-memory VFS (find-and-replace)

No real filesystem access. No subprocess. Uses `@everruns/bashkit` Node.js native bindings (NAPI-RS) loaded directly in pi's process.

## Setup

```bash
# 1. Build the Node.js bindings
cd crates/bashkit-js && npm install && npm run build && cd -

# 2. Install this example's dependencies
cd examples/bashkit-pi && npm install && cd -

# 3. Install pi
npm install -g @mariozechner/pi-coding-agent
```

## Run

```bash
# With OpenAI
pi --provider openai --model gpt-5.4 \
-e examples/bashkit-pi/bashkit-extension.ts \
--api-key "$OPENAI_API_KEY"

# With Anthropic
pi --provider anthropic --model claude-sonnet-4-20250514 \
-e examples/bashkit-pi/bashkit-extension.ts \
--api-key "$ANTHROPIC_API_KEY"

# Non-interactive
pi --provider openai --model gpt-5.4 \
-e examples/bashkit-pi/bashkit-extension.ts \
-p "Create a project structure, write some code, and grep for patterns" \
--no-session
```

## Architecture

```
pi (LLM agent)
├── bash tool ──→ Bash.executeSync() ──→ bashkit virtual bash
├── read tool ──→ Bash.readFile() ──→ bashkit VFS (direct)
├── write tool ──→ Bash.writeFile() ──→ bashkit VFS (direct)
└── edit tool ──→ Bash.readFile() + writeFile() ──→ bashkit VFS (direct)
```

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.

## How It Works

1. Extension creates a single `Bash` instance on load
2. All four tools (bash, read, write, edit) operate on the same virtual filesystem
3. Files created by `write` are visible to `bash`, `read`, `edit` — and vice versa
4. Shell state (variables, cwd, functions) persists across `bash` calls
Loading
Loading