diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..16d35da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +# Deliberately generic, like release.yml: it runs the repository's own tasks rather than spelling +# out commands, so a plugin scaffolded from this one gets working CI by copying the file. + +on: + pull_request: + push: + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + # Type checking resolves the whole module graph, so it is also what catches a dependency + # that cannot be fetched — including an @ora-space/plugin-sdk version that is not published + # yet. That failure is real and should stay visible rather than be worked around here. + - name: Type check + run: deno task check + + # Lint is purely syntactic and resolves nothing, so it stays meaningful even while the + # step above cannot run. + - name: Lint + run: deno task lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28845f8..d730d41 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,9 @@ name: Release +# Deliberately generic: every fact about which CLI is bundled and which upstream asset serves +# each target lives in `bundle.config.ts`, and the packaging itself in `scripts/package.ts`. This +# file should be copyable to another agent plugin unchanged. + on: push: tags: @@ -24,19 +28,19 @@ jobs: - name: Build run: deno task build - - name: Package .orax archive - run: | - set -euo pipefail - NAME=$(grep -m1 '^identifier *=' orax.toml | sed -E 's/^identifier *= *"(.*)"/\1/') - FILE="${NAME}-${GITHUB_REF_NAME}.orax" - zip -j "$FILE" orax.toml dist/main.js logo.svg README.md - echo "ORAX_FILE=$FILE" >> "$GITHUB_ENV" + # Writes dist/packages/*.orax (one per declared target) and dist/manifest.toml. + - name: Package + env: + GH_TOKEN: ${{ github.token }} + run: deno task package --tag "${{ github.ref_name }}" --repo "${{ github.repository }}" - name: Create Release if: github.ref_type == 'tag' env: GH_TOKEN: ${{ github.token }} run: | - gh release create "${GITHUB_REF_NAME}" "${ORAX_FILE}" \ + set -euo pipefail + gh release create "${GITHUB_REF_NAME}" \ + dist/packages/*.orax dist/manifest.toml \ --title "${GITHUB_REF_NAME}" \ --generate-notes diff --git a/.gitignore b/.gitignore index 849ddff..297d888 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,21 @@ +# Build output: the bundled entrypoint, the staging tree, downloaded upstream archives, and the +# .orax packages themselves. All reproducible with `deno task build && deno task package`. dist/ + +# The bundled OpenCode CLI, staged here only to run `deno task simulate` against a real binary. +# Released packages never take it from the repository: `scripts/package.ts` downloads it per +# target at package time. Scoped to `bin/` so genuine static assets stay committable. +assets/bin/ + +# package.json is the legacy Ora manifest and declares no dependencies, but its presence makes a +# stray `npm install` plausible. +node_modules/ +package-lock.json + +# Local-only Deno config, for overriding an unpublished @ora-space/plugin-sdk with `links`. +deno.local.json + +# OS and editor noise +.DS_Store +Thumbs.db +*.swp diff --git a/README.md b/README.md index c830535..51cfc6b 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,80 @@ # ora-space.opencode -An **agent plugin** for Ora that publishes [OpenCode](https://opencode.ai) as a -selectable agent. The plugin runs `opencode acp` (OpenCode's native -[Agent Client Protocol](https://agentclientprotocol.com) mode) as a child -process and bridges it to Ora as a pure ACP pipe. - -Nothing in Ora is hardcoded for this plugin: it is discovered from the installed -plugin directory, validated from `package.json`, and launched as an ordinary -agent provider. Deleting the directory removes the agent. - -``` -┌────────────────────────── Ora host (Rust) ───────────────────────────┐ -│ agent_runtime → plugin_agent │ -│ invoke : agent/start · agent/stop · agent/listModels │ -│ notify : agent/acp (bidirectional, payload never parsed) │ -└────────────────────────────┬─────────────────────────────────────────┘ - │ stdio, 4-byte length + 0x01 + JSON-RPC - v -┌──────────────────── this plugin (Deno process) ──────────────────────┐ -│ src/main.ts OpenCodeAgentPlugin extends AgentPlugin │ -│ src/handlers/* one module per registered API │ -│ src/services/* OpenCode CLI ownership, NDJSON framing │ -└────────────────────────────┬─────────────────────────────────────────┘ - │ NDJSON, one JSON-RPC object per line - v - opencode acp (ACP protocolVersion 1) -``` - -## Contract mapping - -| Host requirement | Implementation | -| ------------------------------------ | ------------------------------------------------------------------------ | -| `ora/register` with methods + emits | `runAgentPlugin` → SDK `defineAgent`: 3 methods, `agent/acp` emit | -| `agent/start` | `handlers/lifecycle.ts` spawns `opencode acp --cwd ` | -| `agent/stop` | kills the CLI, keeps this process alive so a later start can respawn it | -| `agent/listModels` | `handlers/models.ts`: `opencode models`, cached, curated fallback | -| `agent/acp` (both directions) | `handlers/acp.ts` + `services/opencode-client.ts`, payload never parsed | -| CLI absent → `-32001` | `services/command.ts` throws `PluginMethodError(AGENT_NOT_INSTALLED, …)` | -| one plugin = one agent = one process | a single plugin instance owning a single `OpenCodeClient` | - -## API registration architecture - -The plugin follows the class-based organization from Ora's API registration -guide: every registered API is a method on a base class, and the entrypoint only -mounts handler modules onto it. - -- `src/base/agent-plugin.ts` declares `abstract class AgentPlugin`. Required - APIs are `abstract`, so an incomplete plugin fails to compile; optional APIs - (`onStop`, `onActivate`, `onDeactivate`) ship default implementations. -- `runAgentPlugin` flattens the instance into a wire-name keyed dispatch table - by walking its prototype chain, so dispatch is a single map lookup and a - handler mounted as a field (`override onStart = …`) is found exactly like a - method. -- `AGENT_METHOD_ROUTES` / `AGENT_NOTIFICATION_ROUTES` hold the class-method → - JSON-RPC name mapping explicitly, because the host contract fixes the wire - names and deriving them from method names would silently break on a rename. -- Adding an API later means adding one method to the base class, one route - entry, and one handler module — the entrypoint does not grow. - -## Layout - -``` -package.json Ora manifest (ora.kind = "agent", ora.contributes.agent) -deno.json developer tasks only; Ora never reads it -src/ - main.ts entrypoint: mounts handlers onto the base class - base/agent-plugin.ts AgentPlugin base class + dispatch table + runAgentPlugin - handlers/ - lifecycle.ts agent/start, agent/stop - models.ts agent/listModels - acp.ts agent/acp (host → CLI) - services/ - opencode-client.ts spawns and owns `opencode acp`, both stdio pumps - command.ts binary resolution, spawn candidates, not-found mapping - ndjson.ts NDJSON line codec for the CLI's stdio -tests/host-simulator.ts drives this plugin the way the Ora host does -``` - -Every module imports `@ora-space/plugin-sdk` as a fully qualified -`jsr:@ora-space/plugin-sdk@0.1.3` specifier rather than a bare one, because Ora -launches the plugin with `deno run --no-prompt` and no import map: a bare -specifier would have nothing to resolve it against. A `jsr:` specifier needs no -import map — Deno resolves it directly against the JSR registry and caches it -locally — so this still works under Ora's launch flags. Bump the pinned version -in every import together when the SDK changes. +An **agent plugin** for [Ora](https://github.com/ora-space) that adds +[OpenCode](https://opencode.ai) as a selectable agent. Once installed, OpenCode +shows up in Ora's agent picker like any other agent — pick it, and your +conversation runs against the OpenCode CLI through its native +[Agent Client Protocol](https://agentclientprotocol.com) mode (`opencode acp`). + +## What it does + +- Publishes OpenCode as an agent inside Ora, alongside any other agent plugins + you have installed. +- Starts and stops the OpenCode CLI automatically as you switch agents — nothing + to run by hand. +- Streams OpenCode's models, sessions, and responses straight through to Ora's + UI via ACP, including the in-session model picker. +- Ships with the OpenCode CLI bundled inside the package, so there is nothing + else to install. ## Requirements -- The OpenCode CLI on PATH (`opencode`, or the `opencode.cmd` shim npm installs - on Windows). Pin an explicit binary with `ORA_OPENCODE_BIN`. -- Deno, which Ora provides for plugin processes. +- Nothing beyond Ora itself. The OpenCode CLI is bundled inside this package + under `assets/bin/opencode[.exe]` — no separate install, no `PATH` lookup. + Each release is built per platform, and the package refuses to run on a + machine it wasn't built for rather than risk launching a binary that can't + execute. +- If you'd rather run your own build of OpenCode instead of the bundled one, set + `ORA_OPENCODE_BIN` to its full path. -Ora launches agent plugins with -`--allow-run --allow-read --allow-env ---allow-net`; this plugin needs all four -(spawn the CLI, resolve it, read `ORA_OPENCODE_BIN`, let the CLI reach its -providers). +## Installing -## Installation and discovery +Grab the latest `.orax` package from this repository's +[Releases](../../releases) page, or build one yourself (see below), and drop it +into Ora's plugins directory (`/plugins/`) — Ora discovers any +folder there with a `package.json` automatically. Deleting the folder removes +the agent again; there's no other install step. -Ora discovers plugin packages as the direct children of -`/plugins/`, so that directory must resolve to the folder holding -this package. On Windows, a junction keeps the packages in one place: +On Windows, if you keep your installed plugins elsewhere, a junction can point +Ora's plugins directory at them: ```powershell cmd /c mklink /J "\plugins" "%USERPROFILE%\.ora\plugins\installed" ``` -Development runs (`task run:desktop`) set `ORA_DATA_DIR` to the repository's -`.data` directory, so the junction goes at `/.data/plugins`. A packaged -build uses Tauri's application data directory instead. +## Building from source -Every direct child of that directory must be a valid package: a folder without a -`package.json` is reported as a discovery issue. +``` +deno task build +deno task package --tag v0.2.4 --repo ora-space/opencode-agent +``` -## Verification +This produces one `.orax` package per target platform, with the matching +OpenCode CLI bundled inside. `gh` is the only external tool required. -`deno task simulate` runs `tests/host-simulator.ts`, which speaks Ora's binary -frame protocol to a freshly launched plugin process against the real CLI: +To drive the plugin end-to-end the way Ora's host does, stage the bundled CLI +where an installed package would have it, then run the simulator: ``` -ok: register {"methods":["agent/start","agent/stop","agent/listModels"],"emits":["agent/acp"]} -ok: agent/start {"protocol":"acp","acpVersion":1} -ok: initialize {"protocolVersion":1,"agentCapabilities":{…}} -ok: session/new {"sessionId":"ses_…","configOptions":[…]} -[host] << {"method":"agent/acp",…} # streamed session/update forwarded back -ok: listModels 11 models, first {"id":"opencode/big-pickle",…} -ok: agent/stop -plugin exited with code 0 +# macOS / Linux +unzip -o dist/packages/*-.orax 'assets/bin/*' -d . +chmod +x assets/bin/opencode + +# Windows +unzip -o dist/packages/*-x86_64-pc-windows-msvc.orax 'assets/bin/*' -d . + +deno task simulate ``` -`deno task check` type checks and `deno task lint` lints the same sources. +The simulator resolves `packageCommand` against the repository root, so it runs +the binary under `assets/bin/` — never one on your `PATH`. That directory is +git-ignored and is only needed for this. + +`deno task check` type checks and `deno task lint` lints the sources. ## Known limits -- `agent/start` receives the host's home directory as `cwd`; per-session working - directories travel in ACP `session/new`, which this plugin passes through. - The model list is cached for the process lifetime, so models that appear after a provider login need a plugin restart. -- When the CLI exits on its own the plugin logs it and lets the host observe a - stalled connection; the contract has no `agent/exited` notification yet. -- Killing the CLI on `agent/stop` is best effort; Ora retains process-tree - reaping. +- Killing the CLI on agent stop is best effort; Ora retains process-tree reaping + as a backstop. diff --git a/bundle.config.ts b/bundle.config.ts new file mode 100644 index 0000000..d1d40af --- /dev/null +++ b/bundle.config.ts @@ -0,0 +1,21 @@ +import type { BundleConfig } from "./scripts/package.ts"; + +/** + * Declares the upstream CLI this plugin bundles, and which release asset serves each target. + * + * This is the only plugin-specific half of the release pipeline: `scripts/package.ts` and + * `.github/workflows/release.yml` know nothing about OpenCode and are meant to be copied to + * another agent plugin unchanged, with only this file rewritten. + * + * Every entry produces one `.orax`. A target absent here is simply not published: Ora refuses to + * install a package built for another triple, so an unlisted host gets no package rather than a + * binary it cannot run. + */ +export default { + upstream: "anomalyco/opencode", + assets: { + "aarch64-apple-darwin": "opencode-darwin-arm64.zip", + "x86_64-unknown-linux-gnu": "opencode-linux-x64.tar.gz", + "x86_64-pc-windows-msvc": "opencode-windows-x64.zip", + }, +} satisfies BundleConfig; diff --git a/deno.json b/deno.json index 843c6b8..b8fe91e 100644 --- a/deno.json +++ b/deno.json @@ -4,15 +4,20 @@ "exports": "./src/main.ts", "minimumDependencyAge": 0, "imports": { - "@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.4.0" + "@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.5.0", + "@std/cli": "jsr:@std/cli@^1.0.32", + "@std/path": "jsr:@std/path@^1.1.6", + "@std/tar": "jsr:@std/tar@^0.1.10", + "@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8.61" }, "tasks": { - "check": "deno check src/main.ts tests/host-simulator.ts", - "lint": "deno lint src tests", - "format": "deno fmt src tests deno.json package.json README.md", + "check": "deno check src/main.ts scripts/package.ts tests/host-simulator.ts", + "lint": "deno lint src scripts tests bundle.config.ts", + "format": "deno fmt src scripts tests bundle.config.ts deno.json package.json README.md", "simulate": "deno run --allow-run --allow-read --allow-env --allow-net tests/host-simulator.ts", "dev": "deno run --no-prompt --allow-run --allow-read --allow-env --allow-net src/main.ts", - "build": "deno bundle src/main.ts -o dist/main.js" + "build": "deno bundle src/main.ts -o dist/main.js", + "package": "deno run --allow-run --allow-read --allow-write --allow-env scripts/package.ts" }, "compilerOptions": { "strict": true, diff --git a/deno.lock b/deno.lock index dd5e01d..9ba08bc 100644 --- a/deno.lock +++ b/deno.lock @@ -1,9 +1,58 @@ { "version": "5", - "workspace": { - "links": { - "jsr:@ora-space/plugin-sdk@0.4.0": {}, - "npm:@ora-space/plugin-sdk@0.4.0": {} + "specifiers": { + "jsr:@ora-space/plugin-sdk@0.5.0": "0.5.0", + "jsr:@std/cli@^1.0.32": "1.0.32", + "jsr:@std/fmt@^1.0.10": "1.0.10", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@^1.1.6": "1.1.6", + "jsr:@std/streams@^1.0.17": "1.1.2", + "jsr:@std/tar@~0.1.10": "0.1.10", + "jsr:@zip-js/zip-js@^2.8.61": "2.8.61" + }, + "jsr": { + "@ora-space/plugin-sdk@0.5.0": { + "integrity": "421c79eaefef92b087bd93ff9a12e48222a599fecd8c49cfcf5d74df4c48eef3" + }, + "@std/cli@1.0.32": { + "integrity": "188b3a100d6202d64e3f5bd3d799c7fa4f6d77f92cc65eb7f641c1fa0aa92a66", + "dependencies": [ + "jsr:@std/fmt", + "jsr:@std/internal" + ] + }, + "@std/fmt@1.0.10": { + "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5" + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/streams@1.1.2": { + "integrity": "0249bf9b78a999f57032ca4d8e7ccaa57579090242640b35ddc948fbb745d3af" + }, + "@std/tar@0.1.10": { + "integrity": "6bf907f3a4bc8bfef42973ba132d946756a6161ef6b914a9e1c06debe664db17", + "dependencies": [ + "jsr:@std/streams" + ] + }, + "@zip-js/zip-js@2.8.61": { + "integrity": "8da37318aeefa76faa915058722ab8d1ee51e068edc8be69ea0810e01d724955" } + }, + "workspace": { + "dependencies": [ + "jsr:@ora-space/plugin-sdk@0.5.0", + "jsr:@std/cli@^1.0.32", + "jsr:@std/path@^1.1.6", + "jsr:@std/tar@~0.1.10", + "jsr:@zip-js/zip-js@^2.8.61" + ] } } diff --git a/scripts/package.ts b/scripts/package.ts new file mode 100644 index 0000000..1da4113 --- /dev/null +++ b/scripts/package.ts @@ -0,0 +1,291 @@ +/** + * Builds one `.orax` per target for an agent plugin that bundles an upstream CLI. + * + * Nothing here names a particular plugin or CLI: what to download and which asset serves each + * target comes from `bundle.config.ts`, and where the binary lands inside the package comes from + * the plugin's own `bundledBinaryPath`. That is what lets this script and the release workflow be + * copied to another agent plugin unchanged. + * + * Usage: + * deno task package --tag v1.2.3 --repo owner/name + * + * Produces `dist/packages/--.orax` plus `dist/manifest.toml`, the release + * form of the manifest the marketplace index needs. `gh` is the only external tool required: + * archives are read and written in-process so a maintainer can run this anywhere CI can. + */ +import { parseArgs } from "@std/cli/parse-args"; +import { basename, dirname, join, relative } from "@std/path"; +import { UntarStream } from "@std/tar"; +import { BlobReader, ZipReader, ZipWriter } from "@zip-js/zip-js"; +import bundle from "../bundle.config.ts"; +import { + bundledBinaryPath, + type TargetOs, +} from "../src/services/bundled-binary.ts"; + +/** What a plugin declares about the upstream CLI it bundles. */ +export interface BundleConfig { + /** GitHub `owner/name` the CLI is released from. */ + upstream: string; + /** Release asset serving each canonical Rust target triple. */ + assets: Record; +} + +/** One target's resolved packaging inputs. */ +interface TargetPlan { + triple: string; + asset: string; + /** Package-relative path the binary is staged at, and the plugin later asks the host to spawn. */ + binaryPath: string; +} + +const DIST = "dist"; +const PACKAGES_DIR = join(DIST, "packages"); +const DOWNLOAD_DIR = join(DIST, "download"); +const STAGE_DIR = join(DIST, "stage"); + +/** Runs one command, failing loudly rather than letting a broken package be published. */ +async function run(command: string, ...args: string[]): Promise { + const { code, stdout, stderr } = await new Deno.Command(command, { + args, + stdout: "piped", + stderr: "piped", + }).output(); + if (code !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with ${code}: ${ + new TextDecoder().decode(stderr) + }`, + ); + } + return new TextDecoder().decode(stdout).trim(); +} + +/** + * Derives the operating system a canonical Rust target triple runs on. + * + * Only the OS is needed, and only to name the binary: the architecture never changes the package + * layout, because one package serves exactly one triple. + */ +function osOfTriple(triple: string): TargetOs { + if (triple.includes("-windows-")) return "windows"; + if (triple.includes("-apple-")) return "darwin"; + if (triple.includes("-linux-")) return "linux"; + throw new Error(`cannot derive an operating system from target ${triple}`); +} + +/** Reads one required field out of the installed manifest this repository ships. */ +async function manifestField(field: string): Promise { + const source = await Deno.readTextFile("orax.toml"); + const match = source.match(new RegExp(`^${field}\\s*=\\s*"(.*)"`, "m")); + if (match === null) { + throw new Error(`orax.toml declares no ${field}`); + } + return match[1]; +} + +/** + * Reads the one file named `entry` out of an upstream archive and writes it to `destination`. + * + * Archives are read in-process rather than by shelling out to `tar`/`unzip` so this script runs + * the same way on a maintainer's machine as it does in CI, whatever that machine is. Only the + * bytes are taken: the upstream mode is not consulted, because the staged file is chmod'ed to a + * known-good mode below regardless of what upstream happened to record. + */ +async function extractEntry( + archive: string, + entry: string, + destination: string, +): Promise { + if (archive.endsWith(".tar.gz") || archive.endsWith(".tgz")) { + const stream = (await Deno.open(archive)).readable + .pipeThrough(new DecompressionStream("gzip")) + .pipeThrough(new UntarStream()); + for await (const item of stream) { + if (item.path !== entry || item.readable === undefined) { + await item.readable?.cancel(); + continue; + } + await item.readable.pipeTo((await Deno.create(destination)).writable); + return; + } + throw new Error(`${archive} does not contain ${entry}`); + } + if (archive.endsWith(".zip")) { + const reader = new ZipReader(new BlobReader(await openBlob(archive))); + try { + for (const item of await reader.getEntries()) { + // A directory entry carries no reader; only the named file is of interest anyway. + if (item.filename !== entry || item.directory) continue; + const file = await Deno.create(destination); + await item.getData!(file.writable); + return; + } + } finally { + await reader.close(); + } + throw new Error(`${archive} does not contain ${entry}`); + } + throw new Error(`unsupported upstream archive format: ${archive}`); +} + +/** Reads one file as a Blob, which is what `zip-js` takes as a random-access source. */ +async function openBlob(path: string): Promise { + return new Blob([await Deno.readFile(path)]); +} + +/** + * Writes one staged directory tree into a `.orax`, recording the execute bit on `executable`. + * + * The execute bit is what makes the bundled CLI spawnable after Ora extracts the package, and a + * ZIP carries it in the upper 16 bits of the external file attributes. A fixed `0o100755` is + * written rather than whatever upstream recorded, so the package can never install a setuid or + * otherwise surprising mode. + */ +async function writeOrax( + stageDir: string, + destination: string, + executable: string, +): Promise { + const file = await Deno.create(destination); + const writer = new ZipWriter(file.writable); + for await (const entry of walk(stageDir)) { + const relative = relativeSlashPath(stageDir, entry); + await writer.add(relative, new BlobReader(await openBlob(entry)), { + externalFileAttribute: relative === executable + ? (0o100_755 << 16) >>> 0 + : (0o100_644 << 16) >>> 0, + }); + } + await writer.close(); +} + +/** Yields every ordinary file under `root`, depth first. */ +async function* walk(root: string): AsyncGenerator { + for await (const item of Deno.readDir(root)) { + const path = join(root, item.name); + if (item.isDirectory) { + yield* walk(path); + } else if (item.isFile) { + yield path; + } + } +} + +/** Renders one path below `root` as the slash-separated name a ZIP entry carries. */ +function relativeSlashPath(root: string, path: string): string { + return relative(root, path).replaceAll("\\", "/"); +} + +/** Returns the lowercase hex SHA-256 of one file, the spelling `sha256` takes in a manifest. */ +async function sha256Hex(path: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + await Deno.readFile(path), + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +/** Stages one target's package tree and zips it into a `.orax`. */ +async function buildPackage( + plan: TargetPlan, + upstreamTag: string, + fileName: string, +): Promise { + await Deno.remove(STAGE_DIR, { recursive: true }).catch(() => {}); + const staged = join(STAGE_DIR, plan.binaryPath); + await Deno.mkdir(dirname(staged), { recursive: true }); + + const archive = join(DOWNLOAD_DIR, plan.asset); + await run( + "gh", + "release", + "download", + upstreamTag, + "--repo", + bundle.upstream, + "--pattern", + plan.asset, + "--dir", + DOWNLOAD_DIR, + "--clobber", + ); + // Upstream ships the CLI as the sole entry at the archive root, under the same name this + // package uses for it. + await extractEntry(archive, basename(plan.binaryPath), staged); + + await Deno.copyFile(join(DIST, "main.js"), join(STAGE_DIR, "main.js")); + for (const extra of ["logo.svg", "README.md"]) { + await Deno.copyFile(extra, join(STAGE_DIR, extra)).catch(() => {}); + } + // The self-declared target is what lets Ora verify, after extraction, that the package it + // downloaded is really the one built for this machine. + const manifest = await Deno.readTextFile("orax.toml"); + await Deno.writeTextFile( + join(STAGE_DIR, "orax.toml"), + `${manifest.trimEnd()}\n\n[artifact]\ntarget = "${plan.triple}"\n`, + ); + + await writeOrax(STAGE_DIR, join(PACKAGES_DIR, fileName), plan.binaryPath); + await Deno.remove(STAGE_DIR, { recursive: true }); + await Deno.remove(archive).catch(() => {}); +} + +async function main(): Promise { + const flags = parseArgs(Deno.args, { string: ["tag", "repo"] }); + const tag = flags.tag ?? Deno.env.get("GITHUB_REF_NAME"); + const repo = flags.repo ?? Deno.env.get("GITHUB_REPOSITORY"); + if (tag === undefined || repo === undefined) { + throw new Error("both --tag and --repo are required"); + } + + const identifier = await manifestField("identifier"); + await Deno.mkdir(PACKAGES_DIR, { recursive: true }); + await Deno.mkdir(DOWNLOAD_DIR, { recursive: true }); + + // Resolved once so every package in this release bundles the same CLI build: one "latest" + // lookup per target could straddle an upstream release and ship a version skew that only some + // platforms would ever see. + const upstreamTag = await run( + "gh", + "release", + "view", + "--repo", + bundle.upstream, + "--json", + "tagName", + "--jq", + ".tagName", + ); + console.log(`Bundling ${bundle.upstream} ${upstreamTag}`); + + const base = `https://github.com/${repo}/releases/download/${tag}`; + let manifest = (await Deno.readTextFile("orax.toml")).trimEnd(); + for (const [triple, asset] of Object.entries(bundle.assets)) { + const plan: TargetPlan = { + triple, + asset, + binaryPath: bundledBinaryPath(osOfTriple(triple)), + }; + const fileName = `${identifier}-${tag}-${triple}.orax`; + await buildPackage(plan, upstreamTag, fileName); + + const digest = await sha256Hex(join(PACKAGES_DIR, fileName)); + manifest += + `\n\n[[targets]]\ntarget = "${triple}"\nurl = "${base}/${fileName}"\nsha256 = "${digest}"`; + console.log(`packaged ${fileName}`); + } + + // The marketplace index needs the release form of the manifest, which carries the per-target + // download URLs and digests. It is only knowable once the packages exist, so it is generated + // here rather than committed. + await Deno.writeTextFile(join(DIST, "manifest.toml"), `${manifest}\n`); + await Deno.remove(DOWNLOAD_DIR, { recursive: true }).catch(() => {}); + console.log(`\nUpstream CLI: ${upstreamTag}`); +} + +if (import.meta.main) { + await main(); +} diff --git a/src/handlers/models.ts b/src/handlers/models.ts index 0348527..d0ac8f8 100644 --- a/src/handlers/models.ts +++ b/src/handlers/models.ts @@ -1,5 +1,5 @@ -import type { AgentModel } from "@ora-space/plugin-sdk"; -import { tryEachCandidate } from "../services/command.ts"; +import type { AgentModel, HostProcesses } from "@ora-space/plugin-sdk"; +import { resolveOpenCodeProgram } from "../services/command.ts"; /** Reads the raw model id list from OpenCode; injectable so the cache can be exercised. */ export type ModelIdSource = () => Promise; @@ -28,7 +28,8 @@ let cache: Promise | undefined; * changes because the user logged into a new provider therefore needs a plugin restart. */ export function listOpenCodeModels( - source: ModelIdSource = runOpenCodeModels, + processes: HostProcesses, + source: ModelIdSource = () => runOpenCodeModels(processes), ): Promise { if (cache === undefined) { cache = discoverModels(source); @@ -62,20 +63,24 @@ function displayNameFor(id: string): string { return slash === -1 ? id : id.slice(slash + 1); } -/** Runs `opencode models` and returns the non-empty id lines it prints. */ -async function runOpenCodeModels(): Promise { - const output = await tryEachCandidate(async ({ command, extraArgs }) => { - const { code, stdout } = await new Deno.Command(command, { - args: [...extraArgs, "models"], - stdin: "null", - stdout: "piped", - stderr: "piped", - }).output(); - if (code !== 0) { - throw new Error(`opencode models exited with code ${code}`); - } - return new TextDecoder().decode(stdout); +/** + * Runs `opencode models` through the host and returns the non-empty id lines it prints. + * + * This goes through the host rather than `Deno.Command` for the same reason the ACP server does: + * the bundled CLI's path is only resolvable by the host, and a process the host owns is torn down + * with this plugin generation instead of outliving it. + */ +async function runOpenCodeModels(processes: HostProcesses): Promise { + const child = await processes.spawn({ + ...resolveOpenCodeProgram(), + args: ["models"], }); + await child.closeStdin(); + const output = await new Response(child.stdout).text(); + const { code } = await child.exited; + if (code !== 0) { + throw new Error(`opencode models exited with code ${code}`); + } const ids = output .split(/\r?\n/) diff --git a/src/main.ts b/src/main.ts index d7e71af..c9bf8ca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import type { AcpSender, AgentModel, AgentStartContext, + HostProcesses, JsonValue, } from "@ora-space/plugin-sdk"; import { @@ -30,6 +31,8 @@ class OpenCodeAgentPlugin extends AgentPlugin { #send: AcpSender | undefined; /** The workspace root the CLI is running against; also what a Skill Effect restart respawns into. */ #cwd: string | undefined; + /** Set by `onActivate`, which the base class runs before the host can call anything. */ + #processes: HostProcesses | undefined; readonly #client = new OpenCodeClient({ onAcpFrame: (frame) => { @@ -53,6 +56,7 @@ class OpenCodeAgentPlugin extends AgentPlugin { override onActivate(context: PluginContext): void { console.info(`${context.pluginId} activated`); + this.#processes = context.processes; this.#client.attachProcesses(context.processes); } @@ -67,7 +71,12 @@ class OpenCodeAgentPlugin extends AgentPlugin { override onStop = (): Promise => stopOpenCode(this.#client); - override onListModels = (): Promise => listOpenCodeModels(); + override onListModels = (): Promise => { + if (this.#processes === undefined) { + throw new Error("agent/listModels was called before activation"); + } + return listOpenCodeModels(this.#processes); + }; override onAcp = (frame: JsonValue): Promise | void => forwardAcpFrame(this.#client, this.#effects, frame); diff --git a/src/services/bundled-binary.ts b/src/services/bundled-binary.ts new file mode 100644 index 0000000..90ac37d --- /dev/null +++ b/src/services/bundled-binary.ts @@ -0,0 +1,34 @@ +/** + * The one place that decides where the bundled CLI lives inside the package. + * + * Two very different callers depend on this agreeing: `command.ts` asks the host to spawn this + * path at runtime, and the release packaging script stages the upstream binary at it. A mismatch + * would only surface as a broken install, so both derive it from here rather than each spelling + * the path out. + */ + +/** Directory inside the package that holds the bundled CLI. */ +export const BUNDLED_BIN_DIR = "assets/bin"; + +/** Operating systems a package can be built for, as `Deno.build.os` spells them. */ +export type TargetOs = typeof Deno.build.os; + +/** + * Names the bundled CLI for one target operating system. + * + * Windows decides executability by extension at spawn time, so the suffix is not cosmetic: a + * `.exe`-less binary there exists but cannot be started. + */ +export function bundledBinaryName(os: TargetOs): string { + return os === "windows" ? "opencode.exe" : "opencode"; +} + +/** + * Returns the package-relative path of the bundled CLI for one target operating system. + * + * The path carries no architecture segment: releases are built one package per target, so the + * binary that reaches a machine is already the right one. + */ +export function bundledBinaryPath(os: TargetOs = Deno.build.os): string { + return `${BUNDLED_BIN_DIR}/${bundledBinaryName(os)}`; +} diff --git a/src/services/command.ts b/src/services/command.ts index e2a17f3..a13e4de 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -3,91 +3,54 @@ import { HostRequestError, PluginMethodError, } from "@ora-space/plugin-sdk"; - -/** Names one concrete way to launch the OpenCode CLI. */ -export interface OpenCodeCommand { - command: string; - extraArgs: string[]; -} +import { bundledBinaryPath } from "./bundled-binary.ts"; /** - * Resolves the command that launches OpenCode. + * Names the program that launches OpenCode, in the shape `HostProcesses.spawn` accepts. * - * `ORA_OPENCODE_BIN` pins an explicit binary path, which matters on Windows where npm only - * exposes a `.cmd` shim on PATH. Otherwise the platform default is used and expanded by - * {@link spawnCandidates}. + * The two forms decide who resolves the path. `packageCommand` is package-relative and resolved + * by the host against this plugin's install root; `command` is handed to the operating system. */ -export function resolveOpenCodeCommand(): OpenCodeCommand { - const explicit = readEnv("ORA_OPENCODE_BIN"); - if (explicit !== undefined && explicit.trim() !== "") { - return { command: explicit.trim(), extraArgs: [] }; - } - return Deno.build.os === "windows" - ? { command: "opencode.cmd", extraArgs: [] } - : { command: "opencode", extraArgs: [] }; -} +export type OpenCodeProgram = + | { packageCommand: string; command?: never } + | { command: string; packageCommand?: never }; /** - * Expands one resolved command into spawn candidates in priority order. + * Resolves the program that launches OpenCode. * - * npm installs only a `.cmd` shim on Windows while scoop and choco expose `opencode.exe`; trying - * both keeps either installation style working with no user configuration. + * The bundled binary is the normal answer, and this plugin never learns — or computes — the host + * path it lives at. `ORA_OPENCODE_BIN` stays as the one escape hatch: a developer running a + * locally built OpenCode has no way to get that binary into an installed package. */ -export function spawnCandidates(command: string): string[] { - if (Deno.build.os !== "windows") { - return [command]; +export function resolveOpenCodeProgram(): OpenCodeProgram { + const explicit = readEnv("ORA_OPENCODE_BIN"); + if (explicit !== undefined && explicit.trim() !== "") { + return { command: explicit.trim() }; } - const fallback = command.toLowerCase().endsWith(".cmd") - ? "opencode" - : "opencode.cmd"; - return command === fallback ? [command] : [command, fallback]; + return { packageCommand: bundledBinaryPath() }; } /** - * Classifies a spawn failure as a missing binary. + * Rethrows one spawn failure under the classification the host should act on. * - * The host spawns the process now, so this is the host's own classification - * (`program_not_found` means the OS could not resolve the executable) rather than sniffing - * platform-specific error text. + * A missing `ORA_OPENCODE_BIN` target is local configuration, which Ora retries quietly. A bundled + * binary that will not resolve is a broken or wrong-target package: it fails identically on every + * retry, so reporting it as `agent_not_installed` would bury it under an infinite silent retry + * loop instead of surfacing the agent as failing. */ -export function isCommandNotFound(error: unknown): boolean { - return error instanceof HostRequestError && +export function rethrowSpawnFailure( + program: OpenCodeProgram, + error: unknown, +): never { + const notFound = error instanceof HostRequestError && error.kind === "program_not_found"; -} - -/** - * Runs `attempt` against every candidate for the resolved OpenCode command. - * - * The first candidate that does not throw wins. Failures are classified on the way out: a - * failure that is not "binary missing" is the real startup fault and is rethrown as-is, while an - * exhausted candidate list means OpenCode is simply absent, which Ora retries quietly. - */ -export async function tryEachCandidate( - attempt: (resolved: OpenCodeCommand) => T | Promise, -): Promise { - const resolved = resolveOpenCodeCommand(); - const candidates = spawnCandidates(resolved.command); - const failures: unknown[] = []; - for (const command of candidates) { - try { - return await attempt({ command, extraArgs: resolved.extraArgs }); - } catch (error) { - failures.push(error); - } - } - - const realFailure = failures.find((error) => !isCommandNotFound(error)); - if (realFailure !== undefined) { - throw realFailure instanceof Error - ? realFailure - : new Error(String(realFailure)); + if (notFound && program.command !== undefined) { + throw new PluginMethodError( + AGENT_NOT_INSTALLED, + `ORA_OPENCODE_BIN points at ${program.command}, which does not exist`, + ); } - throw new PluginMethodError( - AGENT_NOT_INSTALLED, - `OpenCode is not installed or not on PATH (tried: ${ - candidates.join(", ") - }); install it from https://opencode.ai/docs/ or set ORA_OPENCODE_BIN`, - ); + throw error instanceof Error ? error : new Error(String(error)); } /** Reads an env var, treating a missing read permission as an unset value. */ diff --git a/src/services/opencode-client.ts b/src/services/opencode-client.ts index d29f514..fdd86c8 100644 --- a/src/services/opencode-client.ts +++ b/src/services/opencode-client.ts @@ -1,5 +1,9 @@ import type { HostProcesses, JsonValue } from "@ora-space/plugin-sdk"; -import { tryEachCandidate } from "./command.ts"; +import { + type OpenCodeProgram, + resolveOpenCodeProgram, + rethrowSpawnFailure, +} from "./command.ts"; import { decodeLines, encodeLine } from "./ndjson.ts"; /** The subset of a spawned child process this bridge depends on, so tests can substitute one. */ @@ -14,7 +18,11 @@ export interface SpawnedProcess { export interface OpenCodeClientOptions { /** Overrides process spawning; injected by tests. Production spawns through `attachProcesses`. */ - spawn?: (command: string, args: string[], cwd: string) => SpawnedProcess; + spawn?: ( + program: OpenCodeProgram, + args: string[], + cwd: string, + ) => SpawnedProcess; /** Receives every ACP frame emitted by the CLI, in output order. */ onAcpFrame?: (frame: JsonValue) => void; /** Invoked after the CLI exits on its own, never after an explicit stop. */ @@ -36,7 +44,7 @@ interface RunningProcess { */ export class OpenCodeClient { readonly #spawn: ( - command: string, + program: OpenCodeProgram, args: string[], cwd: string, ) => SpawnedProcess | Promise; @@ -49,7 +57,7 @@ export class OpenCodeClient { constructor(options: OpenCodeClientOptions = {}) { this.#spawn = options.spawn ?? - ((command, args, cwd) => this.#spawnViaHost(command, args, cwd)); + ((program, args, cwd) => this.#spawnViaHost(program, args, cwd)); this.#onAcpFrame = options.onAcpFrame ?? (() => {}); this.#onExited = options.onExited ?? (() => {}); } @@ -79,16 +87,15 @@ export class OpenCodeClient { await this.stop(); this.#expectedExit = false; - await tryEachCandidate(async ({ command, extraArgs }) => { - const process = await this.#spawn( - command, - [...extraArgs, "acp", "--cwd", cwd], - cwd, - ); - this.#running = { process, stdinWriter: process.stdin.getWriter() }; - this.#attach(process); - return process; - }); + const program = resolveOpenCodeProgram(); + let process: SpawnedProcess; + try { + process = await this.#spawn(program, ["acp", "--cwd", cwd], cwd); + } catch (error) { + rethrowSpawnFailure(program, error); + } + this.#running = { process, stdinWriter: process.stdin.getWriter() }; + this.#attach(process); } /** @@ -193,7 +200,7 @@ export class OpenCodeClient { * `SpawnedProcess` so every other method above stays unaware of who owns the OS process. */ async #spawnViaHost( - command: string, + program: OpenCodeProgram, args: string[], cwd: string, ): Promise { @@ -202,7 +209,7 @@ export class OpenCodeClient { "OpenCodeClient cannot spawn before attachProcesses() runs", ); } - const child = await this.#processes.spawn({ command, args, cwd }); + const child = await this.#processes.spawn({ ...program, args, cwd }); return { stdin: new WritableStream({ write: (chunk) => child.write(chunk), diff --git a/tests/host-simulator.ts b/tests/host-simulator.ts index dc0daa5..c6327e6 100644 --- a/tests/host-simulator.ts +++ b/tests/host-simulator.ts @@ -5,6 +5,7 @@ * deno run --allow-run --allow-read --allow-env --allow-net tests/host-simulator.ts */ import type { JsonValue } from "@ora-space/plugin-sdk"; +import { fromFileUrl } from "@std/path"; const JSON_RPC_FRAME_TYPE = 0x01; const MAX_FRAME_LENGTH = 16 * 1024 * 1024; @@ -96,13 +97,22 @@ const writer = child.stdin.getWriter(); const send = (message: JsonValue) => writer.write(encodeFrame(message)); const inbound = decodeFrames(child.stdout)[Symbol.asyncIterator](); +/** + * How long any one step may wait before the run is declared stuck. + * + * Generous because a cold CLI start is genuinely slow, but bounded: without it a step that never + * gets an answer hangs the whole run with no output at all, which is exactly what a bundled binary + * that starts but does not speak ACP produces. + */ +const STEP_TIMEOUT_MS = 90_000; + /** Reads frames until one satisfies `match`, so streamed notifications never desynchronize. */ async function waitFor( match: (message: Record) => boolean, label: string, ): Promise> { while (true) { - const next = await inbound.next(); + const next = await withStepTimeout(inbound.next(), label); if (next.done) { throw new Error(`plugin closed stdout while waiting for ${label}`); } @@ -123,6 +133,32 @@ async function waitFor( } } +/** + * Fails a pending read with the step it was waiting on instead of hanging forever. + * + * The abandoned read is not cancelled: this only ever fires on the way to exiting, and naming the + * stuck step is worth more here than tidily unwinding the stream. + */ +function withStepTimeout(pending: Promise, label: string): Promise { + let timer: number | undefined; + const expiry = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `timed out after ${ + STEP_TIMEOUT_MS / 1000 + }s waiting for ${label}; the CLI started but never answered`, + ), + ), + STEP_TIMEOUT_MS, + ); + }); + return Promise.race([pending, expiry]).finally(() => + clearTimeout(timer) + ) as Promise; +} + /** One subprocess this simulator, playing the host, spawned on the plugin's behalf. */ interface SimulatedChildProcess { child: Deno.ChildProcess; @@ -172,13 +208,28 @@ async function handleChildProcessRequest( } } +/** + * Resolves one spawn request's program the way the real host does. + * + * `packageCommand` is joined onto the package root — this repository, since a simulated run has + * no installed package — so the simulator exercises the same two-form contract Ora enforces. + */ +function resolveSimulatedProgram(params: Record): string { + const packageCommand = params.packageCommand as string | undefined; + if (packageCommand === undefined) { + return params.command as string; + } + const packageRoot = new URL("../", import.meta.url); + return fromFileUrl(new URL(packageCommand, packageRoot)); +} + async function dispatchChildProcessMethod( method: string, params: Record, ): Promise { switch (method) { case "ora/childprocess/spawn": { - const command = params.command as string; + const command = resolveSimulatedProgram(params); const args = (params.args as string[] | undefined) ?? []; const cwd = (params.cwd as string | null | undefined) ?? undefined; const child = new Deno.Command(command, {