diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 75a6c2e..f621fcb 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -4,7 +4,7 @@ "owner": { "name": "NodeOps", "url": "https://createos.sh" }, "plugins": [ { - "name": "claude-code-plugin", + "name": "@createos/claude-code", "source": "./packages/claude-code-plugin", "description": "Run ad-hoc/heavy/untrusted code in disposable CreateOS Sandboxes; offload, parallel fanout, scratch shell, reusable box with sync, port tunnel, public expose, network clusters, S3 disks, WireGuard VPN, fork, pause/resume, and custom images." } diff --git a/.gitignore b/.gitignore index 6411845..560b384 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ -node_modules/ -bun.lock +packages/opencode-plugin/node_modules/ +packages/opencode-plugin/bun.lock +package.json package-lock.json diff --git a/.opencode/plugins/createos.ts b/.opencode/plugins/createos.ts new file mode 100644 index 0000000..7f2cec1 --- /dev/null +++ b/.opencode/plugins/createos.ts @@ -0,0 +1,593 @@ +import type { Plugin } from "@opencode-ai/plugin"; +import { tool } from "@opencode-ai/plugin"; +import * as cli from "../../packages/opencode-plugin/src/cli.ts"; + +interface ActiveSandbox { + sandboxId: string; + cwd: string; + shape: string; +} + +export const CreateOSPlugin: Plugin = async ({ _project, _client, $, directory }) => { + let active: ActiveSandbox | null = null; + let initPromise: Promise | null = null; + const hostCwd = directory; + + if (process.env.CREATEOS_ENABLED === "false") return {}; + + // Lazy init — sandbox created on first tool call, not at plugin load + async function ensureSandbox(): Promise { + if (active) return active; + if (!initPromise) { + initPromise = (async () => { + if (!(await cli.isCreateOSInstalled($))) { + if (!(await cli.autoInstallCLI($))) + throw new Error( + "Failed to install CreateOS CLI. Run: curl -sfL https://raw.githubusercontent.com/NodeOps-app/createos-cli/main/install.sh | sh", + ); + } + if (!(await cli.isLoggedIn($))) + throw new Error("Not logged in to CreateOS. Run: createos login"); + const shape = process.env.CREATEOS_SHAPE ?? "s-2vcpu-2gb"; + const rootfs = process.env.CREATEOS_ROOTFS; + const sandbox = await cli.createSandbox($, { shape, rootfs, ingress: true }); + const cwd = "/root/workspace"; + await cli.sandboxExec($, sandbox.id, `mkdir -p ${cwd}`); + active = { sandboxId: sandbox.id, cwd, shape }; + })(); + } + await initPromise; + if (!active) throw new Error("Sandbox initialization failed"); + return active; + } + + // --- Pi-style system prompt --- + function sandboxPrompt(): string { + const s = active; + if (!s) return ""; + return ` +--- CreateOS Sandbox Environment --- +Sandbox: ${s.sandboxId} +Cwd: ${s.cwd} +Host dir: ${hostCwd} +All commands run remotely in this sandbox. You know the sandbox ID and cwd — never run pwd/hostname to discover them. + +CRITICAL: Use sandbox_exec for ALL shell commands. NEVER use the built-in bash tool — it runs on the user's local Mac, not in the sandbox. + +Quick rules: +- Shell commands → sandbox_exec (command="your command here") +- Read files in sandbox → sandbox_pull (remote_path="/path/to/file") +- Write files to sandbox → sandbox_push (remote_path="/path/to/file", content="...") +- "mount/sync this dir" → sandbox_sync local_dir="${hostCwd}" remote_dir="/root/project" +- Port access → sandbox_preview_url (public URL) > sandbox_tunnel (localhost) > device VPN (last resort) +- Multi-node → sandbox_network_create + sandbox_create with network + sandbox_exec on other sandboxes +--- End CreateOS ---`; + } + + // --- All tools --- + const tools: Record = { + // === PRIMARY TOOL — use for ALL shell commands === + sandbox_exec: tool({ + description: + "Run a shell command inside the CreateOS sandbox. Use this for ALL shell commands — installing packages, running scripts, compiling code, starting servers, checking system info. NEVER use the built-in bash tool.", + args: { + command: tool.schema.string().describe("Shell command to run inside the sandbox"), + sandbox_id: tool.schema + .string() + .optional() + .describe("Target sandbox ID (defaults to the active sandbox)"), + }, + async execute(args) { + const s = await ensureSandbox(); + const target = args.sandbox_id ?? s.sandboxId; + const res = await cli.sandboxExec($, target, args.command); + const parts: string[] = []; + if (res.stdout.trim()) parts.push(res.stdout.trim()); + if (res.stderr.trim()) parts.push(`STDERR:\n${res.stderr.trim()}`); + if (res.code !== 0) parts.push(`Exit code: ${res.code}`); + return parts.join("\n") || "(no output)"; + }, + }), + + // === File transfer === + sandbox_pull: tool({ + description: "Read/download a file from the sandbox. Returns the file content as text.", + args: { + remote_path: tool.schema.string().describe("Absolute path to the file inside the sandbox"), + }, + async execute(args) { + const s = await ensureSandbox(); + return await cli.pullFile($, s.sandboxId, args.remote_path); + }, + }), + + sandbox_push: tool({ + description: "Write/upload content to a file in the sandbox. Creates or overwrites the file.", + args: { + remote_path: tool.schema.string().describe("Absolute path to the file inside the sandbox"), + content: tool.schema.string().describe("File content to write"), + }, + async execute(args) { + const s = await ensureSandbox(); + await cli.pushFile($, s.sandboxId, args.content, args.remote_path); + return `Written: ${args.remote_path}`; + }, + }), + + // === Sandbox lifecycle === + sandbox_info: tool({ + description: "Get the current sandbox status, IP address, shape, region, and ingress URL.", + args: { + sandbox_id: tool.schema.string().optional().describe("Sandbox ID (defaults to current)"), + }, + async execute(args) { + const s = await ensureSandbox(); + const info = await cli.getSandbox($, args.sandbox_id ?? s.sandboxId); + return [ + `ID: ${info.id}`, + `Status: ${info.status}`, + `Name: ${info.name ?? "n/a"}`, + `IP: ${info.ip ?? "n/a"}`, + `Shape: ${(info as any).shape ?? "n/a"}`, + `Region: ${info.region ?? "n/a"}`, + info.ingress_url_template ? `Ingress: ${info.ingress_url_template}` : null, + ] + .filter(Boolean) + .join("\n"); + }, + }), + + sandbox_create: tool({ + description: + "Create an additional sandbox. Use when the user needs multiple sandboxes — multi-node clusters, separate database servers, microservice setups.", + args: { + shape: tool.schema + .string() + .optional() + .describe("VM size (default: s-2vcpu-2gb). Use sandbox_shapes to see options."), + rootfs: tool.schema.string().optional().describe("Base image (default: devbox:1)"), + name: tool.schema.string().optional().describe("Friendly name for the sandbox"), + networks: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Network names to join at creation"), + }, + async execute(args) { + await ensureSandbox(); + const sb = await cli.createSandbox($, { + shape: args.shape, + rootfs: args.rootfs, + name: args.name, + networks: args.networks, + ingress: true, + }); + const lines = [`Sandbox created: ${sb.id}`, `IP: ${sb.ip ?? "pending"}`]; + if (sb.ingress_url_template) lines.push(`Ingress: ${sb.ingress_url_template}`); + if (args.networks?.length) lines.push(`Networks: ${args.networks.join(", ")}`); + return lines.join("\n"); + }, + }), + + sandbox_list: tool({ + description: "List all sandboxes owned by the user, including paused and running ones.", + args: {}, + async execute() { + await ensureSandbox(); + const sbs = await cli.listSandboxes($); + if (!sbs.length) return "No sandboxes found."; + return sbs.map((s) => `${s.id} ${s.status} ${s.name ?? ""} ${s.ip ?? ""}`).join("\n"); + }, + }), + + sandbox_pause: tool({ + description: + "Pause the sandbox, saving its state. The sandbox becomes unavailable until resumed.", + args: { + sandbox_id: tool.schema.string().optional().describe("Sandbox ID (defaults to current)"), + }, + async execute(args) { + const s = await ensureSandbox(); + await cli.pauseSandbox($, args.sandbox_id ?? s.sandboxId); + return `Sandbox pausing. It will be unavailable until resumed.`; + }, + }), + + sandbox_resume: tool({ + description: "Resume a paused sandbox back to running state.", + args: { sandbox_id: tool.schema.string().describe("ID of the paused sandbox to resume") }, + async execute(args) { + await cli.resumeSandbox($, args.sandbox_id); + return `Sandbox ${args.sandbox_id} resuming.`; + }, + }), + + sandbox_fork: tool({ + description: + "Clone a paused sandbox into a brand-new sandbox with the same state. Source must be paused first.", + args: { + sandbox_id: tool.schema + .string() + .optional() + .describe("Sandbox ID to fork (defaults to current)"), + }, + async execute(args) { + const s = await ensureSandbox(); + const f = await cli.forkSandbox($, args.sandbox_id ?? s.sandboxId); + return `Forked → ${f.id} (${f.status}). IP: ${f.ip ?? "pending"}`; + }, + }), + + sandbox_destroy: tool({ + description: "Permanently delete a sandbox. This cannot be undone.", + args: { sandbox_id: tool.schema.string().describe("ID of the sandbox to destroy") }, + async execute(args) { + await cli.destroySandbox($, args.sandbox_id); + return `Sandbox ${args.sandbox_id} destroyed.`; + }, + }), + + // === Config === + sandbox_ingress: tool({ + description: "Enable or disable the public HTTPS URL for a sandbox.", + args: { + enabled: tool.schema.boolean().describe("true to enable, false to disable"), + sandbox_id: tool.schema.string().optional().describe("Sandbox ID (defaults to current)"), + }, + async execute(args) { + const s = await ensureSandbox(); + await cli.editSandbox($, args.sandbox_id ?? s.sandboxId, { ingress: args.enabled }); + if (args.enabled) { + const info = await cli.getSandbox($, args.sandbox_id ?? s.sandboxId); + return `Ingress enabled.${info.ingress_url_template ? ` URL: ${info.ingress_url_template}` : ""}`; + } + return "Ingress disabled."; + }, + }), + + sandbox_firewall: tool({ + description: "Set egress firewall rules. Pass an empty list to allow all outbound traffic.", + args: { + rules: tool.schema + .array(tool.schema.string()) + .describe('Allowed domains or IPs (e.g. ["pypi.org", "1.1.1.1:53"]). Empty = allow all.'), + sandbox_id: tool.schema.string().optional().describe("Sandbox ID (defaults to current)"), + }, + async execute(args) { + const s = await ensureSandbox(); + await cli.editSandbox($, args.sandbox_id ?? s.sandboxId, { egress: args.rules }); + return args.rules.length + ? `Firewall set: ${args.rules.join(", ")}` + : "Firewall cleared — all outbound allowed."; + }, + }), + + sandbox_bandwidth: tool({ + description: "Check bandwidth usage and quota for the sandbox.", + args: { + sandbox_id: tool.schema.string().optional().describe("Sandbox ID (defaults to current)"), + }, + async execute(args) { + const s = await ensureSandbox(); + const bw = (await cli.getBandwidth($, args.sandbox_id ?? s.sandboxId)) as any; + if (!bw) return "No bandwidth data available."; + return `Used: ${bw.used_bytes ?? 0} / Quota: ${bw.quota_bytes ?? 0}${bw.capped ? " — CAPPED" : ""}`; + }, + }), + + sandbox_shapes: tool({ + description: "List available sandbox sizes (vCPU, RAM) before creating a new sandbox.", + args: {}, + async execute() { + return JSON.stringify(await cli.listShapes($), null, 2); + }, + }), + + sandbox_images: tool({ + description: "List available base images (rootfs) for sandbox creation.", + args: {}, + async execute() { + return JSON.stringify(await cli.listRootfs($), null, 2); + }, + }), + + // === Ports & Connectivity === + sandbox_preview_url: tool({ + description: + "Get the public HTTPS URL for a port served inside the sandbox. Use after starting a server to give the user a clickable link.", + args: { port: tool.schema.number().describe("The port the server listens on") }, + async execute(args) { + const s = await ensureSandbox(); + const info = await cli.getSandbox($, s.sandboxId); + if (!info.ingress_url_template) + return "Ingress not enabled. Use sandbox_ingress to enable it first."; + return `Preview URL for port ${args.port}: ${info.ingress_url_template.replace("", String(args.port))}`; + }, + }), + + sandbox_tunnel: tool({ + description: + "Forward a sandbox port to localhost on the user's machine. No setup needed. Prefer sandbox_preview_url for sharing.", + args: { + remote_port: tool.schema.number().describe("Port inside the sandbox"), + local_port: tool.schema + .number() + .optional() + .describe("Local port (defaults to same as remote)"), + }, + async execute(args) { + const s = await ensureSandbox(); + const r = await cli.startTunnel($, s.sandboxId, args.remote_port, args.local_port); + return `Port forward: localhost:${r.localPort} → sandbox:${args.remote_port}\nAccess at: http://localhost:${r.localPort}`; + }, + }), + + sandbox_sync: tool({ + description: + "Mount/sync a local directory from the user's machine into the sandbox. Bidirectional by default.", + args: { + local_dir: tool.schema.string().describe("Absolute path to the local directory"), + remote_dir: tool.schema + .string() + .describe("Absolute path inside the sandbox (e.g. /root/project)"), + mode: tool.schema + .string() + .optional() + .describe('Sync mode: "two-way" (default), "one-way", or "mirror"'), + exclude: tool.schema + .array(tool.schema.string()) + .optional() + .describe('Patterns to exclude (e.g. ["node_modules", "*.log"])'), + }, + async execute(args) { + const s = await ensureSandbox(); + const r = await cli.startSync($, s.sandboxId, args.local_dir, args.remote_dir, { + mode: args.mode, + exclude: args.exclude, + }); + return `Sync started: ${args.local_dir} ↔ sandbox:${args.remote_dir}${args.mode ? ` (${args.mode})` : ""}\nPID: ${r.pid}`; + }, + }), + + // === Networks === + sandbox_network_create: tool({ + description: "Create a new private network for sandbox-to-sandbox communication.", + args: { name: tool.schema.string().describe("Network name") }, + async execute(args) { + const n = await cli.createNetwork($, args.name); + return `Network created: ${n.name} (${n.id})`; + }, + }), + sandbox_network_list: tool({ + description: "List all private networks.", + args: {}, + async execute() { + const nets = await cli.listNetworks($); + if (!nets.length) return "No networks."; + return nets.map((n) => `${n.name} (${n.id}) · ${n.member_count ?? 0} members`).join("\n"); + }, + }), + sandbox_network_show: tool({ + description: "Show network details including member sandbox IPs.", + args: { name: tool.schema.string().describe("Network name or ID") }, + async execute(args) { + const net = await cli.getNetwork($, args.name); + const lines = [`Network: ${net.name} (${net.id})`]; + if (net.members?.length) { + lines.push("Members:"); + for (const m of net.members) + lines.push(` ${m.sandbox_id} · ${m.status} · ${m.ip}${m.name ? ` · ${m.name}` : ""}`); + } else lines.push("No members"); + return lines.join("\n"); + }, + }), + sandbox_network_attach: tool({ + description: "Attach the current sandbox to a private network.", + args: { name: tool.schema.string().describe("Network name or ID") }, + async execute(args) { + const s = await ensureSandbox(); + await cli.attachNetwork($, s.sandboxId, args.name); + return `Attached to "${args.name}".`; + }, + }), + sandbox_network_detach: tool({ + description: "Detach the current sandbox from a private network.", + args: { name: tool.schema.string().describe("Network name or ID") }, + async execute(args) { + const s = await ensureSandbox(); + await cli.detachNetwork($, s.sandboxId, args.name); + return `Detached from "${args.name}".`; + }, + }), + sandbox_network_delete: tool({ + description: "Delete a private network. Detach all sandboxes first.", + args: { name: tool.schema.string().describe("Network name or ID") }, + async execute(args) { + await cli.deleteNetwork($, args.name); + return `Network "${args.name}" deleted.`; + }, + }), + + // === Disks === + sandbox_disk_create: tool({ + description: + "Register an S3-compatible bucket as a persistent disk that can be mounted into sandboxes.", + args: { + name: tool.schema.string().describe("Disk name"), + bucket: tool.schema.string().describe("S3 bucket name"), + endpoint: tool.schema.string().describe("S3 endpoint URL"), + access_key: tool.schema.string().describe("Access key ID"), + secret_key: tool.schema.string().describe("Secret access key"), + region: tool.schema.string().optional().describe("AWS region"), + path_style: tool.schema.boolean().optional().describe("Use path-style URLs (for MinIO)"), + }, + async execute(args) { + const d = await cli.createDisk($, { + name: args.name, + bucket: args.bucket, + endpoint: args.endpoint, + accessKey: args.access_key, + secretKey: args.secret_key, + region: args.region, + pathStyle: args.path_style, + }); + return `Disk created: ${d.name} (${d.id})`; + }, + }), + sandbox_disk_list: tool({ + description: "List all registered S3 disks.", + args: {}, + async execute() { + const disks = await cli.listDisks($); + if (!disks.length) return "No disks."; + return disks + .map( + (d) => `${d.name} (${d.id})${d.config?.bucket ? ` · bucket: ${d.config.bucket}` : ""}`, + ) + .join("\n"); + }, + }), + sandbox_disk_show: tool({ + description: "Show details for a registered disk.", + args: { name: tool.schema.string().describe("Disk name or ID") }, + async execute(args) { + return JSON.stringify(await cli.getDisk($, args.name), null, 2); + }, + }), + sandbox_disk_delete: tool({ + description: "Delete a registered disk. Must be detached from all sandboxes first.", + args: { name: tool.schema.string().describe("Disk name or ID") }, + async execute(args) { + await cli.deleteDisk($, args.name); + return `Disk "${args.name}" deleted.`; + }, + }), + sandbox_disk_attach: tool({ + description: "Mount a registered disk into a running sandbox at a given path.", + args: { + disk_name: tool.schema.string().describe("Disk name or ID"), + mount_path: tool.schema.string().describe("Absolute mount path (e.g. /mnt/data)"), + sandbox_id: tool.schema.string().optional().describe("Sandbox ID (defaults to current)"), + }, + async execute(args) { + const s = await ensureSandbox(); + await cli.attachDisk($, args.sandbox_id ?? s.sandboxId, args.disk_name, args.mount_path); + return `Disk "${args.disk_name}" mounted at ${args.mount_path}`; + }, + }), + sandbox_disk_detach: tool({ + description: "Unmount a disk from a sandbox. The bucket data is untouched.", + args: { + disk_name: tool.schema.string().describe("Disk name or ID"), + mount_path: tool.schema.string().describe("Mount path to detach"), + sandbox_id: tool.schema.string().optional().describe("Sandbox ID (defaults to current)"), + }, + async execute(args) { + const s = await ensureSandbox(); + await cli.detachDisk($, args.sandbox_id ?? s.sandboxId, args.disk_name, args.mount_path); + return `Disk "${args.disk_name}" detached from ${args.mount_path}`; + }, + }), + + // === Device VPN === + sandbox_device_register: tool({ + description: + "Register the user's machine as a device for direct sandbox access. One-time setup. Requires wireguard-tools.", + args: { + name: tool.schema.string().optional().describe("Device name (defaults to hostname)"), + }, + async execute(args) { + const devs = await cli.listDevices($); + if (devs.length) + return `Device already registered: ${devs[0].name} (${devs[0].client_ip ?? "n/a"})`; + const out = await cli.registerDevice($, args.name); + return out || "Device registered."; + }, + }), + sandbox_device_status: tool({ + description: "Check if the user has a registered device for direct sandbox access.", + args: {}, + async execute() { + const devs = await cli.listDevices($); + if (!devs.length) return "No device registered. Use sandbox_device_register first."; + return devs + .map((d) => `${d.name} · ${d.id ?? d.device_id} · IP: ${d.client_ip ?? "n/a"}`) + .join("\n"); + }, + }), + sandbox_vpn_up: tool({ + description: + "Returns the command the user must run in a separate terminal to start the VPN tunnel. Requires sudo.", + args: {}, + async execute() { + return "The user needs to run this command in a separate terminal (requires sudo):\n\n createos sb vpn up\n\nOnce connected, sandbox IPs are reachable directly."; + }, + }), + sandbox_device_attach: tool({ + description: "Attach the user's device to a network for direct IP access to sandboxes.", + args: { network: tool.schema.string().describe("Network name or ID") }, + async execute(args) { + const devs = await cli.listDevices($); + if (!devs.length) + throw new Error("No device registered. Use sandbox_device_register first."); + const devId = devs[0].id ?? devs[0].device_id!; + await cli.attachDeviceToNetwork($, devId, args.network); + return `Device attached to "${args.network}".\nRun \`createos sb vpn up\` to access sandbox IPs directly.`; + }, + }), + sandbox_device_detach: tool({ + description: "Remove the user's device from a network.", + args: { network: tool.schema.string().describe("Network name or ID") }, + async execute(args) { + const devs = await cli.listDevices($); + if (!devs.length) throw new Error("No device registered."); + const devId = devs[0].id ?? devs[0].device_id!; + await cli.detachDeviceFromNetwork($, devId, args.network); + return `Device detached from "${args.network}".`; + }, + }), + }; + + return { + // Inject Pi-style prompt into all agents + config: async (input: any) => { + // Trigger sandbox creation early so prompt has the ID + try { + await ensureSandbox(); + } catch {} + + const prompt = sandboxPrompt(); + if (!prompt) return; + + for (const agentName of ["build", "plan", "general", "explore"]) { + if (input.agent?.[agentName]) { + const existing = input.agent[agentName].prompt ?? ""; + if (!existing.includes("CreateOS Sandbox")) { + input.agent[agentName] = { + ...input.agent[agentName], + prompt: existing + "\n" + prompt, + }; + } + } + } + }, + + "experimental.session.compacting": async (_input: any, output: any) => { + const prompt = sandboxPrompt(); + if (prompt) output.context.push(prompt); + }, + + event: async ({ event }: any) => { + if (event.type === "session.deleted" && active) { + try { + await cli.cleanupTempKey($); + } catch {} + try { + await cli.destroySandbox($, active.sandboxId); + } catch {} + active = null; + } + }, + + tool: tools, + }; +}; diff --git a/.oxlintrc.json b/.oxlintrc.json index 31e054f..a111c7a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -7,5 +7,6 @@ "rules": {}, "env": { "builtin": true - } + }, + "ignorePatterns": ["node_modules"] } diff --git a/CLAUDE.md b/CLAUDE.md index f43954a..a668c7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,11 +1,16 @@ -# CLAUDE.md — createos (marketplace) +# CLAUDE.md — createos (integrations) -Public Claude Code plugin **marketplace** (`createos`). Its one plugin, -`createos-sandbox` (in `createos-sandbox/`), gives Claude a skill + slash -commands that drive the authed `createos` CLI to run ad-hoc / heavy / untrusted -code in disposable CreateOS sandboxes. Marketplace index is the root `README.md`; -plugin usage/install live in `createos-sandbox/README.md`; this file is the -cross-repo mesh guide. +Public plugin marketplace and integrations for CreateOS Sandbox. Three packages +ship IDE plugins that drive the authed `createos` CLI to run ad-hoc / heavy / +untrusted code in disposable CreateOS sandboxes: + +| Package | IDE | Path | +| -------------------- | ----------- | ------------------------------ | +| `claude-code-plugin` | Claude Code | `packages/claude-code-plugin/` | +| `pi-extension` | Pi | `packages/pi-extension/` | +| `@createos/opencode` | OpenCode | `packages/opencode-plugin/` | + +Marketplace index is the root `README.md`; each package has its own `README.md`. ## Decisions @@ -44,21 +49,20 @@ member (see Cross-repo mesh below) — cross-reference only. ## Cross-repo mesh — CreateOS Sandbox -**You are in `createos` — the public Claude Code plugin marketplace; its -`createos-sandbox` plugin** shells out to the `createos` CLI, so `createos-cli` -command / flag changes hit hardest: -keep the skill + slash-command surfaces aligned with the CLI, and any behavior +**You are in `createos` — the public plugin marketplace; its plugins** shell +out to the `createos` CLI, so `createos-cli` command / flag changes hit hardest: +keep the skill + slash-command + tool surfaces aligned with the CLI, and any behavior claim aligned with `fc`. This repo is one of five in the product mesh. ### Repo map -| repo | path | role | public? | changes that ripple across the mesh | -| ---------------- | ---------------------------------------- | --------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------- | -| **fc** | `../fc` | control-plane — **source of truth** | 🔒 private | HTTP API, wire/JSON fields, error shapes, lifecycle/state, limits/quotas, behavior | -| **fc-sdk** | `../fc-sdk` | TypeScript SDK **+ `examples/`** | 🌐 public | public SDK methods, wire types, example apps | -| **createos-cli** | `../createos-cli` | Go CLI | 🌐 public | commands, flags, help/UX text | -| **website-04** | `../website-04` (`content/docs/Sandbox`) | public docs | 🌐 public | REST / SDK / CLI reference + concept pages | -| **createos** | `../createos-claude-plugins` | Claude Code plugin marketplace; `createos-sandbox` plugin over the `createos` CLI | 🌐 public | skills, slash commands, hooks | +| repo | path | role | public? | changes that ripple across the mesh | +| ---------------- | ---------------------------------------- | ---------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------- | +| **fc** | `../fc` | control-plane — **source of truth** | 🔒 private | HTTP API, wire/JSON fields, error shapes, lifecycle/state, limits/quotas, behavior | +| **fc-sdk** | `../fc-sdk` | TypeScript SDK **+ `examples/`** | 🌐 public | public SDK methods, wire types, example apps | +| **createos-cli** | `../createos-cli` | Go CLI | 🌐 public | commands, flags, help/UX text | +| **website-04** | `../website-04` (`content/docs/Sandbox`) | public docs | 🌐 public | REST / SDK / CLI reference + concept pages | +| **createos** | `../createos-claude-plugins` | Plugin marketplace; Claude Code, Pi, OpenCode integrations over the `createos` CLI | 🌐 public | skills, slash commands, hooks, tools | ### What counts as a shared surface diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c741bf3..ee255ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ apps in `apps/` are standalone projects. 1. Fork the repository and create a branch for your change. 2. Keep each PR scoped to one package or app. 3. Use [Conventional Commit](https://www.conventionalcommits.org/) style for your PR title - (e.g. `feat(pi-extension): add X`, `fix(adk-plugin): handle Y`). + (e.g. `feat(opencode): add X`, `fix(pi-extension): handle Y`, `fix(cos): correct Z`). 4. Run lint, build, and tests for the package you changed before opening the PR. 5. Open a pull request. A maintainer will review it and, once approved, merge it into `main`. diff --git a/README.md b/README.md index d869e3e..46c4ef3 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@ # CreateOS Integrations -**[Claude Code](https://docs.claude.com/en/docs/claude-code) plugin & [Pi](https://github.com/anthropics/pi) extension for disposable sandbox compute.** +**[Claude Code](https://docs.claude.com/en/docs/claude-code) plugin, [Pi](https://github.com/anthropics/pi) extension & [OpenCode](https://opencode.ai) plugin for disposable sandbox compute.** -Run code **off your machine** in disposable [CreateOS](https://createos.sh) Sandboxes — from Claude Code or Pi. +Run code **off your machine** in disposable [CreateOS](https://createos.sh) Sandboxes — from Claude Code, Pi, or OpenCode. [![Claude Code](https://img.shields.io/badge/Claude%20Code-plugin-6E56CF)](https://docs.claude.com/en/docs/claude-code) [![Pi](https://img.shields.io/badge/Pi-extension-F97316)](https://github.com/anthropics/pi) +[![OpenCode](https://img.shields.io/badge/OpenCode-plugin-0EA5E9)](https://opencode.ai) [![CreateOS](https://img.shields.io/badge/CreateOS-Sandboxes-0EA5E9)](https://createos.sh) [![Spawn](https://img.shields.io/badge/create%20to%20first%20command-~200ms-22C55E)](https://createos.sh) [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](#contributing) @@ -33,7 +34,7 @@ Heavy builds, flaky test suites, and untrusted code don't belong on your laptop. ```bash # 1. Add the marketplace + install the plugin /plugin marketplace add NodeOps-app/createos-claude-plugins -/plugin install claude-code-plugin@createos +/plugin install @createos/claude-code@createos # 2. Offload a heavy test run to a throwaway box (auto-destroys) /createos-sandbox:offload . "npm ci && npm test" @@ -49,14 +50,25 @@ pi install npm:@createos/pi pi --createos ``` +**OpenCode:** + +```bash +# 1. Install the plugin +opencode plugin @createos/opencode --global + +# 2. Launch opencode — sandbox tools are available automatically +opencode +``` + The `createos` CLI **auto-installs** on first use. Sign in once with `createos login` (browser OAuth, run it in your own terminal) or `export CREATEOS_API_KEY=`; check with `cos auth`. Prefer a local checkout? See [Install](#install). ## Packages -| Package | What it does | -| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [**claude-code-plugin**](./packages/claude-code-plugin) | Hooks-based Claude Code plugin — offload, parallel fanout, scratch shell, reusable box with sync, port tunnel, public HTTPS expose, private-network clusters, BYO-S3 disk mounts, WireGuard VPN, and snapshot/fork — all driving the authed `createos` CLI. | -| [**pi-extension**](./packages/pi-extension) | Pi coding agent extension that transparently routes all built-in commands (bash, read, write, edit, ls, find, grep) to a remote CreateOS Sandbox, plus 26 additional tools for sandbox lifecycle, configuration, port tunnels, file sync, private networks, persistent disks, and device VPN — 33 tools total. | +| Package | What it does | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [**claude-code-plugin**](./packages/claude-code-plugin) | Hooks-based Claude Code plugin — offload, parallel fanout, scratch shell, reusable box with sync, port tunnel, public HTTPS expose, private-network clusters, BYO-S3 disk mounts, WireGuard VPN, and snapshot/fork — all driving the authed `createos` CLI. | +| [**pi-extension**](./packages/pi-extension) | Pi coding agent extension that transparently routes all built-in commands (bash, read, write, edit, ls, find, grep) to a remote CreateOS Sandbox, plus 40 additional tools for sandbox lifecycle, configuration, port tunnels, file sync, private networks, persistent disks, custom image templates, remote editors, and device VPN — 47 tools total. | +| [**@createos/opencode**](./packages/opencode-plugin) | OpenCode plugin with 33 sandbox tools (`sandbox_exec`, `sandbox_push`, `sandbox_pull`, networks, disks, VPN, sync) and system prompt injection for sandbox-first workflows. | ## Claude Code — commands at a glance @@ -80,7 +92,7 @@ Full flags, networking guide, and heavy-build tips live in the [**Claude Code Pl ## Pi — commands at a glance -All built-in tools (bash, read, write, edit, ls, find, grep) transparently route to the sandbox — plus 26 additional tools for lifecycle, networking, disks, and device VPN (33 tools total). +All built-in tools (bash, read, write, edit, ls, find, grep) transparently route to the sandbox — plus 40 additional tools for lifecycle, networking, disks, image templates, remote editors, and device VPN (47 tools total). | Command | What | | -------------------------- | ------------------------------------- | @@ -106,13 +118,27 @@ All built-in tools (bash, read, write, edit, ls, find, grep) transparently route Full tool inventory lives in the [**Pi Extension README**](./packages/pi-extension/README.md). +## OpenCode — tools at a glance (40) + +| Category | Tools | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| **Execute & Files** | `sandbox_exec`, `sandbox_pull`, `sandbox_push` | +| **Lifecycle** | `sandbox_create`, `sandbox_list`, `sandbox_info`, `sandbox_pause`, `sandbox_resume`, `sandbox_fork`, `sandbox_destroy` | +| **Config** | `sandbox_ingress`, `sandbox_firewall`, `sandbox_bandwidth`, `sandbox_shapes`, `sandbox_images` | +| **Ports & Sync** | `sandbox_preview_url`, `sandbox_tunnel`, `sandbox_sync` | +| **Networks** | `sandbox_network_create/list/show/attach/detach/delete` | +| **Disks** | `sandbox_disk_create/list/show/delete/attach/detach` | +| **Device VPN** | `sandbox_device_register/status/attach/detach`, `sandbox_vpn_up` | + +Full reference in [opencode-plugin/README.md](./packages/opencode-plugin/README.md). + ## Install **From GitHub (recommended):** ``` /plugin marketplace add NodeOps-app/createos-claude-plugins -/plugin install claude-code-plugin@createos +/plugin install @createos/claude-code@createos ``` **From a local checkout:** @@ -120,7 +146,7 @@ Full tool inventory lives in the [**Pi Extension README**](./packages/pi-extensi ``` git clone https://github.com/NodeOps-app/createos-claude-plugins /plugin marketplace add /path/to/createos-claude-plugins -/plugin install claude-code-plugin@createos +/plugin install @createos/claude-code@createos ``` **Dev (instant, no install):** @@ -157,9 +183,15 @@ createos-claude-plugins/ # marketplace root │ │ ├─ hooks/ # SessionStart driver-path + PreToolUse offload-hint │ │ ├─ scripts/cos # the CLI driver │ │ └─ README.md -│ └─ pi-extension/ # Pi extension (TypeScript) -│ ├─ index.ts # extension entry point -│ ├─ src/ # tools, CLI wrappers, ops +│ ├─ pi-extension/ # Pi extension (TypeScript) +│ │ ├─ index.ts # extension entry point +│ │ ├─ src/ # tools, CLI wrappers, ops +│ │ └─ README.md +│ └─ opencode-plugin/ # OpenCode plugin +│ ├─ index.ts # plugin entry (CreateOSPlugin) +│ ├─ src/cli.ts # createos CLI wrappers +│ ├─ src/tools.ts # 33 tool definitions +│ ├─ src/util.ts # shellQuote, shortId, joinPath │ └─ README.md ├─ apps/ # (future starter templates) ├─ docs/ @@ -169,11 +201,13 @@ createos-claude-plugins/ # marketplace root ## Contributing -Issues and PRs welcome. The plugin is a thin Claude Code surface over the [`createos`](https://createos.sh) CLI — most command logic lives in [`claude-code-plugin/scripts/cos`](./packages/claude-code-plugin/scripts/cos). Keep the slash-command, skill, and CLI surfaces aligned. +Issues and PRs welcome. All three plugins are thin surfaces over the [`createos`](https://createos.sh) CLI — keep the command surfaces aligned. ## Links -- 🌐 [createos.sh](https://createos.sh) — CreateOS platform -- 📖 [Claude Code plugins](https://docs.claude.com/en/docs/claude-code) — how plugins & marketplaces work -- 📦 [Claude Code Plugin README](./packages/claude-code-plugin/README.md) — full command & flag reference -- 🔧 [Pi Extension README](./packages/pi-extension/README.md) — Pi extension setup & tool inventory +- [createos.sh](https://createos.sh) — CreateOS platform +- [Claude Code plugins](https://docs.claude.com/en/docs/claude-code) — how plugins & marketplaces work +- [OpenCode plugins](https://opencode.ai/docs/plugins/) — OpenCode plugin docs +- [Claude Code plugin README](./packages/claude-code-plugin/README.md) +- [Pi extension README](./packages/pi-extension/README.md) +- [OpenCode plugin README](./packages/opencode-plugin/README.md) diff --git a/docs/plans/opencode-plugin.md b/docs/plans/opencode-plugin.md new file mode 100644 index 0000000..d17b513 --- /dev/null +++ b/docs/plans/opencode-plugin.md @@ -0,0 +1,269 @@ +# OpenCode Plugin Plan — `@createos/opencode` + +> Route all OpenCode built-in tools to a remote CreateOS Sandbox, mirroring the +> Pi extension (`feat/pi`) but adapted to OpenCode's plugin API. + +## 1. Architecture Overview + +``` +packages/opencode-plugin/ +├── index.ts # Plugin entry point (Plugin function) +├── package.json # npm: @createos/opencode +├── tsconfig.json +├── CLAUDE.md # Architecture notes for AI assistants +└── src/ + ├── cli.ts # Thin wrappers around `createos` CLI (port from Pi) + ├── tools.ts # 33 tool registrations using tool() + tool.schema.* + ├── hooks.ts # Lifecycle hooks (session, shell.env, tool.execute.*) + └── util.ts # shellQuote, shortId, joinPath helpers +``` + +### Key Design Decisions + +| Decision | Rationale | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **CLI-only** (no HTTP client) | Same as Pi — shells out to `createos` CLI via `$` (Bun shell). Zero maintenance when CLI adds features. | +| **Same-name tool override** | OpenCode plugin tools with same name as built-ins take precedence. We override: `bash`, `read`, `write`, `edit`, `ls`, `find`, `grep`. | +| **`session.created` + `session.idle`** for lifecycle | No direct `session_start`/`session_shutdown` like Pi — use OpenCode's event system. | +| **No ops.ts** | Pi needed `*Operations` interfaces to plug into its tool factories. OpenCode tools are self-contained — CLI calls go directly in each tool's `execute()`. | +| **No flag system** | Pi used `pi.registerFlag('createos')`. OpenCode uses plugin config in `opencode.json` or `CREATEOS_ENABLED` env var. | + +## 2. API Mapping: Pi Extension → OpenCode Plugin + +| Pi Extension | OpenCode Plugin | Notes | +| ------------------------------------------------------------------ | ------------------------------------------------------------- | ---------------------------------------- | +| `export default function(pi)` | `export const CreateOS: Plugin = async (ctx) => {}` | Different entry point signature | +| `pi.registerTool({ name, parameters: Type.Object(...), execute })` | `tool({ description, args: { ... tool.schema.* }, execute })` | TypeBox → tool.schema (Zod-like) | +| `pi.exec('createos', args)` | `ctx.$\`createos ${args}\`` | Bun shell API | +| `pi.registerFlag('createos')` | `opencode.json` config or env var | No flag registration in OpenCode | +| `pi.registerCommand('sandbox', { handler })` | Not directly supported — use TUI command hook or tool | OpenCode has `tui.command.execute` event | +| `pi.getFlag('createos')` | `process.env.CREATEOS_ENABLED` or config check | | +| `pi.session.get(key)` / `pi.session.set(key)` | File-based state or in-memory Map | No session storage API in OpenCode | +| Signal/abort handling | `context` parameter in `execute()` | | + +## 3. Phased Implementation + +### Phase 1: Scaffold + CLI Layer (port cli.ts + util.ts) + +**Files:** `index.ts`, `src/cli.ts`, `src/util.ts`, `package.json`, `tsconfig.json` + +Port `cli.ts` from Pi, replacing `pi.exec()` calls with Bun shell (`$`): + +```typescript +// Pi version: +async function run(pi: ExtensionAPI, args: string[]): Promise { + const res = await pi.exec("createos", args); + return { code: res.code, stdout: res.stdout ?? "", stderr: res.stderr ?? "" }; +} + +// OpenCode version: +async function run($: BunShell, args: string[]): Promise { + const res = await $`createos ${args}`.quiet(); + return { + code: res.exitCode, + stdout: res.stdout.toString(), + stderr: res.stderr.toString(), + }; +} +``` + +Functions to port (all 20+): + +- `createSandbox`, `getSandbox`, `destroySandbox`, `pauseSandbox`, `resumeSandbox` +- `listSandboxes`, `forkSandbox`, `editSandbox`, `sandboxExec` +- `pushFile`, `pullFile`, `startTunnel`, `getPreviewUrl` +- `createNetwork`, `listNetworks`, `getNetwork`, `deleteNetwork`, `attachNetwork`, `detachNetwork` +- `createDisk`, `listDisks`, `getDisk`, `deleteDisk`, `attachDisk`, `detachDisk` +- `registerDevice`, `deviceStatus`, `vpnUp`, `deviceAttach`, `deviceDetach` +- `listShapes`, `listRootfs`, `getBandwidth` +- `autoInstallCLI`, `cleanupTempKey` + +### Phase 2: Built-in Tool Overrides (7 tools) + +Override OpenCode's built-in tools by registering tools with the same names: + +```typescript +// Example: bash tool override +bash: tool({ + description: "Run a shell command in the CreateOS sandbox", + args: { + command: tool.schema.string().describe("The command to run"), + timeout: tool.schema.number().optional().describe("Timeout in ms"), + }, + async execute(args, context) { + const active = getActive() + if (!active) return fallbackLocal(args, context) + const res = await cli.sandboxExec($, active.sandboxId, args.command) + return res.stdout || "(no output)" + }, +}), +``` + +Tools to override: + +1. **bash** — route shell commands to sandbox via `createos sandbox exec` +2. **read** — pull file content via `createos sandbox pull` +3. **write** — push file content via `createos sandbox push` +4. **edit** — pull → apply edit → push +5. **ls** — `createos sandbox exec 'ls -1A '` +6. **find** — remote find/rg via exec +7. **grep** — remote grep/rg via exec + +### Phase 3: Sandbox-Specific Tools (26 tools) + +Port all sandbox tools, converting TypeBox schemas to `tool.schema.*`: + +```typescript +// Pi (TypeBox): +parameters: Type.Object({ + shape: Type.Optional(Type.String({ description: '...' })), + rootfs: Type.Optional(Type.String({ description: '...' })), +}) + +// OpenCode (tool.schema): +args: { + shape: tool.schema.string().optional().describe('...'), + rootfs: tool.schema.string().optional().describe('...'), +} +``` + +**Lifecycle (8):** sandbox_create, sandbox_exec, sandbox_info, sandbox_list, sandbox_pause, sandbox_resume, sandbox_fork, sandbox_destroy + +**Config (5):** sandbox_ingress, sandbox_firewall, sandbox_bandwidth, sandbox_shapes, sandbox_images + +**Ports & Sync (3):** sandbox_preview_url, sandbox_tunnel, sandbox_sync + +**Networks (6):** sandbox_network_create, sandbox_network_list, sandbox_network_show, sandbox_network_attach, sandbox_network_detach, sandbox_network_delete + +**Disks (6):** sandbox_disk_create, sandbox_disk_list, sandbox_disk_show, sandbox_disk_delete, sandbox_disk_attach, sandbox_disk_detach + +**Device VPN (5):** sandbox_device_register, sandbox_device_status, sandbox_vpn_up, sandbox_device_attach, sandbox_device_detach (note: VPN returns advisory command, same as Pi) + +### Phase 4: Lifecycle Hooks + +```typescript +export const CreateOS: Plugin = async ({ project, client, $, directory }) => { + let active: ActiveSandbox | null = null; + + // Auto-create sandbox on session start + const sandbox = await cli.createSandbox($, { + shape: process.env.CREATEOS_SHAPE ?? "s-2vcpu-2gb", + rootfs: process.env.CREATEOS_ROOTFS, + ingress: true, + }); + active = { sandboxId: sandbox.id, cwd: "/root" }; + + await client.app.log({ + body: { + service: "createos", + level: "info", + message: `Sandbox ready: ${sandbox.id}`, + }, + }); + + return { + // Inject env vars for CLI auth + "shell.env": async (input, output) => { + if (process.env.CREATEOS_API_KEY) { + output.env.CREATEOS_API_KEY = process.env.CREATEOS_API_KEY; + } + }, + + // Cleanup on idle (optional — sandbox auto-destroys) + event: async ({ event }) => { + if (event.type === "session.deleted" && active) { + await cli.destroySandbox($, active.sandboxId); + active = null; + } + }, + + tool: {/* ... all 33 tools ... */}, + }; +}; +``` + +### Phase 5: Configuration & Distribution + +**`package.json`:** + +```json +{ + "name": "@createos/opencode", + "version": "0.1.0", + "type": "module", + "main": "index.ts", + "dependencies": { + "@opencode-ai/plugin": "latest" + }, + "files": ["index.ts", "src", "README.md"] +} +``` + +**User installs via `opencode.json`:** + +```json +{ + "plugin": ["@createos/opencode"] +} +``` + +**Environment variables (configuration):** + +| Var | Default | Description | +| ------------------- | --------------------------- | ----------------------------------------- | +| `CREATEOS_ENABLED` | `true` (when plugin loaded) | Disable to fall back to local tools | +| `CREATEOS_SHAPE` | `s-2vcpu-2gb` | Sandbox shape | +| `CREATEOS_ROOTFS` | `devbox:1` | Base image | +| `CREATEOS_NETWORKS` | (none) | Comma-separated network names to join | +| `CREATEOS_API_KEY` | (none) | API key (alternative to `createos login`) | + +## 4. What Changes vs. Pi Extension + +| Aspect | Pi Extension | OpenCode Plugin | +| ----------------- | --------------------------------------------------------- | ------------------------------------------------------------------ | +| Entry point | `export default function(pi)` | `export const CreateOS: Plugin = async (ctx) => {}` | +| Schema lib | TypeBox (`Type.Object`, `Type.String`) | `tool.schema.*` (Zod-like) | +| Shell exec | `pi.exec('createos', args)` | `$\`createos ...\`` (Bun shell) | +| Flags | `pi.registerFlag()` / `pi.getFlag()` | Env vars | +| Commands | `pi.registerCommand()` | Not needed (tools are sufficient) | +| Session state | `pi.session.get/set` | In-memory + filesystem | +| Ops layer | Required (`*Operations` interfaces) | Not needed — direct CLI calls in `execute()` | +| Tool registration | `pi.registerTool({ name, parameters, execute })` | `tool({ description, args, execute })` returned in `tool:` map | +| Lifecycle | `session_start`, `before_agent_start`, `session_shutdown` | `session.created` event, `shell.env` hook, `session.deleted` event | + +## 5. Reuse from Pi Extension + +**Direct port (adapt syntax only):** + +- `src/cli.ts` — all 30+ CLI wrapper functions (change `pi.exec` → `$`) +- `src/util.ts` — `shellQuote`, `shortId`, `joinPath` (identical) +- Tool descriptions, promptGuidelines text — copy verbatim +- Error handling patterns (`CLIError` class) + +**Rewrite needed:** + +- `index.ts` — completely different plugin shape +- `src/tools.ts` — same logic, different registration API +- `src/ops.ts` — eliminated (operations folded into tool execute functions) +- `src/find-tool.ts`, `src/grep-tool.ts` — logic reused, but inline in tool definitions + +## 6. Estimated Scope + +| Component | Lines (est.) | Effort | +| -------------------------------- | ------------ | ---------------------------------- | +| `src/cli.ts` | ~400 | Port from Pi (syntax changes only) | +| `src/util.ts` | ~15 | Copy from Pi | +| `src/tools.ts` | ~700 | Rewrite tool registrations | +| `src/hooks.ts` | ~80 | New (lifecycle, shell.env) | +| `index.ts` | ~100 | New (plugin entry, sandbox boot) | +| `package.json` + `tsconfig.json` | ~30 | New | +| **Total** | **~1,325** | **~75% port, ~25% new** | + +Compared to Pi's ~5,500 lines: significantly smaller because we eliminate the ops layer, command system, and flag infrastructure. OpenCode's simpler plugin API means less boilerplate. + +## 7. Open Questions + +1. **Sandbox cleanup on exit** — OpenCode's `session.deleted` event fires after the session is gone. Is there a pre-delete hook? If not, rely on sandbox TTL auto-destroy. +2. **Slash commands** — Pi had `/sandbox`, `/network`, `/device`. OpenCode doesn't have a direct equivalent. Options: (a) skip, tools are sufficient; (b) use `tui.command.execute` hook to intercept custom commands. +3. **File sync (Mutagen)** — Pi extension managed temp SSH keys. Do we want sync for OpenCode, or is pull/push-per-file sufficient? Recommend deferring sync to Phase 2 release. +4. **`experimental.session.compacting`** — inject sandbox context during compaction so the LLM remembers the sandbox after context truncation? Likely yes. diff --git a/packages/claude-code-plugin/.claude-plugin/plugin.json b/packages/claude-code-plugin/.claude-plugin/plugin.json index da42a8c..762adbe 100644 --- a/packages/claude-code-plugin/.claude-plugin/plugin.json +++ b/packages/claude-code-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", - "name": "claude-code-plugin", + "name": "@createos/claude-code", "displayName": "CreateOS Sandbox", "version": "0.6.0", "description": "Run ad-hoc, heavy, or untrusted code OFF your machine in disposable CreateOS Sandboxes. One-shot offload, parallel fanout across N boxes, instant scratch shell, a reusable box with file sync for live dev loops, port tunnel to localhost, public HTTPS expose, multi-box private-network clusters, BYO-S3 disk mounts, WireGuard VPN, snapshot/fork, pause/resume to park a warm box at zero compute cost, and custom Dockerfile-built images so boxes boot pre-provisioned. Big dirs auto-excluded from uploads; auto-installs the createos CLI if missing.", diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md new file mode 100644 index 0000000..915bfa3 --- /dev/null +++ b/packages/opencode-plugin/README.md @@ -0,0 +1,173 @@ +# @createos/opencode + +OpenCode plugin that runs all tool calls inside a remote +[CreateOS Sandbox](https://createos.sh) while the agent runs locally. + +CLI-only — every operation shells out to the `createos` CLI. No HTTP client, +no API keys. Auth is handled by `createos login`. + +``` +OpenCode agent (local) → createos CLI → CreateOS API → Sandbox (remote VM) +``` + +## Install + +### From npm (global — works from any project) + +```bash +opencode plugin @createos/opencode --global +``` + +### Local development + +Drop the plugin shim into your project: + +```bash +mkdir -p .opencode/plugins +cat > .opencode/plugins/createos.ts << 'EOF' +export { CreateOSPlugin } from "../../packages/opencode-plugin/index.ts" +EOF +``` + +Then install dependencies in the package directory: + +```bash +cd packages/opencode-plugin && bun install +``` + +## Prerequisites + +1. **createos CLI** — auto-installed on first use, or manually: + + ```bash + curl -sfL https://raw.githubusercontent.com/NodeOps-app/createos-cli/main/install.sh | sh + ``` + +2. **Login** (one-time, browser OAuth): + ```bash + createos login + ``` + +## How it works + +1. Plugin loads and registers 33 `sandbox_*` tools +2. System prompt is injected into all agents telling them to use `sandbox_exec` + for all shell commands instead of the built-in `bash` tool +3. On first tool call, a sandbox is created automatically +4. All subsequent `sandbox_exec` calls run inside that sandbox +5. Sandbox is destroyed when the session ends + +## Configuration + +Environment variables: + +| Variable | Default | Description | +| ------------------ | ------------- | ------------------------------------ | +| `CREATEOS_ENABLED` | `true` | Set to `false` to disable the plugin | +| `CREATEOS_SHAPE` | `s-2vcpu-2gb` | Sandbox VM size | +| `CREATEOS_ROOTFS` | `devbox:1` | Base image for the sandbox | + +## Tool inventory (33 tools) + +### Execute & Files + +| Tool | Description | +| -------------- | ---------------------------------------------------------------------- | +| `sandbox_exec` | Run a shell command inside the sandbox. **Use this for ALL commands.** | +| `sandbox_pull` | Read/download a file from the sandbox | +| `sandbox_push` | Write/upload a file to the sandbox | + +### Sandbox lifecycle + +| Tool | Description | +| ----------------- | ------------------------------------------------ | +| `sandbox_info` | Status, IP, shape, region, ingress URL | +| `sandbox_create` | Create an additional sandbox (multi-node setups) | +| `sandbox_list` | List all sandboxes | +| `sandbox_pause` | Pause sandbox, saving state | +| `sandbox_resume` | Resume a paused sandbox | +| `sandbox_fork` | Clone a paused sandbox | +| `sandbox_destroy` | Permanently delete a sandbox | + +### Config + +| Tool | Description | +| ------------------- | --------------------------- | +| `sandbox_ingress` | Toggle public HTTPS URL | +| `sandbox_firewall` | Set egress firewall rules | +| `sandbox_bandwidth` | Check bandwidth usage/quota | +| `sandbox_shapes` | List available VM sizes | +| `sandbox_images` | List available base images | + +### Ports & connectivity + +| Tool | Description | +| --------------------- | ------------------------------------------------- | +| `sandbox_preview_url` | Get a public HTTPS URL for a port (preferred) | +| `sandbox_tunnel` | Forward a sandbox port to localhost | +| `sandbox_sync` | Bidirectional file sync between local and sandbox | + +### Networks (multi-node) + +| Tool | Description | +| ------------------------ | ----------------------------------- | +| `sandbox_network_create` | Create a private network | +| `sandbox_network_list` | List networks | +| `sandbox_network_show` | Show network details and member IPs | +| `sandbox_network_attach` | Attach sandbox to a network | +| `sandbox_network_detach` | Detach sandbox from a network | +| `sandbox_network_delete` | Delete a network | + +### Persistent storage (S3 disks) + +| Tool | Description | +| --------------------- | ----------------------------------------- | +| `sandbox_disk_create` | Register an S3 bucket as a mountable disk | +| `sandbox_disk_list` | List registered disks | +| `sandbox_disk_show` | Show disk details | +| `sandbox_disk_delete` | Delete a disk registration | +| `sandbox_disk_attach` | Mount a disk into a sandbox | +| `sandbox_disk_detach` | Unmount a disk from a sandbox | + +### Device VPN (direct IP access) + +| Tool | Description | +| ------------------------- | --------------------------------------------------- | +| `sandbox_device_register` | One-time device registration | +| `sandbox_device_status` | Check device registration status | +| `sandbox_device_attach` | Attach device to a network | +| `sandbox_device_detach` | Detach device from a network | +| `sandbox_vpn_up` | Returns the `sudo` command for user to run manually | + +## Differences from the Pi extension + +This plugin ports the [Pi extension](../pi-extension) (`feat/pi` branch) to +OpenCode. The core sandbox tools and CLI wrappers are identical, but OpenCode's +plugin API has limitations: + +| Capability | Pi | OpenCode | +| ---------------- | ------------------------------------------------------ | ------------------------------------------------------------------- | +| Tool replacement | Replaces built-in `bash/read/write/edit` transparently | Cannot replace — uses system prompt to direct LLM to `sandbox_exec` | +| CLI flags | `pi --createos` | Not supported — use `CREATEOS_ENABLED` env var | +| TUI integration | Status bar, notifications | Not available to plugins | +| Slash commands | `/sandbox`, `/network`, `/device` | Not supported | +| Global install | `pi install npm:@createos/pi` | `opencode plugin @createos/opencode --global` | +| Shell access | `pi.exec()` | `child_process.execSync` (OpenCode's `$` had routing issues) | + +## Architecture + +``` +packages/opencode-plugin/ +├── index.ts # Plugin entry — exports CreateOSPlugin +├── package.json # npm: @createos/opencode +├── README.md # This file +├── tsconfig.json +└── src/ + ├── cli.ts # All createos CLI wrappers (execSync-based) + ├── tools.ts # 33 tool definitions using tool() + tool.schema.* + └── util.ts # shellQuote, shortId, joinPath +``` + +## License + +Apache-2.0 diff --git a/packages/opencode-plugin/index.ts b/packages/opencode-plugin/index.ts new file mode 100644 index 0000000..7ee79d1 --- /dev/null +++ b/packages/opencode-plugin/index.ts @@ -0,0 +1,144 @@ +/** + * @createos/opencode — run OpenCode's tools inside a remote, ephemeral CreateOS Sandbox. + * + * CLI-only: every operation goes through `createos` CLI. No HTTP client, + * no API key env vars — just `createos login` and go. + */ + +import type { Plugin } from "@opencode-ai/plugin"; +import * as cli from "./src/cli.ts"; +import { createTools, type ToolSandbox } from "./src/tools.ts"; +import { shortId } from "./src/util.ts"; + +interface ActiveSandbox { + sandboxId: string; + cwd: string; +} + +export const CreateOSPlugin: Plugin = async ({ project, client, $, directory }) => { + let active: ActiveSandbox | null = null; + let initPromise: Promise | null = null; + const hostCwd = directory; + + await client.app.log({ + body: { service: "createos", level: "info", message: "Plugin initialized" }, + }); + + if (process.env.CREATEOS_ENABLED === "false") { + await client.app.log({ + body: { service: "createos", level: "info", message: "Disabled via CREATEOS_ENABLED=false" }, + }); + return {}; + } + + // Lazy sandbox init — triggered on first tool call + async function ensureSandbox(): Promise { + if (active) return active; + + if (!initPromise) { + initPromise = (async () => { + if (!(await cli.isCreateOSInstalled($))) { + await client.app.log({ + body: { service: "createos", level: "info", message: "CLI not found — installing..." }, + }); + if (!(await cli.autoInstallCLI($))) { + throw new Error( + "Failed to install CreateOS CLI. Run: curl -sfL https://raw.githubusercontent.com/NodeOps-app/createos-cli/main/install.sh | sh", + ); + } + } + + if (!(await cli.isLoggedIn($))) { + throw new Error("Not logged in to CreateOS. Run: createos login"); + } + + const shape = process.env.CREATEOS_SHAPE ?? "s-2vcpu-2gb"; + const rootfs = process.env.CREATEOS_ROOTFS; + const networkFlag = process.env.CREATEOS_NETWORKS; + const networks = networkFlag + ? networkFlag + .split(",") + .map((n) => n.trim()) + .filter(Boolean) + : undefined; + + await client.app.log({ + body: { service: "createos", level: "info", message: `Creating sandbox (${shape})...` }, + }); + + const sandbox = await cli.createSandbox($, { + shape, + rootfs, + ingress: true, + networks, + name: `opencode-${shortId(project?.id ?? "session")}`, + }); + + const cwd = "/root/workspace"; + await cli.sandboxExec($, sandbox.id, `mkdir -p ${cwd}`); + + active = { sandboxId: sandbox.id, cwd }; + + await client.app.log({ + body: { + service: "createos", + level: "info", + message: `Sandbox ready: ${shortId(sandbox.id)} (${shape})${sandbox.ingress_url_template ? ` · ingress: ${sandbox.ingress_url_template}` : ""}`, + }, + }); + })(); + } + + await initPromise; + if (!active) throw new Error("Sandbox initialization failed"); + return active; + } + + // Build tools with lazy init wrapper + const getActive = (): ToolSandbox | null => active; + const baseTools = createTools($, getActive); + + const tools: Record = {}; + for (const [name, def] of Object.entries(baseTools)) { + const original = (def as any).execute; + tools[name] = { + ...def, + execute: async (args: any, ctx: any) => { + await ensureSandbox(); + return original(args, ctx); + }, + }; + } + + return { + "experimental.session.compacting": async (_input: any, output: any) => { + if (!active) return; + output.context.push( + `## CreateOS Sandbox Environment\n` + + `Sandbox: ${active.sandboxId}\n` + + `Cwd: ${active.cwd}\n` + + `Host dir: ${hostCwd}\n` + + `All tools run remotely in this sandbox.\n` + + `\n` + + `Quick rules:\n` + + `- "mount/sync this dir" → sandbox_sync local_dir="${hostCwd}" remote_dir="/root/project"\n` + + `- Port access → sandbox_preview_url (public URL) > sandbox_tunnel (localhost) > device VPN (last resort)\n` + + `- Multi-node → sandbox_network_create + sandbox_create with network + sandbox_exec on other sandboxes`, + ); + }, + + event: async ({ event }: { event: { type: string } }) => { + if (event.type === "session.deleted" && active) { + try { + await cli.cleanupTempKey($); + } catch {} + try { + await cli.destroySandbox($, active.sandboxId); + } catch {} + active = null; + } + }, + + tool: tools, + }; +}; diff --git a/packages/opencode-plugin/package.json b/packages/opencode-plugin/package.json new file mode 100644 index 0000000..6b4eb12 --- /dev/null +++ b/packages/opencode-plugin/package.json @@ -0,0 +1,37 @@ +{ + "name": "@createos/opencode", + "version": "0.1.0", + "description": "OpenCode plugin that runs all tool calls inside a remote CreateOS Sandbox", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/NodeOps-app/createos-claude-plugins.git", + "directory": "packages/opencode-plugin" + }, + "author": "CreateOS", + "license": "Apache-2.0", + "keywords": [ + "opencode", + "opencode-plugin", + "createos", + "sandbox" + ], + "files": [ + "dist" + ], + "scripts": { + "build": "bun build index.ts --outdir=dist --target node", + "build:minify": "bun build index.ts --outdir=dist --target node --minify", + "prepublishOnly": "bun run build:minify", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@opencode-ai/plugin": "^1.16.2" + }, + "devDependencies": { + "@types/node": "^22", + "typescript": "^5.6.0" + } +} diff --git a/packages/opencode-plugin/src/cli.ts b/packages/opencode-plugin/src/cli.ts new file mode 100644 index 0000000..3a6dbc4 --- /dev/null +++ b/packages/opencode-plugin/src/cli.ts @@ -0,0 +1,524 @@ +/** + * CLI wrapper for the createos binary. + * + * Ported from the Pi extension's cli.ts — every function that previously took + * `pi: ExtensionAPI` and called `pi.exec('createos', args)` now takes `$: any` + * (Bun shell) and calls `await $\`sh -c ${cmd}\`.nothrow().quiet()`. + */ + +import { shellQuote } from "./util.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ExecResult { + code: number; + stdout: string; + stderr: string; +} + +export interface SandboxInfo { + id: string; + status: string; + name?: string; + ip?: string; + host_id?: string; + region?: string; + ingress_url_template?: string; + [key: string]: unknown; +} + +export interface NetworkInfo { + id: string; + name: string; + member_count?: number; + members?: { sandbox_id: string; status: string; ip: string; name?: string }[]; + [key: string]: unknown; +} + +export interface DiskInfo { + id: string; + name: string; + kind?: string; + config?: { bucket?: string; endpoint?: string; region?: string }; + [key: string]: unknown; +} + +export interface DeviceInfo { + device_id?: string; + id?: string; + name: string; + client_ip?: string; + [key: string]: unknown; +} + +// --------------------------------------------------------------------------- +// Error class +// --------------------------------------------------------------------------- + +export class CLIError extends Error { + code: number; + stdout: string; + stderr: string; + + constructor(command: string, res: ExecResult) { + const msg = res.stderr.trim() || res.stdout.trim() || `command failed with code ${res.code}`; + super(`createos ${command}: ${msg}`); + this.name = "CLIError"; + this.code = res.code; + this.stdout = res.stdout; + this.stderr = res.stderr; + } +} + +// --------------------------------------------------------------------------- +// Internal runner +// --------------------------------------------------------------------------- + +function execCmd(cmd: string): { code: number; stdout: string; stderr: string } { + const { execSync } = require("child_process"); + try { + const stdout = execSync(cmd, { + encoding: "utf-8", + timeout: 120000, + stdio: ["pipe", "pipe", "pipe"], + }); + return { code: 0, stdout, stderr: "" }; + } catch (err: any) { + return { + code: err.status ?? 1, + stdout: err.stdout?.toString() ?? "", + stderr: err.stderr?.toString() ?? "", + }; + } +} + +async function run(_$: any, args: string[]): Promise { + // Shell-quote every arg to prevent pipes/redirects/semicolons from breaking out + const quote = (a: string) => `'${a.replace(/'/g, `'\\''`)}'`; + const cmd = ["createos", ...args.map((a) => (a === "--" ? "--" : quote(a)))].join(" "); + return execCmd(cmd); +} + +// --------------------------------------------------------------------------- +// JSON parser +// --------------------------------------------------------------------------- + +function parseJSON(stdout: string): T { + return JSON.parse(stdout) as T; +} + +// --------------------------------------------------------------------------- +// Sandbox operations +// --------------------------------------------------------------------------- + +export async function createSandbox( + $: any, + opts: { + shape?: string; + rootfs?: string; + ingress?: boolean; + networks?: string[]; + name?: string; + }, +): Promise { + const args = ["-o", "json", "sandbox", "create", "--shape", opts.shape ?? "s-2vcpu-2gb"]; + if (opts.rootfs) args.push("--rootfs", opts.rootfs); + if (opts.ingress) args.push("--ingress"); + if (opts.name) args.push("--name", opts.name); + if (opts.networks) { + for (const net of opts.networks) args.push("--network", net); + } + const res = await run($, args); + if (res.code !== 0) throw new CLIError("sandbox create", res); + return parseJSON(res.stdout); +} + +export async function getSandbox($: any, id: string): Promise { + const res = await run($, ["-o", "json", "sandbox", "get", id]); + if (res.code !== 0) throw new CLIError("sandbox get", res); + return parseJSON(res.stdout); +} + +export async function destroySandbox($: any, id: string): Promise { + const res = await run($, ["sandbox", "rm", "--yes", id]); + if (res.code !== 0) throw new CLIError("sandbox rm", res); +} + +export async function pauseSandbox($: any, id: string): Promise { + const res = await run($, ["sandbox", "pause", id]); + if (res.code !== 0) throw new CLIError("sandbox pause", res); +} + +export async function resumeSandbox($: any, id: string): Promise { + const res = await run($, ["sandbox", "resume", id]); + if (res.code !== 0) throw new CLIError("sandbox resume", res); +} + +export async function listSandboxes($: any): Promise { + const res = await run($, ["-o", "json", "sandbox", "list"]); + if (res.code !== 0) throw new CLIError("sandbox list", res); + const parsed = parseJSON(res.stdout); + return Array.isArray(parsed) ? parsed : parsed.data; +} + +export async function forkSandbox( + $: any, + id: string, + opts?: { paused?: boolean }, +): Promise { + const args = ["-o", "json", "sandbox", "fork", id]; + if (opts?.paused) args.push("--paused"); + const res = await run($, args); + if (res.code !== 0) throw new CLIError("sandbox fork", res); + return parseJSON(res.stdout); +} + +export async function editSandbox( + $: any, + id: string, + opts: { ingress?: boolean; egress?: string[] }, +): Promise { + const args = ["sandbox", "edit", id]; + if (opts.ingress === true) args.push("--ingress", "on"); + if (opts.ingress === false) args.push("--ingress", "off"); + if (opts.egress) { + for (const rule of opts.egress) args.push("--egress", rule); + } + const res = await run($, args); + if (res.code !== 0) throw new CLIError("sandbox edit", res); +} + +// --------------------------------------------------------------------------- +// Shapes & rootfs +// --------------------------------------------------------------------------- + +export async function listShapes($: any): Promise { + const res = await run($, ["-o", "json", "sandbox", "shapes"]); + if (res.code !== 0) throw new CLIError("sandbox shapes", res); + return parseJSON(res.stdout); +} + +export async function listRootfs($: any): Promise { + const res = await run($, ["-o", "json", "sandbox", "rootfs"]); + if (res.code !== 0) throw new CLIError("sandbox rootfs", res); + return parseJSON(res.stdout); +} + +// --------------------------------------------------------------------------- +// Bandwidth +// --------------------------------------------------------------------------- + +export async function getBandwidth($: any, id: string): Promise { + const info = await getSandbox($, id); + return (info as any).bandwidth ?? null; +} + +// --------------------------------------------------------------------------- +// Tunnel +// --------------------------------------------------------------------------- + +export async function startTunnel( + $: any, + sandboxId: string, + remotePort: number, + localPort?: number, +): Promise<{ localPort: number; pid: string }> { + const local = localPort ?? remotePort; + const check = await run($, ["sandbox", "get", sandboxId]); + if (check.code !== 0) throw new CLIError("tunnel preflight", check); + + const args = [ + "sandbox", + "tunnel", + "--remote", + String(remotePort), + "--local", + String(local), + sandboxId, + ]; + const shellCmd = `nohup createos ${args.join(" ")} > /dev/null 2>&1 & echo $!`; + const res = execCmd(shellCmd); + return { localPort: local, pid: res.stdout.trim() }; +} + +// --------------------------------------------------------------------------- +// Temp SSH key +// --------------------------------------------------------------------------- + +let tempKeyPath: string | undefined; + +export async function ensureTempKey(_$: any): Promise { + if (tempKeyPath) return tempKeyPath; + const shellCmd = + 'dir=$(mktemp -d) && ssh-keygen -t ed25519 -f "$dir/id_sync" -N "" -q && echo "$dir/id_sync"'; + const res = execCmd(shellCmd); + if (res.code !== 0) throw new Error(`Failed to generate temp SSH key: ${res.stderr}`); + tempKeyPath = res.stdout.trim(); + return tempKeyPath; +} + +export async function cleanupTempKey(_$: any): Promise { + if (!tempKeyPath) return; + const dir = tempKeyPath.replace(/\/[^/]+$/, ""); + execCmd("rm -rf " + shellQuote(dir)); + tempKeyPath = undefined; +} + +// --------------------------------------------------------------------------- +// File sync (mutagen) +// --------------------------------------------------------------------------- + +export async function startSync( + $: any, + sandboxId: string, + localDir: string, + remoteDir: string, + opts?: { mode?: string; exclude?: string[] }, +): Promise<{ pid: string }> { + const check = await run($, ["sandbox", "get", sandboxId]); + if (check.code !== 0) throw new CLIError("sync preflight", check); + + const keyPath = await ensureTempKey($); + + const args = [ + "sandbox", + "sync", + "--local", + localDir, + "--remote", + remoteDir, + "--yes", + "-i", + keyPath, + ]; + if (opts?.mode) args.push("--mode", opts.mode); + if (opts?.exclude) { + for (const ex of opts.exclude) args.push("--exclude", ex); + } + args.push(sandboxId); + + const shellCmd = `nohup createos ${args.join(" ")} > /dev/null 2>&1 & echo $!`; + const res = execCmd(shellCmd); + return { pid: res.stdout.trim() }; +} + +// --------------------------------------------------------------------------- +// Sandbox exec +// --------------------------------------------------------------------------- + +export async function sandboxExec($: any, id: string, command: string): Promise { + const res = await run($, ["sandbox", "exec", id, "--", "sh", "-c", command]); + return res; +} + +// --------------------------------------------------------------------------- +// File transfer +// --------------------------------------------------------------------------- + +export async function pullFile($: any, id: string, remotePath: string): Promise { + const res = await run($, ["sandbox", "pull", id, remotePath, "-"]); + if (res.code !== 0) throw new CLIError("sandbox pull", res); + return res.stdout; +} + +export async function pushFile( + $: any, + id: string, + content: string, + remotePath: string, +): Promise { + const b64 = Buffer.from(content).toString("base64"); + const cmd = `echo ${shellQuote(b64)} | base64 -d > ${shellQuote(remotePath)}`; + const res = await sandboxExec($, id, cmd); + if (res.code !== 0) throw new CLIError("pushFile", res); +} + +// --------------------------------------------------------------------------- +// Networks +// --------------------------------------------------------------------------- + +export async function createNetwork($: any, name: string): Promise { + const res = await run($, ["-o", "json", "sandbox", "network", "create", name]); + if (res.code !== 0) throw new CLIError("network create", res); + return parseJSON(res.stdout); +} + +export async function listNetworks($: any): Promise { + const res = await run($, ["-o", "json", "sandbox", "network", "ls"]); + if (res.code !== 0) throw new CLIError("network ls", res); + const parsed = parseJSON(res.stdout); + return Array.isArray(parsed) ? parsed : parsed.data; +} + +export async function getNetwork($: any, idOrName: string): Promise { + const res = await run($, ["-o", "json", "sandbox", "network", "show", idOrName]); + if (res.code !== 0) throw new CLIError("network show", res); + return parseJSON(res.stdout); +} + +export async function deleteNetwork($: any, idOrName: string): Promise { + const res = await run($, ["sandbox", "network", "rm", idOrName, "--yes"]); + if (res.code !== 0) throw new CLIError("network rm", res); +} + +export async function attachNetwork($: any, sandboxId: string, netIdOrName: string): Promise { + const res = await run($, ["sandbox", "network", "attach", netIdOrName, sandboxId]); + if (res.code !== 0) throw new CLIError("network attach", res); +} + +export async function detachNetwork($: any, sandboxId: string, netIdOrName: string): Promise { + const res = await run($, ["sandbox", "network", "detach", netIdOrName, sandboxId, "--yes"]); + if (res.code !== 0) throw new CLIError("network detach", res); +} + +// --------------------------------------------------------------------------- +// Disks +// --------------------------------------------------------------------------- + +export async function createDisk( + $: any, + opts: { + name: string; + bucket: string; + endpoint: string; + accessKey: string; + secretKey: string; + region?: string; + pathStyle?: boolean; + }, +): Promise { + const args = [ + "-o", + "json", + "sandbox", + "disk", + "create", + opts.name, + "--bucket", + opts.bucket, + "--endpoint", + opts.endpoint, + "--access-key", + opts.accessKey, + "--secret-key", + opts.secretKey, + ]; + if (opts.region) args.push("--region", opts.region); + if (opts.pathStyle) args.push("--path-style"); + const res = await run($, args); + if (res.code !== 0) throw new CLIError("disk create", res); + return parseJSON(res.stdout); +} + +export async function listDisks($: any): Promise { + const res = await run($, ["-o", "json", "sandbox", "disk", "ls"]); + if (res.code !== 0) throw new CLIError("disk ls", res); + const parsed = parseJSON(res.stdout); + return Array.isArray(parsed) ? parsed : parsed.data; +} + +export async function getDisk($: any, idOrName: string): Promise { + const res = await run($, ["-o", "json", "sandbox", "disk", "show", idOrName]); + if (res.code !== 0) throw new CLIError("disk show", res); + return parseJSON(res.stdout); +} + +export async function deleteDisk($: any, idOrName: string): Promise { + const res = await run($, ["sandbox", "disk", "rm", idOrName, "--yes"]); + if (res.code !== 0) throw new CLIError("disk rm", res); +} + +export async function attachDisk( + $: any, + sandboxId: string, + diskIdOrName: string, + mountPath: string, +): Promise { + const res = await run($, ["sandbox", "disk", "attach", sandboxId, diskIdOrName, mountPath]); + if (res.code !== 0) throw new CLIError("disk attach", res); +} + +export async function detachDisk( + $: any, + sandboxId: string, + diskIdOrName: string, + mountPath: string, +): Promise { + const res = await run($, [ + "sandbox", + "disk", + "detach", + sandboxId, + diskIdOrName, + mountPath, + "--yes", + ]); + if (res.code !== 0) throw new CLIError("disk detach", res); +} + +// --------------------------------------------------------------------------- +// Devices +// --------------------------------------------------------------------------- + +export async function listDevices($: any): Promise { + const res = await run($, ["-o", "json", "sandbox", "devices", "ls"]); + if (res.code !== 0) throw new CLIError("devices ls", res); + const parsed = parseJSON(res.stdout); + return Array.isArray(parsed) ? parsed : parsed.data; +} + +export async function attachDeviceToNetwork( + $: any, + deviceId: string, + netIdOrName: string, +): Promise { + const res = await run($, ["sandbox", "network", "attach", netIdOrName, deviceId]); + if (res.code !== 0) throw new CLIError("network attach (device)", res); +} + +export async function detachDeviceFromNetwork( + $: any, + deviceId: string, + netIdOrName: string, +): Promise { + const res = await run($, ["sandbox", "network", "detach", netIdOrName, deviceId, "--yes"]); + if (res.code !== 0) throw new CLIError("network detach (device)", res); +} + +export async function registerDevice($: any, name?: string): Promise { + const args = ["sandbox", "devices", "register"]; + if (name) args.push("--name", name); + const res = await run($, args); + if (res.code !== 0) throw new CLIError("devices register", res); + return res.stdout; +} + +// --------------------------------------------------------------------------- +// CLI availability & auth +// --------------------------------------------------------------------------- + +export async function isCreateOSInstalled($: any): Promise { + const res = await run($, ["version"]); + return res.code === 0; +} + +const CLI_INSTALL_URL = + "https://raw.githubusercontent.com/NodeOps-app/createos-cli/main/install.sh"; + +export async function autoInstallCLI($: any): Promise { + try { + const shellCmd = `curl -sfL "${CLI_INSTALL_URL}" | sh -`; + const res = execCmd(shellCmd); + if (res.code !== 0) return false; + return isCreateOSInstalled($); + } catch { + return false; + } +} + +export async function isLoggedIn($: any): Promise { + const res = await run($, ["-o", "json", "sandbox", "shapes"]); + return res.code === 0; +} diff --git a/packages/opencode-plugin/src/tools.ts b/packages/opencode-plugin/src/tools.ts new file mode 100644 index 0000000..9e4f3e3 --- /dev/null +++ b/packages/opencode-plugin/src/tools.ts @@ -0,0 +1,624 @@ +/** + * OpenCode plugin tools — sandbox-specific tools for CreateOS. + * + * Unlike Pi (which replaced built-in tools), OpenCode plugins cannot override + * built-ins. All tools use the `sandbox_` prefix and are explicitly sandbox-scoped. + */ + +import { tool } from "@opencode-ai/plugin"; +import * as cli from "./cli.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ToolSandbox { + sandboxId: string; + cwd: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function requireSandbox(getActive: () => ToolSandbox | null): ToolSandbox { + const active = getActive(); + if (!active) { + throw new Error( + "No active CreateOS sandbox. Please enable CreateOS first by creating or selecting a sandbox.", + ); + } + return active; +} + +function fmtBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +} + +// --------------------------------------------------------------------------- +// Tool factory +// --------------------------------------------------------------------------- + +export function createTools($: any, getActive: () => ToolSandbox | null) { + return { + // ===================================================================== + // Sandbox Tools + // ===================================================================== + + sandbox_create: tool({ + description: + "Create a new CreateOS sandbox. Returns the sandbox ID, IP address, shape, and ingress URL once ready.", + args: { + shape: tool.schema + .string() + .optional() + .describe("VM size/shape (e.g. 's-2vcpu-2gb'). Defaults to 's-2vcpu-2gb'."), + rootfs: tool.schema.string().optional().describe("Base image name to use for the sandbox"), + name: tool.schema.string().optional().describe("Human-readable name for the sandbox"), + networks: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Network names to attach to the sandbox at creation time"), + }, + async execute(args) { + const info = await cli.createSandbox($, { + shape: args.shape, + rootfs: args.rootfs, + name: args.name, + networks: args.networks, + }); + const lines = [`Sandbox created.`, ` ID: ${info.id}`, ` Status: ${info.status}`]; + if (info.ip) lines.push(` IP: ${info.ip}`); + if ((info as any).shape) lines.push(` Shape: ${(info as any).shape}`); + if (info.ingress_url_template) lines.push(` Ingress: ${info.ingress_url_template}`); + return lines.join("\n"); + }, + }), + + sandbox_exec: tool({ + description: + "Run a shell command on a specific sandbox by ID. Use this when you need to target a sandbox that is not the currently active one.", + args: { + sandbox_id: tool.schema.string().describe("The sandbox ID to execute the command on"), + command: tool.schema.string().describe("The shell command to run"), + }, + async execute(args) { + const result = await cli.sandboxExec($, args.sandbox_id, args.command); + const parts: string[] = []; + if (result.stdout.trim()) parts.push(result.stdout.trim()); + if (result.stderr.trim()) parts.push(`STDERR:\n${result.stderr.trim()}`); + if (result.code !== 0) parts.push(`Exit code: ${result.code}`); + return parts.join("\n") || "(no output)"; + }, + }), + + sandbox_info: tool({ + description: + "Get detailed status information about a sandbox including its ID, status, name, IP address, shape, region, and ingress URL.", + args: { + sandbox_id: tool.schema + .string() + .optional() + .describe("Sandbox ID to inspect. Defaults to the currently active sandbox."), + }, + async execute(args) { + const id = args.sandbox_id ?? requireSandbox(getActive).sandboxId; + const info = await cli.getSandbox($, id); + const lines = [`ID: ${info.id}`, `Status: ${info.status}`]; + if (info.name) lines.push(`Name: ${info.name}`); + if (info.ip) lines.push(`IP: ${info.ip}`); + if ((info as any).shape) lines.push(`Shape: ${(info as any).shape}`); + if (info.region) lines.push(`Region: ${info.region}`); + if (info.ingress_url_template) lines.push(`Ingress: ${info.ingress_url_template}`); + return lines.join("\n"); + }, + }), + + sandbox_list: tool({ + description: + "List all sandboxes in the current CreateOS account, including their IDs, names, and statuses.", + args: {}, + async execute() { + const sandboxes = await cli.listSandboxes($); + if (sandboxes.length === 0) return "No sandboxes found."; + return sandboxes + .map((sb) => { + const parts = [sb.id, sb.status]; + if (sb.name) parts.push(sb.name); + if (sb.ip) parts.push(sb.ip); + return parts.join(" "); + }) + .join("\n"); + }, + }), + + sandbox_pause: tool({ + description: + "Pause a running sandbox. The sandbox state is preserved and can be resumed later. Paused sandboxes do not consume compute resources.", + args: { + sandbox_id: tool.schema + .string() + .optional() + .describe("Sandbox ID to pause. Defaults to the currently active sandbox."), + }, + async execute(args) { + const id = args.sandbox_id ?? requireSandbox(getActive).sandboxId; + await cli.pauseSandbox($, id); + return `Sandbox ${id} paused.`; + }, + }), + + sandbox_resume: tool({ + description: "Resume a previously paused sandbox, restoring it to a running state.", + args: { + sandbox_id: tool.schema.string().describe("The sandbox ID to resume"), + }, + async execute(args) { + await cli.resumeSandbox($, args.sandbox_id); + return `Sandbox ${args.sandbox_id} resumed.`; + }, + }), + + sandbox_fork: tool({ + description: + "Clone a paused sandbox into a new sandbox. The new sandbox is an exact copy of the original at the point it was paused.", + args: { + sandbox_id: tool.schema + .string() + .optional() + .describe("Sandbox ID to fork. Defaults to the currently active sandbox."), + paused: tool.schema + .boolean() + .optional() + .describe( + "If true, pause the source sandbox before forking (it must be paused to fork).", + ), + }, + async execute(args) { + const id = args.sandbox_id ?? requireSandbox(getActive).sandboxId; + if (args.paused) { + await cli.pauseSandbox($, id); + } + const forked = await cli.forkSandbox($, id); + return `Forked sandbox ${id} -> new sandbox ${forked.id} (status: ${forked.status})`; + }, + }), + + sandbox_destroy: tool({ + description: + "Permanently delete a sandbox. This is irreversible. All data in the sandbox will be lost.", + args: { + sandbox_id: tool.schema.string().describe("The sandbox ID to destroy"), + }, + async execute(args) { + await cli.destroySandbox($, args.sandbox_id); + return `Sandbox ${args.sandbox_id} destroyed.`; + }, + }), + + sandbox_ingress: tool({ + description: + "Toggle public HTTPS ingress for a sandbox. When enabled, the sandbox gets a public URL that can be used to access services running inside it.", + args: { + enabled: tool.schema.boolean().describe("Set to true to enable ingress, false to disable"), + sandbox_id: tool.schema + .string() + .optional() + .describe("Sandbox ID to configure. Defaults to the currently active sandbox."), + }, + async execute(args) { + const id = args.sandbox_id ?? requireSandbox(getActive).sandboxId; + await cli.editSandbox($, id, { ingress: args.enabled }); + if (args.enabled) { + const info = await cli.getSandbox($, id); + return `Ingress enabled for sandbox ${id}.${info.ingress_url_template ? `\nURL template: ${info.ingress_url_template}` : ""}`; + } + return `Ingress disabled for sandbox ${id}.`; + }, + }), + + sandbox_firewall: tool({ + description: + "Set egress firewall rules for a sandbox. Rules control which external destinations the sandbox can reach. Each rule is a string like 'allow tcp 443 example.com' or 'deny all'.", + args: { + rules: tool.schema + .array(tool.schema.string()) + .describe("List of egress firewall rules to apply"), + sandbox_id: tool.schema + .string() + .optional() + .describe("Sandbox ID to configure. Defaults to the currently active sandbox."), + }, + async execute(args) { + const id = args.sandbox_id ?? requireSandbox(getActive).sandboxId; + await cli.editSandbox($, id, { egress: args.rules }); + return `Egress rules updated for sandbox ${id}:\n${args.rules.map((r) => ` - ${r}`).join("\n")}`; + }, + }), + + sandbox_bandwidth: tool({ + description: + "Check bandwidth usage for a sandbox, showing bytes used, quota, remaining allowance, and whether the sandbox is bandwidth-capped.", + args: { + sandbox_id: tool.schema + .string() + .optional() + .describe("Sandbox ID to check. Defaults to the currently active sandbox."), + }, + async execute(args) { + const id = args.sandbox_id ?? requireSandbox(getActive).sandboxId; + const bw = (await cli.getBandwidth($, id)) as any; + if (!bw) return `No bandwidth data available for sandbox ${id}.`; + const used = bw.used_bytes ?? bw.used ?? 0; + const quota = bw.quota_bytes ?? bw.quota ?? 0; + const remaining = Math.max(0, quota - used); + const capped = bw.capped ?? remaining <= 0; + return [ + `Bandwidth for sandbox ${id}:`, + ` Used: ${fmtBytes(used)}`, + ` Quota: ${fmtBytes(quota)}`, + ` Remaining: ${fmtBytes(remaining)}`, + ` Capped: ${capped ? "yes" : "no"}`, + ].join("\n"); + }, + }), + + sandbox_shapes: tool({ + description: + "List all available sandbox shapes (VM sizes). Shows the CPU, memory, and other resource specifications for each shape.", + args: {}, + async execute() { + const shapes = await cli.listShapes($); + if (!shapes || shapes.length === 0) return "No shapes available."; + return JSON.stringify(shapes, null, 2); + }, + }), + + sandbox_images: tool({ + description: + "List all available base images (rootfs) that can be used when creating a sandbox.", + args: {}, + async execute() { + const images = await cli.listRootfs($); + if (!images || images.length === 0) return "No images available."; + return JSON.stringify(images, null, 2); + }, + }), + + sandbox_preview_url: tool({ + description: + "Get the public HTTPS URL for a specific port on the active sandbox. Requires ingress to be enabled on the sandbox.", + args: { + port: tool.schema.number().describe("The port number to get the preview URL for"), + }, + async execute(args) { + const active = requireSandbox(getActive); + const info = await cli.getSandbox($, active.sandboxId); + if (!info.ingress_url_template) { + return "Ingress is not enabled on this sandbox. Enable it first with sandbox_ingress."; + } + const url = info.ingress_url_template.replace("", String(args.port)); + return `Preview URL for port ${args.port}: ${url}`; + }, + }), + + sandbox_tunnel: tool({ + description: + "Create a port-forwarding tunnel from the sandbox to localhost. Maps a remote port on the sandbox to a local port on the host machine.", + args: { + remote_port: tool.schema.number().describe("The port on the sandbox to forward"), + local_port: tool.schema + .number() + .optional() + .describe("The local port to listen on. Defaults to the same as remote_port."), + }, + async execute(args) { + const active = requireSandbox(getActive); + const result = await cli.startTunnel( + $, + active.sandboxId, + args.remote_port, + args.local_port, + ); + return `Tunnel established: localhost:${result.localPort} -> sandbox:${args.remote_port} (PID ${result.pid})`; + }, + }), + + sandbox_sync: tool({ + description: + "Start a bidirectional file sync session between a local directory and a directory inside the sandbox using mutagen.", + args: { + local_dir: tool.schema.string().describe("Local directory path to sync from"), + remote_dir: tool.schema + .string() + .describe("Remote directory path inside the sandbox to sync to"), + mode: tool.schema + .string() + .optional() + .describe("Sync mode. Currently unused, reserved for future use."), + exclude: tool.schema + .array(tool.schema.string()) + .optional() + .describe("List of glob patterns to exclude from sync (e.g. 'node_modules', '.git')"), + }, + async execute(args) { + const active = requireSandbox(getActive); + const result = await cli.startSync($, active.sandboxId, args.local_dir, args.remote_dir, { + mode: args.mode, + exclude: args.exclude, + }); + return `Sync started: ${args.local_dir} <-> sandbox:${args.remote_dir}${args.mode ? ` (${args.mode})` : ""}\nPID: ${result.pid}`; + }, + }), + + // ----- Networks ----- + + sandbox_network_create: tool({ + description: + "Create a new private network that sandboxes can be attached to for secure inter-sandbox communication.", + args: { + name: tool.schema.string().describe("Name for the new network"), + }, + async execute(args) { + const net = await cli.createNetwork($, args.name); + return `Network created.\n ID: ${net.id}\n Name: ${net.name ?? args.name}`; + }, + }), + + sandbox_network_list: tool({ + description: "List all private networks in the current CreateOS account.", + args: {}, + async execute() { + const nets = await cli.listNetworks($); + if (nets.length === 0) return "No networks found."; + return nets + .map((n) => { + const parts = [n.id]; + if (n.name) parts.push(n.name); + return parts.join(" "); + }) + .join("\n"); + }, + }), + + sandbox_network_show: tool({ + description: + "Show detailed information about a specific network including its attached sandboxes.", + args: { + name: tool.schema.string().describe("Network name or ID to inspect"), + }, + async execute(args) { + const net = await cli.getNetwork($, args.name); + return JSON.stringify(net, null, 2); + }, + }), + + sandbox_network_attach: tool({ + description: + "Attach the currently active sandbox to a private network, allowing it to communicate with other sandboxes on the same network.", + args: { + name: tool.schema.string().describe("Network name or ID to attach to"), + }, + async execute(args) { + const active = requireSandbox(getActive); + await cli.attachNetwork($, active.sandboxId, args.name); + return `Sandbox ${active.sandboxId} attached to network ${args.name}.`; + }, + }), + + sandbox_network_detach: tool({ + description: "Detach the currently active sandbox from a private network.", + args: { + name: tool.schema.string().describe("Network name or ID to detach from"), + }, + async execute(args) { + const active = requireSandbox(getActive); + await cli.detachNetwork($, active.sandboxId, args.name); + return `Sandbox ${active.sandboxId} detached from network ${args.name}.`; + }, + }), + + sandbox_network_delete: tool({ + description: "Delete a private network. All sandboxes must be detached first.", + args: { + name: tool.schema.string().describe("Network name or ID to delete"), + }, + async execute(args) { + await cli.deleteNetwork($, args.name); + return `Network ${args.name} deleted.`; + }, + }), + + // ----- Disks ----- + + sandbox_disk_create: tool({ + description: + "Register an S3-compatible disk that can be mounted into sandboxes. Requires S3 bucket credentials.", + args: { + name: tool.schema.string().describe("Name for the disk"), + bucket: tool.schema.string().describe("S3 bucket name"), + endpoint: tool.schema.string().describe("S3-compatible endpoint URL"), + access_key: tool.schema.string().describe("S3 access key ID"), + secret_key: tool.schema.string().describe("S3 secret access key"), + region: tool.schema.string().optional().describe("S3 bucket region"), + path_style: tool.schema + .boolean() + .optional() + .describe("Use path-style S3 addressing instead of virtual-hosted"), + }, + async execute(args) { + const disk = await cli.createDisk($, { + name: args.name, + bucket: args.bucket, + endpoint: args.endpoint, + accessKey: args.access_key, + secretKey: args.secret_key, + region: args.region, + pathStyle: args.path_style, + }); + return `Disk created.\n ID: ${disk.id}\n Name: ${disk.name ?? args.name}`; + }, + }), + + sandbox_disk_list: tool({ + description: "List all registered disks in the current CreateOS account.", + args: {}, + async execute() { + const disks = await cli.listDisks($); + if (disks.length === 0) return "No disks found."; + return disks + .map((d) => { + const parts = [d.id]; + if (d.name) parts.push(d.name); + return parts.join(" "); + }) + .join("\n"); + }, + }), + + sandbox_disk_show: tool({ + description: + "Show detailed information about a specific disk including its S3 configuration.", + args: { + name: tool.schema.string().describe("Disk name or ID to inspect"), + }, + async execute(args) { + const disk = await cli.getDisk($, args.name); + return JSON.stringify(disk, null, 2); + }, + }), + + sandbox_disk_delete: tool({ + description: "Delete a registered disk. The disk must be detached from all sandboxes first.", + args: { + name: tool.schema.string().describe("Disk name or ID to delete"), + }, + async execute(args) { + await cli.deleteDisk($, args.name); + return `Disk ${args.name} deleted.`; + }, + }), + + sandbox_disk_attach: tool({ + description: "Mount a registered disk into a sandbox at a specified path.", + args: { + disk_name: tool.schema.string().describe("Disk name or ID to mount"), + mount_path: tool.schema + .string() + .describe("Filesystem path inside the sandbox where the disk will be mounted"), + sandbox_id: tool.schema + .string() + .optional() + .describe("Sandbox ID to mount the disk into. Defaults to the currently active sandbox."), + }, + async execute(args) { + const id = args.sandbox_id ?? requireSandbox(getActive).sandboxId; + await cli.attachDisk($, id, args.disk_name, args.mount_path); + return `Disk ${args.disk_name} mounted at ${args.mount_path} on sandbox ${id}.`; + }, + }), + + sandbox_disk_detach: tool({ + description: "Unmount a disk from a sandbox.", + args: { + disk_name: tool.schema.string().describe("Disk name or ID to unmount"), + mount_path: tool.schema.string().describe("The mount path to detach from"), + sandbox_id: tool.schema + .string() + .optional() + .describe("Sandbox ID to unmount from. Defaults to the currently active sandbox."), + }, + async execute(args) { + const id = args.sandbox_id ?? requireSandbox(getActive).sandboxId; + await cli.detachDisk($, id, args.disk_name, args.mount_path); + return `Disk ${args.disk_name} detached from ${args.mount_path} on sandbox ${id}.`; + }, + }), + + // ----- Devices ----- + + sandbox_device_register: tool({ + description: + "Register the current machine as a device so it can join private networks alongside sandboxes via VPN.", + args: { + name: tool.schema.string().optional().describe("Human-readable name for the device"), + }, + async execute(args) { + // Check if already registered + const existing = await cli.listDevices($); + if (existing.length > 0) { + return `Device already registered: ${existing[0].id}${existing[0].name ? ` (${existing[0].name})` : ""}`; + } + const output = await cli.registerDevice($, args.name); + return output || "Device registered."; + }, + }), + + sandbox_device_status: tool({ + description: "Check the registration and connection status of the current device.", + args: {}, + async execute() { + const devices = await cli.listDevices($); + if (devices.length === 0) return "No device registered."; + return devices + .map((d) => { + const parts = [d.id]; + if (d.name) parts.push(d.name); + return parts.join(" "); + }) + .join("\n"); + }, + }), + + sandbox_vpn_up: tool({ + description: + "Get the command to bring up the VPN tunnel on this device. The VPN must be started manually by the user because it requires elevated privileges.", + args: {}, + async execute() { + return [ + "To start the VPN tunnel, run the following command in your terminal:", + "", + " createos sb vpn up", + "", + "This requires sudo/admin privileges and must be run interactively.", + ].join("\n"); + }, + }), + + sandbox_device_attach: tool({ + description: + "Attach the current device to a private network, enabling VPN connectivity to sandboxes on that network.", + args: { + network: tool.schema.string().describe("Network name or ID to attach the device to"), + }, + async execute(args) { + const devices = await cli.listDevices($); + if (devices.length === 0) { + throw new Error("No device registered. Use sandbox_device_register first."); + } + const devId = devices[0].id ?? devices[0].device_id!; + await cli.attachDeviceToNetwork($, devId, args.network); + return `Device attached to network "${args.network}".\nRun \`createos sb vpn up\` to access sandbox IPs directly.`; + }, + }), + + sandbox_device_detach: tool({ + description: "Detach the current device from a private network.", + args: { + network: tool.schema.string().describe("Network name or ID to detach the device from"), + }, + async execute(args) { + const devices = await cli.listDevices($); + if (devices.length === 0) { + throw new Error("No device registered. Use sandbox_device_register first."); + } + const devId = devices[0].id ?? devices[0].device_id!; + await cli.detachDeviceFromNetwork($, devId, args.network); + return `Device detached from network "${args.network}".`; + }, + }), + }; +} diff --git a/packages/opencode-plugin/src/util.ts b/packages/opencode-plugin/src/util.ts new file mode 100644 index 0000000..e664775 --- /dev/null +++ b/packages/opencode-plugin/src/util.ts @@ -0,0 +1,13 @@ +/** Small helpers shared across the plugin. */ + +export function shellQuote(arg: string): string { + return `'${arg.replace(/'/g, `'\\''`)}'`; +} + +export function shortId(id: string): string { + return id.slice(0, 12); +} + +export function joinPath(base: string, child: string): string { + return `${base.replace(/[/]+$/, "")}/${child.replace(/^[/]+/, "")}`; +} diff --git a/packages/opencode-plugin/tsconfig.json b/packages/opencode-plugin/tsconfig.json new file mode 100644 index 0000000..cb15d57 --- /dev/null +++ b/packages/opencode-plugin/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "declaration": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["node", "bun-types"] + }, + "include": ["index.ts", "src/**/*.ts"] +}