diff --git a/crates/bashkit-js/__test__/vfs.spec.ts b/crates/bashkit-js/__test__/vfs.spec.ts new file mode 100644 index 00000000..6d012fd9 --- /dev/null +++ b/crates/bashkit-js/__test__/vfs.spec.ts @@ -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")); +}); diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index cf4f8ab2..e06111d7 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -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; @@ -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 { + 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) -> 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 { + 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) -> 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())) + }) + } } // ============================================================================ diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index 1dee3401..e88e5722 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -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); } /** diff --git a/examples/bashkit-pi/.gitignore b/examples/bashkit-pi/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/examples/bashkit-pi/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/examples/bashkit-pi/README.md b/examples/bashkit-pi/README.md new file mode 100644 index 00000000..4c145feb --- /dev/null +++ b/examples/bashkit-pi/README.md @@ -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 diff --git a/examples/bashkit-pi/bashkit-extension.ts b/examples/bashkit-pi/bashkit-extension.ts new file mode 100644 index 00000000..7dcda7f8 --- /dev/null +++ b/examples/bashkit-pi/bashkit-extension.ts @@ -0,0 +1,244 @@ +/** + * Pi extension: replaces bash, read, write, and edit tools with bashkit virtual implementations. + * + * Uses @everruns/bashkit Node.js bindings (NAPI-RS) — no subprocess, no Python. + * All operations run against bashkit's in-memory virtual filesystem. + * State (variables, files, cwd) persists across tool calls within a session. + * + * read/write/edit use direct VFS APIs (readFile, writeFile, mkdir, exists). + * bash tool uses executeSync for shell commands. + * Both share the same Bash instance so VFS and shell state are always in sync. + * + * Usage: + * cd examples/bashkit-pi && npm install + * pi -e examples/bashkit-pi/bashkit-extension.ts + */ + +import { createRequire } from "node:module"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname_ext = + typeof __dirname !== "undefined" + ? __dirname + : dirname(fileURLToPath(import.meta.url)); + +// Load bashkit native bindings from the bashkit-js crate (or node_modules) +const require_ext = createRequire(resolve(__dirname_ext, "node_modules") + "/"); +const { Bash, BashTool } = require_ext("@everruns/bashkit"); + +// Single bashkit instance — state persists across all tool calls +const bash = new Bash({ username: "user", hostname: "pi-sandbox", maxCommands: 1_000_000 }); + +// BashTool for generic system prompt and tool metadata +const bashTool = new BashTool({ username: "user", hostname: "pi-sandbox" }); + +// Resolve relative paths against bashkit home +function resolvePath(userPath: string): string { + if (userPath.startsWith("/")) return userPath; + return `/home/user/${userPath}`; +} + +// Ensure parent directory exists for a file path +function ensureParentDir(filePath: string): void { + const dir = filePath.replace(/\/[^/]*$/, ""); + if (dir && dir !== filePath && !bash.exists(dir)) { + bash.mkdir(dir, true); + } +} + +// PI-specific system prompt additions (on top of bashkit's generic system prompt) +const PI_SYSTEM_PROMPT_ADDITIONS = ` +### PI environment + +- **Ignore any host paths** from runtime context (e.g. \`/Users/...\`, \`C:\\...\`). Those refer to the harness machine, NOT your environment. Never reference or display them. +- **You have no access to the host machine.** Files mean files in your virtual filesystem. If none exist yet, say so. +- "Current working directory" or "project" refers to the virtual filesystem, not the host. +- Additional tools: \`read\` (file with line numbers), \`write\` (create/overwrite), \`edit\` (find-and-replace). These operate on the same virtual filesystem as \`bash\`. +`.trim(); + +// Build full system prompt: generic bashkit prompt + PI-specific additions +function buildSystemPrompt(): string { + return bashTool.systemPrompt() + "\n\n" + PI_SYSTEM_PROMPT_ADDITIONS; +} + +export default function (pi: any) { + // Inject bashkit context into the LLM system prompt + pi.on("before_agent_start", async (event: any) => { + return { + systemPrompt: event.systemPrompt + "\n\n" + buildSystemPrompt(), + }; + }); + + // --- bash tool --- + pi.registerTool({ + name: "bash", + label: "bashkit", + description: bashTool.description(), + parameters: { + type: "object", + properties: { + command: { + type: "string", + description: "Bash command to execute", + }, + timeout: { + type: "number", + description: "Timeout in seconds (optional)", + }, + }, + required: ["command"], + }, + async execute( + _toolCallId: string, + params: { command: string; timeout?: number }, + ) { + const result = bash.executeSync(params.command); + let output = ""; + if (result.stdout) output += result.stdout; + if (result.stderr) output += result.stderr; + if (!output) output = "(no output)"; + if (result.exitCode !== 0) { + output += `\n\nCommand exited with code ${result.exitCode}`; + throw new Error(output); + } + return { + content: [{ type: "text", text: output }], + details: { engine: "bashkit" }, + }; + }, + }); + + // --- read tool (direct VFS) --- + pi.registerTool({ + name: "read", + label: "bashkit-read", + description: + "Read file contents from bashkit's virtual filesystem. Returns file content with line numbers.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path to read" }, + offset: { + type: "number", + description: "Line offset to start reading from (1-based)", + }, + limit: { + type: "number", + description: "Maximum number of lines to return", + }, + }, + required: ["path"], + }, + async execute( + _toolCallId: string, + params: { path: string; offset?: number; limit?: number }, + ) { + const absPath = resolvePath(params.path); + const content = bash.readFile(absPath); + let lines = content.split("\n"); + + // Remove trailing empty line if file ends with newline + if (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + + const offset = (params.offset ?? 1) - 1; + if (offset > 0) lines = lines.slice(offset); + if (params.limit) lines = lines.slice(0, params.limit); + + const numbered = lines + .map((line, i) => `${offset + i + 1}\t${line}`) + .join("\n"); + + return { + content: [{ type: "text", text: numbered || "(empty file)" }], + details: { engine: "bashkit" }, + }; + }, + }); + + // --- write tool (direct VFS) --- + pi.registerTool({ + name: "write", + label: "bashkit-write", + description: + "Write file contents to bashkit's virtual filesystem. Creates parent directories automatically.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path to write" }, + content: { + type: "string", + description: "Content to write to the file", + }, + }, + required: ["path", "content"], + }, + async execute( + _toolCallId: string, + params: { path: string; content: string }, + ) { + const absPath = resolvePath(params.path); + ensureParentDir(absPath); + bash.writeFile(absPath, params.content); + return { + content: [ + { + type: "text", + text: `Wrote ${params.content.length} bytes to ${absPath}`, + }, + ], + details: { engine: "bashkit" }, + }; + }, + }); + + // --- edit tool (direct VFS) --- + pi.registerTool({ + name: "edit", + label: "bashkit-edit", + description: + "Edit a file in bashkit's virtual filesystem by replacing oldText with newText. The oldText must appear exactly once in the file.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path to edit" }, + oldText: { + type: "string", + description: + "Exact text to find and replace (must be unique in file)", + }, + newText: { type: "string", description: "Replacement text" }, + }, + required: ["path", "oldText", "newText"], + }, + async execute( + _toolCallId: string, + params: { path: string; oldText: string; newText: string }, + ) { + const absPath = resolvePath(params.path); + const content = bash.readFile(absPath); + + const count = content.split(params.oldText).length - 1; + if (count === 0) { + throw new Error( + `oldText not found in ${absPath}. File content:\n${content}`, + ); + } + if (count > 1) { + throw new Error( + `oldText found ${count} times in ${absPath}. Must be unique.`, + ); + } + + const newContent = content.replace(params.oldText, params.newText); + bash.writeFile(absPath, newContent); + + return { + content: [{ type: "text", text: `Edited ${absPath}` }], + details: { engine: "bashkit" }, + }; + }, + }); +} diff --git a/examples/bashkit-pi/package-lock.json b/examples/bashkit-pi/package-lock.json new file mode 100644 index 00000000..589bc827 --- /dev/null +++ b/examples/bashkit-pi/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "bashkit-pi-example", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bashkit-pi-example", + "version": "0.0.1", + "dependencies": { + "@everruns/bashkit": "file:../../crates/bashkit-js" + } + }, + "../../crates/bashkit-js": { + "name": "@everruns/bashkit", + "version": "0.1.9", + "license": "MIT", + "devDependencies": { + "@napi-rs/cli": "^3.0.0", + "@types/node": "^25.5.0", + "ava": "^6.2.0", + "oxlint": "^0.16.0", + "prettier": "^3.4.0", + "tsx": "^4.21.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@everruns/bashkit": { + "resolved": "../../crates/bashkit-js", + "link": true + } + } +} diff --git a/examples/bashkit-pi/package.json b/examples/bashkit-pi/package.json new file mode 100644 index 00000000..f1e1944d --- /dev/null +++ b/examples/bashkit-pi/package.json @@ -0,0 +1,10 @@ +{ + "name": "bashkit-pi-example", + "version": "0.0.1", + "private": true, + "type": "module", + "description": "Pi coding agent with bashkit virtual bash + VFS", + "dependencies": { + "@everruns/bashkit": "file:../../crates/bashkit-js" + } +} diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index 0a9dff54..aea676f1 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -415,6 +415,35 @@ Default limits (configurable): | Input size | 10MB | | AST depth | 100 | +## Language Bindings + +### JavaScript/Node.js (`@everruns/bashkit`) + +NAPI-RS bindings in `crates/bashkit-js/`. TypeScript wrapper in `wrapper.ts`. + +| Class | Methods | Notes | +|-------|---------|-------| +| `Bash` | `executeSync`, `execute`, `cancel`, `reset` | Core interpreter | +| `Bash` (VFS) | `readFile`, `writeFile`, `mkdir`, `exists`, `remove` | Direct VFS access via NAPI | +| `Bash` (helpers) | `ls`, `glob` | Shell-based convenience wrappers | +| `BashTool` | `executeSync`, `execute`, `cancel`, `reset` | Interpreter + tool metadata | +| `BashTool` (metadata) | `name`, `shortDescription`, `description`, `help`, `systemPrompt`, `inputSchema`, `outputSchema`, `version` | LLM tool contract | +| `BashTool` (helpers) | `readFile`, `writeFile`, `exists`, `ls`, `glob` | Shell-based VFS wrappers | + +**Platform matrix:** macOS (x86_64, aarch64), Linux (x86_64, aarch64), Windows (x86_64), WASM + +**Tests:** `crates/bashkit-js/__test__/` — VFS roundtrip, interop, error handling + +### Python (`bashkit`) + +PyO3 bindings in `crates/bashkit-python/`. See [013-python-package.md](013-python-package.md). + +### Examples + +| Example | Description | +|---------|-------------| +| `examples/bashkit-pi/` | Pi coding agent extension — replaces bash/read/write/edit tools with bashkit VFS | + ## Testing ### Security Tests