diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 00000000..6521c664 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,31 @@ +# Changesets + +This repo uses [Changesets](https://github.com/changesets/changesets) to manage versions, generate changelogs, and (eventually) publish to npm. + +## Adding a changeset + +When you make a change that should land in a release, run: + +```bash +pnpm changeset +``` + +You'll be prompted to: + +1. Pick the package to bump (`just-bash`). +2. Choose the bump level — `patch` (bug fix), `minor` (feature, no break), `major` (breaking). +3. Write a short summary that will appear in the CHANGELOG. + +This creates a `.changeset/.md` file. Commit it with your PR. + +## Skipping a changeset + +Internal-only changes (CI, docs, repo housekeeping) don't need a changeset. If you skip one and a maintainer wants the change in a release, they can author one before the release PR. + +## Releasing + +Once any unreleased changesets land on `main`, the release workflow opens (or updates) a "chore: release" PR with bumped versions and the generated CHANGELOG. The action runs with `commitMode: github-api`, so the release commit is created via the GitHub REST API and auto-signed by GitHub — no GPG keys or bypass exceptions needed. + +Merging that PR triggers a publish to npm via the workflow's `publish: pnpm release` step. Authentication uses npm Trusted Publishers (OIDC) — no NPM_TOKEN secret is involved. Each published tarball includes a Sigstore-backed provenance attestation linking it back to the GitHub Actions run. + +The npm Trusted Publisher must also be configured before the first publish; see the comment block in `release.yml`. diff --git a/.changeset/awk-comma-continuation.md b/.changeset/awk-comma-continuation.md new file mode 100644 index 00000000..8e989532 --- /dev/null +++ b/.changeset/awk-comma-continuation.md @@ -0,0 +1,11 @@ +--- +"just-bash": patch +--- + +Fix awk lexer to honor POSIX statement continuation across newlines after `,`, +`{`, `&&`, `||`, `?`, `:`, `do`, `else`, `if`, and `while`. Previously, a +multi-line idiom like `printf "%s=%d\n", \n $1, $2` (comma at end-of-line +followed by indented args on the next line) failed with `Unexpected token: +NEWLINE` because the lexer emitted a NEWLINE token unconditionally. The +lexer now suppresses the NEWLINE when it immediately follows one of the +continuation-allowing tokens, matching POSIX awk. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 00000000..d8dcc853 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3/schema.json", + "changelog": [ + "@changesets/changelog-github", + { "repo": "vercel-labs/just-bash" } + ], + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [ + "bash-agent-example", + "cjs-consumer-example", + "custom-command-example", + "website" + ] +} diff --git a/.gitattributes b/.gitattributes index 59f90e0c..940ed069 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -vendor/cpython-emscripten/python.wasm filter=lfs diff=lfs merge=lfs -text +packages/just-bash/vendor/cpython-emscripten/python.wasm filter=lfs diff=lfs merge=lfs -text diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..a4fc37a3 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# Default: changes anywhere need a review from a maintainer. +* @cramforce + +# Release plumbing — the workflow is bound to npm Trusted Publisher by +# filename, so a rename or behavioural change is a security-relevant edit. +# Keep these locked to maintainers explicitly. +/.github/workflows/release.yml @cramforce +/.changeset/config.json @cramforce +/.github/CODEOWNERS @cramforce diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6762b4b1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release + +# Opens / updates a "chore: release" PR when changesets are queued on main. +# Uses changesets/action with `commitMode: github-api`, so commits go through +# the GitHub REST API and are auto-signed by GitHub's signing key. This +# satisfies the "Commits must have verified signatures" repository rule +# without any GPG key management or bypass exceptions. +# +# Publishing to npm is enabled via the npm Trusted Publisher mechanism. +# The workflow filename (`release.yml`) is bound to the publisher config on +# npm.com — renaming or replacing this file will break publishing until the +# npm-side setting is updated to match. +# +# See https://docs.npmjs.com/trusted-publishers for the publisher binding. + +on: + push: + branches: [main] + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + lfs: true + + - uses: pnpm/action-setup@v4 + + # Node 24 (LTS Krypton) ships npm >= 11.12, which has Trusted Publisher + # OIDC authentication. Node 22's bundled npm 10.x can sign provenance + # attestations but cannot use OIDC tokens to authenticate the publish + # itself, leading to a confusing 404 after provenance signing succeeds. + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "pnpm" + registry-url: "https://registry.npmjs.org" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Re-run the full validation gate on the merge commit before doing + # anything release-related. Branch protection covers the PR's HEAD, + # but flaky tests, dep drift, or admin merges can let a broken commit + # land on main. Anything publish-worthy must pass these checks first. + - name: Lint + run: pnpm lint + + - name: Knip + run: pnpm knip + + - name: Typecheck + run: pnpm typecheck + + - name: Build + run: pnpm --filter './packages/*' build + + - name: Check worker sync + run: pnpm check:worker-sync + + - name: Unit tests + run: pnpm test:unit + + - name: Comparison tests + run: pnpm test:comparison + + - name: WASM tests + run: pnpm test:wasm + + - name: Bundle smoke test + run: pnpm test:dist + + - name: Create release PR or publish + uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf # v1.7.0 + with: + version: pnpm version-packages + publish: pnpm release + commit: "chore: release" + title: "chore: release" + commitMode: github-api + env: + NPM_CONFIG_PROVENANCE: "true" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 6b5da1fa..9144ae07 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -23,24 +23,14 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Typecheck + - name: Typecheck packages run: pnpm typecheck - name: Build run: pnpm build - - name: Install example dependencies - run: pnpm install --frozen-lockfile - working-directory: examples/bash-agent - - - name: Typecheck example - run: pnpm typecheck - working-directory: examples/bash-agent - - - name: Install CJS example dependencies - run: pnpm install --no-frozen-lockfile - working-directory: examples/cjs-consumer + - name: Typecheck bash-agent example + run: pnpm --filter bash-agent-example typecheck - - name: Typecheck CJS example - run: npx tsc --noEmit - working-directory: examples/cjs-consumer + - name: Typecheck cjs-consumer example + run: pnpm --filter cjs-consumer-example typecheck diff --git a/.gitignore b/.gitignore index 995c13c0..0a28375e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ package-lock.json .idea .env .env.* -dist /coverage debug-*.ts test-*.ts @@ -16,7 +15,5 @@ todo/ *.parsed.json .pnpm-store .docs-test-tmp/ -src/commands/python3/worker.js -src/commands/js-exec/worker.js fuzz-*.log .claude/settings.local.json diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..33c82c46 --- /dev/null +++ b/.npmrc @@ -0,0 +1,7 @@ +# The @executor-js/* packages are recent prereleases that don't satisfy the +# default `minimum-release-age` constraint some developers have set globally. +# Exempt them so `pnpm install` works without flags. +minimum-release-age-exclude[]=@executor-js/sdk +minimum-release-age-exclude[]=@executor-js/plugin-graphql +minimum-release-age-exclude[]=@executor-js/plugin-openapi +minimum-release-age-exclude[]=@executor-js/plugin-mcp diff --git a/README.md b/README.md index 08dbc503..31d3a964 100644 --- a/README.md +++ b/README.md @@ -1,573 +1,33 @@ -# just-bash +# just-bash monorepo -A virtual bash environment with an in-memory filesystem, written in TypeScript and designed for AI agents. +This repository hosts the [`just-bash`](./packages/just-bash) package and its examples. -Broad support for standard unix commands and bash syntax with optional curl, Python, JS/TS, and sqlite support. +## Packages -**Note**: This is beta software. Use at your own risk and please provide feedback. See [security model](#security-model). +| Package | Path | Description | +| --- | --- | --- | +| [`just-bash`](./packages/just-bash) | `packages/just-bash` | A simulated bash environment with virtual filesystem | -## Quick Start +See the package's own [README](./packages/just-bash/README.md) for usage documentation. -```bash -npm install just-bash -``` - -```typescript -import { Bash } from "just-bash"; - -const bash = new Bash(); -await bash.exec('echo "Hello" > greeting.txt'); -const result = await bash.exec("cat greeting.txt"); -console.log(result.stdout); // "Hello\n" -console.log(result.exitCode); // 0 -``` - -Each `exec()` call gets its own isolated shell state — environment variables, functions, and working directory reset between calls. The **filesystem is shared** across calls, so files written in one `exec()` are visible in the next. - -## Custom Commands - -Extend just-bash with your own TypeScript commands using `defineCommand`: - -```typescript -import { Bash, defineCommand } from "just-bash"; - -const hello = defineCommand("hello", async (args, ctx) => { - const name = args[0] || "world"; - return { stdout: `Hello, ${name}!\n`, stderr: "", exitCode: 0 }; -}); - -const upper = defineCommand("upper", async (args, ctx) => { - return { stdout: ctx.stdin.toUpperCase(), stderr: "", exitCode: 0 }; -}); - -const bash = new Bash({ customCommands: [hello, upper] }); - -await bash.exec("hello Alice"); // "Hello, Alice!\n" -await bash.exec("echo 'test' | upper"); // "TEST\n" -``` - -Custom commands receive a `CommandContext` with `fs`, `cwd`, `env`, `stdin`, and `exec` (for subcommands), and work with pipes, redirections, and all shell features. - -
-

Supported Commands

- -### File Operations - -`cat`, `cp`, `file`, `ln`, `ls`, `mkdir`, `mv`, `readlink`, `rm`, `rmdir`, `split`, `stat`, `touch`, `tree` - -### Text Processing - -`awk`, `base64`, `column`, `comm`, `cut`, `diff`, `expand`, `fold`, `grep` (+ `egrep`, `fgrep`), `head`, `join`, `md5sum`, `nl`, `od`, `paste`, `printf`, `rev`, `rg`, `sed`, `sha1sum`, `sha256sum`, `sort`, `strings`, `tac`, `tail`, `tr`, `unexpand`, `uniq`, `wc`, `xargs` - -### Data Processing - -`jq` (JSON), `sqlite3` (SQLite), `xan` (CSV), `yq` (YAML/XML/TOML/CSV) - -### Optional Runtimes - -`js-exec` (JavaScript/TypeScript via QuickJS; requires `javascript: true`), `python3`/`python` (Python via CPython; requires `python: true`) - -### Compression & Archives - -`gzip` (+ `gunzip`, `zcat`), `tar` - -### Navigation & Environment - -`basename`, `cd`, `dirname`, `du`, `echo`, `env`, `export`, `find`, `hostname`, `printenv`, `pwd`, `tee` - -### Shell Utilities - -`alias`, `bash`, `chmod`, `clear`, `date`, `expr`, `false`, `help`, `history`, `seq`, `sh`, `sleep`, `time`, `timeout`, `true`, `unalias`, `which`, `whoami` - -### Network - -`curl`, `html-to-markdown` (require [network configuration](#network-access)) - -All commands support `--help` for usage information. - -### Shell Features - -- **Pipes**: `cmd1 | cmd2` -- **Redirections**: `>`, `>>`, `2>`, `2>&1`, `<` -- **Command chaining**: `&&`, `||`, `;` -- **Variables**: `$VAR`, `${VAR}`, `${VAR:-default}` -- **Positional parameters**: `$1`, `$2`, `$@`, `$#` -- **Glob patterns**: `*`, `?`, `[...]` -- **If statements**: `if COND; then CMD; elif COND; then CMD; else CMD; fi` -- **Functions**: `function name { ... }` or `name() { ... }` -- **Local variables**: `local VAR=value` -- **Loops**: `for`, `while`, `until` -- **Symbolic links**: `ln -s target link` -- **Hard links**: `ln target link` - -
- -## Configuration - -```typescript -const env = new Bash({ - files: { "/data/file.txt": "content" }, // Initial files - env: { MY_VAR: "value" }, // Initial environment - cwd: "/app", // Starting directory (default: /home/user) - executionLimits: { maxCallDepth: 50 }, // See "Execution Protection" - python: true, // Enable python3/python commands - javascript: true, // Enable js-exec command - // Or with bootstrap: javascript: { bootstrap: "globalThis.X = 1;" } -}); - -// Per-exec overrides -await env.exec("echo $TEMP", { env: { TEMP: "value" }, cwd: "/tmp" }); - -// Pass stdin to the script -await env.exec("cat", { stdin: "hello from stdin\n" }); - -// Start with a clean environment -await env.exec("env", { replaceEnv: true, env: { ONLY: "this" } }); - -// Pass arguments without shell escaping (like spawnSync) -await env.exec("grep", { args: ["-r", "TODO", "src/"] }); - -// Cancel long-running scripts -const controller = new AbortController(); -setTimeout(() => controller.abort(), 5000); -await env.exec("while true; do sleep 1; done", { signal: controller.signal }); - -// Preserve leading whitespace (e.g., for heredocs) -await env.exec("cat <` | Environment variables for this execution only | -| `cwd` | `string` | Working directory for this execution only | -| `stdin` | `string` | Standard input passed to the script | -| `args` | `string[]` | Additional argv passed directly to the first command (bypasses shell parsing; does not change `$1`, `$2`, ...) | -| `replaceEnv` | `boolean` | Start with empty env instead of merging (default: `false`) | -| `signal` | `AbortSignal` | Cooperative cancellation; stops at next statement boundary | -| `rawScript` | `boolean` | Skip leading-whitespace normalization (default: `false`) | +## Layout -## Filesystem Options - -Four filesystem implementations: - -**InMemoryFs** (default) - Pure in-memory filesystem, no disk access: - -```typescript -import { Bash } from "just-bash"; - -const env = new Bash({ - files: { - "/data/config.json": '{"key": "value"}', - // Lazy: called on first read, cached. Never called if written before read. - "/data/large.csv": () => "col1,col2\na,b\n", - "/data/remote.txt": async () => (await fetch("https://example.com")).text(), - }, -}); -``` - -**OverlayFs** - Copy-on-write over a real directory. Reads come from disk, writes stay in memory: - -```typescript -import { Bash } from "just-bash"; -import { OverlayFs } from "just-bash/fs/overlay-fs"; - -const overlay = new OverlayFs({ root: "/path/to/project" }); -const env = new Bash({ fs: overlay, cwd: overlay.getMountPoint() }); - -await env.exec("cat package.json"); // reads from disk -await env.exec('echo "modified" > package.json'); // stays in memory -``` - -**ReadWriteFs** - Direct read-write access to a real directory. Use this if you want the agent to be able to write to your disk: - -```typescript -import { Bash } from "just-bash"; -import { ReadWriteFs } from "just-bash/fs/read-write-fs"; - -const rwfs = new ReadWriteFs({ root: "/path/to/sandbox" }); -const env = new Bash({ fs: rwfs }); - -await env.exec('echo "hello" > file.txt'); // writes to real filesystem ``` - -Keep `ReadWriteFs` pointed at a workspace directory, not at the installed `just-bash` package or any other trusted runtime code. Guest-writable roots should stay separate from trusted code. - -**MountableFs** - Mount multiple filesystems at different paths. Combines read-only and read-write filesystems into a unified namespace: - -```typescript -import { Bash, MountableFs, InMemoryFs } from "just-bash"; -import { OverlayFs } from "just-bash/fs/overlay-fs"; -import { ReadWriteFs } from "just-bash/fs/read-write-fs"; - -const fs = new MountableFs({ base: new InMemoryFs() }); - -// Mount read-only knowledge base -fs.mount("/mnt/knowledge", new OverlayFs({ root: "/path/to/knowledge", readOnly: true })); - -// Mount read-write workspace -fs.mount("/home/agent", new ReadWriteFs({ root: "/path/to/workspace" })); - -const bash = new Bash({ fs, cwd: "/home/agent" }); - -await bash.exec("ls /mnt/knowledge"); // reads from knowledge base -await bash.exec("cp /mnt/knowledge/doc.txt ./"); // cross-mount copy -await bash.exec('echo "notes" > notes.txt'); // writes to workspace +packages/ publishable npm packages +examples/ example consumers (bash-agent, cjs-consumer, website) +.github/ CI workflows ``` -You can also configure mounts in the constructor: - -```typescript -import { MountableFs, InMemoryFs } from "just-bash"; -import { OverlayFs } from "just-bash/fs/overlay-fs"; -import { ReadWriteFs } from "just-bash/fs/read-write-fs"; - -const fs = new MountableFs({ - base: new InMemoryFs(), - mounts: [ - { mountPoint: "/data", filesystem: new OverlayFs({ root: "/shared/data" }) }, - { mountPoint: "/workspace", filesystem: new ReadWriteFs({ root: "/tmp/work" }) }, - ], -}); -``` - -## Optional Capabilities - -### Network Access - -Network access is disabled by default. Enable it with the `network` option: - -```typescript -// Allow specific URLs with GET/HEAD only (safest) -const env = new Bash({ - network: { - allowedUrlPrefixes: [ - "https://api.github.com/repos/myorg/", - "https://api.example.com", - ], - }, -}); - -// Allow specific URLs with additional methods -const env = new Bash({ - network: { - allowedUrlPrefixes: ["https://api.example.com"], - allowedMethods: ["GET", "HEAD", "POST"], // Default: ["GET", "HEAD"] - }, -}); - -// Inject credentials via header transforms (secrets never enter the sandbox) -const env = new Bash({ - network: { - allowedUrlPrefixes: [ - "https://public-api.com", // plain string — no transforms - { - url: "https://ai-gateway.vercel.sh", - transform: [{ headers: { Authorization: "Bearer secret" } }], - }, - ], - }, -}); - -// Allow all URLs and methods (use with caution) -const env = new Bash({ - network: { dangerouslyAllowFullInternetAccess: true }, -}); -``` - -**Note:** The `curl` command only exists when network is configured. Without network configuration, `curl` returns "command not found". - -#### Allow-List Security - -The allow-list enforces: - -- **Origin matching**: URLs must match the exact origin (scheme + host + port) -- **Path prefix**: Only paths starting with the specified prefix are allowed -- **HTTP method restrictions**: Only GET and HEAD by default (configure `allowedMethods` for more) -- **Redirect protection**: Redirects to non-allowed URLs are blocked -- **Header transforms**: Firewall headers are injected at the fetch boundary and override any user-supplied headers with the same name, preventing credential substitution from inside the sandbox. Headers are re-evaluated on each redirect so credentials are never leaked to non-transform hosts - -#### Using curl +## Working in the repo ```bash -# Fetch and process data -curl -s https://api.example.com/data | grep pattern - -# Download and convert HTML to Markdown -curl -s https://example.com | html-to-markdown - -# POST JSON data -curl -X POST -H "Content-Type: application/json" \ - -d '{"key":"value"}' https://api.example.com/endpoint -``` - -### JavaScript Support - -JavaScript and TypeScript execution via QuickJS is opt-in due to additional security surface. Enable with `javascript: true`: - -```typescript -const env = new Bash({ - javascript: true, -}); - -// Execute JavaScript code -await env.exec('js-exec -c "console.log(1 + 2)"'); - -// Run script files (.js, .mjs, .ts, .mts) -await env.exec('js-exec script.js'); - -// ES module mode with imports -await env.exec('js-exec -m -c "import fs from \'fs\'; console.log(fs.readFileSync(\'/data/file.txt\', \'utf8\'))"'); +pnpm install # install all workspace deps +pnpm build # build all packages +pnpm test:run # run unit + comparison tests +pnpm test:dist # smoke-test the bundled output +pnpm lint # biome + per-package banned-pattern checks +pnpm typecheck # tsc across all packages ``` -#### Bootstrap Code - -Run setup code before every `js-exec` invocation with the `bootstrap` option: - -```typescript -const env = new Bash({ - javascript: { - bootstrap: ` - globalThis.API_BASE = "https://api.example.com"; - globalThis.formatDate = (d) => new Date(d).toISOString(); - `, - }, -}); - -await env.exec('js-exec -c "console.log(API_BASE)"'); -// Output: https://api.example.com -``` - -#### Node.js Compatibility - -`js-exec` supports `require()` and `import` with these Node.js modules: - -- **fs**: `readFileSync`, `writeFileSync`, `readdirSync`, `statSync`, `existsSync`, `mkdirSync`, `rmSync`, `fs.promises.*` -- **path**: `join`, `resolve`, `dirname`, `basename`, `extname`, `relative`, `normalize` -- **child_process**: `execSync`, `spawnSync` -- **process**: `argv`, `cwd()`, `exit()`, `env`, `platform`, `version` -- **Other modules**: `os`, `url`, `assert`, `util`, `events`, `buffer`, `stream`, `string_decoder`, `querystring` -- **Globals**: `console`, `fetch`, `Buffer`, `URL`, `URLSearchParams` - -`fs.readFileSync()` returns a `Buffer` by default (matching Node.js). Pass an encoding like `'utf8'` to get a string. - -**Note:** The `js-exec` command only exists when `javascript` is configured. It is not available in browser environments. Execution runs in a QuickJS WASM sandbox with a 64 MB memory limit and configurable timeout (default: 10s, 60s with network). - -### Python Support - -Python (CPython compiled to WASM) is opt-in due to additional security surface. Enable with `python: true`: - -```typescript -const env = new Bash({ - python: true, -}); - -// Execute Python code -await env.exec('python3 -c "print(1 + 2)"'); - -// Run Python scripts -await env.exec('python3 script.py'); -``` - -**Note:** The `python3` and `python` commands only exist when `python: true` is configured. Python is not available in browser environments. - -### SQLite Support - -`sqlite3` uses sql.js (SQLite compiled to WASM), sandboxed from the real filesystem: - -```typescript -const env = new Bash(); - -// Query in-memory database -await env.exec('sqlite3 :memory: "SELECT 1 + 1"'); - -// Query file-based database -await env.exec('sqlite3 data.db "SELECT * FROM users"'); -``` - -**Note:** SQLite is not available in browser environments. Queries run in a worker thread with a configurable timeout (default: 5 seconds) to prevent runaway queries from blocking execution. - -## AST Transform Plugins - -Parse bash scripts into an AST, transform them, and serialize back to bash. Good for instrumenting scripts (e.g., capturing per-command stdout/stderr) or extracting metadata before execution. - -```typescript -import { Bash, BashTransformPipeline, TeePlugin, CommandCollectorPlugin } from "just-bash"; - -// Standalone pipeline — output can be run by any shell -const pipeline = new BashTransformPipeline() - .use(new TeePlugin({ outputDir: "/tmp/logs" })) - .use(new CommandCollectorPlugin()); -const result = pipeline.transform("echo hello | grep hello"); -result.script; // transformed bash string -result.metadata.commands; // ["echo", "grep", "tee"] - -// Integrated API — exec() auto-applies transforms and returns metadata -const bash = new Bash(); -bash.registerTransformPlugin(new CommandCollectorPlugin()); -const execResult = await bash.exec("echo hello | grep hello"); -execResult.metadata?.commands; // ["echo", "grep"] -``` - -See [src/transform/README.md](src/transform/README.md) for the full API, built-in plugins, and how to write custom plugins. - -## Integrations - -### AI SDK Tool - -[`bash-tool`](https://github.com/vercel-labs/bash-tool) wraps just-bash as an [AI SDK](https://ai-sdk.dev/) tool: - -```bash -npm install bash-tool -``` - -```typescript -import { createBashTool } from "bash-tool"; -import { generateText } from "ai"; - -const bashTool = createBashTool({ - files: { "/data/users.json": '[{"name": "Alice"}, {"name": "Bob"}]' }, -}); - -const result = await generateText({ - model: "anthropic/claude-sonnet-4", - tools: { bash: bashTool }, - prompt: "Count the users in /data/users.json", -}); -``` - -See [bash-tool](https://github.com/vercel-labs/bash-tool) for more. - -### Vercel Sandbox Compatible API - -`Sandbox` is a drop-in replacement for [`@vercel/sandbox`](https://vercel.com/docs/vercel-sandbox) — same API, but runs entirely in-process with the virtual filesystem. Start with just-bash for development and testing, swap in a real sandbox when you need a full VM. - -```typescript -import { Sandbox } from "just-bash"; - -// Create a sandbox instance -const sandbox = await Sandbox.create({ cwd: "/app" }); - -// Write files to the virtual filesystem -await sandbox.writeFiles({ - "/app/script.sh": 'echo "Hello World"', - "/app/data.json": '{"key": "value"}', -}); - -// Run commands and get results -const cmd = await sandbox.runCommand("bash /app/script.sh"); -const output = await cmd.stdout(); // "Hello World\n" -const exitCode = (await cmd.wait()).exitCode; // 0 - -// Read files back -const content = await sandbox.readFile("/app/data.json"); - -// Create directories -await sandbox.mkDir("/app/logs", { recursive: true }); - -// Clean up (no-op for Bash, but API-compatible) -await sandbox.stop(); -``` - -## CLI - -### CLI Binary - -Install globally (`npm install -g just-bash`) for a sandboxed CLI: - -```bash -# Execute inline script -just-bash -c 'ls -la && cat package.json | head -5' - -# Execute with specific project root -just-bash -c 'grep -r "TODO" src/' --root /path/to/project - -# Pipe script from stdin -echo 'find . -name "*.ts" | wc -l' | just-bash - -# Execute a script file -just-bash ./scripts/deploy.sh - -# Get JSON output for programmatic use -just-bash -c 'echo hello' --json -# Output: {"stdout":"hello\n","stderr":"","exitCode":0} -``` - -The CLI uses OverlayFS — reads come from the real filesystem, but all writes stay in memory and are discarded after execution. - -**Important**: The project root is mounted at `/home/user/project`. Use this path (or relative paths from the default cwd) to access your files inside the sandbox. - -Options: - -- `-c