From 49b18f64bf0ca4c56e41b009610b9e65457e1aa4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 28 Dec 2025 18:32:02 +0200 Subject: [PATCH 1/3] Filesystem adaptor for just-bash Code lifted from https://github.com/vercel-labs/just-bash/pull/10 --- integrations/just-bash/AgentFs.test.ts | 687 +++++++++++++++++++++++++ integrations/just-bash/AgentFs.ts | 510 ++++++++++++++++++ integrations/just-bash/README.md | 29 ++ integrations/just-bash/index.ts | 1 + 4 files changed, 1227 insertions(+) create mode 100644 integrations/just-bash/AgentFs.test.ts create mode 100644 integrations/just-bash/AgentFs.ts create mode 100644 integrations/just-bash/README.md create mode 100644 integrations/just-bash/index.ts diff --git a/integrations/just-bash/AgentFs.test.ts b/integrations/just-bash/AgentFs.test.ts new file mode 100644 index 0000000..314aa06 --- /dev/null +++ b/integrations/just-bash/AgentFs.test.ts @@ -0,0 +1,687 @@ +import { AgentFS } from "agentfs-sdk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AgentFs } from "./AgentFs.js"; + +describe("AgentFs", () => { + let agentHandle: Awaited>; + let fs: AgentFs; + + beforeEach(async () => { + // Use in-memory database for tests + agentHandle = await AgentFS.open({ path: ":memory:" }); + fs = new AgentFs({ fs: agentHandle }); + }); + + afterEach(async () => { + await agentHandle.close(); + }); + + describe("constructor", () => { + it("should create with default mount point", () => { + expect(fs.getMountPoint()).toBe("/"); + }); + + it("should accept custom mount point", () => { + const customFs = new AgentFs({ + fs: agentHandle, + mountPoint: "/mnt/data", + }); + expect(customFs.getMountPoint()).toBe("/mnt/data"); + }); + + it("should normalize mount point with trailing slash", () => { + const customFs = new AgentFs({ + fs: agentHandle, + mountPoint: "/mnt/data/", + }); + expect(customFs.getMountPoint()).toBe("/mnt/data"); + }); + + it("should throw for non-absolute mount point", () => { + expect( + () => new AgentFs({ fs: agentHandle, mountPoint: "relative" }), + ).toThrow("Mount point must be an absolute path"); + }); + }); + + describe("readFile", () => { + it("should read file as utf8 by default", async () => { + await fs.writeFile("/test.txt", "Hello, World!"); + const content = await fs.readFile("/test.txt"); + expect(content).toBe("Hello, World!"); + }); + + it("should read file with explicit utf8 encoding", async () => { + await fs.writeFile("/test.txt", "Hello"); + const content = await fs.readFile("/test.txt", "utf8"); + expect(content).toBe("Hello"); + }); + + it("should read file with utf-8 encoding", async () => { + await fs.writeFile("/test.txt", "Hello"); + const content = await fs.readFile("/test.txt", "utf-8"); + expect(content).toBe("Hello"); + }); + + it("should read file as base64", async () => { + await fs.writeFile("/test.txt", "Hello"); + const content = await fs.readFile("/test.txt", "base64"); + expect(content).toBe("SGVsbG8="); + }); + + it("should read file as hex", async () => { + await fs.writeFile("/test.txt", "Hi"); + const content = await fs.readFile("/test.txt", "hex"); + expect(content).toBe("4869"); + }); + + it("should read file as binary/latin1", async () => { + const data = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + await fs.writeFile("/test.bin", data); + const content = await fs.readFile("/test.bin", "binary"); + expect(content).toBe("Hello"); + }); + + it("should read file with options object", async () => { + await fs.writeFile("/test.txt", "Test"); + const content = await fs.readFile("/test.txt", { encoding: "base64" }); + expect(content).toBe("VGVzdA=="); + }); + + it("should throw ENOENT for non-existent file", async () => { + await expect(fs.readFile("/nonexistent.txt")).rejects.toThrow("ENOENT"); + }); + }); + + describe("readFileBuffer", () => { + it("should read file as Uint8Array", async () => { + await fs.writeFile("/test.txt", "Hello"); + const buffer = await fs.readFileBuffer("/test.txt"); + expect(buffer).toBeInstanceOf(Uint8Array); + expect(buffer).toEqual(new Uint8Array([72, 101, 108, 108, 111])); + }); + + it("should read binary data correctly", async () => { + const data = new Uint8Array([0, 1, 2, 255, 254, 253]); + await fs.writeFile("/binary.bin", data); + const buffer = await fs.readFileBuffer("/binary.bin"); + expect(buffer).toEqual(data); + }); + + it("should throw ENOENT for non-existent file", async () => { + await expect(fs.readFileBuffer("/nonexistent.bin")).rejects.toThrow( + "ENOENT", + ); + }); + }); + + describe("writeFile", () => { + it("should write string content", async () => { + await fs.writeFile("/test.txt", "Hello, World!"); + const content = await fs.readFile("/test.txt"); + expect(content).toBe("Hello, World!"); + }); + + it("should write binary data", async () => { + const data = new Uint8Array([1, 2, 3, 4, 5]); + await fs.writeFile("/binary.bin", data); + const buffer = await fs.readFileBuffer("/binary.bin"); + expect(buffer).toEqual(data); + }); + + it("should write with base64 encoding", async () => { + await fs.writeFile("/test.txt", "SGVsbG8=", "base64"); + const content = await fs.readFile("/test.txt"); + expect(content).toBe("Hello"); + }); + + it("should write with hex encoding", async () => { + await fs.writeFile("/test.txt", "48656c6c6f", "hex"); + const content = await fs.readFile("/test.txt"); + expect(content).toBe("Hello"); + }); + + it("should write with binary/latin1 encoding", async () => { + await fs.writeFile("/test.txt", "Hello", "latin1"); + const buffer = await fs.readFileBuffer("/test.txt"); + expect(buffer).toEqual(new Uint8Array([72, 101, 108, 108, 111])); + }); + + it("should write with options object", async () => { + await fs.writeFile("/test.txt", "SGk=", { encoding: "base64" }); + const content = await fs.readFile("/test.txt"); + expect(content).toBe("Hi"); + }); + + it("should overwrite existing file", async () => { + await fs.writeFile("/test.txt", "First"); + await fs.writeFile("/test.txt", "Second"); + const content = await fs.readFile("/test.txt"); + expect(content).toBe("Second"); + }); + + it("should create parent directories implicitly", async () => { + await fs.writeFile("/a/b/c/file.txt", "nested"); + const content = await fs.readFile("/a/b/c/file.txt"); + expect(content).toBe("nested"); + }); + }); + + describe("appendFile", () => { + it("should append to existing file", async () => { + await fs.writeFile("/test.txt", "Hello"); + await fs.appendFile("/test.txt", ", World!"); + const content = await fs.readFile("/test.txt"); + expect(content).toBe("Hello, World!"); + }); + + it("should create file if it does not exist", async () => { + await fs.appendFile("/new.txt", "New content"); + const content = await fs.readFile("/new.txt"); + expect(content).toBe("New content"); + }); + + it("should append binary data", async () => { + await fs.writeFile("/test.bin", new Uint8Array([1, 2, 3])); + await fs.appendFile("/test.bin", new Uint8Array([4, 5])); + const buffer = await fs.readFileBuffer("/test.bin"); + expect(buffer).toEqual(new Uint8Array([1, 2, 3, 4, 5])); + }); + + it("should append with encoding", async () => { + await fs.writeFile("/test.txt", "Hello"); + await fs.appendFile("/test.txt", "V29ybGQ=", "base64"); // "World" + const content = await fs.readFile("/test.txt"); + expect(content).toBe("HelloWorld"); + }); + + it("should append multiple times", async () => { + await fs.appendFile("/test.txt", "A"); + await fs.appendFile("/test.txt", "B"); + await fs.appendFile("/test.txt", "C"); + const content = await fs.readFile("/test.txt"); + expect(content).toBe("ABC"); + }); + }); + + describe("exists", () => { + it("should return true for existing file", async () => { + await fs.writeFile("/test.txt", "content"); + expect(await fs.exists("/test.txt")).toBe(true); + }); + + it("should return false for non-existent file", async () => { + expect(await fs.exists("/nonexistent.txt")).toBe(false); + }); + + it("should return true for directory", async () => { + await fs.mkdir("/mydir"); + expect(await fs.exists("/mydir")).toBe(true); + }); + + it("should return true for nested path", async () => { + await fs.writeFile("/a/b/c.txt", "content"); + expect(await fs.exists("/a/b/c.txt")).toBe(true); + expect(await fs.exists("/a/b")).toBe(true); + expect(await fs.exists("/a")).toBe(true); + }); + + it("should return false for partial path", async () => { + await fs.writeFile("/abc.txt", "content"); + expect(await fs.exists("/ab")).toBe(false); + }); + }); + + describe("stat", () => { + it("should return file stats", async () => { + await fs.writeFile("/test.txt", "Hello"); + const stat = await fs.stat("/test.txt"); + expect(stat.isFile).toBe(true); + expect(stat.isDirectory).toBe(false); + expect(stat.isSymbolicLink).toBe(false); + expect(stat.size).toBe(5); + }); + + it("should return directory stats", async () => { + await fs.mkdir("/mydir"); + const stat = await fs.stat("/mydir"); + expect(stat.isFile).toBe(false); + expect(stat.isDirectory).toBe(true); + expect(stat.isSymbolicLink).toBe(false); + }); + + it("should return mode", async () => { + await fs.writeFile("/test.txt", "content"); + const stat = await fs.stat("/test.txt"); + expect(typeof stat.mode).toBe("number"); + }); + + it("should return mtime as Date", async () => { + await fs.writeFile("/test.txt", "content"); + const stat = await fs.stat("/test.txt"); + expect(stat.mtime).toBeInstanceOf(Date); + }); + + it("should throw ENOENT for non-existent path", async () => { + await expect(fs.stat("/nonexistent")).rejects.toThrow("ENOENT"); + }); + + it("should return correct size for binary data", async () => { + const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + await fs.writeFile("/binary.bin", data); + const stat = await fs.stat("/binary.bin"); + expect(stat.size).toBe(10); + }); + }); + + describe("lstat", () => { + it("should return file stats", async () => { + await fs.writeFile("/file.txt", "content"); + const stat = await fs.lstat("/file.txt"); + expect(stat.isFile).toBe(true); + expect(stat.isSymbolicLink).toBe(false); + }); + + it("should return directory stats", async () => { + await fs.mkdir("/dir"); + const stat = await fs.lstat("/dir"); + expect(stat.isDirectory).toBe(true); + }); + + it("should throw ENOENT for non-existent path", async () => { + await expect(fs.lstat("/nonexistent")).rejects.toThrow("ENOENT"); + }); + }); + + describe("mkdir", () => { + it("should create a directory", async () => { + await fs.mkdir("/newdir"); + expect(await fs.exists("/newdir")).toBe(true); + const stat = await fs.stat("/newdir"); + expect(stat.isDirectory).toBe(true); + }); + + it("should throw EEXIST if directory exists", async () => { + await fs.mkdir("/mydir"); + await expect(fs.mkdir("/mydir")).rejects.toThrow("EEXIST"); + }); + + it("should throw EEXIST if file exists at path", async () => { + await fs.writeFile("/myfile", "content"); + await expect(fs.mkdir("/myfile")).rejects.toThrow("EEXIST"); + }); + + it("should not throw with recursive if directory exists", async () => { + await fs.mkdir("/mydir"); + await fs.mkdir("/mydir", { recursive: true }); + }); + + it("should create parent directories with recursive", async () => { + await fs.mkdir("/a/b/c", { recursive: true }); + expect(await fs.exists("/a")).toBe(true); + expect(await fs.exists("/a/b")).toBe(true); + expect(await fs.exists("/a/b/c")).toBe(true); + }); + + it("should throw ENOENT without recursive if parent missing", async () => { + await expect(fs.mkdir("/missing/subdir")).rejects.toThrow("ENOENT"); + }); + + it("should create deeply nested directories", async () => { + await fs.mkdir("/a/b/c/d/e/f", { recursive: true }); + expect(await fs.exists("/a/b/c/d/e/f")).toBe(true); + }); + }); + + describe("readdir", () => { + it("should list directory contents", async () => { + await fs.writeFile("/dir/file1.txt", "a"); + await fs.writeFile("/dir/file2.txt", "b"); + await fs.mkdir("/dir/subdir"); + const entries = await fs.readdir("/dir"); + expect(entries.sort()).toEqual(["file1.txt", "file2.txt", "subdir"]); + }); + + it("should return empty array for empty directory", async () => { + await fs.mkdir("/emptydir"); + const entries = await fs.readdir("/emptydir"); + expect(entries).toEqual([]); + }); + + it("should return sorted entries", async () => { + await fs.writeFile("/dir/z.txt", ""); + await fs.writeFile("/dir/a.txt", ""); + await fs.writeFile("/dir/m.txt", ""); + const entries = await fs.readdir("/dir"); + expect(entries).toEqual(["a.txt", "m.txt", "z.txt"]); + }); + + it("should throw ENOENT for non-existent directory", async () => { + await expect(fs.readdir("/nonexistent")).rejects.toThrow("ENOENT"); + }); + }); + + describe("rm", () => { + it("should remove a file", async () => { + await fs.writeFile("/test.txt", "content"); + await fs.rm("/test.txt"); + expect(await fs.exists("/test.txt")).toBe(false); + }); + + it("should throw ENOENT for non-existent file", async () => { + await expect(fs.rm("/nonexistent.txt")).rejects.toThrow("ENOENT"); + }); + + it("should not throw with force for non-existent file", async () => { + await fs.rm("/nonexistent.txt", { force: true }); + }); + + it("should remove empty directory", async () => { + await fs.mkdir("/emptydir"); + await fs.rm("/emptydir"); + expect(await fs.exists("/emptydir")).toBe(false); + }); + + it("should throw ENOTEMPTY for non-empty directory", async () => { + await fs.writeFile("/dir/file.txt", "content"); + await expect(fs.rm("/dir")).rejects.toThrow("ENOTEMPTY"); + }); + + it("should remove non-empty directory with recursive", async () => { + await fs.writeFile("/dir/file.txt", "content"); + await fs.mkdir("/dir/subdir"); + await fs.writeFile("/dir/subdir/nested.txt", "nested"); + await fs.rm("/dir", { recursive: true }); + expect(await fs.exists("/dir/file.txt")).toBe(false); + expect(await fs.exists("/dir/subdir/nested.txt")).toBe(false); + }); + + it("should handle recursive + force together", async () => { + await fs.rm("/nonexistent", { recursive: true, force: true }); + }); + }); + + describe("cp", () => { + it("should copy a file", async () => { + await fs.writeFile("/src.txt", "content"); + await fs.cp("/src.txt", "/dest.txt"); + expect(await fs.readFile("/dest.txt")).toBe("content"); + // Source should still exist + expect(await fs.exists("/src.txt")).toBe(true); + }); + + it("should copy binary file", async () => { + const data = new Uint8Array([0, 1, 2, 255, 254]); + await fs.writeFile("/src.bin", data); + await fs.cp("/src.bin", "/dest.bin"); + const buffer = await fs.readFileBuffer("/dest.bin"); + expect(buffer).toEqual(data); + }); + + it("should overwrite destination", async () => { + await fs.writeFile("/src.txt", "new content"); + await fs.writeFile("/dest.txt", "old content"); + await fs.cp("/src.txt", "/dest.txt"); + expect(await fs.readFile("/dest.txt")).toBe("new content"); + }); + + it("should throw ENOENT for non-existent source", async () => { + await expect(fs.cp("/nonexistent", "/dest")).rejects.toThrow("ENOENT"); + }); + + it("should throw EISDIR for directory without recursive", async () => { + await fs.mkdir("/dir"); + await expect(fs.cp("/dir", "/dest")).rejects.toThrow("EISDIR"); + }); + + it("should copy directory with recursive", async () => { + await fs.writeFile("/dir/file.txt", "content"); + await fs.cp("/dir", "/dest", { recursive: true }); + expect(await fs.readFile("/dest/file.txt")).toBe("content"); + }); + + it("should copy nested directory structure", async () => { + await fs.writeFile("/src/a/b/c.txt", "nested"); + await fs.writeFile("/src/a/d.txt", "sibling"); + await fs.cp("/src", "/dest", { recursive: true }); + expect(await fs.readFile("/dest/a/b/c.txt")).toBe("nested"); + expect(await fs.readFile("/dest/a/d.txt")).toBe("sibling"); + }); + }); + + describe("mv", () => { + it("should move a file", async () => { + await fs.writeFile("/src.txt", "content"); + await fs.mv("/src.txt", "/dest.txt"); + expect(await fs.exists("/src.txt")).toBe(false); + expect(await fs.readFile("/dest.txt")).toBe("content"); + }); + + it("should move a directory", async () => { + await fs.writeFile("/dir/file.txt", "content"); + await fs.mv("/dir", "/newdir"); + expect(await fs.exists("/dir/file.txt")).toBe(false); + expect(await fs.readFile("/newdir/file.txt")).toBe("content"); + }); + + it("should rename a file in same directory", async () => { + await fs.writeFile("/old.txt", "content"); + await fs.mv("/old.txt", "/new.txt"); + expect(await fs.exists("/old.txt")).toBe(false); + expect(await fs.readFile("/new.txt")).toBe("content"); + }); + + it("should move file to different directory", async () => { + await fs.writeFile("/a/file.txt", "content"); + await fs.mkdir("/b"); + await fs.mv("/a/file.txt", "/b/file.txt"); + expect(await fs.exists("/a/file.txt")).toBe(false); + expect(await fs.readFile("/b/file.txt")).toBe("content"); + }); + + it("should throw ENOENT for non-existent source", async () => { + await expect(fs.mv("/nonexistent", "/dest")).rejects.toThrow("ENOENT"); + }); + }); + + describe("symlink", () => { + it("should create a symlink-like file", async () => { + await fs.writeFile("/target.txt", "content"); + await fs.symlink("/target.txt", "/link.txt"); + // Our symlink implementation stores a JSON marker + const linkContent = await fs.readFile("/link.txt"); + expect(linkContent).toContain("__symlink"); + expect(linkContent).toContain("/target.txt"); + }); + + it("should support relative symlink targets", async () => { + await fs.symlink("../other.txt", "/dir/link.txt"); + const target = await fs.readlink("/dir/link.txt"); + expect(target).toBe("../other.txt"); + }); + + it("should throw EEXIST if path exists", async () => { + await fs.writeFile("/file.txt", "content"); + await expect(fs.symlink("/target", "/file.txt")).rejects.toThrow( + "EEXIST", + ); + }); + + it("should allow creating symlink to non-existent target", async () => { + await fs.symlink("/nonexistent", "/link.txt"); + const target = await fs.readlink("/link.txt"); + expect(target).toBe("/nonexistent"); + }); + }); + + describe("readlink", () => { + it("should read symlink target", async () => { + await fs.symlink("/target.txt", "/link.txt"); + const target = await fs.readlink("/link.txt"); + expect(target).toBe("/target.txt"); + }); + + it("should read relative symlink target", async () => { + await fs.symlink("../relative/path", "/dir/link.txt"); + const target = await fs.readlink("/dir/link.txt"); + expect(target).toBe("../relative/path"); + }); + + it("should throw ENOENT for non-existent path", async () => { + await expect(fs.readlink("/nonexistent")).rejects.toThrow("ENOENT"); + }); + + it("should throw EINVAL for non-symlink file", async () => { + await fs.writeFile("/file.txt", "content"); + await expect(fs.readlink("/file.txt")).rejects.toThrow("EINVAL"); + }); + + it("should throw EINVAL for directory", async () => { + await fs.mkdir("/dir"); + await expect(fs.readlink("/dir")).rejects.toThrow("EINVAL"); + }); + }); + + describe("link", () => { + it("should create a hard link (copy)", async () => { + await fs.writeFile("/original.txt", "content"); + await fs.link("/original.txt", "/hardlink.txt"); + expect(await fs.readFile("/hardlink.txt")).toBe("content"); + }); + + it("should create independent copy", async () => { + await fs.writeFile("/original.txt", "original"); + await fs.link("/original.txt", "/copy.txt"); + await fs.writeFile("/original.txt", "modified"); + // Since AgentFS uses copyFile, the "hard link" is actually a copy + expect(await fs.readFile("/copy.txt")).toBe("original"); + }); + + it("should throw ENOENT for non-existent source", async () => { + await expect(fs.link("/nonexistent", "/link")).rejects.toThrow("ENOENT"); + }); + + it("should throw EEXIST if destination exists", async () => { + await fs.writeFile("/src.txt", "a"); + await fs.writeFile("/dest.txt", "b"); + await expect(fs.link("/src.txt", "/dest.txt")).rejects.toThrow("EEXIST"); + }); + + it("should throw EPERM when source is directory", async () => { + await fs.mkdir("/dir"); + await expect(fs.link("/dir", "/link")).rejects.toThrow("EPERM"); + }); + }); + + describe("chmod", () => { + it("should not throw for existing file", async () => { + await fs.writeFile("/file.txt", "content"); + // chmod is a no-op but shouldn't throw + await fs.chmod("/file.txt", 0o755); + }); + + it("should not throw for existing directory", async () => { + await fs.mkdir("/dir"); + await fs.chmod("/dir", 0o700); + }); + + it("should throw ENOENT for non-existent path", async () => { + await expect(fs.chmod("/nonexistent", 0o755)).rejects.toThrow("ENOENT"); + }); + + it("should accept various mode values", async () => { + await fs.writeFile("/file.txt", "content"); + await fs.chmod("/file.txt", 0o644); + await fs.chmod("/file.txt", 0o777); + await fs.chmod("/file.txt", 0o000); + }); + }); + + describe("resolvePath", () => { + it("should resolve absolute paths", () => { + expect(fs.resolvePath("/base", "/absolute")).toBe("/absolute"); + }); + + it("should resolve relative paths", () => { + expect(fs.resolvePath("/base/dir", "file.txt")).toBe( + "/base/dir/file.txt", + ); + }); + + it("should handle .. in paths", () => { + expect(fs.resolvePath("/base/dir", "../file.txt")).toBe("/base/file.txt"); + }); + + it("should handle . in paths", () => { + expect(fs.resolvePath("/base/dir", "./file.txt")).toBe( + "/base/dir/file.txt", + ); + }); + + it("should handle multiple .. in paths", () => { + expect(fs.resolvePath("/a/b/c/d", "../../file.txt")).toBe( + "/a/b/file.txt", + ); + }); + + it("should not go above root", () => { + expect(fs.resolvePath("/base", "../../../file.txt")).toBe("/file.txt"); + }); + + it("should handle root base", () => { + expect(fs.resolvePath("/", "file.txt")).toBe("/file.txt"); + }); + + it("should resolve empty relative path", () => { + expect(fs.resolvePath("/base/dir", "")).toBe("/base/dir"); + }); + }); + + describe("getAllPaths", () => { + it("should return empty array (no tracking)", async () => { + await fs.writeFile("/file1.txt", "a"); + await fs.mkdir("/dir"); + // getAllPaths returns empty since we don't track paths + const paths = fs.getAllPaths(); + expect(paths).toEqual([]); + }); + }); + + describe("path normalization", () => { + it("should handle trailing slashes", async () => { + await fs.writeFile("/dir/file.txt", "content"); + const content = await fs.readFile("/dir/file.txt/"); + expect(content).toBe("content"); + }); + + it("should handle double slashes", async () => { + await fs.writeFile("/dir/file.txt", "content"); + const content = await fs.readFile("/dir//file.txt"); + expect(content).toBe("content"); + }); + + it("should handle . in path", async () => { + await fs.writeFile("/dir/file.txt", "content"); + const content = await fs.readFile("/dir/./file.txt"); + expect(content).toBe("content"); + }); + + it("should handle .. in path", async () => { + await fs.writeFile("/dir/file.txt", "content"); + const content = await fs.readFile("/dir/subdir/../file.txt"); + expect(content).toBe("content"); + }); + }); + + describe("mount point handling", () => { + it("should work with custom mount point", async () => { + const customFs = new AgentFs({ + fs: agentHandle, + mountPoint: "/mnt/data", + }); + await customFs.writeFile("/mnt/data/file.txt", "content"); + const content = await customFs.readFile("/mnt/data/file.txt"); + expect(content).toBe("content"); + }); + }); +}); diff --git a/integrations/just-bash/AgentFs.ts b/integrations/just-bash/AgentFs.ts new file mode 100644 index 0000000..680a30f --- /dev/null +++ b/integrations/just-bash/AgentFs.ts @@ -0,0 +1,510 @@ +/** + * AgentFs - Read-write filesystem backed by AgentFS (Turso) + * + * Full read-write filesystem that persists to an AgentFS SQLite database. + * Designed for AI agents needing persistent, auditable file storage. + * + * This is a thin wrapper around AgentFS - uses native mkdir, rm, rename, etc. + * + * @see https://docs.turso.tech/agentfs/sdk/typescript + */ + +import type { Filesystem } from "agentfs-sdk"; +import type { + BufferEncoding, + CpOptions, + FsStat, + IFileSystem, + MkdirOptions, + ReadFileOptions, + RmOptions, + WriteFileOptions, +} from "../fs-interface.js"; + +// Text encoder/decoder for encoding conversions +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +type FileContent = string | Uint8Array; + +/** + * Helper to convert content to Uint8Array + */ +function toBuffer(content: FileContent, encoding?: BufferEncoding): Uint8Array { + if (content instanceof Uint8Array) { + return content; + } + + switch (encoding) { + case "base64": + return Uint8Array.from(atob(content), (c) => c.charCodeAt(0)); + case "hex": { + const bytes = new Uint8Array(content.length / 2); + for (let i = 0; i < content.length; i += 2) { + bytes[i / 2] = parseInt(content.slice(i, i + 2), 16); + } + return bytes; + } + case "binary": + case "latin1": + return Uint8Array.from(content, (c) => c.charCodeAt(0)); + default: + return textEncoder.encode(content); + } +} + +/** + * Helper to convert Uint8Array to string with encoding + */ +function fromBuffer( + buffer: Uint8Array, + encoding?: BufferEncoding | null, +): string { + switch (encoding) { + case "base64": + return btoa(String.fromCharCode(...buffer)); + case "hex": + return Array.from(buffer) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + case "binary": + case "latin1": + return String.fromCharCode(...buffer); + default: + return textDecoder.decode(buffer); + } +} + +function getEncoding( + options?: ReadFileOptions | WriteFileOptions | BufferEncoding | null, +): BufferEncoding | undefined { + if (options === null || options === undefined) { + return undefined; + } + if (typeof options === "string") { + return options; + } + return options.encoding ?? undefined; +} + +/** + * Handle to an AgentFS instance (from AgentFS.open()) + */ +export interface AgentFsHandle { + fs: Filesystem; +} + +export interface AgentFsOptions { + /** + * The AgentFS handle from AgentFS.open() + */ + fs: AgentFsHandle; + + /** + * The virtual mount point for the filesystem. + * Defaults to "/". + */ + mountPoint?: string; +} + +/** Default mount point for AgentFs */ +const DEFAULT_MOUNT_POINT = "/"; + +export class AgentFs implements IFileSystem { + private readonly agentFs: Filesystem; + private readonly mountPoint: string; + + constructor(options: AgentFsOptions) { + this.agentFs = options.fs.fs; + + // Normalize mount point + const mp = options.mountPoint ?? DEFAULT_MOUNT_POINT; + this.mountPoint = mp === "/" ? "/" : mp.replace(/\/+$/, ""); + if (!this.mountPoint.startsWith("/")) { + throw new Error(`Mount point must be an absolute path: ${mp}`); + } + } + + /** + * Get the mount point for this filesystem + */ + getMountPoint(): string { + return this.mountPoint; + } + + /** + * Normalize a virtual path (resolve . and .., ensure starts with /) + */ + private normalizePath(path: string): string { + if (!path || path === "/") return "/"; + + let normalized = + path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path; + + if (!normalized.startsWith("/")) { + normalized = `/${normalized}`; + } + + const parts = normalized.split("/").filter((p) => p && p !== "."); + const resolved: string[] = []; + + for (const part of parts) { + if (part === "..") { + resolved.pop(); + } else { + resolved.push(part); + } + } + + return `/${resolved.join("/")}` || "/"; + } + + /** + * Convert virtual path to AgentFS path (strip mount point prefix) + */ + private toAgentPath(virtualPath: string): string { + const normalized = this.normalizePath(virtualPath); + + if (this.mountPoint === "/") { + return normalized; + } + + if (normalized === this.mountPoint) { + return "/"; + } + + if (normalized.startsWith(`${this.mountPoint}/`)) { + return normalized.slice(this.mountPoint.length); + } + + // Path is outside mount point + return normalized; + } + + private dirname(path: string): string { + const normalized = this.normalizePath(path); + if (normalized === "/") return "/"; + const lastSlash = normalized.lastIndexOf("/"); + return lastSlash === 0 ? "/" : normalized.slice(0, lastSlash); + } + + async readFile( + path: string, + options?: ReadFileOptions | BufferEncoding, + ): Promise { + const buffer = await this.readFileBuffer(path); + const encoding = getEncoding(options); + return fromBuffer(buffer, encoding); + } + + async readFileBuffer(path: string): Promise { + const normalized = this.normalizePath(path); + const agentPath = this.toAgentPath(normalized); + + try { + const data = await this.agentFs.readFile(agentPath); + if (typeof data === "string") { + return textEncoder.encode(data); + } + return new Uint8Array(data); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("not found") || msg.includes("ENOENT")) { + throw new Error(`ENOENT: no such file or directory, open '${path}'`); + } + throw e; + } + } + + async writeFile( + path: string, + content: FileContent, + options?: WriteFileOptions | BufferEncoding, + ): Promise { + const normalized = this.normalizePath(path); + const encoding = getEncoding(options); + const buffer = toBuffer(content, encoding); + const agentPath = this.toAgentPath(normalized); + // AgentFS creates parent directories implicitly + await this.agentFs.writeFile(agentPath, Buffer.from(buffer)); + } + + async appendFile( + path: string, + content: FileContent, + options?: WriteFileOptions | BufferEncoding, + ): Promise { + const normalized = this.normalizePath(path); + const encoding = getEncoding(options); + const newBuffer = toBuffer(content, encoding); + + // Try to read existing content + let existingBuffer: Uint8Array; + try { + existingBuffer = await this.readFileBuffer(normalized); + } catch { + existingBuffer = new Uint8Array(0); + } + + const combined = new Uint8Array(existingBuffer.length + newBuffer.length); + combined.set(existingBuffer); + combined.set(newBuffer, existingBuffer.length); + + await this.writeFile(normalized, combined); + } + + async exists(path: string): Promise { + const normalized = this.normalizePath(path); + const agentPath = this.toAgentPath(normalized); + try { + await this.agentFs.access(agentPath); + return true; + } catch { + return false; + } + } + + async stat(path: string): Promise { + const normalized = this.normalizePath(path); + const agentPath = this.toAgentPath(normalized); + + try { + const stats = await this.agentFs.stat(agentPath); + return { + isFile: stats.isFile(), + isDirectory: stats.isDirectory(), + isSymbolicLink: stats.isSymbolicLink(), + mode: stats.mode, + size: stats.size, + mtime: new Date(stats.mtime * 1000), + }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("not found") || msg.includes("ENOENT")) { + throw new Error(`ENOENT: no such file or directory, stat '${path}'`); + } + throw e; + } + } + + async lstat(path: string): Promise { + // AgentFS stat doesn't follow symlinks by default + return this.stat(path); + } + + async mkdir(path: string, options?: MkdirOptions): Promise { + const normalized = this.normalizePath(path); + const agentPath = this.toAgentPath(normalized); + + if (options?.recursive) { + // Create parent directories first + const parent = this.dirname(normalized); + if (parent !== "/" && parent !== normalized) { + const parentExists = await this.exists(parent); + if (!parentExists) { + await this.mkdir(parent, { recursive: true }); + } + } + } + + try { + await this.agentFs.mkdir(agentPath); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("EEXIST") || msg.includes("already exists")) { + if (!options?.recursive) { + throw new Error(`EEXIST: file already exists, mkdir '${path}'`); + } + // With recursive, existing dir is ok + return; + } + if (msg.includes("ENOENT") || msg.includes("not found")) { + throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`); + } + throw e; + } + } + + async readdir(path: string): Promise { + const normalized = this.normalizePath(path); + const agentPath = this.toAgentPath(normalized); + + try { + const entries = await this.agentFs.readdir(agentPath); + return entries.sort(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("not found") || msg.includes("ENOENT")) { + throw new Error(`ENOENT: no such file or directory, scandir '${path}'`); + } + throw e; + } + } + + async rm(path: string, options?: RmOptions): Promise { + const normalized = this.normalizePath(path); + const agentPath = this.toAgentPath(normalized); + + try { + await this.agentFs.rm(agentPath, { + force: options?.force, + recursive: options?.recursive, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("ENOENT") || msg.includes("not found")) { + if (!options?.force) { + throw new Error(`ENOENT: no such file or directory, rm '${path}'`); + } + return; + } + if (msg.includes("ENOTEMPTY") || msg.includes("not empty")) { + throw new Error(`ENOTEMPTY: directory not empty, rm '${path}'`); + } + if (msg.includes("EISDIR")) { + // Directory without recursive - try rmdir for empty directories + try { + await this.agentFs.rmdir(agentPath); + return; + } catch (rmdirErr) { + const rmdirMsg = + rmdirErr instanceof Error ? rmdirErr.message : String(rmdirErr); + if ( + rmdirMsg.includes("ENOTEMPTY") || + rmdirMsg.includes("not empty") + ) { + throw new Error(`ENOTEMPTY: directory not empty, rm '${path}'`); + } + throw rmdirErr; + } + } + throw e; + } + } + + async cp(src: string, dest: string, options?: CpOptions): Promise { + const srcNorm = this.normalizePath(src); + const destNorm = this.normalizePath(dest); + const srcAgent = this.toAgentPath(srcNorm); + const destAgent = this.toAgentPath(destNorm); + + const srcStat = await this.stat(srcNorm); + + if (srcStat.isFile) { + // Use native copyFile for files + await this.agentFs.copyFile(srcAgent, destAgent); + } else if (srcStat.isDirectory) { + if (!options?.recursive) { + throw new Error(`EISDIR: is a directory, cp '${src}'`); + } + // Recursively copy directory + await this.mkdir(destNorm, { recursive: true }); + const children = await this.readdir(srcNorm); + for (const child of children) { + const srcChild = srcNorm === "/" ? `/${child}` : `${srcNorm}/${child}`; + const destChild = + destNorm === "/" ? `/${child}` : `${destNorm}/${child}`; + await this.cp(srcChild, destChild, options); + } + } + } + + async mv(src: string, dest: string): Promise { + const srcNorm = this.normalizePath(src); + const destNorm = this.normalizePath(dest); + const srcAgent = this.toAgentPath(srcNorm); + const destAgent = this.toAgentPath(destNorm); + + try { + await this.agentFs.rename(srcAgent, destAgent); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("ENOENT") || msg.includes("not found")) { + throw new Error(`ENOENT: no such file or directory, mv '${src}'`); + } + throw e; + } + } + + resolvePath(base: string, path: string): string { + if (path.startsWith("/")) { + return this.normalizePath(path); + } + const combined = base === "/" ? `/${path}` : `${base}/${path}`; + return this.normalizePath(combined); + } + + getAllPaths(): string[] { + // AgentFS doesn't provide a way to list all paths efficiently + return []; + } + + async chmod(path: string, mode: number): Promise { + const normalized = this.normalizePath(path); + + // Verify path exists + const pathExists = await this.exists(normalized); + if (!pathExists) { + throw new Error(`ENOENT: no such file or directory, chmod '${path}'`); + } + + // AgentFS doesn't support chmod yet - this is a no-op + void mode; + } + + async symlink(target: string, linkPath: string): Promise { + const normalized = this.normalizePath(linkPath); + + const pathExists = await this.exists(normalized); + if (pathExists) { + throw new Error(`EEXIST: file already exists, symlink '${linkPath}'`); + } + + // AgentFS doesn't support symlinks natively yet + // Create a special file that acts like a symlink + const content = JSON.stringify({ __symlink: target }); + await this.writeFile(normalized, content); + } + + async link(existingPath: string, newPath: string): Promise { + const existingNorm = this.normalizePath(existingPath); + const newNorm = this.normalizePath(newPath); + const existingAgent = this.toAgentPath(existingNorm); + const newAgent = this.toAgentPath(newNorm); + + const existingStat = await this.stat(existingNorm); + if (!existingStat.isFile) { + throw new Error(`EPERM: operation not permitted, link '${existingPath}'`); + } + + const newExists = await this.exists(newNorm); + if (newExists) { + throw new Error(`EEXIST: file already exists, link '${newPath}'`); + } + + // Use copyFile for hard link emulation + await this.agentFs.copyFile(existingAgent, newAgent); + } + + async readlink(path: string): Promise { + const normalized = this.normalizePath(path); + + const pathExists = await this.exists(normalized); + if (!pathExists) { + throw new Error(`ENOENT: no such file or directory, readlink '${path}'`); + } + + // Try to read as symlink (our special JSON format) + try { + const content = await this.readFile(normalized); + const parsed = JSON.parse(content); + if (parsed.__symlink) { + return parsed.__symlink; + } + } catch { + // Not a symlink + } + + throw new Error(`EINVAL: invalid argument, readlink '${path}'`); + } +} diff --git a/integrations/just-bash/README.md b/integrations/just-bash/README.md new file mode 100644 index 0000000..349f9c3 --- /dev/null +++ b/integrations/just-bash/README.md @@ -0,0 +1,29 @@ +# AgentFs + +Persistent filesystem backed by [AgentFS](https://github.com/tursodatabase/agentfs) (SQLite via Turso). + +**Requires `agentfs-sdk@0.4.0-pre.6` or newer** (install with `npm install agentfs-sdk@next`). + +## Usage + +```typescript +import { AgentFS } from "agentfs-sdk"; +import { AgentFs } from "just-bash"; + +const handle = await AgentFS.open({ path: ":memory:" }); // or file path +const fs = new AgentFs({ fs: handle }); + +await fs.writeFile("/hello.txt", "world"); +const content = await fs.readFile("/hello.txt"); + +await handle.close(); +``` + +## Notes + +- Implements `IFileSystem` interface +- Directories created implicitly on write +- `chmod` is a no-op (AgentFS doesn't support modes) +- `symlink`/`readlink` use a JSON marker file (no native symlink support) +- `link` creates a copy (no native hard link support) +- `getAllPaths()` returns `[]` (no efficient way to enumerate) diff --git a/integrations/just-bash/index.ts b/integrations/just-bash/index.ts new file mode 100644 index 0000000..4c71daa --- /dev/null +++ b/integrations/just-bash/index.ts @@ -0,0 +1 @@ +export { AgentFs, type AgentFsHandle, type AgentFsOptions } from "./AgentFs.js"; From c7b67efa7e4c97574732c523f699ecab62cb6f49 Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Sun, 28 Dec 2025 18:34:53 +0200 Subject: [PATCH 2/3] Add AI SDK + just-bash example with AgentFS integration Example demonstrating how to use Vercel AI SDK with just-bash for bash command execution and AgentFS for persistent filesystem storage. Features: - Vercel AI SDK orchestrating Claude as the AI model - Bash command execution via just-bash's createBashTool - Persistent file storage via AgentFS (SQLite) - Interactive shell for chatting with the AI agent Also fixes the AgentFs adapter import to use just-bash package types. --- README.md | 1 + examples/ai-sdk-just-bash/.gitignore | 3 + examples/ai-sdk-just-bash/README.md | 53 +++++++++ examples/ai-sdk-just-bash/agent.ts | 146 ++++++++++++++++++++++++ examples/ai-sdk-just-bash/main.ts | 27 +++++ examples/ai-sdk-just-bash/package.json | 17 +++ examples/ai-sdk-just-bash/shell.ts | 67 +++++++++++ examples/ai-sdk-just-bash/tsconfig.json | 13 +++ integrations/just-bash/AgentFs.ts | 2 +- 9 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 examples/ai-sdk-just-bash/.gitignore create mode 100644 examples/ai-sdk-just-bash/README.md create mode 100644 examples/ai-sdk-just-bash/agent.ts create mode 100644 examples/ai-sdk-just-bash/main.ts create mode 100644 examples/ai-sdk-just-bash/package.json create mode 100644 examples/ai-sdk-just-bash/shell.ts create mode 100644 examples/ai-sdk-just-bash/tsconfig.json diff --git a/README.md b/README.md index 2c51384..7aa1aa1 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ This source repository also contains examples that demonstrate how to integrate - **[Claude Agent SDK](examples/claude-agent/research-assistant)** - Research assistant using Anthropic's Claude Agent SDK - **[OpenAI Agents](examples/openai-agents/research-assistant)** - Research assistant using OpenAI Agents SDK - **[Firecracker](examples/firecracker)** - Minimal Firecracker VM with AgentFS mounted via NFSv3 +- **[AI SDK + just-bash](examples/ai-sdk-just-bash)** - Interactive AI agent using Vercel AI SDK with just-bash for command execution See the **[examples](examples)** directory for more details. diff --git a/examples/ai-sdk-just-bash/.gitignore b/examples/ai-sdk-just-bash/.gitignore new file mode 100644 index 0000000..e48da4c --- /dev/null +++ b/examples/ai-sdk-just-bash/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.agentfs/ diff --git a/examples/ai-sdk-just-bash/README.md b/examples/ai-sdk-just-bash/README.md new file mode 100644 index 0000000..b4e1d77 --- /dev/null +++ b/examples/ai-sdk-just-bash/README.md @@ -0,0 +1,53 @@ +# AI SDK + just-bash Code Explorer Agent + +An interactive AI agent that combines [Vercel AI SDK](https://sdk.vercel.ai/), [just-bash](https://github.com/vercel-labs/just-bash) for bash command execution, and [AgentFS](https://github.com/tursodatabase/agentfs) for persistent filesystem storage. + +This example is forked from the [just-bash bash-agent example](https://github.com/vercel-labs/just-bash/tree/main/examples/bash-agent) with AgentFS integration added. + +## How It Works + +- **Vercel AI SDK** - Orchestrates the AI agent with Claude as the model +- **just-bash** - Provides a bash tool that the AI can use to execute shell commands +- **AgentFS** - Backs the virtual filesystem with SQLite for persistence across sessions + +## Files + +- `main.ts` - Entry point +- `agent.ts` - Agent logic using AI SDK's `streamText` with just-bash's `createBashTool` +- `shell.ts` - Interactive readline shell + +## Setup + +1. Install dependencies: + + ```bash + npm install + ``` + +2. Set your Anthropic API key: + + ```bash + export ANTHROPIC_API_KEY=your-key-here + ``` + +3. Run: + ```bash + npm start + ``` + +## Usage + +Ask questions like: + +- "What commands are available?" +- "How is the grep command implemented?" +- "Show me the Bash class" +- "Find all test files" + +Type `exit` to quit. + +## Development + +```bash +npm run typecheck +``` diff --git a/examples/ai-sdk-just-bash/agent.ts b/examples/ai-sdk-just-bash/agent.ts new file mode 100644 index 0000000..f86cb3e --- /dev/null +++ b/examples/ai-sdk-just-bash/agent.ts @@ -0,0 +1,146 @@ +/** + * AI agent with persistent AgentFS storage + * + * This file contains only the agent logic - see shell.ts for the interactive loop. + * Uses AgentFs to provide persistent filesystem storage backed by SQLite. + * + * The agent starts with the agentfs source code pre-loaded, so you can + * explore the agentfs codebase using agentfs itself! + */ + +import * as nodeFs from "node:fs"; +import * as path from "node:path"; +import { glob } from "glob"; +import { anthropic } from "@ai-sdk/anthropic"; +import { streamText, stepCountIs } from "ai"; +import { createBashTool } from "just-bash/ai"; +import { AgentFs } from "../../integrations/just-bash/index.js"; +import { AgentFS } from "agentfs-sdk"; + +export interface AgentRunner { + chat( + message: string, + callbacks: { + onText: (text: string) => void; + } + ): Promise; +} + +export interface CreateAgentOptions { + onToolCall?: (command: string) => void; + onText?: (text: string) => void; +} + +/** + * Creates an agent runner with persistent filesystem storage + * + * Uses AgentFs backed by SQLite - files persist across sessions. + */ +export async function createAgent( + options: CreateAgentOptions = {} +): Promise { + // Open AgentFS for persistent storage + const agentFsHandle = await AgentFS.open({ id: "just-bash-agent" }); + + const fs = new AgentFs({ fs: agentFsHandle }); + + // Seed agentfs source files on first run + const agentfsRoot = path.resolve(import.meta.dirname, "../.."); + if (!(await fs.exists("/README.md"))) { + console.log("Seeding AgentFS with agentfs source files..."); + + // Find all source files + const patterns = [ + "**/*.ts", + "**/*.rs", + "**/*.toml", + "**/*.json", + "**/*.md", + ]; + const ignorePatterns = [ + "**/node_modules/**", + "**/dist/**", + "**/target/**", + "**/.git/**", + "**/examples/just_bash/**", + ]; + + // Collect all files first + const allFiles: string[] = []; + for (const pattern of patterns) { + const files = await glob(pattern, { + cwd: agentfsRoot, + ignore: ignorePatterns, + nodir: true, + }); + allFiles.push(...files); + } + + // Copy files with progress + let count = 0; + const total = allFiles.length; + for (const file of allFiles) { + const srcPath = path.join(agentfsRoot, file); + const destPath = "/" + file; + + // Create parent directories + const dir = path.dirname(destPath); + if (dir !== "/") { + await fs.mkdir(dir, { recursive: true }).catch(() => {}); + } + + // Copy file + const content = nodeFs.readFileSync(srcPath, "utf-8"); + await fs.writeFile(destPath, content); + + count++; + if (count % 10 === 0 || count === total) { + process.stdout.write(`\rSeeding: ${count}/${total} files...`); + } + } + console.log("\nSeeded AgentFS with agentfs source files."); + } + + const bashTool = createBashTool({ + fs, + extraInstructions: `You are exploring the AgentFS codebase - a persistent filesystem for AI agents. +The filesystem is backed by AgentFS itself (SQLite), so files persist across sessions. + +Use bash commands to explore: +- ls to see the project structure +- cat README.md to read documentation +- grep -r "pattern" . to search code +- find . -name "*.ts" to find TypeScript files + +Key directories: +- /sdk/typescript/src - TypeScript SDK source +- /cli/src - CLI source (Rust) +- /integrations - Framework integrations`, + onCall: options.onToolCall, + }); + + const history: Array<{ role: "user" | "assistant"; content: string }> = []; + + return { + async chat(message, callbacks) { + history.push({ role: "user", content: message }); + + let fullText = ""; + + const result = streamText({ + model: anthropic("claude-sonnet-4-20250514"), + tools: { bash: bashTool }, + stopWhen: stepCountIs(50), + messages: history, + }); + + for await (const chunk of result.textStream) { + options.onText?.(chunk); + callbacks.onText(chunk); + fullText += chunk; + } + + history.push({ role: "assistant", content: fullText }); + }, + }; +} diff --git a/examples/ai-sdk-just-bash/main.ts b/examples/ai-sdk-just-bash/main.ts new file mode 100644 index 0000000..d06bc68 --- /dev/null +++ b/examples/ai-sdk-just-bash/main.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env npx tsx +/** + * just-bash Agent with AgentFS + * + * Usage: npx tsx main.ts + * + * Requires ANTHROPIC_API_KEY environment variable. + */ + +import { createAgent } from "./agent.js"; +import { runShell } from "./shell.js"; + +let lastWasToolCall = false; + +const agent = await createAgent({ + onToolCall: (command) => { + const prefix = lastWasToolCall ? "" : "\n"; + console.log( + `${prefix}\x1b[34m\x1b[1mExecuting bash tool:\x1b[0m \x1b[36m${command.trim()}\x1b[0m` + ); + lastWasToolCall = true; + }, + onText: () => { + lastWasToolCall = false; + }, +}); +runShell(agent); diff --git a/examples/ai-sdk-just-bash/package.json b/examples/ai-sdk-just-bash/package.json new file mode 100644 index 0000000..c82a03c --- /dev/null +++ b/examples/ai-sdk-just-bash/package.json @@ -0,0 +1,17 @@ +{ + "name": "just-bash-agentfs-example", + "version": "1.0.0", + "description": "Interactive AI agent with persistent AgentFS storage", + "type": "module", + "scripts": { + "start": "npx tsx main.ts", + "typecheck": "npx tsc --noEmit" + }, + "dependencies": { + "@ai-sdk/anthropic": "^3.0.0", + "ai": "^6.0.0", + "agentfs-sdk": "file:../../sdk/typescript", + "glob": "^11.0.0", + "just-bash": "^1.0.1" + } +} diff --git a/examples/ai-sdk-just-bash/shell.ts b/examples/ai-sdk-just-bash/shell.ts new file mode 100644 index 0000000..f24163d --- /dev/null +++ b/examples/ai-sdk-just-bash/shell.ts @@ -0,0 +1,67 @@ +/** + * Interactive shell for the just-bash agent with AgentFS + */ + +import * as readline from "node:readline"; +import type { AgentRunner } from "./agent.js"; + +const colors = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + cyan: "\x1b[36m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", +}; + +export function runShell(agent: AgentRunner): void { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + console.log(`${colors.cyan}${colors.bold}╔══════════════════════════════════════════════════════════════╗ +║ just-bash Agent with AgentFS ║ +║ Persistent filesystem backed by SQLite ║ +╚══════════════════════════════════════════════════════════════╝${colors.reset} +`); + console.log( + `${colors.dim}Type your question and press Enter. Type 'exit' to quit.${colors.reset}\n` + ); + + const prompt = (): void => { + rl.question(`${colors.green}You:${colors.reset} `, async (input) => { + const trimmed = input.trim(); + + if (!trimmed) { + prompt(); + return; + } + + if (trimmed.toLowerCase() === "exit") { + console.log("\nGoodbye!"); + rl.close(); + process.exit(0); + } + + process.stdout.write( + `\n${colors.blue}${colors.bold}Agent:${colors.reset} ` + ); + + try { + await agent.chat(trimmed, { + onText: (text) => process.stdout.write(text), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\n${colors.yellow}Error: ${message}${colors.reset}`); + } + + console.log(""); + prompt(); + }); + }; + + prompt(); +} diff --git a/examples/ai-sdk-just-bash/tsconfig.json b/examples/ai-sdk-just-bash/tsconfig.json new file mode 100644 index 0000000..55986f3 --- /dev/null +++ b/examples/ai-sdk-just-bash/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["*.ts", "../../src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/integrations/just-bash/AgentFs.ts b/integrations/just-bash/AgentFs.ts index 680a30f..5e3c1f2 100644 --- a/integrations/just-bash/AgentFs.ts +++ b/integrations/just-bash/AgentFs.ts @@ -19,7 +19,7 @@ import type { ReadFileOptions, RmOptions, WriteFileOptions, -} from "../fs-interface.js"; +} from "just-bash"; // Text encoder/decoder for encoding conversions const textEncoder = new TextEncoder(); From 171f25c8c3a2830a133d8b7dfa8b587ee3ba01d6 Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Mon, 29 Dec 2025 11:30:23 +0200 Subject: [PATCH 3/3] Move just-bash integration into agentfs-sdk as optional subpath export Move the just-bash filesystem adapter from integrations/just-bash/ into sdk/typescript/src/integrations/just-bash/ and expose it via subpath export. Users can import the adapter as: import { agentfs } from "agentfs-sdk/just-bash"; const fs = agentfs(await AgentFS.open({ path: "agent.db" })); const bashTool = createBashTool({ fs }); --- examples/ai-sdk-just-bash/agent.ts | 6 +- integrations/just-bash/index.ts | 1 - sdk/typescript/package-lock.json | 111 ++++++++++++++++++ sdk/typescript/package.json | 13 ++ .../integrations}/just-bash/AgentFs.test.ts | 18 +-- .../src/integrations}/just-bash/AgentFs.ts | 34 +++++- .../src/integrations/just-bash/index.ts | 1 + sdk/typescript/vitest.config.ts | 2 +- 8 files changed, 166 insertions(+), 20 deletions(-) delete mode 100644 integrations/just-bash/index.ts rename {integrations => sdk/typescript/src/integrations}/just-bash/AgentFs.test.ts (98%) rename {integrations => sdk/typescript/src/integrations}/just-bash/AgentFs.ts (94%) create mode 100644 sdk/typescript/src/integrations/just-bash/index.ts diff --git a/examples/ai-sdk-just-bash/agent.ts b/examples/ai-sdk-just-bash/agent.ts index f86cb3e..4944793 100644 --- a/examples/ai-sdk-just-bash/agent.ts +++ b/examples/ai-sdk-just-bash/agent.ts @@ -14,8 +14,8 @@ import { glob } from "glob"; import { anthropic } from "@ai-sdk/anthropic"; import { streamText, stepCountIs } from "ai"; import { createBashTool } from "just-bash/ai"; -import { AgentFs } from "../../integrations/just-bash/index.js"; import { AgentFS } from "agentfs-sdk"; +import { agentfs } from "agentfs-sdk/just-bash"; export interface AgentRunner { chat( @@ -40,9 +40,7 @@ export async function createAgent( options: CreateAgentOptions = {} ): Promise { // Open AgentFS for persistent storage - const agentFsHandle = await AgentFS.open({ id: "just-bash-agent" }); - - const fs = new AgentFs({ fs: agentFsHandle }); + const fs = agentfs(await AgentFS.open({ id: "just-bash-agent" })); // Seed agentfs source files on first run const agentfsRoot = path.resolve(import.meta.dirname, "../.."); diff --git a/integrations/just-bash/index.ts b/integrations/just-bash/index.ts deleted file mode 100644 index 4c71daa..0000000 --- a/integrations/just-bash/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AgentFs, type AgentFsHandle, type AgentFsOptions } from "./AgentFs.js"; diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index b4e941b..680b57d 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -19,8 +19,17 @@ "@vitest/browser": "^4.0.16", "@vitest/browser-playwright": "^4.0.16", "@vitest/ui": "^4.0.1", + "just-bash": "^1.0.1", "typescript": "^5.3.0", "vitest": "^4.0.1" + }, + "peerDependencies": { + "just-bash": ">=1.0.0" + }, + "peerDependenciesMeta": { + "just-bash": { + "optional": true + } } }, "node_modules/@emnapi/core": { @@ -496,6 +505,29 @@ "node": ">=18" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -503,6 +535,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz", @@ -1220,6 +1259,16 @@ "node": ">=18" } }, + "node_modules/diff": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", + "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1356,6 +1405,35 @@ ], "license": "BSD-3-Clause" }, + "node_modules/just-bash": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/just-bash/-/just-bash-1.0.1.tgz", + "integrity": "sha512-6B8zmG8j1F2WQvCF0A8UzH1cLVVvtkuGdZ98NmXruGLfOtpDoCZfbW/Mu9tTUJBk7P0R3YuxwmLq07I2xdBH4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "diff": "^8.0.2", + "minimatch": "^10.1.1", + "sprintf-js": "^1.1.3", + "turndown": "^7.2.2" + }, + "bin": { + "just-bash": "dist/bin/just-bash.js", + "just-bash-shell": "dist/bin/shell/shell.js" + }, + "peerDependencies": { + "ai": "^6.0.0", + "zod": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "ai": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1366,6 +1444,22 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -1609,6 +1703,13 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -1684,6 +1785,16 @@ "dev": true, "license": "0BSD" }, + "node_modules/turndown": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz", + "integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index f9de469..2fe5080 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -21,6 +21,10 @@ "node": "./dist/index_node.js", "browser": "./dist/index_browser.js", "default": "./dist/index_node.js" + }, + "./just-bash": { + "import": "./dist/integrations/just-bash/index.js", + "types": "./dist/integrations/just-bash/index.d.ts" } }, "keywords": [ @@ -43,9 +47,18 @@ "@vitest/browser": "^4.0.16", "@vitest/browser-playwright": "^4.0.16", "@vitest/ui": "^4.0.1", + "just-bash": "^1.0.1", "typescript": "^5.3.0", "vitest": "^4.0.1" }, + "peerDependencies": { + "just-bash": ">=1.0.0" + }, + "peerDependenciesMeta": { + "just-bash": { + "optional": true + } + }, "dependencies": { "@tursodatabase/database": "^0.4.0-pre.18", "@tursodatabase/database-common": "^0.4.0-pre.18", diff --git a/integrations/just-bash/AgentFs.test.ts b/sdk/typescript/src/integrations/just-bash/AgentFs.test.ts similarity index 98% rename from integrations/just-bash/AgentFs.test.ts rename to sdk/typescript/src/integrations/just-bash/AgentFs.test.ts index 314aa06..24cd619 100644 --- a/integrations/just-bash/AgentFs.test.ts +++ b/sdk/typescript/src/integrations/just-bash/AgentFs.test.ts @@ -1,15 +1,15 @@ -import { AgentFS } from "agentfs-sdk"; +import { AgentFS } from "../../index_node.js"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AgentFs } from "./AgentFs.js"; +import { AgentFsWrapper } from "./AgentFs.js"; -describe("AgentFs", () => { +describe("AgentFsWrapper", () => { let agentHandle: Awaited>; - let fs: AgentFs; + let fs: AgentFsWrapper; beforeEach(async () => { // Use in-memory database for tests agentHandle = await AgentFS.open({ path: ":memory:" }); - fs = new AgentFs({ fs: agentHandle }); + fs = new AgentFsWrapper({ fs: agentHandle }); }); afterEach(async () => { @@ -22,7 +22,7 @@ describe("AgentFs", () => { }); it("should accept custom mount point", () => { - const customFs = new AgentFs({ + const customFs = new AgentFsWrapper({ fs: agentHandle, mountPoint: "/mnt/data", }); @@ -30,7 +30,7 @@ describe("AgentFs", () => { }); it("should normalize mount point with trailing slash", () => { - const customFs = new AgentFs({ + const customFs = new AgentFsWrapper({ fs: agentHandle, mountPoint: "/mnt/data/", }); @@ -39,7 +39,7 @@ describe("AgentFs", () => { it("should throw for non-absolute mount point", () => { expect( - () => new AgentFs({ fs: agentHandle, mountPoint: "relative" }), + () => new AgentFsWrapper({ fs: agentHandle, mountPoint: "relative" }), ).toThrow("Mount point must be an absolute path"); }); }); @@ -675,7 +675,7 @@ describe("AgentFs", () => { describe("mount point handling", () => { it("should work with custom mount point", async () => { - const customFs = new AgentFs({ + const customFs = new AgentFsWrapper({ fs: agentHandle, mountPoint: "/mnt/data", }); diff --git a/integrations/just-bash/AgentFs.ts b/sdk/typescript/src/integrations/just-bash/AgentFs.ts similarity index 94% rename from integrations/just-bash/AgentFs.ts rename to sdk/typescript/src/integrations/just-bash/AgentFs.ts index 5e3c1f2..f96ad78 100644 --- a/integrations/just-bash/AgentFs.ts +++ b/sdk/typescript/src/integrations/just-bash/AgentFs.ts @@ -9,18 +9,26 @@ * @see https://docs.turso.tech/agentfs/sdk/typescript */ -import type { Filesystem } from "agentfs-sdk"; +import type { Filesystem } from "../../filesystem.js"; import type { BufferEncoding, CpOptions, FsStat, IFileSystem, MkdirOptions, - ReadFileOptions, RmOptions, - WriteFileOptions, } from "just-bash"; +/** Options for reading files */ +interface ReadFileOptions { + encoding?: BufferEncoding | null; +} + +/** Options for writing files */ +interface WriteFileOptions { + encoding?: BufferEncoding; +} + // Text encoder/decoder for encoding conversions const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -82,7 +90,7 @@ function getEncoding( return undefined; } if (typeof options === "string") { - return options; + return options as BufferEncoding; } return options.encoding ?? undefined; } @@ -110,7 +118,7 @@ export interface AgentFsOptions { /** Default mount point for AgentFs */ const DEFAULT_MOUNT_POINT = "/"; -export class AgentFs implements IFileSystem { +export class AgentFsWrapper implements IFileSystem { private readonly agentFs: Filesystem; private readonly mountPoint: string; @@ -508,3 +516,19 @@ export class AgentFs implements IFileSystem { throw new Error(`EINVAL: invalid argument, readlink '${path}'`); } } + +/** + * Create a just-bash compatible filesystem backed by AgentFS. + * + * @example + * ```ts + * import { AgentFS } from "agentfs-sdk"; + * import { agentfs } from "agentfs-sdk/just-bash"; + * + * const fs = agentfs(await AgentFS.open({ path: "agent.db" })); + * const bashTool = createBashTool({ fs }); + * ``` + */ +export function agentfs(handle: AgentFsHandle, mountPoint?: string): IFileSystem { + return new AgentFsWrapper({ fs: handle, mountPoint }); +} diff --git a/sdk/typescript/src/integrations/just-bash/index.ts b/sdk/typescript/src/integrations/just-bash/index.ts new file mode 100644 index 0000000..9291ca1 --- /dev/null +++ b/sdk/typescript/src/integrations/just-bash/index.ts @@ -0,0 +1 @@ +export { agentfs, AgentFsWrapper, type AgentFsHandle, type AgentFsOptions } from "./AgentFs.js"; diff --git a/sdk/typescript/vitest.config.ts b/sdk/typescript/vitest.config.ts index c1b1f57..fe8286d 100644 --- a/sdk/typescript/vitest.config.ts +++ b/sdk/typescript/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['tests/**/*.test.ts'], + include: ['tests/**/*.test.ts', 'src/integrations/**/*.test.ts'], exclude: ['**/node_modules/**', '**/dist/**', '**/resources/**'], coverage: { provider: 'v8',