diff --git a/.github/workflows/deploy-plugins.yml b/.github/workflows/deploy-plugins.yml index 0e763ea0..52eb0cd8 100644 --- a/.github/workflows/deploy-plugins.yml +++ b/.github/workflows/deploy-plugins.yml @@ -1,14 +1,16 @@ -# Deploy per-agent plugin distributions to their own repositories. +# Deploy per-agent distributions to their own repositories. # # This repository is the single source of truth (skills + routing metadata, -# feeding skills.sentry.dev). Each AI assistant needs the plugin in a different -# shape, so this workflow builds each one and deploys it to a dedicated -# repository whose ROOT is exactly that agent's plugin: +# feeding skills.sentry.dev). Each AI assistant needs the integration in a +# different shape, so this workflow builds each one and deploys it to a +# dedicated repository whose root is exactly that agent's distribution: # -# claude -> getsentry/plugin-claude -# cursor -> getsentry/plugin-cursor -# codex -> getsentry/plugin-codex -# grok -> getsentry/plugin-grok +# claude -> getsentry/plugin-claude +# cursor -> getsentry/plugin-cursor +# codex -> getsentry/plugin-codex +# grok -> getsentry/plugin-grok +# opencode -> getsentry/plugin-opencode +# opencode2 -> getsentry/plugin-opencode2 # # Each plugin repository carries two rolling branches, and which one a run writes # is the whole difference between a deploy and a release: @@ -25,14 +27,14 @@ # A release also tags the plugin repository (`v`), giving each shipped # version an addressable ref for pinning and rollback. # -# Marketplaces consume `getsentry/plugin-` by git ref. Each job builds its -# agent's tree from this repo, then commits it onto the target branch of the -# target repo, replacing the previous contents. The four jobs target four -# different repos, so they run in parallel without contention. +# Marketplaces and installers consume `getsentry/plugin-` by git ref. Each +# job builds its agent's tree from this repo, then commits it onto the target +# branch of the target repo, replacing the previous contents. The six jobs target +# six different repos, so they run in parallel without contention. # # Cross-repo writes use a GitHub App token scoped per-job to a single plugin # repo; the default GITHUB_TOKEN cannot push to other repositories. The app must -# be installed on the org with contents:write on the four plugin repos, its ID +# be installed on the org with contents:write on the six plugin repos, its ID # stored as the PLUGIN_DEPLOY_APP_ID variable and its private key as the # PLUGIN_DEPLOY_KEY secret. Each target repo must already exist with `main` as # its default branch; `develop` is branched off it on the first deploy. @@ -83,7 +85,7 @@ jobs: strategy: fail-fast: false matrix: - agent: [claude, cursor, codex, grok] + agent: [claude, cursor, codex, grok, opencode, opencode2] concurrency: group: deploy-plugin-${{ matrix.agent }}-${{ inputs.target_branch || 'develop' }} cancel-in-progress: false @@ -108,6 +110,20 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - name: Set up Node for OpenCode validation + if: matrix.agent == 'opencode' || matrix.agent == 'opencode2' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + + - name: Install OpenCode V1 CLI + if: matrix.agent == 'opencode' + run: npm install -g opencode-ai@1.18.0 + + - name: Install OpenCode V2 CLI + if: matrix.agent == 'opencode2' + run: npm install -g @opencode-ai/cli@0.0.0-next-16420 + - name: Build and deploy plugin-${{ matrix.agent }} env: AGENT: ${{ matrix.agent }} diff --git a/.github/workflows/smoke-installer.yml b/.github/workflows/smoke-installer.yml index 2ce3726e..3dfa7bea 100644 --- a/.github/workflows/smoke-installer.yml +++ b/.github/workflows/smoke-installer.yml @@ -1,15 +1,19 @@ # End-to-end smoke test of the installer across Linux, macOS, and Windows. # # Installs the real agent CLIs, then runs the installer in non-interactive mode -# (--no-interactive) so it installs the Sentry plugin for every detected agent -# without a prompt, and verifies each plugin actually landed. +# (--no-interactive) so it installs the Sentry integration for every detected +# agent without a prompt, and verifies each distribution actually landed. # -# Claude Code, Codex, and Grok ship cross-platform npm CLIs, so they get a real -# install against their default marketplaces. Cursor has no headless CLI to +# Claude Code, Codex, Grok, and OpenCode V1 ship cross-platform npm CLIs, so +# they get real CLI installs. Cursor has no headless CLI to # install in CI, but our Cursor install is only a `git clone` of the public # plugin repo into ~/.cursor/plugins/local/sentry — so we put a `cursor` stub on # PATH to satisfy detection and verify the real clone (this is what exercises # the per-OS path handling, including Windows). +# +# The OpenCode target repositories are replaced with temporary local git +# repositories in both jobs; this tests the real clone flow before those +# generated repositories are first deployed. name: Smoke installer @@ -21,10 +25,12 @@ on: branches: [main] paths: - "packages/installer/**" + - "mcp.json" - ".github/workflows/smoke-installer.yml" pull_request: paths: - "packages/installer/**" + - "mcp.json" - ".github/workflows/smoke-installer.yml" workflow_dispatch: @@ -57,8 +63,24 @@ jobs: - name: Build run: pnpm --filter @sentry/ai build - - name: Install Claude Code, Codex, and Grok CLIs - run: npm install -g @anthropic-ai/claude-code @openai/codex @xai-official/grok + - name: Install Claude Code, Codex, Grok, and OpenCode V1 CLIs + run: npm install -g @anthropic-ai/claude-code @openai/codex @xai-official/grok opencode-ai@1.18.0 + + - name: Prepare local OpenCode V1 distribution + run: | + set -euo pipefail + repo="$RUNNER_TEMP/plugin-opencode" + mkdir -p "$repo/skills/sentry-installer-smoke" + printf '1\n' > "$repo/.sentry-opencode-v1" + printf '%s\n' '---' 'name: sentry-installer-smoke' 'description: Verify the Sentry OpenCode installer' '---' '' '# Sentry installer smoke' > "$repo/skills/sentry-installer-smoke/SKILL.md" + git -C "$repo" init -q + git -C "$repo" config user.name smoke-test + git -C "$repo" config user.email smoke@example.com + git -C "$repo" add . + git -C "$repo" commit -qm 'test: seed OpenCode bundle' + repo_url="$(node -e 'console.log(require("node:url").pathToFileURL(process.argv[1]).href)' "$repo")" + git config --global protocol.file.allow always + git config --global "url.${repo_url}.insteadOf" https://github.com/getsentry/plugin-opencode.git - name: Put a Cursor stub on PATH run: | @@ -96,4 +118,74 @@ jobs: echo "== cursor ==" node -e "const p=require('path').join(require('os').homedir(),'.cursor','plugins','local','sentry'); if(!require('fs').existsSync(p)){console.error('cursor: plugin dir missing at '+p);process.exit(1)} console.log(p)" + echo "== opencode ==" + opencode debug skill > "$RUNNER_TEMP/opencode-skills.json" + grep -q 'sentry-installer-smoke' "$RUNNER_TEMP/opencode-skills.json" || { echo "opencode: sentry skill missing"; exit 1; } + node - <<'NODE' + const fs = require("node:fs"); + const path = require("node:path"); + const os = require("node:os"); + const root = path.join(os.homedir(), ".config", "opencode"); + const file = ["opencode.json", "opencode.jsonc"].map((name) => path.join(root, name)).find(fs.existsSync); + if (!file) throw new Error("opencode: global config missing"); + const config = JSON.parse(fs.readFileSync(file, "utf8")); + if (!config.mcp?.sentry?.url?.includes("mcp.sentry.dev")) throw new Error("opencode: Sentry MCP missing"); + NODE + echo "All agents have the Sentry plugin." + + # OpenCode V2 currently recommends WSL on Windows and cannot share V1's MCP + # config shape, so exercise it separately from the cross-platform V1 matrix. + smoke-opencode2: + name: OpenCode V2 beta + runs-on: ubuntu-latest + env: + SENTRY_DSN: "" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: pnpm/action-setup@a15d269cd4658e1107c09f1fabf4cbd7bd1f308a # v4.4.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @sentry/ai build + - run: npm install -g @opencode-ai/cli@0.0.0-next-16420 + + - name: Prepare local OpenCode V2 distribution + run: | + set -euo pipefail + repo="$RUNNER_TEMP/plugin-opencode2" + mkdir -p "$repo/skills/sentry-installer-smoke" + printf '2\n' > "$repo/.sentry-opencode-v2" + printf '%s\n' '---' 'name: sentry-installer-smoke' 'description: Verify the Sentry OpenCode V2 installer' 'metadata:' ' opencode/autoinvoke: "false"' '---' '' '# Sentry installer smoke' > "$repo/skills/sentry-installer-smoke/SKILL.md" + git -C "$repo" init -q + git -C "$repo" config user.name smoke-test + git -C "$repo" config user.email smoke@example.com + git -C "$repo" add . + git -C "$repo" commit -qm 'test: seed OpenCode V2 bundle' + repo_url="$(node -e 'console.log(require("node:url").pathToFileURL(process.argv[1]).href)' "$repo")" + git config --global protocol.file.allow always + git config --global "url.${repo_url}.insteadOf" https://github.com/getsentry/plugin-opencode2.git + + - name: Run installer (non-interactive) + run: node packages/installer/dist/index.js install --no-interactive + + - name: Verify OpenCode V2 installation + run: | + set -euo pipefail + node - <<'NODE' + const fs = require("node:fs"); + const path = require("node:path"); + const os = require("node:os"); + const bundle = path.join(os.homedir(), ".config", "opencode", "skills", "sentry", "skills", "sentry-installer-smoke", "SKILL.md"); + if (!fs.existsSync(bundle)) throw new Error(`opencode2: Sentry skill missing at ${bundle}`); + const root = path.join(os.homedir(), ".config", "opencode"); + const file = ["opencode.json", "opencode.jsonc"].map((name) => path.join(root, name)).find(fs.existsSync); + if (!file) throw new Error("opencode2: global config missing"); + const config = JSON.parse(fs.readFileSync(file, "utf8")); + if (!config.mcp?.servers?.sentry?.url?.includes("mcp.sentry.dev")) throw new Error("opencode2: Sentry MCP missing"); + NODE diff --git a/.gitignore b/.gitignore index eabebd36..93608372 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .claude/* !.claude/settings.json .DS_Store +node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 2635cb79..dd88de03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,8 @@ ## Project Overview -Sentry plugin for AI coding assistants (Claude Code, Cursor, Codex, and Grok). -Provides MCP server integration and skills. +Sentry plugin for AI coding assistants (Claude Code, Cursor, Codex, Grok, and OpenCode +V1/V2). Provides MCP server integration and skills. ## Commit Attribution @@ -24,8 +24,8 @@ src/plugins/version.json # The one release version, stamped into every manifest src/SKILL_TREE.md # Generated skill index (see Skill Tree Navigation below) ``` -Per-agent plugin metadata is generated by the `src/plugins//build.sh` scripts and -published to each agent’s distribution repository; it is not committed here. +Per-agent distribution metadata is generated by the `src/plugins//build.sh` +scripts and published to each agent’s distribution repository; it is not committed here. Skill frontmatter is `name`, `description`, and `license`. Nothing else is required, and the retired router fields (`category`, `parent`, `role`, `disable-model-invocation`) are @@ -53,6 +53,11 @@ it). Claude declares the server inline in its `plugin.json` (`mcpServers`), so the Claude build ships no MCP file. +OpenCode V1 and V2 generate native `opencode.json` files from `mcp.json`. V1 places +`sentry` directly under `mcp`; V2 places it under `mcp.servers`. Their installer +harnesses use the corresponding CLI command to merge that entry into the user’s global +config. + ## Releasing the Plugins The four agent plugins version in lockstep from `src/plugins/version.json`. Each agent diff --git a/README.md b/README.md index 86a289e6..5297cd20 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,15 @@ > [!IMPORTANT] > **This is a skill *source* repository — not something you install directly.** The -> skills here are built from this source into installable plugins for +> skills here are built from this source into installable distributions for > [Claude Code](https://github.com/getsentry/plugin-claude), > [Cursor](https://github.com/getsentry/plugin-cursor), -> [Codex](https://github.com/getsentry/plugin-codex), and -> [Grok](https://github.com/getsentry/plugin-grok) — install one of those, not this -> repo. They’re also served over HTTP at [skills.sentry.dev](https://skills.sentry.dev) -> for agents to fetch directly. +> [Codex](https://github.com/getsentry/plugin-codex), +> [Grok](https://github.com/getsentry/plugin-grok), +> [OpenCode V1](https://github.com/getsentry/plugin-opencode), and +> [OpenCode V2](https://github.com/getsentry/plugin-opencode2) — install one of those, +> not this repo. They’re also served over HTTP at +> [skills.sentry.dev](https://skills.sentry.dev) for agents to fetch directly. > In the future we may also publish the skills as a generic, standalone skills > repository. @@ -22,8 +24,10 @@ The plugin gives your assistant the context it needs to do it right. Supports [**Claude Code**](https://github.com/getsentry/plugin-claude), [**Cursor**](https://github.com/getsentry/plugin-cursor), -[**Codex**](https://github.com/getsentry/plugin-codex), and -[**Grok**](https://github.com/getsentry/plugin-grok). +[**Codex**](https://github.com/getsentry/plugin-codex), +[**Grok**](https://github.com/getsentry/plugin-grok), +[**OpenCode V1**](https://github.com/getsentry/plugin-opencode), and +[**OpenCode V2**](https://github.com/getsentry/plugin-opencode2). ## What You Can Do @@ -65,11 +69,12 @@ Set up OTel Collector with Sentry exporter ## Distribution This repository is the single source of truth for all skills, but it is not itself an -installable plugin. -Each assistant needs the plugin in a slightly different shape, so the -per-agent plugins are **built** from it by the build scripts under +installable distribution. +Each assistant needs the integration in a slightly different shape, so the per-agent +distributions are **built** from it by the build scripts under `src/plugins//build.sh`. CI runs these on every push and deploys each result to -its own **distribution repository**, whose root is exactly that agent’s plugin: +its own **distribution repository**, whose root is exactly that agent’s installable +bundle or native plugin: | Agent | Distribution repository | | --- | --- | @@ -77,6 +82,8 @@ its own **distribution repository**, whose root is exactly that agent’s plugin | Cursor | [`getsentry/plugin-cursor`](https://github.com/getsentry/plugin-cursor) | | Codex | [`getsentry/plugin-codex`](https://github.com/getsentry/plugin-codex) | | Grok | [`getsentry/plugin-grok`](https://github.com/getsentry/plugin-grok) | +| OpenCode V1 | [`getsentry/plugin-opencode`](https://github.com/getsentry/plugin-opencode) | +| OpenCode V2 | [`getsentry/plugin-opencode2`](https://github.com/getsentry/plugin-opencode2) | These repositories are generated; do not edit them. Each one’s README has the install instructions for that agent. @@ -94,11 +101,11 @@ skill tree’s `disable-model-invocation` flags for Codex’s `agents/openai.yam ```bash git clone https://github.com/getsentry/sentry-for-ai.git cd sentry-for-ai -src/plugins/codex/build.sh /tmp/sentry-codex # or src/plugins/{claude,cursor,grok} +src/plugins/codex/build.sh /tmp/sentry-codex # or src/plugins/{claude,cursor,grok,opencode,opencode2} ``` To build any target locally, run `src/plugins//build.sh ` (`claude`, -`cursor`, `codex`, or `grok`). +`cursor`, `codex`, `grok`, `opencode`, or `opencode2`). ## Skills diff --git a/packages/installer/README.md b/packages/installer/README.md index 0ceef24d..5d81390c 100644 --- a/packages/installer/README.md +++ b/packages/installer/README.md @@ -8,7 +8,8 @@ and fix production issues, and how to configure alerts, AI monitoring, and more. This package detects which assistants you have installed and wires the plugin into each one for you. -Supports **Claude Code**, **Codex**, **Cursor**, and **Grok**. +Supports **Claude Code**, **Codex**, **Cursor**, **Grok**, **OpenCode V1**, and +**OpenCode V2**. ```bash npx @sentry/ai install @@ -40,7 +41,8 @@ The non-interactive mode is intended for CI and unattended runs and skips this p ## What it installs -For each detected assistant, the installer runs that tool’s native plugin command: +For each detected assistant, the installer uses its native package command or installs +the generated skill bundle: | Assistant | How it’s installed | | --- | --- | @@ -48,8 +50,10 @@ For each detected assistant, the installer runs that tool’s native plugin comm | Codex | `codex plugin add sentry` from the Sentry plugin marketplace | | Cursor | Clones [`getsentry/plugin-cursor`](https://github.com/getsentry/plugin-cursor) into `~/.cursor/plugins/local/sentry` | | Grok | `grok plugin install getsentry/plugin-grok` | +| OpenCode V1 | Clones [`getsentry/plugin-opencode`](https://github.com/getsentry/plugin-opencode) into OpenCode’s global skills and adds the V1 Sentry MCP entry | +| OpenCode V2 | Clones [`getsentry/plugin-opencode2`](https://github.com/getsentry/plugin-opencode2) into OpenCode’s global skills and adds the V2 Sentry MCP entry | -Each per-agent plugin is built and published from the +Each per-agent distribution is built and published from the [`sentry-for-ai`](https://github.com/getsentry/sentry-for-ai) repository, which is the source of truth for all skills. @@ -62,13 +66,19 @@ npx @sentry/ai remove --no-interactive # remove from every agent that has it `uninstall` is an alias for `remove`. This only offers agents that currently have the plugin, and removes the Sentry plugin itself — each tool’s plugin marketplace is left -registered. Restart your AI tools afterward to drop the plugin. +registered. OpenCode’s CLIs do not provide an MCP remove command, so the installer +identifies the config entry to delete manually. +Restart your AI tools afterward to drop the plugin. ## Requirements - Node.js 18 or newer - The assistant CLI you want to set up must already be installed and on your `PATH` -- `git` is required for the Cursor install +- `git` is required for the Cursor and OpenCode installs + +OpenCode V1 and the OpenCode V2 beta use the same global config path but incompatible +MCP shapes. When both `opencode` and `opencode2` are installed, the installer configures +V2 only. ## License diff --git a/packages/installer/package.json b/packages/installer/package.json index d56252b2..f792ff33 100644 --- a/packages/installer/package.json +++ b/packages/installer/package.json @@ -35,6 +35,7 @@ "@listr2/prompt-adapter-inquirer": "^4.2.4", "@sentry/node": "^10.57.0", "citty": "^0.1.6", + "jsonc-parser": "^3.3.1", "listr2": "^10.2.1" }, "devDependencies": { diff --git a/packages/installer/src/__tests__/fake-system.ts b/packages/installer/src/__tests__/fake-system.ts index 30ea22b5..e099de6c 100644 --- a/packages/installer/src/__tests__/fake-system.ts +++ b/packages/installer/src/__tests__/fake-system.ts @@ -4,17 +4,30 @@ import type { ShellResult, SystemDeps } from "../system"; export interface FakeSystemOptions { run?: (command: string) => ShellResult; existing?: string[]; + files?: Record; platform?: NodeJS.Platform; homedir?: string; } export function fakeSystem(options: FakeSystemOptions = {}): SystemDeps { - const existing = new Set(options.existing ?? []); + const files = new Map(Object.entries(options.files ?? {})); + const existing = new Set([...(options.existing ?? []), ...files.keys()]); const run = options.run ?? (() => ({ ok: true })); return { run: vi.fn(async (command: string) => run(command)), exists: vi.fn((path: string) => existing.has(path)), + readTextFile: vi.fn((path: string) => { + const contents = files.get(path); + if (contents === undefined) { + throw new Error(`ENOENT: ${path}`); + } + return contents; + }), + writeTextFile: vi.fn((path: string, contents: string) => { + files.set(path, contents); + existing.add(path); + }), platform: options.platform ?? "linux", homedir: options.homedir ?? "/home/user", }; diff --git a/packages/installer/src/__tests__/harnesses.test.ts b/packages/installer/src/__tests__/harnesses.test.ts index 4639bacf..02a11044 100644 --- a/packages/installer/src/__tests__/harnesses.test.ts +++ b/packages/installer/src/__tests__/harnesses.test.ts @@ -1,10 +1,14 @@ import { join } from "node:path"; +import { getNodeValue, parseTree } from "jsonc-parser"; import { describe, expect, it } from "vitest"; import type { ShellResult } from "../system"; import { createClaude } from "../harnesses/claude"; import { createCodex } from "../harnesses/codex"; import { createCursor } from "../harnesses/cursor"; import { createGrok } from "../harnesses/grok"; +import { createOpenCode } from "../harnesses/opencode"; +import { createOpenCode2 } from "../harnesses/opencode2"; +import type { Harness } from "../harnesses/types"; import { fakeSystem } from "./fake-system"; const ok: ShellResult = { ok: true }; @@ -382,6 +386,278 @@ describe("grok harness", () => { }); }); +interface OpenCodeHarnessCase { + label: string; + binary: string; + repository: string; + mcpCommand: string; + mcpConfigPath: string[]; + configWithIncompatibleMcp: string; + incompatibleMcpConfigPath: string[]; + marker: string; + incompatibleMarker: string; + create: (system: ReturnType) => Harness; +} + +function testOpenCodeHarness(testCase: OpenCodeHarnessCase) { + describe(`${testCase.label} harness`, () => { + const target = "/home/user/.config/opencode/skills/sentry"; + const marker = `${target}/${testCase.marker}`; + const incompatibleMarker = `${target}/${testCase.incompatibleMarker}`; + const configPath = "/home/user/.config/opencode/opencode.jsonc"; + + it(`detects when the ${testCase.binary} binary is on PATH`, async () => { + const system = fakeSystem({ + run: (command) => (command === `which ${testCase.binary}` ? ok : notFound), + }); + expect(await testCase.create(system).detect()).toBe(true); + }); + + it(`does not detect when ${testCase.binary} is missing`, async () => { + const harness = testCase.create(fakeSystem({ run: () => notFound })); + expect(await harness.detect()).toBe(false); + }); + + it("reports installed when the shared bundle directory exists", async () => { + const harness = testCase.create( + fakeSystem({ homedir: "/home/user", existing: [target, marker] }), + ); + expect(await harness.isInstalled()).toBe(true); + }); + + it("reports not installed when the shared bundle directory is absent", async () => { + const harness = testCase.create(fakeSystem({ homedir: "/home/user" })); + expect(await harness.isInstalled()).toBe(false); + }); + + it("reports the other OpenCode version's shared bundle as installed", async () => { + const harness = testCase.create( + fakeSystem({ homedir: "/home/user", existing: [target, incompatibleMarker] }), + ); + expect(await harness.isInstalled()).toBe(true); + }); + + it("removes an incompatible bundle before installation", async () => { + const system = fakeSystem({ + homedir: "/home/user", + existing: [target, incompatibleMarker], + }); + const cleaned = await testCase.create(system).cleanup!(); + + expect(cleaned).toBe("Removed the incompatible Sentry OpenCode bundle"); + expect(system.run).toHaveBeenCalledWith(`rm -rf "${target}"`); + }); + + it("reports both incompatible bundle and MCP cleanup when both occur", async () => { + const system = fakeSystem({ + homedir: "/home/user", + existing: [target, incompatibleMarker], + files: { [configPath]: testCase.configWithIncompatibleMcp }, + }); + const cleaned = await testCase.create(system).cleanup!(); + + expect(cleaned).toBe("Removed the incompatible Sentry OpenCode bundle and MCP configuration"); + }); + + it("preserves MCP configuration when incompatible bundle removal fails", async () => { + const system = fakeSystem({ + homedir: "/home/user", + existing: [target, incompatibleMarker], + files: { [configPath]: testCase.configWithIncompatibleMcp }, + run: () => ({ ok: false, stderr: "bundle is locked" }), + }); + + await expect(testCase.create(system).cleanup!()).rejects.toThrow("bundle is locked"); + expect(system.writeTextFile).not.toHaveBeenCalled(); + }); + + it("leaves its own bundle untouched during cleanup", async () => { + const system = fakeSystem({ homedir: "/home/user", existing: [target, marker] }); + expect(await testCase.create(system).cleanup!()).toBeNull(); + expect(system.run).not.toHaveBeenCalledWith(`rm -rf "${target}"`); + }); + + it("removes an unmarked partial installation before cloning", async () => { + const system = fakeSystem({ homedir: "/home/user", existing: [target] }); + const cleaned = await testCase.create(system).cleanup!(); + + expect(cleaned).toContain("partial"); + expect(system.run).toHaveBeenCalledWith(`rm -rf "${target}"`); + }); + + it("removes the incompatible MCP shape while preserving JSONC", async () => { + const system = fakeSystem({ + homedir: "/home/user", + files: { [configPath]: testCase.configWithIncompatibleMcp }, + }); + const cleaned = await testCase.create(system).cleanup!(); + + expect(cleaned).toContain("MCP"); + expect(system.writeTextFile).toHaveBeenCalledOnce(); + const written = (system.writeTextFile as any).mock.calls[0][1] as string; + expect(written).toContain("// keep this comment"); + expect(getNodeValue(parseTree(written)!)).not.toHaveProperty( + testCase.incompatibleMcpConfigPath.join("."), + ); + }); + + it("requires git to install", async () => { + const unavailable = testCase.create(fakeSystem({ run: () => notFound })); + expect((await unavailable.canInstall()).ok).toBe(false); + + const available = testCase.create( + fakeSystem({ run: (command) => (command === "which git" ? ok : notFound) }), + ); + expect(await available.canInstall()).toEqual({ ok: true }); + }); + + it("clones the bundle and configures the native MCP shape", async () => { + const system = fakeSystem({ homedir: "/home/user" }); + const outcome = await testCase.create(system).install(); + + expect(outcome).toMatchObject({ + kind: "done", + command: `git clone ${testCase.repository} "${target}"`, + }); + expect(system.run).toHaveBeenCalledWith('mkdir -p "/home/user/.config/opencode/skills"'); + expect(system.run).toHaveBeenCalledWith(`git clone ${testCase.repository} "${target}"`); + expect(system.run).toHaveBeenCalledWith( + `${testCase.mcpCommand} "https://mcp.sentry.dev/mcp?utm_source=plugin"`, + ); + }); + + it("recovers from a partial directory when install is called directly", async () => { + const system = fakeSystem({ homedir: "/home/user", existing: [target] }); + await testCase.create(system).install(); + + expect(system.run).toHaveBeenCalledWith(`rm -rf "${target}"`); + expect(system.run).toHaveBeenCalledWith(`git clone ${testCase.repository} "${target}"`); + }); + + it("replaces the other version's bundle instead of pulling it", async () => { + const system = fakeSystem({ + homedir: "/home/user", + existing: [target, incompatibleMarker], + }); + const outcome = await testCase.create(system).update(); + + expect(outcome).toMatchObject({ + kind: "done", + command: `git clone ${testCase.repository} "${target}"`, + }); + expect(system.run).toHaveBeenCalledWith(`rm -rf "${target}"`); + expect(system.run).not.toHaveBeenCalledWith(`git -C "${target}" pull`); + }); + + it("pulls the bundle and restores MCP configuration on update", async () => { + const system = fakeSystem({ homedir: "/home/user", existing: [target, marker] }); + const outcome = await testCase.create(system).update(); + + expect(outcome).toMatchObject({ kind: "done", command: `git -C "${target}" pull` }); + expect(system.run).toHaveBeenCalledWith(`git -C "${target}" pull`); + expect(system.run).toHaveBeenCalledWith( + `${testCase.mcpCommand} "https://mcp.sentry.dev/mcp?utm_source=plugin"`, + ); + }); + + it("removes the bundle and both possible MCP entries", async () => { + const config = + '{\n // keep this comment\n "mcp": {\n "sentry": { "type": "remote" },\n "servers": {\n "sentry": { "type": "remote" },\n "other": { "type": "remote" }\n }\n }\n}'; + const system = fakeSystem({ + homedir: "/home/user", + existing: [target, marker], + files: { [configPath]: config }, + }); + const outcome = await testCase.create(system).remove(); + + expect(outcome).toEqual({ kind: "done", command: `rm -rf "${target}"` }); + const written = (system.writeTextFile as any).mock.calls[0][1] as string; + expect(written).toContain("// keep this comment"); + expect(getNodeValue(parseTree(written)!)).not.toHaveProperty("mcp.sentry"); + expect(getNodeValue(parseTree(written)!)).not.toHaveProperty("mcp.servers.sentry"); + expect(getNodeValue(parseTree(written)!)).toHaveProperty("mcp.servers.other"); + }); + + it("uses native directory commands on Windows", async () => { + const homedir = "C:\\Users\\user"; + const windowsTarget = join(homedir, ".config", "opencode", "skills", "sentry"); + const parent = join(homedir, ".config", "opencode", "skills"); + const system = fakeSystem({ homedir, platform: "win32" }); + const harness = testCase.create(system); + + await harness.install(); + expect(system.run).toHaveBeenCalledWith(`if not exist "${parent}" mkdir "${parent}"`); + + await harness.remove(); + expect(system.run).toHaveBeenCalledWith(`rmdir /s /q "${windowsTarget}"`); + }); + + it("forwards the output sink to every install command", async () => { + const system = fakeSystem({ homedir: "/home/user" }); + const sink = {} as NodeJS.WritableStream; + await testCase.create(system).install(sink); + + expect(system.run).toHaveBeenCalledWith(`git clone ${testCase.repository} "${target}"`, sink); + expect(system.run).toHaveBeenCalledWith( + `${testCase.mcpCommand} "https://mcp.sentry.dev/mcp?utm_source=plugin"`, + sink, + ); + }); + }); +} + +testOpenCodeHarness({ + label: "OpenCode V1", + binary: "opencode", + repository: "https://github.com/getsentry/plugin-opencode.git", + mcpCommand: "opencode mcp add sentry --url", + mcpConfigPath: ["mcp", "sentry"], + configWithIncompatibleMcp: + '{\n // keep this comment\n "mcp": {\n "servers": {\n "sentry": { "type": "remote" }\n }\n }\n}', + incompatibleMcpConfigPath: ["mcp", "servers", "sentry"], + marker: ".sentry-opencode-v1", + incompatibleMarker: ".sentry-opencode-v2", + create: createOpenCode, +}); + +testOpenCodeHarness({ + label: "OpenCode V2", + binary: "opencode2", + repository: "https://github.com/getsentry/plugin-opencode2.git", + mcpCommand: "opencode2 mcp add sentry --global --url", + mcpConfigPath: ["mcp", "servers", "sentry"], + configWithIncompatibleMcp: + '{\n // keep this comment\n "mcp": {\n "sentry": { "type": "remote" }\n }\n}', + incompatibleMcpConfigPath: ["mcp", "sentry"], + marker: ".sentry-opencode-v2", + incompatibleMarker: ".sentry-opencode-v1", + create: createOpenCode2, +}); + +describe("OpenCode version precedence", () => { + it("prefers V2 when both explicit binaries are installed", async () => { + const system = fakeSystem({ run: () => ok }); + expect(await createOpenCode(system).detect()).toBe(false); + expect(await createOpenCode2(system).detect()).toBe(true); + }); + + it("lets the preferred V2 harness remove a V1 bundle", async () => { + const target = "/home/user/.config/opencode/skills/sentry"; + const system = fakeSystem({ + homedir: "/home/user", + run: () => ok, + existing: [target, `${target}/.sentry-opencode-v1`], + }); + const v2 = createOpenCode2(system); + + expect(await createOpenCode(system).detect()).toBe(false); + expect(await v2.detect()).toBe(true); + expect(await v2.isInstalled()).toBe(true); + await v2.remove(); + expect(system.run).toHaveBeenCalledWith(`rm -rf "${target}"`); + }); +}); + describe("cursor harness", () => { it("detects when the cursor binary is on PATH", async () => { const harness = createCursor(fakeSystem({ run: () => ok })); diff --git a/packages/installer/src/harnesses/index.ts b/packages/installer/src/harnesses/index.ts index 9ca1c93c..d2356790 100644 --- a/packages/installer/src/harnesses/index.ts +++ b/packages/installer/src/harnesses/index.ts @@ -3,17 +3,19 @@ import { createClaude } from "./claude"; import { createCodex } from "./codex"; import { createCursor } from "./cursor"; import { createGrok } from "./grok"; +import { createOpenCode } from "./opencode"; +import { createOpenCode2 } from "./opencode2"; import type { Harness } from "./types"; export type { Harness, InstallOutcome } from "./types"; -export { createClaude, createCodex, createCursor, createGrok }; +export { createClaude, createCodex, createCursor, createGrok, createOpenCode, createOpenCode2 }; /** * Every harness, built against the real system. * * Built on call rather than at module load: the harnesses used to be - * module-level constants, which meant importing this barrel constructed all four - * as a side effect and left two ways to get one. Now there is a single + * module-level constants, which meant importing this barrel constructed all of + * them as a side effect and left two ways to get one. Now there is a single * construction path. */ export function buildHarnesses(): Harness[] { @@ -22,5 +24,7 @@ export function buildHarnesses(): Harness[] { createCodex(realSystem), createCursor(realSystem), createGrok(realSystem), + createOpenCode(realSystem), + createOpenCode2(realSystem), ]; } diff --git a/packages/installer/src/harnesses/opencode-common.ts b/packages/installer/src/harnesses/opencode-common.ts new file mode 100644 index 00000000..2ce3abd9 --- /dev/null +++ b/packages/installer/src/harnesses/opencode-common.ts @@ -0,0 +1,179 @@ +import { dirname, join } from "node:path"; +import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser"; +import mcp from "../../../../mcp.json"; +import type { OutputSink, SystemDeps } from "../system"; +import type { Harness, InstallOutcome } from "./types"; +import { detectOnPath, runCommand } from "./shell"; + +const MCP_URL = mcp.mcpServers.sentry.url; + +interface OpenCodeHarnessOptions { + id: string; + name: string; + binary: string; + repository: string; + mcpCommand: string; + mcpConfigPath: string[]; + incompatibleMcpConfigPath: string[]; + marker: string; + incompatibleMarker: string; + supersededBy?: string; +} + +function bundleDir(system: SystemDeps): string { + return join(system.homedir, ".config", "opencode", "skills", "sentry"); +} + +async function ensureParentDirectory(system: SystemDeps, output?: OutputSink): Promise { + const parent = dirname(bundleDir(system)); + const command = + system.platform === "win32" + ? `if not exist "${parent}" mkdir "${parent}"` + : `mkdir -p "${parent}"`; + await runCommand(system, command, output); +} + +async function removeBundle(system: SystemDeps, output?: OutputSink): Promise { + const dir = bundleDir(system); + const command = system.platform === "win32" ? `rmdir /s /q "${dir}"` : `rm -rf "${dir}"`; + await runCommand(system, command, output); + return command; +} + +function markerPath(system: SystemDeps, marker: string): string { + return join(bundleDir(system), marker); +} + +function removeMcpConfigPaths(system: SystemDeps, mcpConfigPaths: string[][]): boolean { + const configDir = join(system.homedir, ".config", "opencode"); + let removed = false; + + for (const filename of ["opencode.json", "opencode.jsonc"]) { + const configPath = join(configDir, filename); + if (!system.exists(configPath)) { + continue; + } + + let contents = system.readTextFile(configPath); + let changed = false; + for (const mcpConfigPath of mcpConfigPaths) { + const tree = parseTree(contents); + if (!tree || !findNodeAtLocation(tree, mcpConfigPath)) { + continue; + } + + const edits = modify(contents, mcpConfigPath, undefined, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }); + contents = applyEdits(contents, edits); + changed = true; + } + + if (changed) { + system.writeTextFile(configPath, contents); + removed = true; + } + } + + return removed; +} + +export function createOpenCodeHarness( + system: SystemDeps, + options: OpenCodeHarnessOptions, +): Harness { + const hasOwnBundle = () => system.exists(markerPath(system, options.marker)); + const hasIncompatibleBundle = () => system.exists(markerPath(system, options.incompatibleMarker)); + const configureMcp = async (output?: OutputSink) => { + removeMcpConfigPaths(system, [options.incompatibleMcpConfigPath]); + await runCommand(system, `${options.mcpCommand} "${MCP_URL}"`, output); + }; + + return { + id: options.id, + name: options.name, + + detect: async () => { + if (!(await detectOnPath(system, options.binary))) { + return false; + } + + // V1 and V2 share a config path but use incompatible MCP shapes. Prefer + // the V2 beta when both explicit binaries are installed so unattended + // installs never race while writing the same file. + return !options.supersededBy || !(await detectOnPath(system, options.supersededBy)); + }, + + // The active OpenCode CLI owns the shared bundle even when it was installed + // by the other version. This keeps removal available and lets installation + // replace an incompatible checkout instead of hiding it. + isInstalled: async () => hasOwnBundle() || hasIncompatibleBundle(), + + canInstall: async () => + (await detectOnPath(system, "git")) + ? { ok: true } + : { ok: false, reason: `git is required to clone the ${options.name} bundle` }, + + cleanup: async (output) => { + const incompatibleBundle = hasIncompatibleBundle(); + const partialBundle = system.exists(bundleDir(system)) && !hasOwnBundle(); + + if (incompatibleBundle || partialBundle) { + await removeBundle(system, output); + } + + const incompatibleMcp = removeMcpConfigPaths(system, [options.incompatibleMcpConfigPath]); + + if (incompatibleBundle) { + return incompatibleMcp + ? "Removed the incompatible Sentry OpenCode bundle and MCP configuration" + : "Removed the incompatible Sentry OpenCode bundle"; + } + if (partialBundle) { + return "Removed a partial Sentry OpenCode installation"; + } + if (incompatibleMcp) { + return "Removed the incompatible Sentry OpenCode MCP configuration"; + } + return null; + }, + + install: async (output): Promise => { + // Recover when install is called directly after a failed clone, without + // relying on the orchestration layer to have run cleanup first. + if (system.exists(bundleDir(system)) && !hasOwnBundle()) { + await removeBundle(system, output); + } + + await ensureParentDirectory(system, output); + const command = `git clone ${options.repository} "${bundleDir(system)}"`; + await runCommand(system, command, output); + await configureMcp(output); + return { kind: "done", command }; + }, + + update: async (output): Promise => { + if (!hasOwnBundle()) { + if (system.exists(bundleDir(system))) { + await removeBundle(system, output); + } + await ensureParentDirectory(system, output); + const command = `git clone ${options.repository} "${bundleDir(system)}"`; + await runCommand(system, command, output); + await configureMcp(output); + return { kind: "done", command }; + } + + const command = `git -C "${bundleDir(system)}" pull`; + await runCommand(system, command, output); + await configureMcp(output); + return { kind: "done", command }; + }, + + remove: async (output): Promise => { + const command = await removeBundle(system, output); + removeMcpConfigPaths(system, [options.mcpConfigPath, options.incompatibleMcpConfigPath]); + return { kind: "done", command }; + }, + }; +} diff --git a/packages/installer/src/harnesses/opencode.ts b/packages/installer/src/harnesses/opencode.ts new file mode 100644 index 00000000..92614c83 --- /dev/null +++ b/packages/installer/src/harnesses/opencode.ts @@ -0,0 +1,18 @@ +import type { SystemDeps } from "../system"; +import type { Harness } from "./types"; +import { createOpenCodeHarness } from "./opencode-common"; + +export function createOpenCode(system: SystemDeps): Harness { + return createOpenCodeHarness(system, { + id: "opencode", + name: "OpenCode V1", + binary: "opencode", + repository: "https://github.com/getsentry/plugin-opencode.git", + mcpCommand: "opencode mcp add sentry --url", + mcpConfigPath: ["mcp", "sentry"], + incompatibleMcpConfigPath: ["mcp", "servers", "sentry"], + marker: ".sentry-opencode-v1", + incompatibleMarker: ".sentry-opencode-v2", + supersededBy: "opencode2", + }); +} diff --git a/packages/installer/src/harnesses/opencode2.ts b/packages/installer/src/harnesses/opencode2.ts new file mode 100644 index 00000000..760f8b45 --- /dev/null +++ b/packages/installer/src/harnesses/opencode2.ts @@ -0,0 +1,17 @@ +import type { SystemDeps } from "../system"; +import type { Harness } from "./types"; +import { createOpenCodeHarness } from "./opencode-common"; + +export function createOpenCode2(system: SystemDeps): Harness { + return createOpenCodeHarness(system, { + id: "opencode2", + name: "OpenCode V2 (Beta)", + binary: "opencode2", + repository: "https://github.com/getsentry/plugin-opencode2.git", + mcpCommand: "opencode2 mcp add sentry --global --url", + mcpConfigPath: ["mcp", "servers", "sentry"], + incompatibleMcpConfigPath: ["mcp", "sentry"], + marker: ".sentry-opencode-v2", + incompatibleMarker: ".sentry-opencode-v1", + }); +} diff --git a/packages/installer/src/system.ts b/packages/installer/src/system.ts index 10a9ea34..0a3ad5f2 100644 --- a/packages/installer/src/system.ts +++ b/packages/installer/src/system.ts @@ -1,5 +1,5 @@ import { exec, spawn } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { promisify } from "node:util"; @@ -24,6 +24,8 @@ export interface SystemDeps { // captured in the result); without it, output is simply buffered. run(command: string, output?: OutputSink): Promise; exists(path: string): boolean; + readTextFile(path: string): string; + writeTextFile(path: string, contents: string): void; platform: NodeJS.Platform; homedir: string; } @@ -81,6 +83,8 @@ export const realSystem: SystemDeps = { } }, exists: existsSync, + readTextFile: (path) => readFileSync(path, "utf8"), + writeTextFile: (path, contents) => writeFileSync(path, contents, "utf8"), platform: process.platform, homedir: homedir(), }; diff --git a/packages/installer/tsconfig.json b/packages/installer/tsconfig.json index 634c7aca..1cde1f73 100644 --- a/packages/installer/tsconfig.json +++ b/packages/installer/tsconfig.json @@ -7,6 +7,7 @@ "rootDir": "src", "strict": true, "esModuleInterop": true, + "resolveJsonModule": true, "skipLibCheck": true, "types": ["node"] }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b5a413d..4779db4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: citty: specifier: ^0.1.6 version: 0.1.6 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 listr2: specifier: ^10.2.1 version: 10.2.1 @@ -834,6 +837,9 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1785,6 +1791,8 @@ snapshots: dependencies: get-east-asian-width: 1.6.0 + jsonc-parser@3.3.1: {} + lightningcss-android-arm64@1.32.0: optional: true diff --git a/src/plugins/opencode/README.md b/src/plugins/opencode/README.md new file mode 100644 index 00000000..a028a47c --- /dev/null +++ b/src/plugins/opencode/README.md @@ -0,0 +1,60 @@ +# Sentry for OpenCode V1 + +The Sentry skill bundle for [OpenCode V1](https://opencode.ai/docs/). It teaches +OpenCode how to set up Sentry, debug production issues through the Sentry MCP server, +and configure monitoring features. + +This is an installer-managed skill and MCP bundle, not an OpenCode runtime plugin. +Installation uses OpenCode’s global skill discovery and MCP configuration surfaces +separately. + +> [!IMPORTANT] +> This repository is generated. +> It is built from [getsentry/sentry-for-ai](https://github.com/getsentry/sentry-for-ai) +> and includes every skill in that library. +> Do not edit files here; make changes in that repository and they will be rebuilt into +> this one. + +## Install + +```bash +git clone https://github.com/getsentry/plugin-opencode.git ~/.config/opencode/skills/sentry +opencode mcp add sentry --url 'https://mcp.sentry.dev/mcp?utm_source=plugin' +``` + +Restart OpenCode after installation. +The first Sentry MCP operation starts browser OAuth. +You can also authenticate explicitly: + +```bash +opencode mcp auth sentry +``` + +The generated root `opencode.json` makes this checkout directly runnable and shows the +V1 MCP shape. The commands above merge Sentry into your global config instead of +replacing any existing settings. + +## What’s included + +- The full hydrated Sentry skill library for SDK setup, debugging workflows, and feature + configuration. +- A native V1 remote MCP configuration for [mcp.sentry.dev](https://mcp.sentry.dev). + +OpenCode V1 ignores `disable-model-invocation`, so it advertises all bundled skills. +Skill bodies still load on demand through OpenCode’s `skill` tool. + +## Update or remove + +```bash +git -C ~/.config/opencode/skills/sentry pull +rm -rf ~/.config/opencode/skills/sentry +``` + +OpenCode V1 has no MCP remove command. +To remove Sentry completely, also delete the `mcp.sentry` entry from +`~/.config/opencode/opencode.json` or `~/.config/opencode/opencode.jsonc`. + +OpenCode V1 and the OpenCode V2 beta currently write incompatible MCP shapes to the same +global config file. +Do not configure both versions in that file at the same time; use the +V2 bundle when `opencode2` is your active CLI. diff --git a/src/plugins/opencode/build.sh b/src/plugins/opencode/build.sh new file mode 100755 index 00000000..761f6489 --- /dev/null +++ b/src/plugins/opencode/build.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# +# build.sh - Build the OpenCode V1 Sentry skill and MCP distribution. +# +# OpenCode discovers SKILL.md files recursively below a configured skill source. +# The distribution therefore keeps the shared skills under skills/ and includes +# a root opencode.json so the checkout is also directly runnable. The installer +# clones this tree below ~/.config/opencode/skills/sentry, where OpenCode's +# recursive global discovery finds the same files without replacing user config. +# +# V1 ignores disable-model-invocation, so skills ship as-authored. Its MCP shape +# places named servers directly below mcp; the config is generated from the +# repository's mcp.json source of truth. +# +# Usage: build.sh (TARGET_DIR assumed empty) + +set -euo pipefail + +TARGET_DIR="${1:?usage: build.sh }" +SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SRC_DIR/../../.." && pwd)" +cd "$REPO_ROOT" +source "$REPO_ROOT/scripts/build-common.sh" +resolve_content_root "$REPO_ROOT/src" + +copy_skills "$CONTENT_ROOT" "$TARGET_DIR/skills" +copy_skill_tree "$CONTENT_ROOT" "$TARGET_DIR/SKILL_TREE.md" +rsync -a assets/ "$TARGET_DIR/assets/" +cp "$SRC_DIR/README.md" "$TARGET_DIR/README.md" +cp LICENSE "$TARGET_DIR/LICENSE" +printf '1\n' > "$TARGET_DIR/.sentry-opencode-v1" + +jq '{ + "$schema": "https://opencode.ai/config.json", + skills: { paths: ["./skills"] }, + mcp: { + sentry: { + type: "remote", + url: .mcpServers.sentry.url + } + } +}' mcp.json > "$TARGET_DIR/opencode.json" + +echo "Built OpenCode V1 bundle into $TARGET_DIR (content from $CONTENT_ROOT)." diff --git a/src/plugins/opencode/validate.sh b/src/plugins/opencode/validate.sh new file mode 100755 index 00000000..0fe1fef8 --- /dev/null +++ b/src/plugins/opencode/validate.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# validate.sh - Validate the built OpenCode V1 distribution before publishing. +# +# Usage: validate.sh (a tree produced by build.sh) + +set -euo pipefail + +TARGET_DIR="${1:?usage: validate.sh }" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +TARGET_DIR="$(cd "$TARGET_DIR" && pwd)" + +for f in "$TARGET_DIR/.sentry-opencode-v1" "$TARGET_DIR/opencode.json" "$TARGET_DIR/SKILL_TREE.md" "$TARGET_DIR/README.md" "$TARGET_DIR/LICENSE"; do + [ -f "$f" ] || { echo "missing required file: $f" >&2; exit 1; } +done +grep -qx '1' "$TARGET_DIR/.sentry-opencode-v1" + +jq -e ' + .["$schema"] == "https://opencode.ai/config.json" and + .skills.paths == ["./skills"] and + .mcp.sentry.type == "remote" and + .mcp.sentry.url == "https://mcp.sentry.dev/mcp?utm_source=plugin" +' "$TARGET_DIR/opencode.json" >/dev/null + +cmp "$REPO_ROOT/src/SKILL_TREE.md" "$TARGET_DIR/SKILL_TREE.md" + +SOURCE_SKILL_COUNT="$(find "$REPO_ROOT/src/skills" -mindepth 2 -maxdepth 2 -name SKILL.md | wc -l | tr -d ' ')" +TARGET_SKILL_COUNT="$(find "$TARGET_DIR/skills" -mindepth 2 -maxdepth 2 -name SKILL.md | wc -l | tr -d ' ')" +[ "$TARGET_SKILL_COUNT" -eq "$SOURCE_SKILL_COUNT" ] || { + echo "skill count mismatch: expected $SOURCE_SKILL_COUNT, found $TARGET_SKILL_COUNT" >&2 + exit 1 +} + +"$REPO_ROOT/scripts/build-skill-tree.sh" --check + +python3 - "$TARGET_DIR/skills" <<'PY' +from pathlib import Path +import re +import sys + +skills_dir = Path(sys.argv[1]) +missing = [] +for skill_file in skills_dir.glob("*/SKILL.md"): + for link in re.findall(r"\]\(([^)]+\.md(?:#[^)]*)?)\)", skill_file.read_text()): + path = link.split("#", 1)[0] + if path.startswith(("http://", "https://")): + continue + if not (skill_file.parent / path).is_file(): + missing.append(f"{skill_file}: {path}") +if missing: + raise SystemExit("missing packaged skill references:\n" + "\n".join(missing)) +PY + +if command -v opencode >/dev/null 2>&1; then + WORK="$(mktemp -d)" + trap 'rm -rf "$WORK"' EXIT + ( + cd "$TARGET_DIR" + HOME="$WORK/home" \ + XDG_CONFIG_HOME="$WORK/config" \ + XDG_DATA_HOME="$WORK/data" \ + XDG_CACHE_HOME="$WORK/cache" \ + OPENCODE_DISABLE_EXTERNAL_SKILLS=1 \ + OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 \ + opencode debug skill > "$WORK/skills.json" + ) + jq -e --argjson expected "$SOURCE_SKILL_COUNT" ' + [.[] | select(.name | startswith("sentry-"))] | length == $expected + ' "$WORK/skills.json" >/dev/null +fi + +echo "Validated OpenCode V1 bundle at $TARGET_DIR." diff --git a/src/plugins/opencode2/README.md b/src/plugins/opencode2/README.md new file mode 100644 index 00000000..a7cffaef --- /dev/null +++ b/src/plugins/opencode2/README.md @@ -0,0 +1,60 @@ +# Sentry for OpenCode V2 + +The Sentry skill bundle for the [OpenCode V2 beta](https://opencode.ai/v2/docs/). It +teaches OpenCode how to set up Sentry, debug production issues through the Sentry MCP +server, and configure monitoring features. + +This is an installer-managed skill and MCP bundle, not an OpenCode runtime plugin. +V2 plugins can register skills but cannot register an MCP server, so installation +configures those two surfaces separately. + +> [!IMPORTANT] +> This repository is generated. +> It is built from [getsentry/sentry-for-ai](https://github.com/getsentry/sentry-for-ai) +> and includes every skill in that library. +> Do not edit files here; make changes in that repository and they will be rebuilt into +> this one. + +## Install + +```bash +git clone https://github.com/getsentry/plugin-opencode2.git ~/.config/opencode/skills/sentry +opencode2 mcp add sentry --global --url 'https://mcp.sentry.dev/mcp?utm_source=plugin' +``` + +Restart OpenCode after installation. +The first Sentry MCP operation starts browser OAuth. +You can also authenticate explicitly: + +```bash +opencode2 mcp auth sentry +``` + +The generated root `opencode.json` makes this checkout directly runnable and shows the +V2 MCP shape. The commands above merge Sentry into your global config instead of +replacing any existing settings. + +## What’s included + +- The full hydrated Sentry skill library for SDK setup, debugging workflows, and feature + configuration. +- A native V2 remote MCP configuration for [mcp.sentry.dev](https://mcp.sentry.dev). + +Routed leaf skills use `metadata.opencode/autoinvoke: "false"`, so V2 omits them from +model-facing discovery while keeping them available for explicit loading. +Router and standalone skills remain advertised. + +## Update or remove + +```bash +git -C ~/.config/opencode/skills/sentry pull +rm -rf ~/.config/opencode/skills/sentry +``` + +The V2 beta has no MCP remove command. +To remove Sentry completely, also delete the `mcp.servers.sentry` entry from +`~/.config/opencode/opencode.json` or `~/.config/opencode/opencode.jsonc`. + +OpenCode V1 and the OpenCode V2 beta currently write incompatible MCP shapes to the same +global config file. Do not configure both versions in that file at the same time; this +bundle is for the `opencode2` CLI. diff --git a/src/plugins/opencode2/build.sh b/src/plugins/opencode2/build.sh new file mode 100755 index 00000000..f030050e --- /dev/null +++ b/src/plugins/opencode2/build.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# build.sh - Build the OpenCode V2 beta Sentry skill and MCP distribution. +# +# V2 discovers skills recursively below skills/ and uses a different MCP shape +# from V1. It also ignores disable-model-invocation; its native replacement is +# metadata.opencode/autoinvoke: "false". prepare-skills.py performs that +# frontmatter translation for each hidden leaf after the shared skill copy. +# +# Usage: build.sh (TARGET_DIR assumed empty) + +set -euo pipefail + +TARGET_DIR="${1:?usage: build.sh }" +SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SRC_DIR/../../.." && pwd)" +cd "$REPO_ROOT" +source "$REPO_ROOT/scripts/build-common.sh" +resolve_content_root "$REPO_ROOT/src" + +copy_skills "$CONTENT_ROOT" "$TARGET_DIR/skills" +python3 "$SRC_DIR/prepare-skills.py" "$TARGET_DIR/skills" +copy_skill_tree "$CONTENT_ROOT" "$TARGET_DIR/SKILL_TREE.md" +rsync -a assets/ "$TARGET_DIR/assets/" +cp "$SRC_DIR/README.md" "$TARGET_DIR/README.md" +cp LICENSE "$TARGET_DIR/LICENSE" +printf '2\n' > "$TARGET_DIR/.sentry-opencode-v2" + +jq '{ + "$schema": "https://opencode.ai/config.json", + skills: ["./skills"], + mcp: { + servers: { + sentry: { + type: "remote", + url: .mcpServers.sentry.url + } + } + } +}' mcp.json > "$TARGET_DIR/opencode.json" + +echo "Built OpenCode V2 bundle into $TARGET_DIR (content from $CONTENT_ROOT)." diff --git a/src/plugins/opencode2/prepare-skills.py b/src/plugins/opencode2/prepare-skills.py new file mode 100755 index 00000000..a9ce8cae --- /dev/null +++ b/src/plugins/opencode2/prepare-skills.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Translate hidden skill frontmatter to OpenCode V2's native metadata.""" + +import sys +from pathlib import Path + + +def transform(skill_file: Path) -> bool: + lines = skill_file.read_text(encoding="utf-8").splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return False + + try: + end = next(i for i, line in enumerate(lines[1:], 1) if line.rstrip("\r\n") == "---") + except StopIteration: + return False + + hidden = next( + (i for i, line in enumerate(lines[1:end], 1) if line.strip() == "disable-model-invocation: true"), + None, + ) + if hidden is None: + return False + + del lines[hidden] + end -= 1 + + metadata = next( + (i for i, line in enumerate(lines[1:end], 1) if line.rstrip("\r\n") == "metadata:"), + None, + ) + if metadata is None: + lines[end:end] = ["metadata:\n", ' opencode/autoinvoke: "false"\n'] + else: + metadata_end = metadata + 1 + while metadata_end < end and lines[metadata_end].startswith((" ", "\t")): + if lines[metadata_end].strip().startswith("opencode/autoinvoke:"): + raise SystemExit(f"{skill_file}: opencode/autoinvoke already exists") + metadata_end += 1 + lines.insert(metadata_end, ' opencode/autoinvoke: "false"\n') + + skill_file.write_text("".join(lines), encoding="utf-8") + return True + + +def main() -> None: + skills_dir = Path(sys.argv[1]) + transformed = sum(transform(path) for path in sorted(skills_dir.glob("*/SKILL.md"))) + print(f"translated hidden skills: {transformed}") + + +if __name__ == "__main__": + main() diff --git a/src/plugins/opencode2/validate.sh b/src/plugins/opencode2/validate.sh new file mode 100755 index 00000000..54961a44 --- /dev/null +++ b/src/plugins/opencode2/validate.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# validate.sh - Validate the built OpenCode V2 beta distribution before publishing. +# +# Usage: validate.sh (a tree produced by build.sh) + +set -euo pipefail + +TARGET_DIR="${1:?usage: validate.sh }" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +TARGET_DIR="$(cd "$TARGET_DIR" && pwd)" + +for f in "$TARGET_DIR/.sentry-opencode-v2" "$TARGET_DIR/opencode.json" "$TARGET_DIR/SKILL_TREE.md" "$TARGET_DIR/README.md" "$TARGET_DIR/LICENSE"; do + [ -f "$f" ] || { echo "missing required file: $f" >&2; exit 1; } +done +grep -qx '2' "$TARGET_DIR/.sentry-opencode-v2" + +jq -e ' + .["$schema"] == "https://opencode.ai/config.json" and + .skills == ["./skills"] and + .mcp.servers.sentry.type == "remote" and + .mcp.servers.sentry.url == "https://mcp.sentry.dev/mcp?utm_source=plugin" +' "$TARGET_DIR/opencode.json" >/dev/null + +cmp "$REPO_ROOT/src/SKILL_TREE.md" "$TARGET_DIR/SKILL_TREE.md" + +SOURCE_SKILL_COUNT="$(find "$REPO_ROOT/src/skills" -mindepth 2 -maxdepth 2 -name SKILL.md | wc -l | tr -d ' ')" +TARGET_SKILL_COUNT="$(find "$TARGET_DIR/skills" -mindepth 2 -maxdepth 2 -name SKILL.md | wc -l | tr -d ' ')" +[ "$TARGET_SKILL_COUNT" -eq "$SOURCE_SKILL_COUNT" ] || { + echo "skill count mismatch: expected $SOURCE_SKILL_COUNT, found $TARGET_SKILL_COUNT" >&2 + exit 1 +} + +SOURCE_HIDDEN_COUNT="$(grep -Rl '^disable-model-invocation: true$' "$REPO_ROOT/src/skills" --include=SKILL.md | wc -l | tr -d ' ')" +TARGET_HIDDEN_COUNT="$(grep -Rl '^ opencode/autoinvoke: "false"$' "$TARGET_DIR/skills" --include=SKILL.md | wc -l | tr -d ' ')" +[ "$TARGET_HIDDEN_COUNT" -eq "$SOURCE_HIDDEN_COUNT" ] || { + echo "hidden skill metadata mismatch: expected $SOURCE_HIDDEN_COUNT, found $TARGET_HIDDEN_COUNT" >&2 + exit 1 +} +if grep -R '^disable-model-invocation:' "$TARGET_DIR/skills" --include=SKILL.md; then + echo "V2 bundle still contains disable-model-invocation" >&2 + exit 1 +fi + +"$REPO_ROOT/scripts/build-skill-tree.sh" --check + +python3 - "$TARGET_DIR/skills" <<'PY' +from pathlib import Path +import re +import sys + +skills_dir = Path(sys.argv[1]) +missing = [] +for skill_file in skills_dir.glob("*/SKILL.md"): + for link in re.findall(r"\]\(([^)]+\.md(?:#[^)]*)?)\)", skill_file.read_text()): + path = link.split("#", 1)[0] + if path.startswith(("http://", "https://")): + continue + if not (skill_file.parent / path).is_file(): + missing.append(f"{skill_file}: {path}") +if missing: + raise SystemExit("missing packaged skill references:\n" + "\n".join(missing)) +PY + +# The V2 beta has no dedicated config validator. The structural checks above +# enforce its config contract; this smoke check also verifies the pinned beta +# CLI can start its standalone API from the generated checkout. +if command -v opencode2 >/dev/null 2>&1; then + WORK="$(mktemp -d)" + trap 'rm -rf "$WORK"' EXIT + mkdir -p "$WORK/home" + ( + cd "$TARGET_DIR" + HOME="$WORK/home" \ + XDG_CONFIG_HOME="$WORK/config" \ + XDG_DATA_HOME="$WORK/data" \ + XDG_CACHE_HOME="$WORK/cache" \ + opencode2 api --standalone GET /api/skill > "$WORK/skills.json" + ) + jq -e '.data | type == "array"' "$WORK/skills.json" >/dev/null +fi + +echo "Validated OpenCode V2 bundle at $TARGET_DIR."