From 17d8c9961449b58ea7eef11becbc04ee90990a0e Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Sat, 28 Mar 2026 13:33:28 -0500 Subject: [PATCH] chore: making plugin development easier and adding more concise docs --- .gitignore | 1 + README.md | 146 ++----------------------------------- docs/getting-started.md | 9 +++ docs/plugins/approval.md | 55 ++++++++++++++ docs/plugins/authoring.md | 45 ++++++++++++ docs/plugins/overview.md | 36 +++++++++ package.json | 3 +- src/plugins/loader.test.ts | 77 +++++++++++++++++++ src/plugins/loader.ts | 70 +++++++++++++++++- 9 files changed, 297 insertions(+), 145 deletions(-) create mode 100644 docs/getting-started.md create mode 100644 docs/plugins/approval.md create mode 100644 docs/plugins/authoring.md create mode 100644 docs/plugins/overview.md diff --git a/.gitignore b/.gitignore index ffe6ace..d6dc59b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules dist .DS_Store .git-old +example-plugin/ \ No newline at end of file diff --git a/README.md b/README.md index f264cdc..3b8de34 100644 --- a/README.md +++ b/README.md @@ -8,145 +8,9 @@ Buddy is a terminal-first AI assistant with a guided onboarding flow, a chat TUI npm install -g @teichai/buddy ``` -## Getting started +## Docs -Run `buddy onboard` for a guided first-time setup. It starts by asking whether the Buddy server is local or remote, then walks you through provider setup, naming, and safety defaults one step at a time before a final review screen saves everything. - -After onboarding: - -- Run `buddy` to open the chat UI. -- Run `buddy config` to tweak advanced settings like Discord and blocked directories. -- Run `buddy server start` to launch the local server in the background when you want a dedicated daemon. - -## Plugins - -Buddy can auto-load custom tool plugins from `~/.buddy/plugins`. Each plugin lives in its own folder, ships a compiled ESM entrypoint, and can expose one or more tools to the model. - -### Folder layout - -```text -~/.buddy/plugins/ - weather-tools/ - package.json - dist/ - index.js -``` - -Buddy scans each direct child directory of `~/.buddy/plugins` on every chat turn. The folder contents are the source of truth in v1, so there are no extra enable or disable flags yet. - -### `package.json` - -Each plugin folder must include a `package.json` with `name`, `version`, and `buddy.entry`: - -```json -{ - "name": "@acme/weather-tools", - "version": "1.0.0", - "type": "module", - "buddy": { - "entry": "./dist/index.js" - } -} -``` - -### Authoring API - -Buddy exposes a TypeScript SDK at `@teichai/buddy/plugin`. - -```ts -import { definePlugin, defineTool } from "@teichai/buddy/plugin"; - -export default definePlugin({ - id: "weather-tools", - name: "Weather Tools", - description: "Weather helpers for Buddy", - author: "Acme, Inc.", - repositoryUrl: "https://github.com/acme/weather-tools", - tools: [ - defineTool({ - id: "forecast", - description: "Fetch a weather forecast for a city.", - parameters: { - type: "object", - properties: { - city: { type: "string", description: "City to look up." } - }, - required: ["city"], - additionalProperties: false - }, - summarize(args) { - return { - summary: `Fetch forecast for ${String(args.city ?? "unknown city")}`, - path: `weather:${String(args.city ?? "unknown")}` - }; - }, - async execute(_context, args) { - return `Sunny in ${String(args.city ?? "unknown")}`; - } - }) - ] -}); -``` - -Plugin metadata: - -- Required: `id`, `tools` -- Optional: `name`, `version`, `description`, `author`, `repositoryUrl` - -`repositoryUrl` must be an absolute `http` or `https` URL if you provide it. - -### Approval behavior - -Tools can opt into approval in two ways. - -Static approval: - -```ts -defineTool({ - id: "dangerous-action", - description: "Run a risky action.", - requiresApproval: true, - parameters: { type: "object", additionalProperties: false }, - summarize() { - return { summary: "Run dangerous action", path: "dangerous-action" }; - }, - async execute() { - return "done"; - } -}); -``` - -Conditional approval from inside the tool: - -```ts -import { defineTool, requestApproval } from "@teichai/buddy/plugin"; - -defineTool({ - id: "deploy", - description: "Deploy the current release.", - parameters: { - type: "object", - properties: { - force: { type: "boolean" } - }, - additionalProperties: false - }, - summarize() { - return { summary: "Deploy release", path: "release" }; - }, - async execute(_context, args) { - if (args.force === true) { - return requestApproval({ - summary: "Force deploy release", - path: "release", - reason: "Force mode bypasses the normal deployment checks.", - continueWith: async () => "forced deploy complete" - }); - } - - return "deploy complete"; - } -}); -``` - -In v1, plugin permissions are Buddy approval semantics for tool calls. Plugins are still trusted in-process code. +- [Getting started](./docs/getting-started.md) +- [Plugin overview](./docs/plugins/overview.md) +- [Plugin authoring](./docs/plugins/authoring.md) +- [Plugin approvals](./docs/plugins/approval.md) diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..d851b0a --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,9 @@ +# Getting started + +Run `buddy onboard` for a guided first-time setup. It starts by asking whether the Buddy server is local or remote, then walks you through provider setup, naming, and safety defaults one step at a time before a final review screen saves everything. + +After onboarding: + +- Run `buddy` to open the chat UI. +- Run `buddy config` to tweak advanced settings like Discord and blocked directories. +- Run `buddy server start` to launch the local server in the background when you want a dedicated daemon. diff --git a/docs/plugins/approval.md b/docs/plugins/approval.md new file mode 100644 index 0000000..58cb510 --- /dev/null +++ b/docs/plugins/approval.md @@ -0,0 +1,55 @@ +# Plugin approvals + +Tools can opt into approval in two ways. + +## Static approval + +```ts +defineTool({ + id: "dangerous-action", + description: "Run a risky action.", + requiresApproval: true, + parameters: { type: "object", additionalProperties: false }, + summarize() { + return { summary: "Run dangerous action", path: "dangerous-action" }; + }, + async execute() { + return "done"; + } +}); +``` + +## Conditional approval + +```ts +import { defineTool, requestApproval } from "@teichai/buddy/plugin"; + +defineTool({ + id: "deploy", + description: "Deploy the current release.", + parameters: { + type: "object", + properties: { + force: { type: "boolean" } + }, + additionalProperties: false + }, + summarize() { + return { summary: "Deploy release", path: "release" }; + }, + async execute(_context, args) { + if (args.force === true) { + return requestApproval({ + summary: "Force deploy release", + path: "release", + reason: "Force mode bypasses the normal deployment checks.", + continueWith: async () => "forced deploy complete" + }); + } + + return "deploy complete"; + } +}); +``` + +In v1, plugin permissions are Buddy approval semantics for tool calls. Plugins are still trusted in-process code. diff --git a/docs/plugins/authoring.md b/docs/plugins/authoring.md new file mode 100644 index 0000000..2178349 --- /dev/null +++ b/docs/plugins/authoring.md @@ -0,0 +1,45 @@ +# Plugin authoring + +Buddy exposes a TypeScript SDK at `@teichai/buddy/plugin`. + +```ts +import { definePlugin, defineTool } from "@teichai/buddy/plugin"; + +export default definePlugin({ + id: "weather-tools", + name: "Weather Tools", + description: "Weather helpers for Buddy", + author: "Acme, Inc.", + repositoryUrl: "https://github.com/acme/weather-tools", + tools: [ + defineTool({ + id: "forecast", + description: "Fetch a weather forecast for a city.", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "City to look up." } + }, + required: ["city"], + additionalProperties: false + }, + summarize(args) { + return { + summary: `Fetch forecast for ${String(args.city ?? "unknown city")}`, + path: `weather:${String(args.city ?? "unknown")}` + }; + }, + async execute(_context, args) { + return `Sunny in ${String(args.city ?? "unknown")}`; + } + }) + ] +}); +``` + +Plugin metadata: + +- Required: `id`, `tools` +- Optional: `name`, `version`, `description`, `author`, `repositoryUrl` + +`repositoryUrl` must be an absolute `http` or `https` URL if you provide it. diff --git a/docs/plugins/overview.md b/docs/plugins/overview.md new file mode 100644 index 0000000..0e3d1d8 --- /dev/null +++ b/docs/plugins/overview.md @@ -0,0 +1,36 @@ +# Plugin overview + +Buddy can auto-load custom tool plugins from `~/.buddy/plugins`. Each plugin lives in its own folder, ships a compiled ESM entrypoint, and can expose one or more tools to the model. + +At runtime, plugins do not need their own private `node_modules/` just to import the Buddy SDK. Buddy resolves `@teichai/buddy/plugin` for them, and plugins can also reuse dependencies that are already available from Buddy or a shared parent `node_modules` such as `~/.buddy/plugins/node_modules`. + +## Folder layout + +```text +~/.buddy/plugins/ + weather-tools/ + package.json + dist/ + index.js +``` + +Buddy scans each direct child directory of `~/.buddy/plugins` on every chat turn. The folder contents are the source of truth in v1, so there are no extra enable or disable flags yet. + +## `package.json` + +Each plugin folder must include a `package.json` with `name`, `version`, and `buddy.entry`: + +```json +{ + "name": "@acme/weather-tools", + "version": "1.0.0", + "type": "module", + "buddy": { + "entry": "./dist/index.js" + } +} +``` + +## Example Plugin + +Find our example plugin [here](https://github.com/TeichAI/example-plugin). \ No newline at end of file diff --git a/package.json b/package.json index 5a9d1e9..08d2812 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@teichai/buddy", - "version": "0.0.4", + "version": "0.0.5", "description": "Terminal-first AI assistant with onboarding, chat UI, and local or remote server support.", "license": "MIT", "type": "module", @@ -16,6 +16,7 @@ "files": [ "dist", "LICENSE", + "docs", "README.md" ], "repository": { diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 7a87d86..66b7ec7 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -78,6 +78,83 @@ test("loadPlugins reads optional plugin metadata from the plugin export", async } }); +test("loadPlugins resolves the Buddy plugin SDK without a plugin-local node_modules directory", async () => { + const pluginDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "buddy-loader-sdk-")); + + try { + await writePlugin({ + pluginDirectory, + directoryName: "example", + source: ` + import { definePlugin, defineTool } from "@teichai/buddy/plugin"; + + export default definePlugin({ + id: "example", + tools: [ + defineTool({ + id: "hello", + description: "Say hello.", + parameters: { type: "object", additionalProperties: false }, + summarize() { + return "Hello"; + }, + async execute() { + return "ok"; + } + }) + ] + }); + ` + }); + + const result = await loadPlugins(pluginDirectory); + assert.equal(result.diagnostics.length, 0); + assert.equal(result.plugins.length, 1); + assert.equal(result.plugins[0]?.plugin.id, "example"); + } finally { + await fs.rm(pluginDirectory, { recursive: true, force: true }); + } +}); + +test("loadPlugins can fall back to Buddy's bundled dependencies for plugin imports", async () => { + const pluginDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "buddy-loader-deps-")); + + try { + await writePlugin({ + pluginDirectory, + directoryName: "colors", + source: ` + import { definePlugin, defineTool } from "@teichai/buddy/plugin"; + import chalk from "chalk"; + + export default definePlugin({ + id: "colors", + tools: [ + defineTool({ + id: "paint", + description: "Paint text.", + parameters: { type: "object", additionalProperties: false }, + summarize() { + return "Paint"; + }, + async execute() { + return chalk.green("ok"); + } + }) + ] + }); + ` + }); + + const result = await loadPlugins(pluginDirectory); + assert.equal(result.diagnostics.length, 0); + assert.equal(result.plugins.length, 1); + assert.equal(await result.plugins[0]?.plugin.tools[0]?.execute({} as never, {}), "ok"); + } finally { + await fs.rm(pluginDirectory, { recursive: true, force: true }); + } +}); + test("loadPlugins reports invalid metadata and duplicate plugin ids without crashing", async () => { const pluginDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "buddy-loader-dupes-")); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 4ba2ebb..593b8de 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1,6 +1,8 @@ +import fsSync from "node:fs"; import fs from "node:fs/promises"; +import { registerHooks } from "node:module"; import path from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import type { BuddyPlugin, BuddyTool } from "./sdk.js"; import { pluginsPath } from "../utils/paths.js"; @@ -26,6 +28,65 @@ export interface LoadedPlugin { plugin: BuddyPlugin; } +const buddyPluginModuleUrl = (() => { + const candidates = ["../plugin.js", "../plugin.ts"]; + + for (const candidate of candidates) { + const candidateUrl = new URL(candidate, import.meta.url); + if (fsSync.existsSync(fileURLToPath(candidateUrl))) { + return candidateUrl.href; + } + } + + throw new Error("Unable to locate the Buddy plugin SDK module."); +})(); + +const registeredPluginDirectories = new Set(); + +function isWithinDirectory(filePath: string, directoryPath: string): boolean { + const relativePath = path.relative(directoryPath, filePath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +registerHooks({ + resolve(specifier, context, nextResolve) { + try { + return nextResolve(specifier, context); + } catch (error) { + if (!context.parentURL?.startsWith("file:")) { + throw error; + } + + const parentPath = fileURLToPath(context.parentURL); + const isPluginModule = [...registeredPluginDirectories].some((directoryPath) => + isWithinDirectory(parentPath, directoryPath) + ); + const isBareSpecifier = + !specifier.startsWith(".") && + !specifier.startsWith("/") && + !specifier.startsWith("file:") && + !specifier.startsWith("data:") && + !specifier.startsWith("node:"); + + if (!isPluginModule || !isBareSpecifier) { + throw error; + } + + if (specifier === "@teichai/buddy/plugin") { + return { + shortCircuit: true, + url: buddyPluginModuleUrl + }; + } + + return nextResolve(specifier, { + ...context, + parentURL: import.meta.url + }); + } + } +}); + function requireNonEmptyString(value: unknown, label: string): string { if (typeof value !== "string" || !value.trim()) { throw new Error(`${label} must be a non-empty string.`); @@ -155,8 +216,11 @@ async function readPluginPackage(pluginDirectory: string): Promise<{ } async function importPlugin(entryPath: string): Promise { - const stat = await fs.stat(entryPath); - const moduleUrl = `${pathToFileURL(entryPath).href}?mtime=${stat.mtimeMs}`; + const resolvedEntryPath = await fs.realpath(entryPath); + const stat = await fs.stat(resolvedEntryPath); + const moduleUrl = `${pathToFileURL(resolvedEntryPath).href}?mtime=${stat.mtimeMs}`; + const pluginDirectory = path.dirname(resolvedEntryPath); + registeredPluginDirectories.add(pluginDirectory); const loaded = (await import(moduleUrl)) as { default?: unknown }; return loaded.default; }