From f2f7d76e188d51408e1223ea88996325965c4d54 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Thu, 27 Aug 2026 19:08:21 +0000 Subject: [PATCH 1/3] Support OpenCode 2 alongside OpenCode 1 OpenCode 2 replaces the plugin API. A plugin no longer returns hooks from a `server` function: it exports a `setup` function, registers servers and commands with transforms, and intercepts live operations with hooks. One package can serve both releases, because each release resolves a different package export. OpenCode 1 resolves `./server`, and OpenCode 2 resolves `.`, so neither release loads the other's file. - `src/v1.js` keeps the OpenCode 1 plugin. Its configuration, permission, and hook behavior does not change. - `src/v2.js` adds the OpenCode 2 plugin. - `src/shared.js` holds what does not differ: the server defaults, the naming rules, the guidance text, the command templates, the trigger pattern, and the destructive tools. Behavior differences that the two plugin APIs force: - OpenCode 2 gives plugins no permission draft, so the plugin changes a decision from `allow` to `ask` in the `permission.evaluate` hook. A configured `deny` never reaches the hook. - OpenCode 2 has no compaction hook, so the compaction instruction is part of the guidance text there. OpenCode 1 keeps its hook. - OpenCode 2 separates the MCP `startup`, `catalog`, and `execution` timeouts, and adds the `oauth` and `codemode` options. The guidance text changes for both releases. It now names the `sprites_file_*` tools, which the Sprites MCP server offers, in place of the previous base64 transfer advice. It also names the `mcp auth` command of the release that is running. Tests cover both releases: the export map through Node resolution, each entry point directly, the real OpenCode 1 CLI, and a real OpenCode 2 service. `test/context.js` replays transforms and disposes registrations the way the runtime does, and `test/type-contract.ts` asserts that it stays assignable to the real plugin contract. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TiiJqxPLpgfJXG5fvTUa73 --- CONTRIBUTING.md | 88 +- README.md | 169 +- index.js | 371 ----- package-lock.json | 1389 ++++++++++++++++- package.json | 25 +- src/shared.js | 221 +++ src/v1.js | 260 +++ src/v2.js | 301 ++++ test/context.js | 285 ++++ test/exports.test.js | 39 + ....test.js => opencode1.integration.test.js} | 0 test/opencode2.integration.test.js | 136 ++ test/plugin.bun.js | 32 +- test/type-contract.ts | 141 +- test/{plugin.test.js => v1.test.js} | 2 +- test/v2.test.js | 618 ++++++++ tsconfig.json | 7 +- 17 files changed, 3558 insertions(+), 526 deletions(-) delete mode 100644 index.js create mode 100644 src/shared.js create mode 100644 src/v1.js create mode 100644 src/v2.js create mode 100644 test/context.js create mode 100644 test/exports.test.js rename test/{opencode.integration.test.js => opencode1.integration.test.js} (100%) create mode 100644 test/opencode2.integration.test.js rename test/{plugin.test.js => v1.test.js} (99%) create mode 100644 test/v2.test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aea077c..1babe97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,12 @@ # Contributing -Thanks for helping improve the Sprites plugin for OpenCode. +Thank you for your help with the Sprites plugin for OpenCode. ## Requirements -- Node.js 20 or newer -- OpenCode 1.18.23 or newer in the 1.x series for integration testing -- Bun when running the Bun smoke test locally +- Node.js 20 or later +- Bun, to run the Bun smoke test locally +- The OpenCode 1 and OpenCode 2 CLIs, which `npm install` adds as development dependencies ## Set up the repository @@ -16,58 +16,92 @@ Install the locked development dependencies: npm ci ``` -Point OpenCode at the checkout while developing: +npm 12 and later block install scripts. The `allowScripts` field in `package.json` lets `@opencode-ai/cli` select its platform binary. -```json +Point OpenCode at the checkout while you develop. Use `plugin` with a `file://` URL for OpenCode 1, and `plugins` for OpenCode 2: + +```jsonc { "$schema": "https://opencode.ai/config.json", - "plugin": ["file:///absolute/path/to/sprites-opencode-plugin"] + "plugins": ["/absolute/path/to/sprites-opencode-plugin"], } ``` -The directory form lets OpenCode resolve the package's `./server` export and enforce its `engines.opencode` range. +OpenCode 2 watches the plugin files and loads them again when they change. If a change does not become active, run `opencode2 service restart`. + +## Layout + +One package serves both OpenCode releases through its export map: -Do not copy or symlink `index.js` into `.opencode/plugins/` or `~/.config/opencode/plugins/`. It is a package entry point rather than a drop-in file plugin, and the TUI may try to load it through the wrong plugin path. Drop-in plugins also cannot receive an options object. Use package-directory configuration for local development. +| Path | Export | Release | +| --------------- | ------------ | ---------- | +| `src/v1.js` | `./server` | OpenCode 1 | +| `src/v2.js` | `.` | OpenCode 2 | +| `src/shared.js` | not exported | both | -## Run checks +OpenCode 1 resolves the package's `./server` subpath, and OpenCode 2 resolves `.`, so neither release loads the other's file. `test/exports.test.js` asserts that split through Node's own resolution. -Run the same primary checks used in CI: +`src/shared.js` holds what does not differ between the releases: the server defaults, the naming rules, the guidance text, the command templates, the trigger pattern, and the destructive tools. Put a Sprites rule there, and put an API difference in the entry point that needs it. + +## Run the checks + +Run the same primary checks that CI runs: ```sh npm run check npm pack --dry-run ``` -`npm run check` verifies formatting, runs strict TypeScript checking, and executes the Node test suite. The integration tests run `opencode debug config` in isolated XDG directories so configuration is exercised by the real OpenCode CLI. +`npm run check` examines the format, runs strict TypeScript checks, and runs the Node test suite. + +The Node suite has these parts: -OpenCode installs npm plugins with Bun, so CI also runs an import and configuration smoke test under Bun: +- `test/exports.test.js` asserts that the export map gives each release its own entry point. +- `test/v1.test.js` and `test/v2.test.js` drive each entry point directly. The v2 tests use a fake plugin context from `test/context.js`. +- `test/opencode1.integration.test.js` runs `opencode debug config` in isolated XDG directories, so the real OpenCode 1 CLI resolves the configuration. +- `test/opencode2.integration.test.js` starts an isolated OpenCode 2 background service. It makes sure that the real runtime registers the MCP server and the commands. These tests are slow, because the service must start. + +The integration tests fail when the `opencode2` binary is absent. `npm ci` installs that binary, so an absent binary is an error. A silent skip would let `npm run check` report success while nothing runs against the real runtime. To skip these tests on purpose, set `SPRITES_SKIP_OPENCODE_TESTS=1`. + +The fake context in `test/context.js` keeps the same rules as the runtime: + +- It keeps the transforms and replays them each time it materializes the configuration. A test can therefore run `setup` two times and find the same result as a plugin reload. +- A registration removes its own contribution when you dispose it. +- `emit` gives the event to the subscriber and waits until the plugin processes it. The tests do not use timers. +- Each server has its own status, and `setStatus` changes it. The tests can therefore move a server between states, and can give two servers different states. +- Each method has the type of its runtime signature, and `test/type-contract.ts` asserts that the assembled context is assignable to the real contract. The type check fails if the fake becomes more permissive than the runtime. + +OpenCode installs plugins with Bun, so CI also runs a Bun smoke test: ```sh npm run test:bun ``` -CI runs the Node suite on Node.js 20 and 24. - ## Implementation notes -- `index.js` is the package entry point and exports the preferred OpenCode v1 plugin module. The `./server` export supports package-directory loading. -- The plugin registers the Sprites MCP server, commands, permission defaults, session-scoped system guidance, compaction guidance, and event handling. -- Guidance activates only for explicit Sprites signals or Sprites tool calls. Active state propagates to child sessions and is removed when a session is deleted. -- OpenCode always prefixes MCP tools with the server name. Destructive permission patterns intentionally cover both the verified raw Sprites tool names and redundantly prefixed variants. -- OpenCode's system-transform hook does not identify small-model calls. After a session becomes active, title or summary generation that reuses the session ID may also receive the Sprites guidance. -- OpenCode's MCP timeout controls both initial connection and tool discovery. Keep changes to the 60-second default mindful of startup stalls when the endpoint is unavailable. -- The MCP status cache is deliberately longer than one model step and is invalidated by MCP tool-change events. +- `src/v2.js` exports an OpenCode 2 plugin object with an `id` and a `setup` function. `setup` registers the MCP server and the commands with transforms, and registers permission, tool, and session hooks. It returns a cleanup function. +- `src/v1.js` exports an OpenCode 1 plugin module with an `id` and a `server` function. It writes the MCP server, the commands, and the permission defaults into the configuration. +- The plugin has no runtime dependencies. Types come from `@opencode-ai/plugin` for OpenCode 1, and from the `@opencode-ai/plugin-v2` alias of the same package for the OpenCode 2 beta. Both are development dependencies. +- Command registration is unconditional. OpenCode replays the transform after a reload, and the replay sees the commands from the previous registration. A conditional registration therefore deletes its own commands. +- OpenCode 2 gives plugins no permission draft. The plugin changes a decision from `allow` to `ask` in the `permission.evaluate` hook. A configured `deny` never reaches the hook. +- Guidance becomes active only for an explicit Sprites signal, a Sprites tool call, a Sprites permission decision, or a Sprites command. The active state moves to child sessions, and the plugin removes it when a session is deleted. +- The MCP status cache is longer than one model step. The `mcp.status.changed` event clears it. An empty server list counts as unknown, because MCP configuration can be later than the first check. +- OpenCode 2 has no compaction hook. Under OpenCode 2 the compaction instruction is part of the system guidance instead. OpenCode 1 keeps its compaction hook. +- OpenCode does not enforce `engines.opencode`. The field records intent, and it does not gate loading in either release. +- The session `context` hook does not run for title or compaction requests. Those requests do not receive the guidance. -When changing hooks or configuration behavior, update the unit tests and the SDK contract assertions in `test/type-contract.ts`. When changing package loading or option handling, update the real OpenCode integration tests as well. +When you change hooks or configuration behavior, update the unit tests and the type assertions in `test/type-contract.ts`. When you change plugin loading or option handling, update the OpenCode 2 integration tests too. ## Pull requests -Keep changes focused, explain user-visible behavior, and include tests for behavior changes. Before opening a pull request, run the checks above and ensure `git diff --check` reports no whitespace errors. +Keep changes small, explain the user-visible behavior, and include tests for changes in behavior. Before you open a pull request, run the checks above, and make sure that `git diff --check` reports no whitespace errors. ## Release process -Publishing requires access to the `@flydotio` npm organization. +Publication needs access to the `@flydotio` npm organization. + +For the first publication, configure an `NPM_TOKEN` secret in the repository's `npm` GitHub environment. The release workflow then runs with provenance when you publish a GitHub release. -For the initial package publish, configure an `NPM_TOKEN` secret in the repository's `npm` GitHub environment. Publishing a GitHub release then runs the release workflow with provenance. +After the package is on npmjs.com, configure npm trusted publishing for this repository, and remove the `NODE_AUTH_TOKEN` fallback from `.github/workflows/release.yml`. -After the package exists on npmjs.com, configure npm trusted publishing for this repository and remove the `NODE_AUTH_TOKEN` fallback from `.github/workflows/release.yml`. +The OpenCode 2 plugin API is in beta. Publish a compatible plugin update when the v2 entry points or contracts change. diff --git a/README.md b/README.md index d39599f..f539526 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,18 @@ Use [Fly.io Sprites](https://sprites.dev) from OpenCode as persistent, isolated Linux environments for builds, tests, sandboxes, previews, and long-running services. +One package supports both OpenCode releases. OpenCode 1 loads the package's `./server` export, and OpenCode 2 loads its `.` export. Each entry point uses the plugin API of its own release, and both share the same server defaults, tool names, guidance, commands, and destructive-tool rules. + The plugin connects OpenCode to the hosted Sprites MCP server, uses OpenCode's browser-based OAuth flow, adds Sprites commands and workflow guidance, and asks for approval before destructive remote operations. You do not need to install the Sprites CLI or create an API token. ## Requirements -- OpenCode 1.18.23 or newer in the 1.x series +- OpenCode 1.18.23 or later in the 1.x series, or +- OpenCode 2 (the `opencode2` beta CLI) ## Install -Add the package to your `opencode.json`: +For OpenCode 1, add the package to `opencode.json`. The key is `plugin`: ```json { @@ -19,9 +22,22 @@ Add the package to your `opencode.json`: } ``` -OpenCode installs npm plugins automatically with Bun at startup. Restart OpenCode after changing the configuration. +For OpenCode 2, add it to `opencode.jsonc`. The key is `plugins`: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugins": ["@flydotio/sprites-opencode-plugin"], +} +``` -If the package is not yet available from npm, clone this repository and point OpenCode at the checkout instead: +OpenCode 2 can also install the package with its CLI: + +```sh +opencode2 plugin add @flydotio/sprites-opencode-plugin +``` + +If the package is not yet available from npm, clone this repository and point OpenCode at the checkout. OpenCode 1 needs an absolute `file://` URL. OpenCode 2 accepts a path or a `file://` URL: ```json { @@ -30,21 +46,22 @@ If the package is not yet available from npm, clone this repository and point Op } ``` -Use an absolute `file://` URL. This installation form has the same features as the npm package. +Point OpenCode at the package directory, not at one source file. The two entry points come from the package export map, and a drop-in file plugin cannot receive an options object. -Use the package-directory entry shown above rather than copying or symlinking `index.js` into an OpenCode `plugins` directory. This plugin uses its package `./server` export and is not distributed as a drop-in file plugin. +Restart OpenCode after you change the configuration. ## Authenticate -Run `/sprites-status` or ask OpenCode to list your Sprites. The first Sprites request should start OpenCode's browser OAuth flow. +Run `/sprites-status`, or ask OpenCode to list your Sprites. The first Sprites request starts OpenCode's browser OAuth flow. -If the browser does not open, authenticate the plugin-provided server explicitly: +If the browser does not open, authenticate the server. Use `opencode` for OpenCode 1, and `opencode2` for OpenCode 2: ```sh opencode mcp auth sprites +opencode2 mcp auth sprites ``` -Then retry the original request. An empty Sprite list is a successful authenticated result. +Then do the original request again. An empty Sprite list is a successful authenticated result. ## Use Sprites @@ -53,34 +70,36 @@ You can ask OpenCode to: - List or create Sprites. - Clone a repository into a Sprite and run its build or test suite remotely. - Start a long-running development server or preview as a Sprite service. -- Create a filesystem checkpoint before a risky change and restore it later. -- Inspect or update a Sprite's outbound network policy. +- Create a filesystem checkpoint before a risky change, and restore it later. +- Read, write, and move files in the Sprite filesystem. +- Examine or change a Sprite's outbound network policy. -The plugin also adds two slash commands: +The plugin also adds two commands: -- `/sprites-status` performs a read-only connectivity and authentication check. -- `/sprites-smoke` walks through a list → create → exec smoke test. Destroying the test Sprite still requires explicit intent. +- `/sprites-status` does a read-only connectivity and authentication check. +- `/sprites-smoke` does a list, create, and exec smoke test. To destroy the test Sprite, you must ask for it. -OpenCode itself continues to run outside the Sprite. Local workspace and shell operations stay on your machine; Sprites MCP tools perform remote work. In particular: +OpenCode continues to run outside the Sprite. Local workspace and shell operations stay on your machine. The Sprites MCP tools do the remote work: - One-off remote commands use `sprites_exec`. -- Long-running remote processes use `sprites_service_*` tools. -- Reversible filesystem snapshots use `sprites_checkpoint_*` tools. -- Outbound access is governed by `sprites_policy_network_*` tools. +- Long-running remote processes use the `sprites_service_*` tools. +- Reversible filesystem snapshots use the `sprites_checkpoint_*` tools. +- Remote files use the `sprites_file_*` tools. +- Outbound access uses the `sprites_policy_network_*` tools. -There is no dedicated MCP file-write tool. For substantial work, clone a repository into the Sprite. For small generated files, ask OpenCode to transfer base64-encoded content rather than relying on nested shell quoting. +OpenCode 2 groups MCP tools in Code Mode by default. The model then calls these tools as `tools.sprites.(input)`. The permission action stays `sprites_`. To put the tools on the model's native tool list instead, set the `codemode` option to `false`. ## What the plugin adds - Hosted MCP access at `https://sprites.dev/mcp`. -- Sprites workflow and safety guidance in relevant sessions without adding that context to unrelated work. -- Session inheritance so a subagent working with Sprites receives the same guidance as its parent. +- Sprites workflow and safety guidance in relevant sessions, but not in unrelated work. +- Session inheritance, so a subagent that works with Sprites gets the same guidance as its parent. - Approval prompts for Sprite destruction, checkpoint restore, and complete network-policy replacement. -- Compaction guidance that preserves active Sprite names, service state, checkpoints, and pending approvals. +- Under OpenCode 1, compaction guidance that keeps the active Sprite names, service state, checkpoints, and pending approvals. OpenCode 2 has no compaction hook, so that instruction is part of the guidance itself. ## Options -OpenCode accepts an options object alongside the package specifier: +OpenCode passes an options object to the plugin. OpenCode 1 uses a tuple, and OpenCode 2 uses an object: ```json { @@ -88,50 +107,70 @@ OpenCode accepts an options object alongside the package specifier: "plugin": [ [ "@flydotio/sprites-opencode-plugin", - { + { "mcpName": "sprites-staging", "timeout": 15000 } + ] + ] +} +``` + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugins": [ + { + "package": "@flydotio/sprites-opencode-plugin", + "options": { "mcpName": "sprites-staging", "url": "https://staging.example.com/mcp", - "timeout": 15000, + "timeout": { "startup": 15000, "catalog": 15000 }, "headers": { "X-Environment": "staging" }, - "commands": false, + "oauth": false, + "codemode": false, + "mcp": true, + "commands": true, "guidance": true, "permissions": true, - "mcp": true - } - ] - ] + }, + }, + ], } ``` -| Option | Default | Meaning | -| ------------- | ------------------------- | ----------------------------------------------------- | -| `mcpName` | `sprites` | MCP server name and generated tool-name prefix. | -| `url` | `https://sprites.dev/mcp` | Remote MCP endpoint. Must use HTTP or HTTPS. | -| `timeout` | `60000` | MCP connection and discovery timeout in milliseconds. | -| `headers` | `{}` | Headers merged over the plugin's attribution headers. | -| `mcp` | `true` | Register the default MCP server. | -| `commands` | `true` | Register `/sprites-status` and `/sprites-smoke`. | -| `guidance` | `true` | Add workflow guidance to relevant sessions. | -| `permissions` | `true` | Add destructive-tool approval defaults. | +| Option | OpenCode | Default | Meaning | +| ------------- | -------- | ------------------------- | ---------------------------------------------------------- | +| `mcpName` | 1 and 2 | `sprites` | MCP server name and generated tool-name prefix. | +| `url` | 1 and 2 | `https://sprites.dev/mcp` | Remote MCP endpoint. Must use HTTP or HTTPS. | +| `timeout` | 1 and 2 | see below | MCP timeouts in milliseconds. | +| `headers` | 1 and 2 | `{}` | Headers merged over the plugin's attribution headers. | +| `mcp` | 1 and 2 | `true` | Register the default MCP server. | +| `commands` | 1 and 2 | `true` | Register `/sprites-status` and `/sprites-smoke`. | +| `guidance` | 1 and 2 | `true` | Add workflow guidance to relevant sessions. | +| `permissions` | 1 and 2 | `true` | Guard the destructive Sprites tools. | +| `oauth` | 2 only | OAuth enabled | OAuth client settings, or `false` for a header credential. | +| `codemode` | 2 only | OpenCode default (`true`) | Show the Sprites tools through Code Mode. | -When `mcp` is `false`, guidance is active only if an MCP entry with the configured `mcpName` already exists. +OpenCode 1 uses one timeout for the connection and for tool discovery. Its default is 60 seconds. OpenCode 2 separates the timeouts, and this plugin then leaves OpenCode's own defaults in place: 30 seconds for `startup`, 30 seconds for `catalog`, and 12 hours for `execution`. Under OpenCode 2, `timeout` accepts an object with `startup`, `catalog`, and `execution`, or a single number that applies to `startup` and `catalog`. -OpenCode uses the same timeout for the initial remote connection and tool discovery. The 60-second default limits startup stalls when `sprites.dev` is unreachable; increase it only when a slower endpoint warrants the longer connection wait. +The `oauth` field accepts the OpenCode 2 snake_case fields: `client_id`, `client_secret`, `scope`, `callback_port`, and `redirect_uri`. OpenCode 2 ignores an option that belongs to the other release, and so does OpenCode 1. ## Configuration and permissions -The plugin adds defaults without overwriting user-owned values: +The plugin adds defaults, but it does not replace your values: + +- An MCP server that your configuration already defines with the same name wins completely. +- To disable the Sprites server, set `enabled` to `false` (OpenCode 1) or `disabled` to `true` (OpenCode 2) on that server. The plugin then adds no guidance. + +Under OpenCode 1, the plugin also keeps your commands and your exact permission rules, and it preserves a global `"deny"`. Its guarded permission patterns are `sprites_*destroy_sprite`, `sprites_*checkpoint_restore`, and `sprites_*policy_network_update`. A broad rule such as `"sprites_*": "allow"` is overridden by those later patterns. Use exact rules for the guarded tools, or set `permissions` to `false`, when blanket approval is intentional. + +OpenCode 2 gives plugins no permission draft, so there the plugin examines each decision as it happens: -- An existing MCP entry with the configured name wins completely. -- Existing `sprites-status` or `sprites-smoke` commands win. -- Existing exact permission rules win. -- A global `"deny"` is preserved. -- A broad rule such as `"sprites_*": "allow"` is overridden by the plugin's later destructive-tool patterns. Use exact rules for the guarded tools, or set `permissions` to `false`, when blanket approval is intentional. -- Setting the configured MCP server's `enabled` field to `false` suppresses injected guidance. +- A configured `deny` is final. OpenCode does not call the plugin. +- A configured or default `ask` stays an `ask`. +- An `allow` for `sprites_*destroy_sprite`, `sprites_*checkpoint_restore`, or `sprites_*policy_network_update` becomes an `ask`, and the plugin adds the reason. -The guarded patterns are `sprites_*destroy_sprite`, `sprites_*checkpoint_restore`, and `sprites_*policy_network_update`. +Set the `permissions` option to `false` when you intend to allow these tools without a prompt. -`opencode run` rejects `ask` permissions in non-interactive mode unless `--auto` is supplied; `--auto` approves them. For automation, set exact tool permissions intentionally and review the safety consequences rather than relying on an interactive prompt. +`opencode run` and `opencode2 run` reject `ask` permissions in non-interactive mode. Supply `--auto` to approve them. For automation, set exact permission rules on purpose, and examine the safety consequences. The MCP connection sends fixed client-attribution headers: @@ -140,40 +179,40 @@ Fly-Client-Agent: opencode Fly-Client-Interactive: false ``` -These headers contain no user-, machine-, organization-, repository-, or prompt-specific information. They are not used for authentication or authorization. +These headers contain no user, machine, organization, repository, or prompt information. They are not used for authentication or authorization. ## OAuth access restrictions -Restricted connector tokens use a non-empty Sprite-name prefix and may limit how many Sprites the connector can create. The common default is `mcp-`, but the prefix can be customized during OAuth. The plugin tells OpenCode to learn the actual restriction from the API and retry a failed creation once with the required prefix. +Restricted connector tokens use a non-empty Sprite-name prefix, and they can limit how many Sprites the connector creates. The usual default is `mcp-`, but the prefix is configurable during OAuth. The plugin tells OpenCode to learn the actual restriction from the API, and to try a failed creation again one time with the required prefix. -Choosing full access during OAuth removes the prefix restriction and grants access to every Sprite in the organization. Use it only when organization-wide control is intentional. +Full access during OAuth removes the prefix restriction and gives access to every Sprite in the organization. Use it only when organization-wide control is your intention. ## Safety Sprite state is durable: -- Destroying a Sprite permanently deletes its filesystem, services, checkpoints, and URL. -- Restoring a checkpoint discards newer filesystem state. -- Updating a network policy replaces the complete rule set rather than merging it. +- Destruction of a Sprite permanently deletes its filesystem, services, checkpoints, and URL. +- Restoration of a checkpoint discards newer filesystem state. +- An update of a network policy replaces the complete rule set. It does not merge the rules. -Treat anything served through a Sprite URL as potentially internet-accessible. Never expose secrets, environment variables, tokens, arbitrary files, admin or debug endpoints, or unfiltered logs over HTTP. +Anything that a Sprite URL serves can be accessible from the internet. Do not expose secrets, environment variables, tokens, arbitrary files, admin or debug endpoints, or unfiltered logs over HTTP. ## Troubleshooting -If Sprites tools are missing: +If the Sprites tools are not available: -1. Run `opencode mcp list` and confirm the configured server is present. -2. Restart OpenCode so plugin and MCP configuration are reloaded. -3. Run `opencode mcp auth sprites` if the server is present but unauthorized. -4. Use `opencode mcp debug sprites` to inspect OAuth discovery and connectivity. +1. Run `opencode mcp list`, or `opencode2 mcp list`, and make sure that the server is in the list. +2. Restart OpenCode to load the plugin and its MCP configuration again. Under OpenCode 2, `opencode2 service restart` does this. +3. Run `opencode mcp auth sprites`, or `opencode2 mcp auth sprites`, if the server is in the list but is not authorized. +4. Under OpenCode 1, `opencode mcp debug sprites` reports OAuth discovery and connectivity. -Replace `sprites` in those commands if you configured another `mcpName`. +Replace `sprites` in these commands if you set a different `mcpName`. Do not install the Sprites CLI, use raw Sprites API calls, invent access tokens, or register a second Sprites MCP server as an authentication workaround. ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for local development, testing, implementation notes, and the release process. +Refer to [CONTRIBUTING.md](CONTRIBUTING.md) for local development, tests, implementation notes, and the release process. ## License diff --git a/index.js b/index.js deleted file mode 100644 index e98032c..0000000 --- a/index.js +++ /dev/null @@ -1,371 +0,0 @@ -const DEFAULT_MCP_NAME = "sprites"; -const DEFAULT_MCP_URL = "https://sprites.dev/mcp"; -const DEFAULT_MCP_TIMEOUT_MS = 60_000; -const STATUS_CACHE_MS = 30_000; - -const DEFAULT_HEADERS = Object.freeze({ - "Fly-Client-Interactive": "false", - "Fly-Client-Agent": "opencode", -}); - -const RISKY_RAW_TOOLS = Object.freeze([ - "destroy_sprite", - "checkpoint_restore", - "policy_network_update", -]); - -const SPRITES_TRIGGER = - /\bsprites\.dev\b|\bfly\s+sprites?\b|\bsprites_[a-zA-Z0-9_-]*/i; - -/** - * @typedef {object} SpritesOptions - * @property {string=} mcpName Name used to register the MCP server. - * @property {string=} url Sprites MCP endpoint, including staging or self-hosted endpoints. - * @property {number=} timeout MCP connection and discovery timeout in milliseconds. - * @property {Record=} headers Additional or replacement request headers. - * @property {boolean=} mcp Register the default MCP server. - * @property {boolean=} commands Register the Sprites slash commands. - * @property {boolean=} guidance Inject Sprites workflow guidance for relevant sessions. - * @property {boolean=} permissions Add approval defaults for destructive Sprites tools. - */ - -/** @param {string} value */ -function sanitize(value) { - return value.replace(/[^a-zA-Z0-9_-]/g, "_"); -} - -/** @param {string} mcpName @param {string} rawName */ -function toolName(mcpName, rawName) { - return `${sanitize(mcpName)}_${sanitize(rawName)}`; -} - -/** @param {string} mcpName @param {string} rawName */ -function toolPermissionPattern(mcpName, rawName) { - return `${sanitize(mcpName)}_*${sanitize(rawName)}`; -} - -/** @param {unknown} value @returns {value is Record} */ -function isRecord(value) { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -/** @param {unknown} value @returns {Record} */ -function asRecord(value) { - return isRecord(value) ? value : {}; -} - -/** @param {unknown} value @param {string} name @param {string} fallback */ -function stringOption(value, name, fallback) { - if (value === undefined) return fallback; - if (typeof value !== "string" || value.trim() === "") { - throw new TypeError( - `Sprites plugin option ${name} must be a non-empty string`, - ); - } - return value.trim(); -} - -/** @param {unknown} value @param {string} name @param {boolean} fallback */ -function booleanOption(value, name, fallback) { - if (value === undefined) return fallback; - if (typeof value !== "boolean") { - throw new TypeError(`Sprites plugin option ${name} must be a boolean`); - } - return value; -} - -/** @param {unknown} value */ -function headersOption(value) { - if (value === undefined) return {}; - if (!isRecord(value)) { - throw new TypeError( - "Sprites plugin option headers must be a string-to-string object", - ); - } - /** @type {Record} */ - const headers = {}; - for (const [key, header] of Object.entries(value)) { - if (typeof header !== "string") { - throw new TypeError(`Sprites plugin header ${key} must be a string`); - } - headers[key] = header; - } - return headers; -} - -/** @param {import("@opencode-ai/plugin").PluginOptions | undefined} raw */ -function parseOptions(raw) { - const input = raw ?? {}; - const mcpName = stringOption(input.mcpName, "mcpName", DEFAULT_MCP_NAME); - const url = stringOption(input.url, "url", DEFAULT_MCP_URL); - const parsedURL = new URL(url); - if (parsedURL.protocol !== "https:" && parsedURL.protocol !== "http:") { - throw new TypeError("Sprites plugin option url must use http or https"); - } - - const timeoutValue = input.timeout ?? DEFAULT_MCP_TIMEOUT_MS; - if (!Number.isInteger(timeoutValue) || Number(timeoutValue) <= 0) { - throw new TypeError( - "Sprites plugin option timeout must be a positive integer", - ); - } - - return { - mcpName, - url: parsedURL.toString(), - timeout: Number(timeoutValue), - headers: { ...DEFAULT_HEADERS, ...headersOption(input.headers) }, - mcp: booleanOption(input.mcp, "mcp", true), - commands: booleanOption(input.commands, "commands", true), - guidance: booleanOption(input.guidance, "guidance", true), - permissions: booleanOption(input.permissions, "permissions", true), - }; -} - -/** @template T @param {Record} record @param {string} key @param {T} value */ -function addDefault(record, key, value) { - if (!Object.hasOwn(record, key)) record[key] = value; -} - -/** @template T @param {T} value @returns {T} */ -function clone(value) { - return structuredClone(value); -} - -/** @param {string} mcpName */ -function commandDefaults(mcpName) { - const list = toolName(mcpName, "list_sprites"); - return { - "sprites-status": { - description: "Check Sprites connectivity and list visible environments", - template: [ - "Perform a read-only Sprites integration check.", - `Call ${list} and summarize each Sprite's exact name, status, and URL.`, - "An empty list is a successful authenticated result.", - "Do not create, modify, restore, stop, or destroy anything.", - `If authentication is required, tell me to complete OpenCode's OAuth flow for the ${mcpName} MCP server, then retry once.`, - ].join(" "), - }, - "sprites-smoke": { - description: "Run a safe list, create, and exec Sprites smoke test", - template: [ - `Run a Sprites smoke test using only ${sanitize(mcpName)}_* MCP tools: list Sprites, create a short-lived task-named Sprite,`, - "then run `echo smoke-ok` in it and report the exit status and output.", - "Do not destroy the Sprite unless I explicitly request cleanup after seeing its exact name; otherwise leave it running and report it.", - "Additional request: $ARGUMENTS", - ].join(" "), - }, - }; -} - -/** @param {string} mcpName */ -function systemGuidance(mcpName) { - const prefix = sanitize(mcpName); - const list = toolName(mcpName, "list_sprites"); - const create = toolName(mcpName, "create_sprite"); - const exec = toolName(mcpName, "exec"); - return `## Sprites plugin - -Sprites are persistent, isolated remote Linux development environments. OpenCode is outside each Sprite: the local workspace and shell are not the Sprite filesystem. Use the \`${prefix}_*\` MCP tools as the control plane for Sprites, remote sandboxes, and isolated compute. - -- List with \`${list}\`; an empty list is authenticated success. Create with \`${create}\`. Every Sprite-scoped call needs the exact returned Sprite name. -- Restricted OAuth connectors require a name prefix, commonly \`mcp-\` but possibly customized. If create reports a required prefix, retry once with that exact prefix and report the actual name. Do not invent credentials or fall back to raw HTTP or a Sprites CLI. -- Use \`${exec}\` for short remote commands and \`${prefix}_service_*\` for long-running processes. Use \`${prefix}_checkpoint_*\` for reversible filesystem work and \`${prefix}_policy_network_*\` for outbound access. -- There is no dedicated remote file-write tool. Prefer cloning repositories. For small generated files, encode content locally as base64 and use a simple remote decode command; avoid fragile heredocs and nested shell quoting. -- If no target was named, list Sprites and choose only an obvious match; ask when ambiguous. Keep responses focused on names, status, URLs, exit codes, service state, and checkpoint IDs. -- \`${toolName(mcpName, "destroy_sprite")}\` is irreversible: call it only after explicit delete/destroy/remove intent for the exact Sprite. -- \`${toolName(mcpName, "checkpoint_restore")}\` discards newer filesystem state: confirm the exact Sprite and checkpoint and offer to checkpoint the current state first. -- \`${toolName(mcpName, "policy_network_update")}\` replaces the complete outbound rule set: read the current policy first and send the intended complete policy. -- Before risky installs, migrations, or destructive commands through \`${exec}\`, create a checkpoint. If a created service has an HTTP port, treat it as internet-accessible and never expose secrets, environment variables, arbitrary files, admin/debug endpoints, or unfiltered logs. -- OpenCode handles OAuth for the remote MCP server. If automatic authentication does not start, direct the user to \`opencode mcp auth ${mcpName}\`, wait for completion, and retry the original tool once.`; -} - -/** @param {unknown} parts @param {string} toolPrefix */ -function mentionsSprites(parts, toolPrefix) { - if (!Array.isArray(parts)) return false; - return parts.some( - (part) => - isRecord(part) && - part.type === "text" && - typeof part.text === "string" && - (SPRITES_TRIGGER.test(part.text) || - part.text.toLowerCase().includes(toolPrefix.toLowerCase())), - ); -} - -/** @type {import("@opencode-ai/plugin").Plugin} */ -const SpritesPlugin = async ({ client, directory }, rawOptions) => { - const options = parseOptions(rawOptions); - const commands = commandDefaults(options.mcpName); - const guidance = systemGuidance(options.mcpName); - const riskyPermissions = RISKY_RAW_TOOLS.map((name) => ({ - pattern: toolPermissionPattern(options.mcpName, name), - exact: [ - toolName(options.mcpName, name), - toolName(options.mcpName, `sprites_${name}`), - ], - })); - const toolPrefix = `${sanitize(options.mcpName)}_`; - const activeSessions = new Set(); - let guidanceEnabled = options.guidance; - let serverConfigured = false; - let statusCache = { checkedAt: 0, usable: true }; - - async function serverIsUsable() { - if (!serverConfigured) return false; - const now = Date.now(); - if (now - statusCache.checkedAt < STATUS_CACHE_MS) - return statusCache.usable; - try { - const result = await client.mcp.status({ query: { directory } }); - const status = result.data?.[options.mcpName]; - statusCache = { - checkedAt: now, - // Missing or failed status should not break an LLM request. Only an - // explicit disabled state suppresses otherwise relevant guidance. - usable: status?.status !== "disabled", - }; - } catch { - statusCache = { checkedAt: now, usable: true }; - } - return statusCache.usable; - } - - return { - config: async (config) => { - const mcp = config.mcp ?? {}; - Object.assign(config, { mcp }); - if (options.mcp) { - addDefault(mcp, options.mcpName, { - type: "remote", - url: options.url, - enabled: true, - oauth: {}, - headers: clone(options.headers), - timeout: options.timeout, - }); - } - serverConfigured = Object.hasOwn(mcp, options.mcpName); - const configuredServer = mcp[options.mcpName]; - guidanceEnabled = - options.guidance && - (!isRecord(configuredServer) || configuredServer.enabled !== false); - - if (options.commands) { - const commandConfig = config.command ?? {}; - Object.assign(config, { command: commandConfig }); - for (const [name, command] of Object.entries(commands)) { - addDefault(commandConfig, name, clone(command)); - } - } - - if (options.permissions) { - /** @type {Record} */ - let permission; - if (typeof config.permission === "string") { - permission = { "*": config.permission }; - Object.assign(config, { permission }); - } else if (isRecord(config.permission)) { - permission = config.permission; - } else { - permission = {}; - Object.assign(config, { permission }); - } - - if (permission["*"] !== "deny") { - for (const risky of riskyPermissions) { - // OpenCode evaluates permission rules from last to first. Append - // our suffix wildcard after broad user rules, then re-append any - // user-owned exact rules so they remain the final authority. - const exactRules = risky.exact - .filter((tool) => Object.hasOwn(permission, tool)) - .map((tool) => ({ tool, action: permission[tool] })); - const action = Object.hasOwn(permission, risky.pattern) - ? permission[risky.pattern] - : "ask"; - delete permission[risky.pattern]; - permission[risky.pattern] = action; - for (const exact of exactRules) { - delete permission[exact.tool]; - permission[exact.tool] = exact.action; - } - } - } - } - }, - - "chat.message": async (input, output) => { - if (guidanceEnabled && mentionsSprites(output.parts, toolPrefix)) - activeSessions.add(input.sessionID); - }, - - "command.execute.before": async (input) => { - if ( - guidanceEnabled && - (input.command === "sprites-status" || - input.command === "sprites-smoke") - ) { - activeSessions.add(input.sessionID); - } - }, - - "tool.execute.before": async (input) => { - if (guidanceEnabled && input.tool.startsWith(toolPrefix)) - activeSessions.add(input.sessionID); - }, - - "experimental.chat.system.transform": async (input, output) => { - if ( - !input.sessionID || - !guidanceEnabled || - !activeSessions.has(input.sessionID) - ) - return; - if (!(await serverIsUsable())) return; - if (!output.system.includes(guidance)) output.system.push(guidance); - }, - - "experimental.session.compacting": async (input, output) => { - if (!guidanceEnabled || !activeSessions.has(input.sessionID)) return; - const context = `Preserve active Sprites state: exact target names, the learned OAuth name prefix, relevant URLs, service states, checkpoint IDs, network-policy decisions, and pending destructive-operation approvals. Keep local OpenCode state distinct from remote Sprite state. The MCP server name is ${options.mcpName}.`; - if (!output.context.includes(context)) output.context.push(context); - }, - - event: async ({ event }) => { - if ( - event.type === "session.created" && - event.properties.info.parentID && - activeSessions.has(event.properties.info.parentID) - ) { - activeSessions.add(event.properties.info.id); - } - - if (event.type === "session.deleted") { - activeSessions.delete(event.properties.info.id); - } - - // mcp.tools.changed reaches hooks at runtime but is not yet represented - // in the v1 Event union used by @opencode-ai/plugin. - const eventType = /** @type {string} */ (event.type); - const properties = asRecord(event.properties); - if ( - eventType === "mcp.tools.changed" && - properties.server === options.mcpName - ) { - statusCache.checkedAt = 0; - } - }, - - dispose: async () => { - activeSessions.clear(); - }, - }; -}; - -/** @type {import("@opencode-ai/plugin").PluginModule} */ -const plugin = { - id: "sprites", - server: SpritesPlugin, -}; - -export default plugin; diff --git a/package-lock.json b/package-lock.json index 98969ea..a279f09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,16 @@ "version": "0.1.0", "license": "MIT", "devDependencies": { + "@opencode-ai/cli": "0.0.0-beta-18414", "@opencode-ai/plugin": "1.18.23", + "@opencode-ai/plugin-v2": "npm:@opencode-ai/plugin@0.0.0-beta-18414", "opencode-ai": "1.18.23", "prettier": "^3.9.6", "typescript": "^7.0.2" }, "engines": { "node": ">=20", - "opencode": ">=1.18.23 <2" + "opencode": ">=1.18.23" } }, "node_modules/@ai-sdk/provider": { @@ -32,6 +34,93 @@ "node": ">=18" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", @@ -116,6 +205,235 @@ "win32" ] }, + "node_modules/@opencode-ai/ai": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/ai/-/ai-0.0.0-beta-18414.tgz", + "integrity": "sha512-ZDFdDHnD4E8bgyabkoAA8Nppxi8h9+zZNcUpSC1aUdDFcRrR0rsbVEEgZzeFojp+I90rNEVkZaP2fBksu/3Sag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode-ai/schema": "0.0.0-beta-18414", + "@smithy/eventstream-codec": "4.2.14", + "@smithy/util-utf8": "4.2.2", + "aws4fetch": "1.0.20", + "effect": "4.0.0-rc.111", + "google-auth-library": "10.5.0" + } + }, + "node_modules/@opencode-ai/ai/node_modules/effect": { + "version": "4.0.0-rc.111", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.111.tgz", + "integrity": "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.9.0", + "msgpackr": "^2.0.5" + } + }, + "node_modules/@opencode-ai/cli": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/cli/-/cli-0.0.0-beta-18414.tgz", + "integrity": "sha512-oL6xUh+u9jGE+qhQtcrSSo9o9PQfCT6RsuSC6B6xUuHN70D7tKFFGSRjaL3rLk+70xFES/bYdKPSVag2mPYkjw==", + "cpu": [ + "arm64", + "x64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "bin": { + "opencode2": "bin/opencode2.exe" + }, + "optionalDependencies": { + "@opencode-ai/cli-darwin-arm64": "0.0.0-beta-18414", + "@opencode-ai/cli-darwin-x64": "0.0.0-beta-18414", + "@opencode-ai/cli-darwin-x64-baseline": "0.0.0-beta-18414", + "@opencode-ai/cli-linux-arm64": "0.0.0-beta-18414", + "@opencode-ai/cli-linux-arm64-musl": "0.0.0-beta-18414", + "@opencode-ai/cli-linux-x64": "0.0.0-beta-18414", + "@opencode-ai/cli-linux-x64-baseline": "0.0.0-beta-18414", + "@opencode-ai/cli-linux-x64-baseline-musl": "0.0.0-beta-18414", + "@opencode-ai/cli-linux-x64-musl": "0.0.0-beta-18414", + "@opencode-ai/cli-windows-arm64": "0.0.0-beta-18414", + "@opencode-ai/cli-windows-x64": "0.0.0-beta-18414", + "@opencode-ai/cli-windows-x64-baseline": "0.0.0-beta-18414" + } + }, + "node_modules/@opencode-ai/cli-darwin-arm64": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/cli-darwin-arm64/-/cli-darwin-arm64-0.0.0-beta-18414.tgz", + "integrity": "sha512-io2l9Pj54zRlYX7CS0wmcfhXOrWxlNJJd7aUmZqPWczqjZDuFGRr2gu5cjL6J/I/mkTWVSuJRTLqbRMfwHaRCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opencode-ai/cli-darwin-x64": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/cli-darwin-x64/-/cli-darwin-x64-0.0.0-beta-18414.tgz", + "integrity": "sha512-rQKBWFYaOifWdFGZIhnIjk8+sns6H9DiaZLHooM5D1k1Q30xtKw6tY4dW8Xp3QF1VAH0pJulfZQChY4eIK8Eww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opencode-ai/cli-darwin-x64-baseline": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/cli-darwin-x64-baseline/-/cli-darwin-x64-baseline-0.0.0-beta-18414.tgz", + "integrity": "sha512-HmNpkvWGlONMmWs27gAVXIscRakHFOdQlhCIZDz7+QUnU42MeENaMXONaG/Z6J4sbA+QUgXt1vkkaVaKux7YCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opencode-ai/cli-linux-arm64": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/cli-linux-arm64/-/cli-linux-arm64-0.0.0-beta-18414.tgz", + "integrity": "sha512-R+Bjnsdf5zyx2PKM9RAhqVUxsR19GD5p2vLejzu/GxRu8MoK1YuhXkWpRtXow2BrW03NOZUbWu/IvUNIeg06Dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opencode-ai/cli-linux-arm64-musl": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/cli-linux-arm64-musl/-/cli-linux-arm64-musl-0.0.0-beta-18414.tgz", + "integrity": "sha512-WbKtRQuHKT0stk97wdIVabHCuDJONwwM5FbxmmSb/QZTkaW+pnUCxm0tivSfrsOC5sxC8uBW+pfxYazV2PCMgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opencode-ai/cli-linux-x64": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/cli-linux-x64/-/cli-linux-x64-0.0.0-beta-18414.tgz", + "integrity": "sha512-Puth62wKnFZL1Gjj3/V4iIaTBOxSKkcOnY4qbRl6qLHdwVR/oPddDZ3jmuL4t4PLJQeCjA+uIV9TUQJEgV1S/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opencode-ai/cli-linux-x64-baseline": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/cli-linux-x64-baseline/-/cli-linux-x64-baseline-0.0.0-beta-18414.tgz", + "integrity": "sha512-LrWH4mVKQ3/JTFIrizoRKU8d3HhHEruIn/dHf2loYmP79QBYjNFQlXueKnpA8IPErIcyyo1WFAcOGGlfvJR7+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opencode-ai/cli-linux-x64-baseline-musl": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/cli-linux-x64-baseline-musl/-/cli-linux-x64-baseline-musl-0.0.0-beta-18414.tgz", + "integrity": "sha512-sJ7YNrvKxu5LnxITDK1QKb5cZh/ukwbOksyf/4CCwjny5ZvgTVlM+X0br1arFbLNdBStwDafVb8qenoZv4IrSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opencode-ai/cli-linux-x64-musl": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/cli-linux-x64-musl/-/cli-linux-x64-musl-0.0.0-beta-18414.tgz", + "integrity": "sha512-42rHzrw9QCwlyOGyDIiOXlSOplLkuZTlo6gAxa58+7oeurrVC3mUeUUz0aSWXAj0ekqZy8jcR4IWGRuedFzVMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opencode-ai/cli-windows-arm64": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/cli-windows-arm64/-/cli-windows-arm64-0.0.0-beta-18414.tgz", + "integrity": "sha512-txd0mR6PxNuZRVcGv5I74P+eOOUYtgfxpG85KYQc3fheDplGOszUIoBQT80lia11A3JNRfMaYdnDWAZzn5ZWcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/cli-windows-x64": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/cli-windows-x64/-/cli-windows-x64-0.0.0-beta-18414.tgz", + "integrity": "sha512-m1qoc8uFd3HW1dki2skyqxdm8JdvXOXntIWPeE+6voHQsMKCX7HUMduQdp7WjHDNIWTvHzfRoRU2r+NYHlbnpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/cli-windows-x64-baseline": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/cli-windows-x64-baseline/-/cli-windows-x64-baseline-0.0.0-beta-18414.tgz", + "integrity": "sha512-kxh5VhEFYch/3kcmfPwF4DqOgy1IRQrNOoysSVfxZAg7JEaVACvfQPHWwQOfscXeaLwX7CZ4Jx4Ty+cSPrIilw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@opencode-ai/plugin": { "version": "1.18.23", "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.23.tgz", @@ -145,6 +463,125 @@ } } }, + "node_modules/@opencode-ai/plugin-v2": { + "name": "@opencode-ai/plugin", + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-0.0.0-beta-18414.tgz", + "integrity": "sha512-LMbrlatgIkz3Qza5HhLKNBbqitN7Cd/l7ToIhjHbXLFu9blWn+F+awwft94+ZKChaWomFImcYlBw2FbCpDBFEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/ai": "0.0.0-beta-18414", + "@opencode-ai/client": "0.0.0-beta-18414", + "@opencode-ai/protocol": "0.0.0-beta-18414", + "@opencode-ai/schema": "0.0.0-beta-18414", + "@standard-schema/spec": "1.1.0", + "effect": "4.0.0-rc.111", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opencode-ai/theme": "0.0.0-beta-18414", + "@opentui/core": ">=0.5.8", + "@opentui/solid": ">=0.5.8", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "@opencode-ai/theme": { + "optional": true + }, + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/plugin-v2/node_modules/@opencode-ai/client": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-18414.tgz", + "integrity": "sha512-G/0UtYjhDq/fkmqFz60yCwlmUB8HTHUTNA0GlfTHzKt4t/sQ1+wqhPG61FWV6UsIvPOB6jWXtYqs5aeKCM+2fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode-ai/protocol": "0.0.0-beta-18414", + "@opencode-ai/schema": "0.0.0-beta-18414" + }, + "peerDependencies": { + "effect": "4.0.0-rc.111", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/plugin-v2/node_modules/effect": { + "version": "4.0.0-rc.111", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.111.tgz", + "integrity": "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.9.0", + "msgpackr": "^2.0.5" + } + }, + "node_modules/@opencode-ai/protocol": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-18414.tgz", + "integrity": "sha512-TeckyJdHDgt02/ctNV1Bg86tw1Wkc0OxgGjki/lhv7xWwiskAkgTvpcxA9zFa7QNftVLr0wU+tmibca99aC4hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode-ai/schema": "0.0.0-beta-18414", + "effect": "4.0.0-rc.111" + } + }, + "node_modules/@opencode-ai/protocol/node_modules/effect": { + "version": "4.0.0-rc.111", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.111.tgz", + "integrity": "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.9.0", + "msgpackr": "^2.0.5" + } + }, + "node_modules/@opencode-ai/schema": { + "version": "0.0.0-beta-18414", + "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-18414.tgz", + "integrity": "sha512-fuOKoTeqQQNITmFo/Ru4+8Ltc4U9BJpq2c7oipP3Pox6L1I/9p0NN5fAb2JJ73FbBnp9KZszKZCcGYn9cTQ5TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "effect": "4.0.0-rc.111" + } + }, + "node_modules/@opencode-ai/schema/node_modules/effect": { + "version": "4.0.0-rc.111", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.111.tgz", + "integrity": "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.9.0", + "msgpackr": "^2.0.5" + } + }, "node_modules/@opencode-ai/sdk": { "version": "1.18.23", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.23.tgz", @@ -155,6 +592,115 @@ "cross-spawn": "7.0.6" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", + "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz", + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.5.2.tgz", + "integrity": "sha512-nxu3SgmAw9JXT2CtkU0m/XNLWpP9MsaBx1zAGAypCbYj15tIFlmcYwpF+Oh18le83d+IM9PT7ENdXnE4C+d5mA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.5.2.tgz", + "integrity": "sha512-iq+cW3mAb7vfcxEEpYi3zXKpDtbrIFyanWjQl4zBq4seWD4OSxXDWSfespZxenX6aEaighn+NR3u1nU1DSvs3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -502,6 +1048,124 @@ "node": ">=16.20.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aws4fetch": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", + "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -517,6 +1181,34 @@ "node": ">= 8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -528,6 +1220,23 @@ "node": ">=8" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/effect": { "version": "4.0.0-beta.83", "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", @@ -547,35 +1256,238 @@ "yaml": "^2.9.0" } }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-check": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.4.tgz", + "integrity": "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "7.1.3", + "google-logging-utils": "1.1.3", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.2.0.tgz", + "integrity": "sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "dev": true, "license": "MIT", "dependencies": { - "pure-rand": "^8.0.0" + "gaxios": "^7.0.0", + "jws": "^4.0.0" }, "engines": { - "node": ">=12.17.0" + "node": ">=18" } }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } }, "node_modules/ini": { "version": "7.0.0", @@ -587,6 +1499,16 @@ "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -594,6 +1516,32 @@ "dev": true, "license": "ISC" }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", @@ -601,6 +1549,29 @@ "dev": true, "license": "(AFL-2.1 OR BSD-3-Clause)" }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kubernetes-types": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", @@ -608,6 +1579,46 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/msgpackr": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.6.tgz", @@ -648,6 +1659,46 @@ "dev": true, "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", @@ -758,6 +1809,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -797,6 +1851,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -810,6 +1867,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -854,6 +1914,13 @@ "win32" ] }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -864,6 +1931,23 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/prettier": { "version": "3.9.6", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", @@ -897,6 +1981,43 @@ ], "license": "MIT" }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -920,6 +2041,123 @@ "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/toml": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", @@ -930,6 +2168,13 @@ "node": ">=20" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -979,6 +2224,16 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -995,6 +2250,104 @@ "node": ">= 8" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index f225e5e..15ac243 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { "name": "@flydotio/sprites-opencode-plugin", "version": "0.1.0", - "description": "Use Fly.io Sprites as isolated remote development environments from OpenCode.", + "description": "Use Fly.io Sprites as isolated remote development environments from OpenCode 1 and OpenCode 2.", "type": "module", - "main": "./index.js", + "main": "./src/v2.js", "exports": { - ".": "./index.js", - "./server": "./index.js" + ".": "./src/v2.js", + "./server": "./src/v1.js" }, "files": [ - "index.js", + "src", "LICENSE", "README.md" ], @@ -17,9 +17,12 @@ "check": "npm run format:check && npm run typecheck && npm test", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "node --test test/plugin.test.js test/opencode.integration.test.js", + "test": "npm run test:exports && npm run test:v1 && npm run test:v2", + "test:v1": "node --test test/v1.test.js test/opencode1.integration.test.js", + "test:v2": "node --test test/v2.test.js test/opencode2.integration.test.js", "test:bun": "bun test ./test/plugin.bun.js", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:exports": "node --test test/exports.test.js" }, "keywords": [ "opencode", @@ -42,12 +45,18 @@ "publishConfig": { "access": "public" }, + "allowScripts": { + "@opencode-ai/cli": true, + "opencode-ai": true + }, "engines": { "node": ">=20", - "opencode": ">=1.18.23 <2" + "opencode": ">=1.18.23" }, "devDependencies": { + "@opencode-ai/cli": "0.0.0-beta-18414", "@opencode-ai/plugin": "1.18.23", + "@opencode-ai/plugin-v2": "npm:@opencode-ai/plugin@0.0.0-beta-18414", "opencode-ai": "1.18.23", "prettier": "^3.9.6", "typescript": "^7.0.2" diff --git a/src/shared.js b/src/shared.js new file mode 100644 index 0000000..df3da74 --- /dev/null +++ b/src/shared.js @@ -0,0 +1,221 @@ +/** + * Behavior that the OpenCode 1 and OpenCode 2 entry points have in common. + * + * The two plugin APIs are different, but the Sprites rules are not: the same + * server defaults, the same tool names, the same guidance, the same commands, + * and the same destructive tools. Keep those here so the two entry points + * cannot drift apart. + */ + +export const DEFAULT_MCP_NAME = "sprites"; +export const DEFAULT_MCP_URL = "https://sprites.dev/mcp"; + +export const DEFAULT_HEADERS = Object.freeze({ + "Fly-Client-Interactive": "false", + "Fly-Client-Agent": "opencode", +}); + +/** Raw Sprites tool names that need explicit approval before they run. */ +export const RISKY_RAW_TOOLS = Object.freeze({ + destroy_sprite: + "Destroying a Sprite permanently deletes its filesystem, services, checkpoints, and URL.", + checkpoint_restore: + "Restoring a checkpoint discards filesystem state newer than the checkpoint.", + policy_network_update: + "Updating a network policy replaces the complete outbound rule set.", +}); + +const SPRITES_TRIGGER = + /\bsprites\.dev\b|\bfly\s+sprites?\b|\bsprites_[a-zA-Z0-9_-]*/i; + +/** @param {string} value */ +export function sanitize(value) { + return value.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +/** @param {string} mcpName @param {string} rawName */ +export function toolName(mcpName, rawName) { + return `${sanitize(mcpName)}_${sanitize(rawName)}`; +} + +/** @param {unknown} value @returns {value is Record} */ +export function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** @param {unknown} value @param {string} name @param {string} fallback */ +export function stringOption(value, name, fallback) { + if (value === undefined) return fallback; + if (typeof value !== "string" || value.trim() === "") { + throw new TypeError( + `Sprites plugin option ${name} must be a non-empty string`, + ); + } + return value.trim(); +} + +/** @param {unknown} value @param {string} name @param {boolean} fallback */ +export function booleanOption(value, name, fallback) { + if (value === undefined) return fallback; + if (typeof value !== "boolean") { + throw new TypeError(`Sprites plugin option ${name} must be a boolean`); + } + return value; +} + +/** @param {unknown} value @param {string} name */ +export function millisecondsOption(value, name) { + if (!Number.isInteger(value) || Number(value) <= 0) { + throw new TypeError( + `Sprites plugin option ${name} must be a positive integer`, + ); + } + return Number(value); +} + +/** @param {unknown} value */ +export function headersOption(value) { + if (value === undefined) return {}; + if (!isRecord(value)) { + throw new TypeError( + "Sprites plugin option headers must be a string-to-string object", + ); + } + /** @type {Record} */ + const headers = {}; + for (const [key, header] of Object.entries(value)) { + if (typeof header !== "string") { + throw new TypeError(`Sprites plugin header ${key} must be a string`); + } + headers[key] = header; + } + return headers; +} + +/** @param {unknown} value */ +export function endpointOption(value) { + const url = stringOption(value, "url", DEFAULT_MCP_URL); + const parsed = new URL(url); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new TypeError("Sprites plugin option url must use http or https"); + } + return parsed.toString(); +} + +/** Options that both entry points accept the same way. */ +export function commonOptions(/** @type {Record} */ input) { + return { + mcpName: stringOption(input.mcpName, "mcpName", DEFAULT_MCP_NAME), + url: endpointOption(input.url), + headers: { ...DEFAULT_HEADERS, ...headersOption(input.headers) }, + mcp: booleanOption(input.mcp, "mcp", true), + commands: booleanOption(input.commands, "commands", true), + guidance: booleanOption(input.guidance, "guidance", true), + permissions: booleanOption(input.permissions, "permissions", true), + }; +} + +/** @param {unknown} text @param {string} toolPrefix */ +export function mentionsSprites(text, toolPrefix) { + if (typeof text !== "string") return false; + return ( + SPRITES_TRIGGER.test(text) || + text.toLowerCase().includes(toolPrefix.toLowerCase()) + ); +} + +/** + * Reports why a tool needs approval, or undefined when it does not. The suffix + * match covers the verified raw tool names and any prefixed variant of them. + * + * @param {string} action + */ +export function riskyReason(action) { + for (const [rawName, reason] of Object.entries(RISKY_RAW_TOOLS)) { + if (action.endsWith(sanitize(rawName))) return reason; + } + return undefined; +} + +/** + * @param {string} mcpName + * @param {object} [runtime] + * @param {string} [runtime.cli] Command name that authenticates the MCP server. + * @param {string} [runtime.argumentsSuffix] Text that carries the caller's own request. + */ +export function commandTemplates(mcpName, runtime = {}) { + const { cli = "opencode", argumentsSuffix } = runtime; + const list = toolName(mcpName, "list_sprites"); + const smoke = [ + `Run a Sprites smoke test using only ${sanitize(mcpName)}_* MCP tools: list Sprites, create a short-lived task-named Sprite,`, + "then run `echo smoke-ok` in it and report the exit status and output.", + "Do not destroy the Sprite unless I explicitly request cleanup after seeing its exact name; otherwise leave it running and report it.", + ]; + if (argumentsSuffix) smoke.push(argumentsSuffix); + + return { + "sprites-status": { + description: "Check Sprites connectivity and list visible environments", + template: [ + "Perform a read-only Sprites integration check.", + `Call ${list} and summarize each Sprite's exact name, status, and URL.`, + "An empty list is a successful authenticated result.", + "Do not create, modify, restore, stop, or destroy anything.", + `If authentication is required, tell me to run \`${cli} mcp auth ${mcpName}\`, then retry once.`, + ].join(" "), + }, + "sprites-smoke": { + description: "Run a safe list, create, and exec Sprites smoke test", + template: smoke.join(" "), + }, + }; +} + +/** + * @param {string} mcpName + * @param {object} [runtime] + * @param {string} [runtime.cli] Command name that authenticates the MCP server. + * @param {boolean} [runtime.files] The MCP server offers remote file tools. + * @param {boolean} [runtime.compaction] The entry point has no compaction hook. + */ +export function systemGuidance(mcpName, runtime = {}) { + const { cli = "opencode", files = true, compaction = false } = runtime; + const prefix = sanitize(mcpName); + const list = toolName(mcpName, "list_sprites"); + const create = toolName(mcpName, "create_sprite"); + const exec = toolName(mcpName, "exec"); + + const transfer = files + ? `- Move files with \`${prefix}_file_read\`, \`${prefix}_file_write\`, \`${prefix}_file_list\`, \`${prefix}_file_delete\`, \`${prefix}_file_rename\`, and \`${prefix}_file_copy\` instead of heredocs and nested shell quoting. Prefer cloning a repository over transferring many files.` + : "- There is no dedicated remote file-write tool. Prefer cloning repositories. For small generated files, encode content locally as base64 and use a simple remote decode command; avoid fragile heredocs and nested shell quoting."; + + const lines = [ + `## Sprites plugin`, + "", + `Sprites are persistent, isolated remote Linux development environments. OpenCode is outside each Sprite: the local workspace and shell are not the Sprite filesystem. Use the \`${prefix}_*\` MCP tools as the control plane for Sprites, remote sandboxes, and isolated compute.`, + "", + `- List with \`${list}\`; an empty list is authenticated success. Create with \`${create}\`. Every Sprite-scoped call needs the exact returned Sprite name.`, + "- Restricted OAuth connectors require a name prefix, commonly `mcp-` but possibly customized. If create reports a required prefix, retry once with that exact prefix and report the actual name. Do not invent credentials or fall back to raw HTTP or a Sprites CLI.", + `- Use \`${exec}\` for short remote commands and \`${prefix}_service_*\` for long-running processes. Use \`${prefix}_checkpoint_*\` for reversible filesystem work and \`${prefix}_policy_network_*\` for outbound access.`, + transfer, + "- If no target was named, list Sprites and choose only an obvious match; ask when ambiguous. Keep responses focused on names, status, URLs, exit codes, service state, and checkpoint IDs.", + `- \`${toolName(mcpName, "destroy_sprite")}\` is irreversible: call it only after explicit delete/destroy/remove intent for the exact Sprite.`, + `- \`${toolName(mcpName, "checkpoint_restore")}\` discards newer filesystem state: confirm the exact Sprite and checkpoint and offer to checkpoint the current state first.`, + `- \`${toolName(mcpName, "policy_network_update")}\` replaces the complete outbound rule set: read the current policy first and send the intended complete policy.`, + `- Before risky installs, migrations, or destructive commands through \`${exec}\`, create a checkpoint. If a created service has an HTTP port, treat it as internet-accessible and never expose secrets, environment variables, arbitrary files, admin/debug endpoints, or unfiltered logs.`, + `- OpenCode handles OAuth for the remote MCP server. If automatic authentication does not start, direct the user to \`${cli} mcp auth ${mcpName}\`, wait for completion, and retry the original tool once.`, + ]; + + if (compaction) { + lines.push( + "- Compaction drops detail: before summarizing, restate the exact Sprite names, the learned OAuth name prefix, relevant URLs, service states, checkpoint IDs, and pending destructive-operation approvals.", + ); + } + + return lines.join("\n"); +} + +/** Context that survives compaction, for entry points with a compaction hook. */ +export function compactionContext(/** @type {string} */ mcpName) { + return `Preserve active Sprites state: exact target names, the learned OAuth name prefix, relevant URLs, service states, checkpoint IDs, network-policy decisions, and pending destructive-operation approvals. Keep local OpenCode state distinct from remote Sprite state. The MCP server name is ${mcpName}.`; +} diff --git a/src/v1.js b/src/v1.js new file mode 100644 index 0000000..1189f6a --- /dev/null +++ b/src/v1.js @@ -0,0 +1,260 @@ +/** + * The OpenCode 1 entry point, loaded through the package's "./server" export. + * + * OpenCode 1 has a configuration hook, so this entry point writes the MCP + * server, the commands, and the permission defaults into the configuration. + */ + +import { + DEFAULT_HEADERS, + RISKY_RAW_TOOLS, + booleanOption, + commandTemplates, + commonOptions, + compactionContext, + isRecord, + mentionsSprites, + millisecondsOption, + sanitize, + systemGuidance, + toolName, +} from "./shared.js"; + +const DEFAULT_MCP_TIMEOUT_MS = 60_000; +const STATUS_CACHE_MS = 30_000; + +/** + * @typedef {object} SpritesOptions + * @property {string=} mcpName Name used to register the MCP server. + * @property {string=} url Sprites MCP endpoint, including staging or self-hosted endpoints. + * @property {number=} timeout MCP connection and discovery timeout in milliseconds. + * @property {Record=} headers Additional or replacement request headers. + * @property {boolean=} mcp Register the default MCP server. + * @property {boolean=} commands Register the Sprites slash commands. + * @property {boolean=} guidance Inject Sprites workflow guidance for relevant sessions. + * @property {boolean=} permissions Add approval defaults for destructive Sprites tools. + */ + +/** @param {string} mcpName @param {string} rawName */ +function toolPermissionPattern(mcpName, rawName) { + return `${sanitize(mcpName)}_*${sanitize(rawName)}`; +} + +/** @param {unknown} value @returns {Record} */ +function asRecord(value) { + return isRecord(value) ? value : {}; +} + +/** @param {import("@opencode-ai/plugin").PluginOptions | undefined} raw */ +function parseOptions(raw) { + const input = raw ?? {}; + return { + ...commonOptions(input), + // OpenCode 1 uses one timeout for the connection and for tool discovery. + timeout: millisecondsOption( + input.timeout ?? DEFAULT_MCP_TIMEOUT_MS, + "timeout", + ), + }; +} + +/** @template T @param {Record} record @param {string} key @param {T} value */ +function addDefault(record, key, value) { + if (!Object.hasOwn(record, key)) record[key] = value; +} + +/** @template T @param {T} value @returns {T} */ +function clone(value) { + return structuredClone(value); +} + +/** @param {unknown} parts @param {string} toolPrefix */ +function mentionsParts(parts, toolPrefix) { + if (!Array.isArray(parts)) return false; + return parts.some( + (part) => + isRecord(part) && + part.type === "text" && + mentionsSprites(part.text, toolPrefix), + ); +} + +/** @type {import("@opencode-ai/plugin").Plugin} */ +const SpritesPlugin = async ({ client, directory }, rawOptions) => { + const options = parseOptions(rawOptions); + const commands = commandTemplates(options.mcpName, { + cli: "opencode", + argumentsSuffix: "Additional request: $ARGUMENTS", + }); + const guidance = systemGuidance(options.mcpName, { cli: "opencode" }); + const riskyPermissions = Object.keys(RISKY_RAW_TOOLS).map((name) => ({ + pattern: toolPermissionPattern(options.mcpName, name), + exact: [ + toolName(options.mcpName, name), + toolName(options.mcpName, `sprites_${name}`), + ], + })); + const toolPrefix = `${sanitize(options.mcpName)}_`; + const activeSessions = new Set(); + let guidanceEnabled = options.guidance; + let serverConfigured = false; + let statusCache = { checkedAt: 0, usable: true }; + + async function serverIsUsable() { + if (!serverConfigured) return false; + const now = Date.now(); + if (now - statusCache.checkedAt < STATUS_CACHE_MS) + return statusCache.usable; + try { + const result = await client.mcp.status({ query: { directory } }); + const status = result.data?.[options.mcpName]; + statusCache = { + checkedAt: now, + // Missing or failed status should not break an LLM request. Only an + // explicit disabled state suppresses otherwise relevant guidance. + usable: status?.status !== "disabled", + }; + } catch { + statusCache = { checkedAt: now, usable: true }; + } + return statusCache.usable; + } + + return { + config: async (config) => { + const mcp = config.mcp ?? {}; + Object.assign(config, { mcp }); + if (options.mcp) { + addDefault(mcp, options.mcpName, { + type: "remote", + url: options.url, + enabled: true, + oauth: {}, + headers: clone(options.headers), + timeout: options.timeout, + }); + } + serverConfigured = Object.hasOwn(mcp, options.mcpName); + const configuredServer = mcp[options.mcpName]; + guidanceEnabled = + options.guidance && + (!isRecord(configuredServer) || configuredServer.enabled !== false); + + if (options.commands) { + const commandConfig = config.command ?? {}; + Object.assign(config, { command: commandConfig }); + for (const [name, command] of Object.entries(commands)) { + addDefault(commandConfig, name, clone(command)); + } + } + + if (options.permissions) { + /** @type {Record} */ + let permission; + if (typeof config.permission === "string") { + permission = { "*": config.permission }; + Object.assign(config, { permission }); + } else if (isRecord(config.permission)) { + permission = config.permission; + } else { + permission = {}; + Object.assign(config, { permission }); + } + + if (permission["*"] !== "deny") { + for (const risky of riskyPermissions) { + // OpenCode evaluates permission rules from last to first. Append + // our suffix wildcard after broad user rules, then re-append any + // user-owned exact rules so they remain the final authority. + const exactRules = risky.exact + .filter((tool) => Object.hasOwn(permission, tool)) + .map((tool) => ({ tool, action: permission[tool] })); + const action = Object.hasOwn(permission, risky.pattern) + ? permission[risky.pattern] + : "ask"; + delete permission[risky.pattern]; + permission[risky.pattern] = action; + for (const exact of exactRules) { + delete permission[exact.tool]; + permission[exact.tool] = exact.action; + } + } + } + } + }, + + "chat.message": async (input, output) => { + if (guidanceEnabled && mentionsParts(output.parts, toolPrefix)) + activeSessions.add(input.sessionID); + }, + + "command.execute.before": async (input) => { + if ( + guidanceEnabled && + (input.command === "sprites-status" || + input.command === "sprites-smoke") + ) { + activeSessions.add(input.sessionID); + } + }, + + "tool.execute.before": async (input) => { + if (guidanceEnabled && input.tool.startsWith(toolPrefix)) + activeSessions.add(input.sessionID); + }, + + "experimental.chat.system.transform": async (input, output) => { + if ( + !input.sessionID || + !guidanceEnabled || + !activeSessions.has(input.sessionID) + ) + return; + if (!(await serverIsUsable())) return; + if (!output.system.includes(guidance)) output.system.push(guidance); + }, + + "experimental.session.compacting": async (input, output) => { + if (!guidanceEnabled || !activeSessions.has(input.sessionID)) return; + const context = compactionContext(options.mcpName); + if (!output.context.includes(context)) output.context.push(context); + }, + + event: async ({ event }) => { + if ( + event.type === "session.created" && + event.properties.info.parentID && + activeSessions.has(event.properties.info.parentID) + ) { + activeSessions.add(event.properties.info.id); + } + + if (event.type === "session.deleted") { + activeSessions.delete(event.properties.info.id); + } + + // mcp.tools.changed reaches hooks at runtime but is not yet represented + // in the v1 Event union used by @opencode-ai/plugin. + const eventType = /** @type {string} */ (event.type); + const properties = asRecord(event.properties); + if ( + eventType === "mcp.tools.changed" && + properties.server === options.mcpName + ) { + statusCache.checkedAt = 0; + } + }, + + dispose: async () => { + activeSessions.clear(); + }, + }; +}; + +/** @type {import("@opencode-ai/plugin").PluginModule} */ +const plugin = { + id: "sprites", + server: SpritesPlugin, +}; + +export default plugin; diff --git a/src/v2.js b/src/v2.js new file mode 100644 index 0000000..0c1e6c5 --- /dev/null +++ b/src/v2.js @@ -0,0 +1,301 @@ +/** + * The OpenCode 2 entry point, loaded through the package's "." export. + * + * OpenCode 2 has no configuration hook. A plugin registers servers and + * commands with transforms, and intercepts live operations with hooks. + */ + +import { + RISKY_RAW_TOOLS, + booleanOption, + commandTemplates, + commonOptions, + isRecord, + mentionsSprites, + millisecondsOption, + riskyReason, + sanitize, + systemGuidance, + toolName, +} from "./shared.js"; + +const STATUS_CACHE_MS = 30_000; +const MAX_ACTIVE_SESSIONS = 1_000; + +/** + * @typedef {object} SpritesOptions + * @property {string=} mcpName Name used to register the MCP server. + * @property {string=} url Sprites MCP endpoint, including staging or self-hosted endpoints. + * @property {number | {startup?: number, catalog?: number, execution?: number}=} timeout MCP timeout overrides in milliseconds. + * @property {Record=} headers Additional or replacement request headers. + * @property {false | Record=} oauth OAuth client settings, or false for header credentials. + * @property {boolean=} codemode Expose Sprites tools through Code Mode. + * @property {boolean=} mcp Register the default MCP server. + * @property {boolean=} commands Register the Sprites slash commands. + * @property {boolean=} guidance Inject Sprites workflow guidance for relevant sessions. + * @property {boolean=} permissions Ask before destructive Sprites tools run. + */ + +/** + * OpenCode v2 separates connection, discovery, and execution timeouts. A plain + * number keeps the v1 spelling and applies to connection and discovery only. + * + * @param {unknown} value + */ +function timeoutOption(value) { + if (value === undefined) return undefined; + if (typeof value === "number") { + const timeout = millisecondsOption(value, "timeout"); + return { startup: timeout, catalog: timeout }; + } + if (!isRecord(value)) { + throw new TypeError( + "Sprites plugin option timeout must be a positive integer or an object", + ); + } + /** @type {{startup?: number, catalog?: number, execution?: number}} */ + const timeout = {}; + for (const key of /** @type {const} */ ([ + "startup", + "catalog", + "execution", + ])) { + if (value[key] === undefined) continue; + timeout[key] = millisecondsOption(value[key], `timeout.${key}`); + } + if (Object.keys(timeout).length === 0) return undefined; + return timeout; +} + +/** @param {unknown} value */ +function oauthOption(value) { + if (value === undefined) return undefined; + if (value === false) return /** @type {const} */ (false); + if (!isRecord(value)) { + throw new TypeError( + "Sprites plugin option oauth must be false or an OAuth client object", + ); + } + /** @type {Record} */ + const oauth = {}; + for (const [key, setting] of Object.entries(value)) { + if (typeof setting !== "string" && typeof setting !== "number") { + throw new TypeError( + `Sprites plugin oauth field ${key} must be a string or number`, + ); + } + oauth[key] = setting; + } + return oauth; +} + +/** @param {import("@opencode-ai/plugin-v2").PluginOptions | undefined} raw */ +function parseOptions(raw) { + const input = raw ?? {}; + return { + ...commonOptions(input), + timeout: timeoutOption(input.timeout), + oauth: oauthOption(input.oauth), + codemode: + input.codemode === undefined + ? undefined + : booleanOption(input.codemode, "codemode", true), + }; +} + +/** @param {ReturnType} options */ +function serverConfig(options) { + /** @type {Record} */ + const config = { + type: "remote", + url: options.url, + headers: { ...options.headers }, + }; + if (options.oauth !== undefined) config.oauth = options.oauth; + if (options.codemode !== undefined) config.codemode = options.codemode; + if (options.timeout !== undefined) config.timeout = { ...options.timeout }; + return /** @type {import("@opencode-ai/plugin-v2").Mcp.ServerConfig} */ ( + /** @type {unknown} */ (config) + ); +} + +/** + * Strips mention offsets that no longer line up once a command rewrites the + * prompt text around the caller's arguments. + * + * @template {{mention?: unknown}} T + * @param {ReadonlyArray | undefined} entries + */ +function withoutMentions(entries) { + if (!entries?.length) return undefined; + return entries.map(({ mention: _mention, ...entry }) => entry); +} + +/** @type {import("@opencode-ai/plugin-v2").Plugin.Plugin} */ +const SpritesPlugin = { + id: "sprites", + async setup(ctx) { + const options = parseOptions(ctx.options); + const toolPrefix = `${sanitize(options.mcpName)}_`; + const guidance = systemGuidance(options.mcpName, { + cli: "opencode2", + // OpenCode 2 has no compaction hook, so the instruction lives in the + // guidance itself. + compaction: true, + }); + const templates = commandTemplates(options.mcpName, { cli: "opencode2" }); + + /** @type {Set} */ + const activeSessions = new Set(); + let statusCache = { checkedAt: 0, usable: false }; + + /** @param {string | undefined} sessionID */ + function activate(sessionID) { + if (!options.guidance || !sessionID) return; + activeSessions.delete(sessionID); + activeSessions.add(sessionID); + // Session deletion is best-effort, so bound the set rather than trusting + // every session to announce its end. + while (activeSessions.size > MAX_ACTIVE_SESSIONS) { + const oldest = activeSessions.values().next().value; + if (oldest === undefined) break; + activeSessions.delete(oldest); + } + } + + async function serverIsUsable() { + const now = Date.now(); + if (now - statusCache.checkedAt < STATUS_CACHE_MS) + return statusCache.usable; + try { + const servers = (await ctx.mcp.list()).data ?? []; + const server = servers.find((entry) => entry.name === options.mcpName); + // An empty catalog usually means MCP configuration has not been + // materialized yet, so treat it as unknown rather than caching a + // missing server. + if (server === undefined && servers.length === 0) return true; + statusCache = { + checkedAt: now, + // A missing server means nothing registered it. Only an explicit + // disabled state suppresses otherwise relevant guidance; a failed or + // unauthenticated server still needs the recovery instructions. + usable: server !== undefined && server.status.status !== "disabled", + }; + } catch { + statusCache = { checkedAt: now, usable: true }; + } + return statusCache.usable; + } + + if (options.mcp) { + await ctx.mcp.transform((draft) => { + // A server that configuration or an earlier plugin already defined + // wins completely. + if (draft.get(options.mcpName) !== undefined) return; + draft.set(options.mcpName, serverConfig(options)); + }); + } + + if (options.commands) { + // OpenCode replays this transform on every reload, and a reload sees the + // previous registration's commands, so registration cannot be + // conditional on the current catalog without erasing itself. + const definitions = Object.entries(templates).map( + ([name, { description, template }]) => ({ + name, + description, + /** @param {import("@opencode-ai/plugin-v2/promise/command").CommandInvocation} input */ + execute: async ({ sessionID, prompt, delivery }) => { + activate(sessionID); + const args = + typeof prompt.text === "string" ? prompt.text.trim() : ""; + await ctx.session.prompt({ + sessionID, + text: args + ? `${template}\n\nAdditional request: ${args}` + : template, + files: withoutMentions(prompt.files), + agents: withoutMentions(prompt.agents), + skills: withoutMentions(prompt.skills), + delivery, + }); + }, + }), + ); + + await ctx.command.transform((draft) => { + for (const definition of definitions) draft.add(definition); + }); + } + + if (options.guidance || options.permissions) { + await ctx.permission.hook("evaluate", (input) => { + if (!input.action.startsWith(toolPrefix)) return; + activate(input.sessionID); + if (!options.permissions) return; + const reason = riskyReason(input.action); + // An explicit deny never reaches this hook, and an existing ask needs + // no escalation. + if (reason === undefined || input.effect !== "allow") return; + input.effect = "ask"; + input.message ??= reason; + }); + } + + if (options.guidance) { + await ctx.tool.hook("execute.before", (input) => { + if (input.tool.startsWith(toolPrefix)) activate(input.sessionID); + }); + + await ctx.session.hook("prompt", (input) => { + if (mentionsSprites(input.prompt.text, toolPrefix)) + activate(input.sessionID); + }); + + await ctx.session.hook("context", async (input) => { + if (!activeSessions.has(input.sessionID)) return; + if (!(await serverIsUsable())) return; + if (input.system.some((part) => part.text === guidance)) return; + input.system.push({ type: "text", text: guidance }); + }); + } + + const events = new AbortController(); + void (async () => { + try { + for await (const event of ctx.event.subscribe({ + signal: events.signal, + })) { + switch (event.type) { + case "session.created": + // A subagent working on Sprites needs the same guidance as the + // session that started the work. + if ( + event.data.parentID !== undefined && + activeSessions.has(event.data.parentID) + ) { + activate(event.data.sessionID); + } + break; + case "session.deleted": + activeSessions.delete(event.data.sessionID); + break; + case "mcp.status.changed": + if (event.data.server === options.mcpName) + statusCache = { checkedAt: 0, usable: statusCache.usable }; + break; + } + } + } catch { + // The stream ends when the plugin unloads or the server goes away. + } + })(); + + return () => { + events.abort(); + activeSessions.clear(); + }; + }, +}; + +export default SpritesPlugin; diff --git a/test/context.js b/test/context.js new file mode 100644 index 0000000..16c79df --- /dev/null +++ b/test/context.js @@ -0,0 +1,285 @@ +/** + * A stand-in for the OpenCode 2 plugin context. + * + * OpenCode keeps transforms and replays them on a fresh draft each time it + * materializes the configuration, so this harness does the same. Registrations + * remove their own contribution, and event delivery waits for the plugin to + * process the event. + * + * Every method is typed with the signature from `@opencode-ai/plugin`, so the + * harness cannot offer an easier API than the runtime. `test/type-contract.ts` + * asserts the same thing for the assembled context. + * + * @typedef {import("@opencode-ai/plugin-v2").Plugin.Context} Context + * @typedef {import("@opencode-ai/plugin-v2/promise/registration").Registration} Registration + * @typedef {import("@opencode-ai/plugin-v2/promise/mcp").MCPDraft} MCPDraft + * @typedef {import("@opencode-ai/plugin-v2/promise/command").CommandDraft} CommandDraft + * @typedef {import("@opencode-ai/plugin-v2/promise/command").CommandDefinition} CommandDefinition + * @typedef {Context["event"]["subscribe"] extends (...args: never[]) => AsyncIterable ? E : never} ServerEvent + */ + +/** + * @param {object} [input] + * @param {Record} [input.options] + * @param {Record} [input.servers] Servers that configuration already defines. + * @param {string[]} [input.commands] Command names that configuration already defines. + * @param {"connected" | "pending" | "disabled" | "failed" | "needs_auth"} [input.status] + * @param {() => void} [input.onMcpList] + */ +export function createContext({ + options = {}, + servers = {}, + commands = [], + status = "connected", + onMcpList = () => {}, +} = {}) { + // The lower-precedence layer that OpenCode builds from configuration. + const configuredServers = Object.entries(servers); + const configuredCommands = commands.map((name) => ({ name })); + + /** @type {{callback: (draft: MCPDraft) => void}[]} */ + const mcpTransforms = []; + /** @type {{callback: (draft: CommandDraft) => void}[]} */ + const commandTransforms = []; + /** @type {Map unknown}[]>} */ + const hooks = new Map(); + /** @type {unknown[]} */ + const prompts = []; + /** @type {Set<(item: {event: ServerEvent, done: () => void}) => void>} */ + const listeners = new Set(); + + /** + * Removes one entry from a list, one time only. + * + * @template T + * @param {T[]} list + * @param {T} entry + * @returns {Registration} + */ + function registration(list, entry) { + return { + dispose: async () => { + const index = list.indexOf(entry); + if (index >= 0) list.splice(index, 1); + }, + }; + } + + /** + * @template T + * @param {{callback: (draft: T) => void}[]} list + * @param {(draft: T) => void} callback + */ + function transform(list, callback) { + const entry = { callback }; + list.push(entry); + return registration(list, entry); + } + + /** + * @param {string} domain + * @param {string | number | symbol} name + * @param {(input: any) => unknown} callback + */ + function hook(domain, name, callback) { + const key = `${domain}.${String(name)}`; + const list = hooks.get(key) ?? []; + hooks.set(key, list); + const entry = { callback }; + list.push(entry); + return registration(list, entry); + } + + /** Replays every active transform, the way OpenCode does after a reload. */ + function materialize() { + /** @type {Map} */ + const draftServers = new Map(configuredServers); + for (const { callback } of mcpTransforms) { + callback({ + list: () => [...draftServers.entries()], + get: (name) => draftServers.get(name), + set: (name, config) => draftServers.set(name, config), + update: (name, update) => update(draftServers.get(name)), + remove: (name) => draftServers.delete(name), + }); + } + + /** @type {(CommandDefinition | {name: string})[]} */ + const draftCommands = configuredCommands.map((command) => ({ ...command })); + for (const { callback } of commandTransforms) { + callback({ + add: (definition) => { + const index = draftCommands.findIndex( + (command) => command.name === definition.name, + ); + if (index >= 0) draftCommands[index] = definition; + else draftCommands.push(definition); + }, + }); + } + + return { servers: draftServers, commands: draftCommands }; + } + + // Location identifiers are branded strings at the type level. + const location = /** @type {Context["location"]} */ ( + /** @type {unknown} */ ({ + directory: "/work", + project: { id: "test", directory: "/work", canonical: "/work" }, + }) + ); + + /** + * Status is per server, so a test can move one server between states while + * the others stay the same. + * + * @type {Map} + */ + const serverStatus = new Map(); + + /** @param {string} name */ + function statusOf(name) { + const current = serverStatus.get(name) ?? status; + // A failed server always reports why it failed. + return current === "failed" + ? /** @type {const} */ ({ status: current, error: "test failure" }) + : { status: current }; + } + + const ctx = { + app: { name: "cli", version: "0.0.0-test", channel: "beta" }, + location, + options, + mcp: { + list: async () => { + onMcpList(); + return { + location, + data: [...materialize().servers.keys()].map((name) => ({ + name, + status: statusOf(name), + })), + }; + }, + /** @param {(draft: MCPDraft) => void} callback */ + transform: async (callback) => transform(mcpTransforms, callback), + }, + command: { + list: async () => ({ + location, + data: /** @type {any} */ (materialize().commands), + }), + /** @param {(draft: CommandDraft) => void} callback */ + transform: async (callback) => transform(commandTransforms, callback), + }, + permission: { + /** @type {Context["permission"]["hook"]} */ + hook: async (name, callback) => hook("permission", name, callback), + }, + session: { + /** @type {Context["session"]["hook"]} */ + hook: async (name, callback) => hook("session", name, callback), + /** @type {Context["session"]["prompt"]} */ + prompt: async (input) => { + prompts.push(input); + return /** @type {any} */ (input); + }, + }, + tool: { + /** @type {Context["tool"]["hook"]} */ + hook: async (name, callback) => hook("tool", name, callback), + }, + event: { + /** @param {{signal?: AbortSignal}} [requestOptions] */ + subscribe: ({ signal } = {}) => ({ + async *[Symbol.asyncIterator]() { + /** @type {{event: ServerEvent, done: () => void}[]} */ + const queue = []; + /** @type {(() => void) | undefined} */ + let wake; + /** @param {{event: ServerEvent, done: () => void}} item */ + const listener = (item) => { + queue.push(item); + wake?.(); + }; + listeners.add(listener); + try { + while (!signal?.aborted) { + if (queue.length === 0) { + await new Promise((resolve) => { + wake = () => resolve(undefined); + signal?.addEventListener("abort", wake, { once: true }); + }); + wake = undefined; + } + while (queue.length > 0) { + const item = + /** @type {{event: ServerEvent, done: () => void}} */ ( + queue.shift() + ); + try { + // The generator resumes only after the consumer's loop body + // finishes, so this reports real delivery. + yield item.event; + } finally { + item.done(); + } + } + } + } finally { + listeners.delete(listener); + for (const item of queue) item.done(); + } + }, + }), + }, + }; + + return { + ctx, + prompts, + servers: () => materialize().servers, + commands: () => materialize().commands, + /** + * Changes one server's status, the way a connection change does. + * + * @param {string} name + * @param {"connected" | "pending" | "disabled" | "failed" | "needs_auth"} next + */ + setStatus(name, next) { + serverStatus.set(name, next); + }, + /** @param {string} name */ + command: (name) => + materialize().commands.find((entry) => entry.name === name), + /** + * Invokes every callback registered for a hook, in registration order. + * + * @param {string} key + * @template T + * @param {T} input + */ + async fire(key, input) { + for (const { callback } of hooks.get(key) ?? []) await callback(input); + return input; + }, + /** @param {string} key */ + has: (key) => (hooks.get(key)?.length ?? 0) > 0, + /** + * Publishes an event and waits until every subscriber has processed it. + * + * @param {any} event + */ + async emit(event) { + if (listeners.size === 0) return; + await Promise.all( + [...listeners].map( + (listener) => + new Promise((resolve) => + listener({ event, done: () => resolve(undefined) }), + ), + ), + ); + }, + }; +} diff --git a/test/exports.test.js b/test/exports.test.js new file mode 100644 index 0000000..61364c1 --- /dev/null +++ b/test/exports.test.js @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Node resolves a package's own name through its exports map, so these are the +// same specifiers OpenCode uses. OpenCode 1 loads "./server" and OpenCode 2 +// loads ".". +const NAME = "@flydotio/sprites-opencode-plugin"; + +test("the package root is the OpenCode 2 plugin", async () => { + const { default: plugin } = await import(NAME); + + assert.equal(plugin.id, "sprites"); + assert.equal(typeof plugin.setup, "function"); + assert.equal(plugin.server, undefined); +}); + +test("the server export is the OpenCode 1 plugin", async () => { + const { default: plugin } = await import(`${NAME}/server`); + + assert.equal(plugin.id, "sprites"); + assert.equal(typeof plugin.server, "function"); + assert.equal(plugin.setup, undefined); +}); + +test("both entry points describe the same Sprites rules", async () => { + const [{ default: v2 }, { default: v1 }] = await Promise.all([ + import(NAME), + import(`${NAME}/server`), + ]); + const shared = await import("../src/shared.js"); + + assert.equal(v1.id, v2.id); + for (const rawName of Object.keys(shared.RISKY_RAW_TOOLS)) { + assert.match( + shared.systemGuidance("sprites"), + new RegExp(shared.toolName("sprites", rawName)), + ); + } +}); diff --git a/test/opencode.integration.test.js b/test/opencode1.integration.test.js similarity index 100% rename from test/opencode.integration.test.js rename to test/opencode1.integration.test.js diff --git a/test/opencode2.integration.test.js b/test/opencode2.integration.test.js new file mode 100644 index 0000000..86b0d89 --- /dev/null +++ b/test/opencode2.integration.test.js @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createServer } from "node:net"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const opencode = path.join(root, "node_modules", ".bin", "opencode2"); + +// These tests drive the real OpenCode 2 background service. `npm ci` installs +// that binary, so a missing binary is a failure, not a skip: a silent skip +// would let `npm run check` pass while nothing runs against the real runtime. +// Set SPRITES_SKIP_OPENCODE_TESTS to opt out on purpose. +const optOut = process.env.SPRITES_SKIP_OPENCODE_TESTS + ? "SPRITES_SKIP_OPENCODE_TESTS is set" + : false; + +function requireOpenCode() { + assert.ok( + existsSync(opencode), + `OpenCode 2 is not installed at ${opencode}. Run npm ci, or set SPRITES_SKIP_OPENCODE_TESTS=1 to skip these tests.`, + ); +} + +/** Reserves a port so an isolated service does not collide with a running one. */ +async function freePort() { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = /** @type {import("node:net").AddressInfo} */ ( + server.address() + ); + await new Promise((resolve) => server.close(resolve)); + return port; +} + +async function startWorkspace(t) { + const directory = mkdtempSync(path.join(tmpdir(), "sprites-opencode2-")); + const environment = { + ...process.env, + XDG_CACHE_HOME: path.join(directory, ".xdg", "cache"), + XDG_CONFIG_HOME: path.join(directory, ".xdg", "config"), + XDG_DATA_HOME: path.join(directory, ".xdg", "data"), + XDG_STATE_HOME: path.join(directory, ".xdg", "state"), + }; + + const run = (args, timeout = 120_000) => + spawnSync(opencode, args, { + cwd: directory, + encoding: "utf8", + timeout, + env: environment, + }); + + // The plugin points at a closed port, so the server reaches a failed state + // quickly without any network access. + const port = await freePort(); + writeFileSync( + path.join(directory, "opencode.jsonc"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugins: [ + { + package: root, + options: { + url: `http://127.0.0.1:${port}/mcp`, + oauth: false, + timeout: { startup: 1_000, catalog: 1_000 }, + }, + }, + ], + }), + ); + + run(["service", "set", "port", String(await freePort())]); + t.after(() => { + run(["service", "stop"], 30_000); + rmSync(directory, { recursive: true, force: true }); + }); + + return { directory, run }; +} + +/** Polls until the location finishes loading its plugins. */ +async function until(check, attempts = 12) { + let last; + for (let attempt = 0; attempt < attempts; attempt += 1) { + last = check(); + if (last !== undefined) return last; + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + return last; +} + +test( + "OpenCode 2 registers the plugin's MCP server", + { skip: optOut }, + async (t) => { + requireOpenCode(); + const { run } = await startWorkspace(t); + + const output = await until(() => { + const result = run(["mcp", "list"]); + return result.stdout?.includes("sprites") ? result.stdout : undefined; + }); + + assert.match(output ?? "", /sprites/); + }, +); + +test( + "OpenCode 2 registers the Sprites commands", + { skip: optOut }, + async (t) => { + requireOpenCode(); + const { directory, run } = await startWorkspace(t); + const query = `/api/command?location[directory]=${directory}`; + + const commands = await until(() => { + const result = run(["api", "GET", query]); + try { + const names = JSON.parse(result.stdout).data.map( + (/** @type {{name: string}} */ command) => command.name, + ); + return names.includes("sprites-status") ? names : undefined; + } catch { + return undefined; + } + }); + + assert.ok(commands?.includes("sprites-status"), JSON.stringify(commands)); + assert.ok(commands?.includes("sprites-smoke"), JSON.stringify(commands)); + }, +); diff --git a/test/plugin.bun.js b/test/plugin.bun.js index f5e56f7..2cb5c9f 100644 --- a/test/plugin.bun.js +++ b/test/plugin.bun.js @@ -1,9 +1,11 @@ import { expect, test } from "bun:test"; -import plugin from "../index.js"; +import v1 from "../src/v1.js"; +import v2 from "../src/v2.js"; +import { createContext } from "./context.js"; -test("loads and configures under Bun", async () => { - const hooks = await plugin.server({ +test("the OpenCode 1 entry point configures under Bun", async () => { + const hooks = await v1.server({ client: { mcp: { status: async () => ({ data: { sprites: { status: "connected" } } }), @@ -15,6 +17,30 @@ test("loads and configures under Bun", async () => { await hooks.config(config); + expect(v1.id).toBe("sprites"); expect(config.mcp.sprites.url).toBe("https://sprites.dev/mcp"); expect(config.permission["sprites_*destroy_sprite"]).toBe("ask"); }); + +test("the OpenCode 2 entry point registers under Bun", async () => { + const harness = createContext(); + + const cleanup = await v2.setup(harness.ctx); + + expect(v2.id).toBe("sprites"); + expect(harness.servers().get("sprites").url).toBe("https://sprites.dev/mcp"); + expect(harness.commands().map((command) => command.name)).toEqual([ + "sprites-status", + "sprites-smoke", + ]); + + const evaluation = await harness.fire("permission.evaluate", { + sessionID: "bun", + action: "sprites_destroy_sprite", + resources: ["*"], + effect: "allow", + }); + expect(evaluation.effect).toBe("ask"); + + await cleanup(); +}); diff --git a/test/type-contract.ts b/test/type-contract.ts index 2c32e13..f26eb3e 100644 --- a/test/type-contract.ts +++ b/test/type-contract.ts @@ -1,12 +1,27 @@ -import { createOpencodeClient } from "@opencode-ai/sdk"; +// Type assertions for both entry points. The OpenCode 1 types come from +// `@opencode-ai/plugin`, and the OpenCode 2 beta types from the +// `@opencode-ai/plugin-v2` alias of the same package. import type { Config, Hooks, PluginInput, - PluginOptions, + PluginOptions as V1PluginOptions, } from "@opencode-ai/plugin"; +import type { + Mcp, + Plugin, + PluginOptions as V2PluginOptions, +} from "@opencode-ai/plugin-v2"; +import type { CommandInvocation } from "@opencode-ai/plugin-v2/promise/command"; +import type { PermissionEvaluation } from "@opencode-ai/plugin-v2/promise/permission"; +import type { SessionContext } from "@opencode-ai/plugin-v2/promise/session"; +import { createOpencodeClient } from "@opencode-ai/sdk"; + +import v1 from "../src/v1.js"; +import v2 from "../src/v2.js"; +import { createContext } from "./context.js"; -import plugin from "../index.js"; +// --- OpenCode 1 ----------------------------------------------------------- const client = createOpencodeClient({ baseUrl: "http://opencode.test", @@ -18,13 +33,9 @@ const client = createOpencodeClient({ declare const shell: PluginInput["$"]; -const input = { +const v1Input = { client, - project: { - id: "test-project", - worktree: "/work", - time: { created: 0 }, - }, + project: { id: "test-project", worktree: "/work", time: { created: 0 } }, directory: "/work", worktree: "/work", experimental_workspace: { register() {} }, @@ -32,7 +43,7 @@ const input = { $: shell, } satisfies PluginInput; -const options = { +const v1Options = { mcpName: "sprites-staging", url: "https://staging.example.test/mcp", timeout: 15_000, @@ -41,31 +52,97 @@ const options = { commands: true, guidance: true, permissions: true, -} satisfies PluginOptions; +} satisfies V1PluginOptions; -async function exerciseHooks(hooks: Hooks) { +async function exerciseV1(hooks: Hooks) { const config: Config = {}; await hooks.config?.(config); - await client.mcp.status({ query: { directory: input.directory } }); - - const beforeTool: Parameters>[0] = { - tool: "sprites_list_sprites", - sessionID: "session", - callID: "call", - }; - await hooks["tool.execute.before"]?.(beforeTool, { args: {} }); - - const systemInput: Parameters< - NonNullable - >[0] = { - sessionID: "session", - model: {} as Parameters< - NonNullable - >[0]["model"], - }; - await hooks["experimental.chat.system.transform"]?.(systemInput, { - system: [], + await hooks["tool.execute.before"]?.( + { tool: "sprites_list_sprites", sessionID: "session", callID: "call" }, + { args: {} }, + ); + await hooks["experimental.chat.system.transform"]?.( + { + sessionID: "session", + model: {} as Parameters< + NonNullable + >[0]["model"], + }, + { system: [] }, + ); +} + +void v1.server(v1Input, v1Options).then(exerciseV1); + +// --- OpenCode 2 ----------------------------------------------------------- + +const v2Contract: Plugin.Plugin = v2; + +const v2Options = { + ...v1Options, + timeout: { startup: 15_000, catalog: 15_000, execution: 600_000 }, + oauth: { client_id: "client", callback_port: 19_876 }, + codemode: false, +} satisfies V2PluginOptions; + +const server: Mcp.ServerConfig = { + type: "remote", + url: v2Options.url, + headers: v2Options.headers, + oauth: false, + codemode: v2Options.codemode, + timeout: v2Options.timeout, +}; + +declare const invocation: CommandInvocation; +declare const evaluation: PermissionEvaluation; +declare const sessionContext: SessionContext; + +async function exerciseV2(setupContext: Plugin.Context) { + const cleanup = await v2Contract.setup(setupContext); + if (typeof cleanup === "function") await cleanup(); + + await setupContext.session.prompt({ + sessionID: invocation.sessionID, + text: invocation.prompt.text, + delivery: invocation.delivery, }); + + evaluation.effect = "ask"; + evaluation.message = "needs approval"; + sessionContext.system.push({ type: "text", text: "guidance" }); } -void plugin.server(input, options).then(exerciseHooks); +// The fake plugin context must not invent a friendlier API than the runtime. +type Fake = ReturnType["ctx"]; +declare const fake: Fake; + +const conformance: { + app: Plugin.Context["app"]; + location: Plugin.Context["location"]; + options: Plugin.Context["options"]; + mcpList: Plugin.Context["mcp"]["list"]; + mcpTransform: Plugin.Context["mcp"]["transform"]; + commandList: Plugin.Context["command"]["list"]; + commandTransform: Plugin.Context["command"]["transform"]; + permissionHook: Plugin.Context["permission"]["hook"]; + sessionHook: Plugin.Context["session"]["hook"]; + sessionPrompt: Plugin.Context["session"]["prompt"]; + toolHook: Plugin.Context["tool"]["hook"]; + eventSubscribe: Plugin.Context["event"]["subscribe"]; +} = { + app: fake.app, + location: fake.location, + options: fake.options, + mcpList: fake.mcp.list, + mcpTransform: fake.mcp.transform, + commandList: fake.command.list, + commandTransform: fake.command.transform, + permissionHook: fake.permission.hook, + sessionHook: fake.session.hook, + sessionPrompt: fake.session.prompt, + toolHook: fake.tool.hook, + eventSubscribe: fake.event.subscribe, +}; + +export { conformance, exerciseV2, server }; diff --git a/test/plugin.test.js b/test/v1.test.js similarity index 99% rename from test/plugin.test.js rename to test/v1.test.js index 4c72ad5..d1e82ba 100644 --- a/test/plugin.test.js +++ b/test/v1.test.js @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createOpencodeClient } from "@opencode-ai/sdk"; -import plugin from "../index.js"; +import plugin from "../src/v1.js"; function fakeClient(status = "connected", onStatus = () => {}) { return createOpencodeClient({ diff --git a/test/v2.test.js b/test/v2.test.js new file mode 100644 index 0000000..8c05306 --- /dev/null +++ b/test/v2.test.js @@ -0,0 +1,618 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import plugin from "../src/v2.js"; +import { createContext } from "./context.js"; + +async function setup(options, overrides) { + const harness = createContext({ options, ...overrides }); + const cleanup = await plugin.setup(harness.ctx); + return { ...harness, cleanup }; +} + +function sessionCreated(sessionID, parentID) { + return { + type: "session.created", + id: `evt-${sessionID}`, + created: 0, + durable: { aggregateID: sessionID, seq: 1, version: 1 }, + data: { + sessionID, + parentID, + projectID: "test", + location: { directory: "/work" }, + slug: sessionID, + version: "0.0.0-test", + }, + }; +} + +test("exports an OpenCode v2 plugin", () => { + assert.equal(plugin.id, "sprites"); + assert.equal(typeof plugin.setup, "function"); + assert.deepEqual(Object.keys(plugin).sort(), ["id", "setup"]); +}); + +test("registers the Sprites MCP server", async () => { + const { servers } = await setup(); + + assert.deepEqual(servers().get("sprites"), { + type: "remote", + url: "https://sprites.dev/mcp", + headers: { + "Fly-Client-Interactive": "false", + "Fly-Client-Agent": "opencode", + }, + }); +}); + +test("supports custom MCP settings and optional features", async () => { + const { servers, commands, ctx, has } = await setup({ + mcpName: "sprites-staging", + url: "https://staging.example.test/mcp", + timeout: 15_000, + headers: { "X-Test": "yes" }, + oauth: false, + codemode: false, + commands: false, + guidance: false, + permissions: false, + }); + + assert.deepEqual(servers().get("sprites-staging"), { + type: "remote", + url: "https://staging.example.test/mcp", + headers: { + "Fly-Client-Interactive": "false", + "Fly-Client-Agent": "opencode", + "X-Test": "yes", + }, + oauth: false, + codemode: false, + timeout: { startup: 15_000, catalog: 15_000 }, + }); + assert.deepEqual(commands(), []); + assert.equal(has("session.context"), false); + assert.equal(has("permission.evaluate"), false); + assert.equal(ctx.options.mcpName, "sprites-staging"); +}); + +test("accepts separate v2 timeouts and OAuth client settings", async () => { + const { servers } = await setup({ + timeout: { catalog: 45_000, execution: 600_000 }, + oauth: { client_id: "abc", callback_port: 19876 }, + }); + + const server = servers().get("sprites"); + assert.deepEqual(server.timeout, { catalog: 45_000, execution: 600_000 }); + assert.deepEqual(server.oauth, { client_id: "abc", callback_port: 19876 }); +}); + +test("leaves a server that configuration already defines untouched", async () => { + const existing = { type: "remote", url: "https://example.test/mcp" }; + const { servers } = await setup(undefined, { + servers: { sprites: existing }, + }); + + assert.equal(servers().get("sprites"), existing); +}); + +test("skips MCP registration when mcp is false", async () => { + const { servers } = await setup({ mcp: false }); + + assert.equal(servers().size, 0); +}); + +test("registers commands that prompt the session", async () => { + const harness = await setup(); + const status = harness.command("sprites-status"); + const smoke = harness.command("sprites-smoke"); + + assert.equal( + status.description, + "Check Sprites connectivity and list visible environments", + ); + assert.ok(smoke); + + await status.execute({ + sessionID: "cmd", + prompt: { text: "" }, + delivery: "steer", + }); + await smoke.execute({ + sessionID: "cmd", + prompt: { + text: "use a big Sprite", + files: [ + { uri: "file:///note.md", mention: { start: 0, end: 1, text: "" } }, + ], + }, + delivery: "queue", + }); + + assert.match(harness.prompts[0].text, /sprites_list_sprites/); + assert.equal(harness.prompts[0].delivery, "steer"); + assert.match(harness.prompts[1].text, /Additional request: use a big Sprite/); + assert.equal(harness.prompts[1].delivery, "queue"); + assert.deepEqual(harness.prompts[1].files, [{ uri: "file:///note.md" }]); +}); + +test("a command run marks its session active", async () => { + const harness = await setup(); + + await harness + .command("sprites-status") + .execute({ sessionID: "cmd", prompt: { text: "" }, delivery: "steer" }); + const context = await harness.fire("session.context", { + sessionID: "cmd", + system: [], + }); + + assert.equal(context.system.length, 1); +}); + +test("registers both commands regardless of the current catalog", async () => { + // OpenCode replays the transform on reload, and that replay sees the + // previous registration, so registration must not depend on the catalog. + const harness = await setup(undefined, { + commands: ["sprites-status", "sprites-smoke"], + }); + + assert.deepEqual( + harness.commands().map((command) => command.name), + ["sprites-status", "sprites-smoke"], + ); + assert.equal(typeof harness.command("sprites-status").execute, "function"); +}); + +test("a second setup keeps both commands and one MCP server", async () => { + // A plugin reload runs setup again while the previous registration is still + // active. Neither pass may delete the other's commands. + const harness = createContext(); + await plugin.setup(harness.ctx); + await plugin.setup(harness.ctx); + + assert.deepEqual( + harness.commands().map((command) => command.name), + ["sprites-status", "sprites-smoke"], + ); + assert.equal(harness.servers().size, 1); +}); + +test("a disposed registration removes its commands and server", async () => { + const harness = createContext(); + /** @type {{dispose: () => Promise}[]} */ + const registrations = []; + const ctx = { + ...harness.ctx, + mcp: { + ...harness.ctx.mcp, + transform: async (callback) => { + const entry = await harness.ctx.mcp.transform(callback); + registrations.push(entry); + return entry; + }, + }, + command: { + ...harness.ctx.command, + transform: async (callback) => { + const entry = await harness.ctx.command.transform(callback); + registrations.push(entry); + return entry; + }, + }, + }; + await plugin.setup(ctx); + assert.equal(harness.commands().length, 2); + + for (const entry of registrations) await entry.dispose(); + + assert.deepEqual(harness.commands(), []); + assert.equal(harness.servers().size, 0); +}); + +test("escalates destructive Sprites tools to an approval prompt", async () => { + const harness = await setup(); + + for (const action of [ + "sprites_destroy_sprite", + "sprites_checkpoint_restore", + "sprites_policy_network_update", + ]) { + const evaluation = await harness.fire("permission.evaluate", { + sessionID: "risky", + action, + resources: ["*"], + effect: "allow", + }); + assert.equal(evaluation.effect, "ask", action); + assert.match(evaluation.message, /\S/); + } + + const safe = await harness.fire("permission.evaluate", { + sessionID: "risky", + action: "sprites_list_sprites", + resources: ["*"], + effect: "allow", + }); + assert.equal(safe.effect, "allow"); + + const unrelated = await harness.fire("permission.evaluate", { + sessionID: "risky", + action: "shell", + resources: ["rm -rf /"], + effect: "allow", + }); + assert.equal(unrelated.effect, "allow"); +}); + +test("guards a redundantly prefixed tool name", async () => { + const harness = await setup(); + + const evaluation = await harness.fire("permission.evaluate", { + sessionID: "risky", + action: "sprites_sprites_destroy_sprite", + resources: ["*"], + effect: "allow", + }); + + assert.equal(evaluation.effect, "ask"); +}); + +test("keeps an existing ask and never relaxes a decision", async () => { + const harness = await setup(); + + const asked = await harness.fire("permission.evaluate", { + sessionID: "risky", + action: "sprites_destroy_sprite", + resources: ["*"], + effect: "ask", + message: "user message", + }); + + assert.equal(asked.effect, "ask"); + assert.equal(asked.message, "user message"); +}); + +test("permissions false leaves destructive decisions alone but still tracks the session", async () => { + const harness = await setup({ permissions: false }); + + const evaluation = await harness.fire("permission.evaluate", { + sessionID: "risky", + action: "sprites_destroy_sprite", + resources: ["*"], + effect: "allow", + }); + assert.equal(evaluation.effect, "allow"); + + const context = await harness.fire("session.context", { + sessionID: "risky", + system: [], + }); + assert.equal(context.system.length, 1); +}); + +test("injects guidance only for relevant, session-bound requests", async () => { + const harness = await setup(); + + const unrelated = await harness.fire("session.context", { + sessionID: "unrelated", + system: [], + }); + assert.deepEqual(unrelated.system, []); + + await harness.fire("session.prompt", { + sessionID: "sprite-session", + prompt: { text: "Create an environment through sprites.dev" }, + }); + const relevant = { sessionID: "sprite-session", system: [] }; + await harness.fire("session.context", relevant); + await harness.fire("session.context", relevant); + + assert.equal(relevant.system.length, 1); + assert.equal(relevant.system[0].type, "text"); + assert.match(relevant.system[0].text, /destroy_sprite.*irreversible/s); + assert.match( + relevant.system[0].text, + /policy_network_update.*replaces the complete/s, + ); + assert.match(relevant.system[0].text, /sprites_file_write/); +}); + +test("does not activate for 2D graphics or generic remote environments", async () => { + const harness = await setup(); + + for (const [index, text] of [ + "Optimize this sprite sheet", + "Fix the CSS sprites", + "Preview the sprite animation", + "Use a remote development environment", + "Deploy this application to Fly.io", + ].entries()) { + const sessionID = `graphics-${index}`; + await harness.fire("session.prompt", { sessionID, prompt: { text } }); + const context = await harness.fire("session.context", { + sessionID, + system: [], + }); + assert.deepEqual(context.system, [], text); + } +}); + +test("sprites.dev, fly sprites, and textual tool IDs activate guidance", async () => { + const harness = await setup(); + + for (const [index, text] of [ + "Create this through sprites.dev", + "Use fly sprites for the build", + "Call sprites_list_sprites first", + ].entries()) { + const sessionID = `signal-${index}`; + await harness.fire("session.prompt", { sessionID, prompt: { text } }); + const context = await harness.fire("session.context", { + sessionID, + system: [], + }); + assert.equal(context.system.length, 1, text); + } +}); + +test("a Sprites tool call or permission check activates its session", async () => { + const harness = await setup(); + + await harness.fire("tool.execute.before", { + tool: "sprites_list_sprites", + sessionID: "tool-session", + }); + await harness.fire("permission.evaluate", { + sessionID: "permission-session", + action: "sprites_exec", + resources: ["*"], + effect: "allow", + }); + + for (const sessionID of ["tool-session", "permission-session"]) { + const context = await harness.fire("session.context", { + sessionID, + system: [], + }); + assert.equal(context.system.length, 1, sessionID); + } +}); + +test("active parent sessions propagate guidance to sub-sessions", async () => { + const harness = await setup(); + await harness.fire("session.prompt", { + sessionID: "parent", + prompt: { text: "Use sprites.dev for this task" }, + }); + + await harness.emit(sessionCreated("child", "parent")); + const child = await harness.fire("session.context", { + sessionID: "child", + system: [], + }); + assert.equal(child.system.length, 1); + + await harness.emit(sessionCreated("orphan", "someone-else")); + const orphan = await harness.fire("session.context", { + sessionID: "orphan", + system: [], + }); + assert.deepEqual(orphan.system, []); + + await harness.emit({ + type: "session.deleted", + id: "evt-delete", + created: 0, + durable: { aggregateID: "child", seq: 2, version: 2 }, + data: { sessionID: "child" }, + }); + const deleted = await harness.fire("session.context", { + sessionID: "child", + system: [], + }); + assert.deepEqual(deleted.system, []); + + await harness.cleanup(); +}); + +test("a disabled MCP server suppresses guidance", async () => { + const harness = await setup(undefined, { status: "disabled" }); + + await harness.fire("session.prompt", { + sessionID: "disabled-session", + prompt: { text: "Call sprites_list_sprites" }, + }); + const context = await harness.fire("session.context", { + sessionID: "disabled-session", + system: [], + }); + + assert.deepEqual(context.system, []); +}); + +test("an unregistered MCP server suppresses guidance", async () => { + const harness = await setup( + { mcp: false }, + { servers: { other: { type: "remote", url: "https://example.test/mcp" } } }, + ); + + await harness.fire("session.prompt", { + sessionID: "unregistered", + prompt: { text: "Call sprites_list_sprites" }, + }); + const context = await harness.fire("session.context", { + sessionID: "unregistered", + system: [], + }); + + assert.deepEqual(context.system, []); +}); + +test("a failed or unauthenticated server still receives recovery guidance", async () => { + const harness = await setup(undefined, { status: "needs_auth" }); + + await harness.fire("session.prompt", { + sessionID: "needs-auth", + prompt: { text: "Call sprites_list_sprites" }, + }); + const context = await harness.fire("session.context", { + sessionID: "needs-auth", + system: [], + }); + + assert.equal(context.system.length, 1); + assert.match(context.system[0].text, /opencode2 mcp auth sprites/); +}); + +test("only the configured server controls guidance", async () => { + const harness = await setup(undefined, { + servers: { other: { type: "remote", url: "https://example.test/mcp" } }, + }); + harness.setStatus("other", "disabled"); + + await harness.fire("tool.execute.before", { + tool: "sprites_list_sprites", + sessionID: "mixed", + }); + const context = await harness.fire("session.context", { + sessionID: "mixed", + system: [], + }); + + assert.equal(context.system.length, 1); +}); + +test("follows the server from connected to disabled and back", async () => { + const harness = await setup(); + await harness.fire("tool.execute.before", { + tool: "sprites_list_sprites", + sessionID: "transitions", + }); + + const statusChanged = { + type: "mcp.status.changed", + id: "evt-status", + created: 0, + data: { server: "sprites" }, + }; + + const connected = await harness.fire("session.context", { + sessionID: "transitions", + system: [], + }); + assert.equal(connected.system.length, 1); + + harness.setStatus("sprites", "disabled"); + await harness.emit(statusChanged); + const disabled = await harness.fire("session.context", { + sessionID: "transitions", + system: [], + }); + assert.deepEqual(disabled.system, []); + + harness.setStatus("sprites", "connected"); + await harness.emit(statusChanged); + const recovered = await harness.fire("session.context", { + sessionID: "transitions", + system: [], + }); + assert.equal(recovered.system.length, 1); +}); + +test("holds the cached status until an event invalidates it", async () => { + const harness = await setup(); + await harness.fire("tool.execute.before", { + tool: "sprites_list_sprites", + sessionID: "stale", + }); + await harness.fire("session.context", { sessionID: "stale", system: [] }); + + // No event, so the plugin keeps the cached result for its cache window. + harness.setStatus("sprites", "disabled"); + const cached = await harness.fire("session.context", { + sessionID: "stale", + system: [], + }); + + assert.equal(cached.system.length, 1); +}); + +test("caches MCP status and invalidates it on status changes", async () => { + let statusCalls = 0; + const harness = await setup(undefined, { onMcpList: () => statusCalls++ }); + await harness.fire("tool.execute.before", { + tool: "sprites_list_sprites", + sessionID: "cache-session", + }); + + await harness.fire("session.context", { + sessionID: "cache-session", + system: [], + }); + await harness.fire("session.context", { + sessionID: "cache-session", + system: [], + }); + assert.equal(statusCalls, 1); + + await harness.emit({ + type: "mcp.status.changed", + id: "evt-mcp", + created: 0, + data: { server: "sprites" }, + }); + await harness.fire("session.context", { + sessionID: "cache-session", + system: [], + }); + assert.equal(statusCalls, 2); + + await harness.emit({ + type: "mcp.status.changed", + id: "evt-other", + created: 0, + data: { server: "other" }, + }); + await harness.fire("session.context", { + sessionID: "cache-session", + system: [], + }); + assert.equal(statusCalls, 2); + + await harness.cleanup(); +}); + +test("cleanup stops tracking sessions", async () => { + const harness = await setup(); + await harness.fire("session.prompt", { + sessionID: "cleanup-session", + prompt: { text: "Use sprites.dev" }, + }); + + await harness.cleanup(); + + const context = await harness.fire("session.context", { + sessionID: "cleanup-session", + system: [], + }); + assert.deepEqual(context.system, []); +}); + +test("rejects malformed plugin options", async () => { + await assert.rejects(() => setup({ timeout: 0 }), /positive integer/); + await assert.rejects( + () => setup({ timeout: { catalog: -1 } }), + /timeout\.catalog must be a positive integer/, + ); + await assert.rejects( + () => setup({ url: "file:///tmp/mcp" }), + /http or https/, + ); + await assert.rejects(() => setup({ commands: "no" }), /must be a boolean/); + await assert.rejects( + () => setup({ oauth: true }), + /must be false or an OAuth/, + ); +}); diff --git a/tsconfig.json b/tsconfig.json index 6cea582..9e22b7d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,10 @@ "strict": true, "target": "ES2022" }, - "include": ["index.js", "test/type-contract.ts"] + "include": [ + "src/shared.js", + "src/v1.js", + "src/v2.js", + "test/type-contract.ts" + ] } From f3d1b0eac0592d70ef3dd37668fa9fdf67cbd1c2 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Thu, 27 Aug 2026 19:30:10 +0000 Subject: [PATCH 2/3] Omit empty attachment keys when a command prompts `/sprites-status` failed with "Expected array at [\"files\"]". The command executor passed `files`, `agents`, and `skills` to `session.prompt` even when the caller sent no attachments. Prompt validation accepts an absent key, but it rejects a key whose value is undefined. Build the attachment keys only when they carry entries. The fake plugin context records the prompt input without validating it, so a unit test alone cannot catch this. The new test asserts the absence of the keys, and the fix is verified against a real OpenCode 2 service: the command fails with the reported error before it, and succeeds after. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TiiJqxPLpgfJXG5fvTUa73 --- src/v2.js | 22 ++++++++++++++-------- test/v2.test.js | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/v2.js b/src/v2.js index 0c1e6c5..ca78dda 100644 --- a/src/v2.js +++ b/src/v2.js @@ -120,15 +120,19 @@ function serverConfig(options) { } /** - * Strips mention offsets that no longer line up once a command rewrites the - * prompt text around the caller's arguments. + * Returns the attachment key only when it carries entries, and strips the + * mention offsets that no longer line up once a command rewrites the prompt + * text around the caller's arguments. * * @template {{mention?: unknown}} T + * @param {string} key * @param {ReadonlyArray | undefined} entries */ -function withoutMentions(entries) { - if (!entries?.length) return undefined; - return entries.map(({ mention: _mention, ...entry }) => entry); +function withoutMentions(key, entries) { + if (!entries?.length) return {}; + return { + [key]: entries.map(({ mention: _mention, ...entry }) => entry), + }; } /** @type {import("@opencode-ai/plugin-v2").Plugin.Plugin} */ @@ -209,14 +213,16 @@ const SpritesPlugin = { activate(sessionID); const args = typeof prompt.text === "string" ? prompt.text.trim() : ""; + // Attachment keys must be absent when there is nothing to send. + // An explicit undefined fails prompt validation. await ctx.session.prompt({ sessionID, text: args ? `${template}\n\nAdditional request: ${args}` : template, - files: withoutMentions(prompt.files), - agents: withoutMentions(prompt.agents), - skills: withoutMentions(prompt.skills), + ...withoutMentions("files", prompt.files), + ...withoutMentions("agents", prompt.agents), + ...withoutMentions("skills", prompt.skills), delivery, }); }, diff --git a/test/v2.test.js b/test/v2.test.js index 8c05306..c40fef1 100644 --- a/test/v2.test.js +++ b/test/v2.test.js @@ -137,6 +137,26 @@ test("registers commands that prompt the session", async () => { assert.deepEqual(harness.prompts[1].files, [{ uri: "file:///note.md" }]); }); +test("a prompt with no attachments omits the attachment keys", async () => { + // Prompt validation rejects an explicit undefined, so an empty attachment + // list must not appear as a key at all. + const harness = await setup(); + + await harness + .command("sprites-status") + .execute({ sessionID: "cmd", prompt: { text: "" }, delivery: "steer" }); + + const [input] = harness.prompts; + for (const key of ["files", "agents", "skills"]) { + assert.equal(key in input, false, key); + } + assert.deepEqual(Object.keys(input).sort(), [ + "delivery", + "sessionID", + "text", + ]); +}); + test("a command run marks its session active", async () => { const harness = await setup(); From 4c8c7f5e3d2e86ea8a0067e7452d6008f5398a87 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Thu, 27 Aug 2026 22:36:25 +0000 Subject: [PATCH 3/3] Turn Code Mode off for the Sprites MCP server OpenCode 2 groups MCP tools in Code Mode by default. The model then reaches them through a dispatcher, as `tools.sprites.(input)`. The Sprites MCP server does not support that call shape yet, so the plugin sets `codemode` to `false` on the server it registers. The `sprites_*` tools are then on the model's own tool list. A real model request carries 24 Sprites tools with Code Mode off, and none with it on. The `codemode` option still accepts `true`, for use after the server supports the dispatcher. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TiiJqxPLpgfJXG5fvTUa73 --- CONTRIBUTING.md | 1 + README.md | 2 +- src/v2.js | 12 ++++++------ test/plugin.bun.js | 1 + test/v2.test.js | 11 +++++++++++ 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1babe97..0302eb3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,6 +87,7 @@ npm run test:bun - Guidance becomes active only for an explicit Sprites signal, a Sprites tool call, a Sprites permission decision, or a Sprites command. The active state moves to child sessions, and the plugin removes it when a session is deleted. - The MCP status cache is longer than one model step. The `mcp.status.changed` event clears it. An empty server list counts as unknown, because MCP configuration can be later than the first check. - OpenCode 2 has no compaction hook. Under OpenCode 2 the compaction instruction is part of the system guidance instead. OpenCode 1 keeps its compaction hook. +- The plugin sets `codemode` to `false` on its MCP server. Code Mode reaches MCP tools through a dispatcher, and the Sprites MCP server does not support that call shape yet. With Code Mode off, the `sprites_*` tools are on the model's own tool list. Remove the default when the server supports the dispatcher. - OpenCode does not enforce `engines.opencode`. The field records intent, and it does not gate loading in either release. - The session `context` hook does not run for title or compaction requests. Those requests do not receive the guidance. diff --git a/README.md b/README.md index f539526..166d396 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ OpenCode continues to run outside the Sprite. Local workspace and shell operatio - Remote files use the `sprites_file_*` tools. - Outbound access uses the `sprites_policy_network_*` tools. -OpenCode 2 groups MCP tools in Code Mode by default. The model then calls these tools as `tools.sprites.(input)`. The permission action stays `sprites_`. To put the tools on the model's native tool list instead, set the `codemode` option to `false`. +OpenCode 2 groups MCP tools in Code Mode by default. The model then reaches them through a dispatcher, as `tools.sprites.(input)`. The Sprites MCP server does not support that call shape yet, so the plugin turns Code Mode off for its own server. The Sprites tools are therefore on the model's native tool list, with their `sprites_*` names. Set the `codemode` option to `true` to opt in to Code Mode after the server supports it. ## What the plugin adds diff --git a/src/v2.js b/src/v2.js index ca78dda..a8e1114 100644 --- a/src/v2.js +++ b/src/v2.js @@ -29,7 +29,7 @@ const MAX_ACTIVE_SESSIONS = 1_000; * @property {number | {startup?: number, catalog?: number, execution?: number}=} timeout MCP timeout overrides in milliseconds. * @property {Record=} headers Additional or replacement request headers. * @property {false | Record=} oauth OAuth client settings, or false for header credentials. - * @property {boolean=} codemode Expose Sprites tools through Code Mode. + * @property {boolean=} codemode Reach the Sprites tools through Code Mode. Off by default. * @property {boolean=} mcp Register the default MCP server. * @property {boolean=} commands Register the Sprites slash commands. * @property {boolean=} guidance Inject Sprites workflow guidance for relevant sessions. @@ -96,10 +96,10 @@ function parseOptions(raw) { ...commonOptions(input), timeout: timeoutOption(input.timeout), oauth: oauthOption(input.oauth), - codemode: - input.codemode === undefined - ? undefined - : booleanOption(input.codemode, "codemode", true), + // Code Mode reaches MCP tools through a dispatcher rather than the + // provider's tool list. The Sprites MCP server does not support that call + // shape yet, so the plugin turns Code Mode off for its own server. + codemode: booleanOption(input.codemode, "codemode", false), }; } @@ -112,7 +112,7 @@ function serverConfig(options) { headers: { ...options.headers }, }; if (options.oauth !== undefined) config.oauth = options.oauth; - if (options.codemode !== undefined) config.codemode = options.codemode; + config.codemode = options.codemode; if (options.timeout !== undefined) config.timeout = { ...options.timeout }; return /** @type {import("@opencode-ai/plugin-v2").Mcp.ServerConfig} */ ( /** @type {unknown} */ (config) diff --git a/test/plugin.bun.js b/test/plugin.bun.js index 2cb5c9f..f692d62 100644 --- a/test/plugin.bun.js +++ b/test/plugin.bun.js @@ -29,6 +29,7 @@ test("the OpenCode 2 entry point registers under Bun", async () => { expect(v2.id).toBe("sprites"); expect(harness.servers().get("sprites").url).toBe("https://sprites.dev/mcp"); + expect(harness.servers().get("sprites").codemode).toBe(false); expect(harness.commands().map((command) => command.name)).toEqual([ "sprites-status", "sprites-smoke", diff --git a/test/v2.test.js b/test/v2.test.js index c40fef1..0f3d403 100644 --- a/test/v2.test.js +++ b/test/v2.test.js @@ -43,9 +43,20 @@ test("registers the Sprites MCP server", async () => { "Fly-Client-Interactive": "false", "Fly-Client-Agent": "opencode", }, + // Code Mode reaches MCP tools through a dispatcher, and the Sprites MCP + // server does not support that call shape yet. + codemode: false, }); }); +test("keeps Code Mode off unless it is asked for", async () => { + const off = await setup(); + assert.equal(off.servers().get("sprites").codemode, false); + + const on = await setup({ codemode: true }); + assert.equal(on.servers().get("sprites").codemode, true); +}); + test("supports custom MCP settings and optional features", async () => { const { servers, commands, ctx, has } = await setup({ mcpName: "sprites-staging",