feat(agents): add opencode plugin integration (#107)

* feat(agents): add opencode plugin integration

Adds @struktoai/mirage-agents/opencode subpath: mirageTools(ws) and
miragePlugin(ws) that expose a Mirage Workspace as OpenCode's read,
write, edit, ls, bash, glob, and grep tools. Includes docs page,
example plugin file, and 15 vitest tests against a RAM workspace.

* feat(agents): runnable opencode example + plain-string tool results

OpenCode pins @opencode-ai/plugin@1.1.6, which requires tool execute()
to return Promise<string>. Our tools returned {title, output, metadata}
which crashed with "text2.split is not a function" inside the OpenCode
subprocess. Return plain strings to match.

Drop "Mirage workspace" wording from tool descriptions so the agent
sees plain capability descriptions.

Adds examples/typescript/agents/opencode with a runnable runner that
spawns OpenCode via @opencode-ai/sdk, loads a RAM-backed plugin that
pre-writes /hello.txt, and prompts gpt-5.4-mini to cat it.

* feat(agents): per-session workspace resolver for opencode

mirageTools and miragePlugin now accept either a Workspace or a
(ctx: ToolContext) => Workspace | Promise<Workspace> resolver. The
resolver is called per tool invocation, so apps can pool workspaces
by sessionID, directory, or any other key.

Example updated to demo per-session pooling: each session gets its
own RAM workspace seeded with its sessionID; the runner drives two
sessions in parallel and shows each receives its own file.

* docs(agents): use opencode logo for card icon

* fix(docs): add trailing newline to opencode logo svg
This commit is contained in:
Zecheng Zhang
2026-05-27 13:02:30 -07:00
committed by GitHub
parent 210ff740b9
commit ccdb432cad
12 changed files with 689 additions and 9 deletions
+1
View File
@@ -303,6 +303,7 @@
"typescript/agents/pi",
"typescript/agents/vercel",
"typescript/agents/mastra",
"typescript/agents/opencode",
"typescript/agents/claude-code",
"typescript/agents/codex"
]
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512"><svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" fill="#131010"></rect>
<path d="M320 224V352H192V224H320Z" fill="#5A5858"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" fill="white"></path>
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
@media (prefers-color-scheme: dark) { :root { filter: none; } }
</style></svg>

After

Width:  |  Height:  |  Size: 613 B

+3
View File
@@ -28,6 +28,9 @@ For browser apps, swap `@struktoai/mirage-node` for `@struktoai/mirage-browser`.
<Card title="Mastra" icon="wand-magic-sparkles" href="/typescript/agents/mastra">
For [Mastra](https://mastra.ai) `Agent` definitions.
</Card>
<Card title="OpenCode" icon="/images/opencode-logo.svg" href="/typescript/agents/opencode">
Plugin tools for [OpenCode](https://opencode.ai) that swap `read`, `write`, `edit`, and `bash`.
</Card>
<Card title="Claude Code" icon="/images/claude-logo.svg" href="/typescript/agents/claude-code">
Mount a workspace via FUSE and run `claude` against it.
</Card>
+73
View File
@@ -0,0 +1,73 @@
---
title: OpenCode
description: Hand a Mirage workspace to OpenCode as a plugin via the @struktoai/mirage-agents/opencode adapter.
icon: /images/opencode-logo.svg
---
[OpenCode](https://opencode.ai) is an open-source coding agent that runs in your terminal, IDE, or desktop. Its plugin API lets you register tools that take precedence over the built-ins by name, so a Mirage plugin can swap OpenCode's `read`, `write`, `edit`, `ls`, `bash`, `glob`, and `grep` to operate on a `Workspace` instead of the local disk. Once installed, anything OpenCode reads or writes flows through Mirage and reaches every mounted resource (S3, GCS, Postgres, Linear, Slack, ...) as files.
## Install
`@struktoai/mirage-agents/opencode` is a tool factory plus a thin plugin shim. Pair it with `@struktoai/mirage-node` for any Node or Bun project.
```bash
bun add @struktoai/mirage-agents @struktoai/mirage-node
```
OpenCode runs `bun install` on startup against `.opencode/package.json`, so this works whether you use bun, pnpm, or npm in the host project.
## Usage
Drop a plugin file into `.opencode/plugins/` (project-local) or `~/.config/opencode/plugins/` (global). OpenCode auto-discovers it and merges the returned `tool` dict over its built-ins.
```ts .opencode/plugins/mirage.ts
import { MountMode, OpsRegistry, RAMResource, Workspace } from '@struktoai/mirage-node'
import { miragePlugin } from '@struktoai/mirage-agents/opencode'
const ram = new RAMResource()
const ops = new OpsRegistry()
for (const op of ram.ops()) ops.register(op)
const ws = new Workspace({ '/': ram }, { mode: MountMode.WRITE, ops })
export default miragePlugin(ws)
```
Add the dependencies to `.opencode/package.json` so OpenCode resolves them on startup:
```json .opencode/package.json
{
"dependencies": {
"@struktoai/mirage-agents": "*",
"@struktoai/mirage-node": "*"
}
}
```
Then run `opencode` in that directory. The agent's `read /hello.txt`, `bash 'find /'`, `grep foo /`, etc. now all flow through the Mirage workspace.
## Exports
| Symbol | Purpose |
| --- | --- |
| `mirageTools(ws)` | Returns `{ read, write, edit, ls, bash, glob, grep }`, each shaped as an OpenCode `ToolDefinition`. Use this if you want to compose with other plugin hooks. |
| `miragePlugin(ws)` | Returns a `Plugin` function for default-exporting from a plugin file. Wraps `mirageTools` under the `tool` hook. |
### Tool reference
Each tool's `execute` returns a plain string (matching the OpenCode plugin contract).
| Tool | Input | Output |
| --- | --- | --- |
| `read` | `{ filePath }` | UTF-8 text, or a binary-stub note for non-text files |
| `write` | `{ filePath, content }` | Confirmation line (auto-mkdirs parent) |
| `edit` | `{ filePath, oldString, newString, replaceAll? }` | Confirmation line with occurrence count |
| `ls` | `{ path }` | Newline-separated entries, dirs suffixed with `/` |
| `bash` | `{ command }` | Merged stdout/stderr (routes through Mirage shell) |
| `glob` | `{ pattern, path? }` | `find -name` results |
| `grep` | `{ pattern, path? }` | `grep -rn` results |
`bash`, `glob`, and `grep` go through `ws.execute()`, so they pick up every Mirage shell builtin (resource-aware `head`, `tail`, `find`, `grep`, etc.) across mounts.
## Examples
- [`examples/typescript/agents/opencode/ram_opencode.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/agents/opencode/ram_opencode.ts), an end-to-end runner that spawns OpenCode via `@opencode-ai/sdk`, loads a RAM-backed plugin that pre-writes `/hello.txt`, and asks `gpt-5.4-mini` to `cat` it.
@@ -0,0 +1,44 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { MountMode, OpsRegistry, RAMResource, Workspace } from '@struktoai/mirage-node'
import { miragePlugin } from '@struktoai/mirage-agents/opencode'
async function makeWs(sessionID: string): Promise<Workspace> {
const ram = new RAMResource()
const ops = new OpsRegistry()
for (const op of ram.ops()) ops.register(op)
const ws = new Workspace({ '/': ram }, { mode: MountMode.WRITE, ops })
await ws.fs.writeFile('/hello.txt', `hi from session ${sessionID}`)
return ws
}
const workspaces = new Map<string, Workspace>()
const pending = new Map<string, Promise<Workspace>>()
async function wsFor(ctx: { sessionID: string }): Promise<Workspace> {
const cached = workspaces.get(ctx.sessionID)
if (cached !== undefined) return cached
const inflight = pending.get(ctx.sessionID)
if (inflight !== undefined) return inflight
const p = makeWs(ctx.sessionID).then((ws) => {
workspaces.set(ctx.sessionID, ws)
pending.delete(ctx.sessionID)
return ws
})
pending.set(ctx.sessionID, p)
return p
}
export default miragePlugin(wsFor)
@@ -0,0 +1,70 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { config as loadEnv } from 'dotenv'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
createOpencodeServer,
createOpencodeClient,
type OpencodeClient,
} from '@opencode-ai/sdk'
const here = dirname(fileURLToPath(import.meta.url))
loadEnv({ path: resolve(here, '../../../../.env.development') })
if (process.env.OPENAI_API_KEY === undefined || process.env.OPENAI_API_KEY === '') {
console.error('OPENAI_API_KEY missing in .env.development')
process.exit(1)
}
process.chdir(here)
const server = await createOpencodeServer({
timeout: 30_000,
config: {
provider: {
openai: {
npm: '@ai-sdk/openai',
name: 'OpenAI',
models: { 'gpt-5.4-mini': { name: 'GPT-5.4 mini' } },
},
},
},
})
const client = createOpencodeClient({ baseUrl: server.url })
const MODEL = { providerID: 'openai', modelID: 'gpt-5.4-mini' }
const PROMPT = 'Run `cat /hello.txt` with the bash tool and report exactly what it printed.'
async function runSession(c: OpencodeClient, title: string): Promise<void> {
const session = await c.session.create({ body: { title } })
const sessionId = (session.data as { id: string }).id
const result = await c.session.prompt({
path: { id: sessionId },
body: { model: MODEL, parts: [{ type: 'text', text: PROMPT }] },
})
const data = result.data as { parts?: Array<{ type: string; text?: string }> }
for (const part of data.parts ?? []) {
if (part.type === 'text' && part.text !== undefined && part.text.length > 0) {
console.log(`[${title}] ${part.text}`)
}
}
}
try {
await Promise.all([runSession(client, 'alice'), runSession(client, 'bob')])
} finally {
server.close()
}
+1
View File
@@ -9,6 +9,7 @@
"@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2",
"@mastra/core": "^1.29.1",
"@opencode-ai/sdk": "^1.15.11",
"@openai/agents": "^0.8.0",
"@struktoai/mirage-agents": "workspace:*",
"@struktoai/mirage-browser": "workspace:*",
+4
View File
@@ -57,6 +57,10 @@
"./mastra": {
"types": "./dist/mastra/index.d.ts",
"import": "./dist/mastra/index.js"
},
"./opencode": {
"types": "./dist/opencode/index.d.ts",
"import": "./dist/opencode/index.js"
}
},
"files": [
@@ -0,0 +1,206 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { describe, expect, it } from 'vitest'
import { OpsRegistry, RAMResource, MountMode, Workspace } from '@struktoai/mirage-node'
import { mirageTools, miragePlugin } from './index.ts'
function mkWs(): Workspace {
const ram = new RAMResource()
const ops = new OpsRegistry()
for (const op of ram.ops()) ops.register(op)
return new Workspace({ '/': ram }, { mode: MountMode.WRITE, ops })
}
async function callTool(t: unknown, input: unknown): Promise<string> {
const exec = (t as { execute?: (input: unknown, ctx: unknown) => unknown }).execute
if (typeof exec !== 'function') throw new Error('tool has no execute')
const ctx = {
sessionID: 's',
messageID: 'm',
agent: 'a',
abort: new AbortController().signal,
}
const result = await exec(input, ctx)
return result as string
}
describe('opencode mirageTools.read', () => {
it('reads a text file', async () => {
const ws = mkWs()
await ws.fs.writeFile('/notes.txt', 'hello')
const out = await callTool(mirageTools(ws).read, { filePath: '/notes.txt' })
expect(out).toBe('hello')
})
it('returns error message for missing file', async () => {
const out = await callTool(mirageTools(mkWs()).read, { filePath: '/missing.txt' })
expect(out.startsWith('Error:')).toBe(true)
})
it('returns binary stub for non-text files', async () => {
const ws = mkWs()
await ws.fs.writeFile('/blob.bin', new Uint8Array([0, 1, 2, 3]))
const out = await callTool(mirageTools(ws).read, { filePath: '/blob.bin' })
expect(out).toContain('Binary file')
})
})
describe('opencode mirageTools.write', () => {
it('writes a new file', async () => {
const ws = mkWs()
const out = await callTool(mirageTools(ws).write, { filePath: '/out.txt', content: 'data' })
expect(out).toContain('/out.txt')
expect(await ws.fs.readFileText('/out.txt')).toBe('data')
})
it('creates missing parent directories', async () => {
const ws = mkWs()
await callTool(mirageTools(ws).write, { filePath: '/a/b/c.txt', content: 'x' })
expect(await ws.fs.readFileText('/a/b/c.txt')).toBe('x')
})
})
describe('opencode mirageTools.edit', () => {
it('replaces single occurrence', async () => {
const ws = mkWs()
await ws.fs.writeFile('/f.txt', 'foo bar baz')
const out = await callTool(mirageTools(ws).edit, {
filePath: '/f.txt',
oldString: 'bar',
newString: 'BAR',
})
expect(out).toContain('1 occurrence')
expect(await ws.fs.readFileText('/f.txt')).toBe('foo BAR baz')
})
it('rejects multiple occurrences without replaceAll', async () => {
const ws = mkWs()
await ws.fs.writeFile('/f.txt', 'aa aa')
const out = await callTool(mirageTools(ws).edit, {
filePath: '/f.txt',
oldString: 'aa',
newString: 'X',
})
expect(out).toContain('appears 2 times')
})
it('replaces all when replaceAll is true', async () => {
const ws = mkWs()
await ws.fs.writeFile('/f.txt', 'aa aa')
const out = await callTool(mirageTools(ws).edit, {
filePath: '/f.txt',
oldString: 'aa',
newString: 'X',
replaceAll: true,
})
expect(out).toContain('2 occurrences')
expect(await ws.fs.readFileText('/f.txt')).toBe('X X')
})
it('returns error when string not found', async () => {
const ws = mkWs()
await ws.fs.writeFile('/f.txt', 'hello')
const out = await callTool(mirageTools(ws).edit, {
filePath: '/f.txt',
oldString: 'world',
newString: 'X',
})
expect(out).toContain('string not found')
})
})
describe('opencode mirageTools.ls', () => {
it('lists entries with trailing slash for dirs', async () => {
const ws = mkWs()
await ws.fs.writeFile('/a.txt', 'a')
await ws.fs.mkdir('/d')
const out = await callTool(mirageTools(ws).ls, { path: '/' })
const entries = out.split('\n').sort()
expect(entries).toContain('/a.txt')
expect(entries).toContain('/d/')
})
})
describe('opencode mirageTools.bash', () => {
it('runs a shell command and returns stdout', async () => {
const out = await callTool(mirageTools(mkWs()).bash, { command: 'echo hello' })
expect(out).toBe('hello')
})
it('captures stderr on failure', async () => {
const out = await callTool(mirageTools(mkWs()).bash, { command: 'cat /nope.txt' })
expect(out.length).toBeGreaterThan(0)
})
})
describe('opencode mirageTools.glob', () => {
it('finds files matching a name pattern', async () => {
const ws = mkWs()
await ws.fs.writeFile('/a.ts', '')
await ws.fs.writeFile('/b.ts', '')
await ws.fs.writeFile('/c.md', '')
const out = await callTool(mirageTools(ws).glob, { pattern: '*.ts' })
expect(out).toContain('/a.ts')
expect(out).toContain('/b.ts')
expect(out).not.toContain('/c.md')
})
})
describe('opencode mirageTools.grep', () => {
it('finds text matches across files', async () => {
const ws = mkWs()
await ws.fs.writeFile('/a.txt', 'hello world')
await ws.fs.writeFile('/b.txt', 'goodbye')
const out = await callTool(mirageTools(ws).grep, { pattern: 'hello' })
expect(out).toContain('/a.txt')
expect(out).toContain('hello')
})
})
describe('opencode miragePlugin', () => {
it('returns a plugin that registers tools', async () => {
const ws = mkWs()
const plugin = miragePlugin(ws)
const hooks = await plugin({})
expect(hooks.tool).toBeDefined()
expect(Object.keys(hooks.tool ?? {}).sort()).toEqual([
'bash',
'edit',
'glob',
'grep',
'ls',
'read',
'write',
])
})
})
describe('opencode resolver (per-session workspace)', () => {
it('routes each session to its own workspace', async () => {
const wsA = mkWs()
const wsB = mkWs()
await wsA.fs.writeFile('/note.txt', 'alice')
await wsB.fs.writeFile('/note.txt', 'bob')
const tools = mirageTools((ctx) => (ctx.sessionID === 'a' ? wsA : wsB))
const exec = (t: unknown) =>
(t as { execute: (a: unknown, c: unknown) => Promise<string> }).execute
const ctxA = { sessionID: 'a', messageID: 'm', agent: '', abort: new AbortController().signal }
const ctxB = { sessionID: 'b', messageID: 'm', agent: '', abort: new AbortController().signal }
expect(await exec(tools.read)({ filePath: '/note.txt' }, ctxA)).toBe('alice')
expect(await exec(tools.read)({ filePath: '/note.txt' }, ctxB)).toBe('bob')
})
})
@@ -0,0 +1,268 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { Workspace } from '@struktoai/mirage-node'
import { z } from 'zod'
export interface ToolContext {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
}
export type WsResolver = (ctx: ToolContext) => Workspace | Promise<Workspace>
export type WsLike = Workspace | WsResolver
interface ToolDefinition<Args extends z.ZodRawShape = z.ZodRawShape> {
description: string
args: Args
execute(args: z.infer<z.ZodObject<Args>>, context: ToolContext): Promise<string>
}
function tool<Args extends z.ZodRawShape>(input: ToolDefinition<Args>): ToolDefinition<Args> {
return input
}
function isResolver(ws: WsLike): ws is WsResolver {
return typeof ws === 'function'
}
async function resolveWs(ws: WsLike, ctx: ToolContext): Promise<Workspace> {
return isResolver(ws) ? ws(ctx) : ws
}
function parentOf(path: string): string {
const trimmed = path.replace(/\/+$/, '')
const idx = trimmed.lastIndexOf('/')
if (idx <= 0) return '/'
return trimmed.slice(0, idx)
}
async function ensureParent(ws: Workspace, path: string): Promise<void> {
const parent = parentOf(path)
if (parent === '/' || parent === '') return
if (await ws.fs.exists(parent)) return
await ensureParent(ws, parent)
try {
await ws.fs.mkdir(parent)
} catch (err) {
if (!(await ws.fs.exists(parent))) throw err
}
}
const TEXT_EXTS = new Set([
'txt',
'md',
'json',
'jsonl',
'yaml',
'yml',
'csv',
'tsv',
'xml',
'html',
'htm',
'js',
'mjs',
'cjs',
'ts',
'tsx',
'jsx',
'py',
'rb',
'rs',
'go',
'java',
'c',
'cpp',
'h',
'hpp',
'sh',
'bash',
'zsh',
'sql',
'log',
'env',
'ini',
'toml',
'conf',
'cfg',
])
function extOf(path: string): string {
const dot = path.lastIndexOf('.')
if (dot < 0) return ''
return path.slice(dot + 1).toLowerCase()
}
function isLikelyText(path: string): boolean {
return TEXT_EXTS.has(extOf(path))
}
function errMsg(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
export function mirageTools(ws: WsLike): Record<string, ToolDefinition> {
const read = tool({
description:
'Read a file. Returns UTF-8 text for source/data files; for binary files returns a metadata stub. Use the bash tool to inspect binaries.',
args: {
filePath: z.string().describe('Absolute path of the file to read.'),
},
execute: async ({ filePath }, ctx) => {
const w = await resolveWs(ws, ctx)
let bytes: Uint8Array
try {
bytes = await w.fs.readFile(filePath)
} catch (err) {
return `Error: ${errMsg(err)}`
}
if (isLikelyText(filePath)) {
return new TextDecoder('utf-8', { fatal: false }).decode(bytes)
}
return `Binary file ${filePath} (${String(bytes.length)} bytes). Use the bash tool with head/file/wc/od to inspect.`
},
})
const write = tool({
description: 'Write content to a file. Creates missing parent directories.',
args: {
filePath: z.string().describe('Absolute path of the file to write.'),
content: z.string().describe('UTF-8 text content to write.'),
},
execute: async ({ filePath, content }, ctx) => {
const w = await resolveWs(ws, ctx)
await ensureParent(w, filePath)
await w.fs.writeFile(filePath, content)
return `Wrote ${String(content.length)} bytes to ${filePath}`
},
})
const edit = tool({
description:
'Replace a string inside an existing file. Errors if the string appears more than once unless replaceAll is true.',
args: {
filePath: z.string().describe('Absolute path of the file to edit.'),
oldString: z.string().describe('The exact string to replace.'),
newString: z.string().describe('The replacement string.'),
replaceAll: z
.boolean()
.optional()
.describe('Replace every occurrence rather than requiring a unique match.'),
},
execute: async ({ filePath, oldString, newString, replaceAll }, ctx) => {
const w = await resolveWs(ws, ctx)
let current: string
try {
current = await w.fs.readFileText(filePath)
} catch {
return `Error: file '${filePath}' not found`
}
const count = current.split(oldString).length - 1
if (count === 0) {
return `Error: string not found in file: '${oldString}'`
}
if (count > 1 && replaceAll !== true) {
return `Error: string '${oldString}' appears ${String(count)} times. Use replaceAll=true`
}
const next =
replaceAll === true
? current.split(oldString).join(newString)
: current.replace(oldString, newString)
await w.fs.writeFile(filePath, next)
const occurrences = replaceAll === true ? count : 1
return `Edited ${filePath} (${String(occurrences)} occurrence${occurrences === 1 ? '' : 's'})`
},
})
const ls = tool({
description: 'List entries of a directory.',
args: {
path: z.string().describe('Absolute directory path.'),
},
execute: async ({ path }, ctx) => {
const w = await resolveWs(ws, ctx)
let entries: string[]
try {
entries = await w.fs.readdir(path)
} catch (err) {
return `Error: ${errMsg(err)}`
}
const lines: string[] = []
for (const entry of entries) {
const isDir = await w.fs.isDir(entry)
lines.push(isDir ? `${entry}/` : entry)
}
return lines.join('\n')
},
})
const bash = tool({
description: 'Execute a shell command and return stdout, stderr, and exit code.',
args: {
command: z.string().describe('The shell command to execute.'),
},
execute: async ({ command }, ctx) => {
const w = await resolveWs(ws, ctx)
const io = await w.execute(command)
const parts: string[] = []
if (io.stdoutText.length > 0) parts.push(io.stdoutText)
if (io.stderrText.length > 0) parts.push(io.stderrText)
return parts.join('\n').trim()
},
})
const glob = tool({
description: 'Find files matching a name pattern.',
args: {
pattern: z.string().describe('Filename pattern (e.g. "*.ts").'),
path: z.string().optional().describe('Directory to search under. Defaults to /.'),
},
execute: async ({ pattern, path }, ctx) => {
const w = await resolveWs(ws, ctx)
const root = path ?? '/'
const io = await w.execute(`find ${root} -name '${pattern.replace(/'/g, "'\\''")}'`)
return io.stdoutText.trim()
},
})
const grep = tool({
description: 'Search for a regex pattern in files.',
args: {
pattern: z.string().describe('Pattern to search for.'),
path: z.string().optional().describe('Directory or file to search under. Defaults to /.'),
},
execute: async ({ pattern, path }, ctx) => {
const w = await resolveWs(ws, ctx)
const root = path ?? '/'
const escaped = pattern.replace(/'/g, "'\\''")
const io = await w.execute(`grep -rn '${escaped}' ${root}`)
return io.stdoutText.trim()
},
})
return { read, write, edit, ls, bash, glob, grep }
}
interface Hooks {
tool?: Record<string, ToolDefinition>
}
type PluginFn = (input: unknown) => Promise<Hooks>
export function miragePlugin(ws: WsLike): PluginFn {
return () => Promise.resolve({ tool: mirageTools(ws) })
}
@@ -22,6 +22,7 @@ export default defineConfig({
'src/pi/index.ts',
'src/vercel/index.ts',
'src/mastra/index.ts',
'src/opencode/index.ts',
],
format: ['esm'],
dts: {
+11 -9
View File
@@ -83,6 +83,9 @@ importers:
'@openai/agents':
specifier: ^0.8.0
version: 0.8.5(@cfworker/json-schema@4.1.1)(ws@8.20.0)(zod@4.3.6)
'@opencode-ai/sdk':
specifier: ^1.15.11
version: 1.15.11
'@struktoai/mirage-agents':
specifier: workspace:*
version: link:../../typescript/packages/agents
@@ -1390,6 +1393,9 @@ packages:
peerDependencies:
zod: ^4.0.0
'@opencode-ai/sdk@1.15.11':
resolution: {integrity: sha512-IyYyDVsO8SKbKbkSadHpDuYnYC+2vmEeLU+rW+rH2M54Sigq6l3gDHno16+U6SRut+lowbph7v/ry3WbV67V3w==}
'@opentelemetry/api-logs@0.217.0':
resolution: {integrity: sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==}
engines: {node: '>=8.0.0'}
@@ -7011,6 +7017,10 @@ snapshots:
- utf-8-validate
- ws
'@opencode-ai/sdk@1.15.11':
dependencies:
cross-spawn: 7.0.6
'@opentelemetry/api-logs@0.217.0':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -8094,14 +8104,6 @@ snapshots:
optionalDependencies:
vite: 7.3.2(@types/node@22.19.17)(tsx@4.21.0)(yaml@2.8.3)
'@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.12.2)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.2(@types/node@24.12.2)(tsx@4.21.0)(yaml@2.8.3)
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
@@ -11332,7 +11334,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.12.2)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@22.19.17)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4