diff --git a/.github/workflows/deploy-plugins.yml b/.github/workflows/deploy-plugins.yml index 0e763ea0..56152bff 100644 --- a/.github/workflows/deploy-plugins.yml +++ b/.github/workflows/deploy-plugins.yml @@ -9,6 +9,7 @@ # cursor -> getsentry/plugin-cursor # codex -> getsentry/plugin-codex # grok -> getsentry/plugin-grok +# pi -> getsentry/plugin-pi # # 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 +26,16 @@ # 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 consume `getsentry/plugin-` by git ref. Each build job +# validates its agent's tree without deployment credentials and uploads it as an +# artifact. A separate deploy job then mints a repository-scoped token and +# commits only that validated artifact onto the target branch of the target +# repo. The five matrix entries target five 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 five 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. @@ -77,16 +80,18 @@ permissions: contents: read jobs: - deploy: + build: runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + # Every matrix entry builds the same checkout, so the last writer wins + # with the same value. Read out of the checkout rather than github.sha, + # which in a called workflow reports the caller's commit instead. + src_sha: ${{ steps.build.outputs.src_sha }} strategy: fail-fast: false matrix: - agent: [claude, cursor, codex, grok] - concurrency: - group: deploy-plugin-${{ matrix.agent }}-${{ inputs.target_branch || 'develop' }} - cancel-in-progress: false + agent: [claude, cursor, codex, grok, pi] steps: - name: Checkout source uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -96,6 +101,76 @@ jobs: # which needs the tags and the commits since the last one. fetch-depth: 0 + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + + - name: Set up Node for Pi validation + if: matrix.agent == 'pi' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + + - name: Install Pi CLI + if: matrix.agent == 'pi' + run: npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.83.0 + + - name: Build plugin-${{ matrix.agent }} + id: build + env: + AGENT: ${{ matrix.agent }} + DIST_TAG: ${{ inputs.dist_tag || '' }} + run: | + set -euo pipefail + + echo "src_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + # A release stamps the version being released; anything else is a + # develop build and gets labelled with its distance from the last tag. + if [[ -z "$DIST_TAG" ]]; then + PLUGIN_VERSION="$(scripts/dev-version.sh)" + export PLUGIN_VERSION + echo "::notice::stamping develop build as ${PLUGIN_VERSION}" + fi + + DIST_DIR="$(mktemp -d)/dist" + "src/plugins/${AGENT}/build.sh" "$DIST_DIR" + "src/plugins/${AGENT}/validate.sh" "$DIST_DIR" + + mkdir -p "artifacts/${AGENT}" + rsync -a --delete "$DIST_DIR/" "artifacts/${AGENT}/" + + - name: Upload validated plugin-${{ matrix.agent }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: plugin-${{ matrix.agent }}-${{ github.sha }} + path: artifacts/${{ matrix.agent }}/ + if-no-files-found: error + include-hidden-files: true + retention-days: 1 + + deploy: + needs: build + # A failed build omits only that agent's artifact. Run the deploy matrix for + # the remaining artifacts instead of treating the build matrix as one gate. + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + agent: [claude, cursor, codex, grok, pi] + concurrency: + group: deploy-plugin-${{ matrix.agent }}-${{ inputs.target_branch || 'develop' }} + cancel-in-progress: false + steps: + - name: Download validated plugin-${{ matrix.agent }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: plugin-${{ matrix.agent }}-${{ github.sha }} + path: artifact + + # Mint the write credential only after every third-party package and + # artifact action has finished executing in this job. - name: Mint deploy token id: token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 @@ -105,14 +180,12 @@ jobs: owner: getsentry repositories: plugin-${{ matrix.agent }} - - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - - - name: Build and deploy plugin-${{ matrix.agent }} + - name: Deploy plugin-${{ matrix.agent }} env: AGENT: ${{ matrix.agent }} TARGET_BRANCH: ${{ inputs.target_branch || 'develop' }} DIST_TAG: ${{ inputs.dist_tag || '' }} + SRC_SHA: ${{ needs.build.outputs.src_sha }} GH_TOKEN: ${{ steps.token.outputs.token }} run: | set -euo pipefail @@ -120,18 +193,6 @@ jobs: TARGET_REPO="plugin-${AGENT}" WORKTREE="$(mktemp -d)/dist" - # Read the built commit out of the checkout rather than github.sha, - # which in a called workflow reports the caller's commit instead. - SRC_SHA="$(git rev-parse HEAD)" - - # A release stamps the version being released; anything else is a - # develop build and gets labelled with its distance from the last tag. - if [[ -z "$DIST_TAG" ]]; then - PLUGIN_VERSION="$(scripts/dev-version.sh)" - export PLUGIN_VERSION - echo "::notice::stamping develop build as ${PLUGIN_VERSION}" - fi - # Clone the target repo (lands on its default branch, `main`). git clone "https://x-access-token:${GH_TOKEN}@github.com/getsentry/${TARGET_REPO}.git" "$WORKTREE" git -C "$WORKTREE" config user.name "github-actions[bot]" @@ -146,14 +207,10 @@ jobs: git -C "$WORKTREE" checkout -b "$TARGET_BRANCH" fi - # Rewrite the whole tree: clear tracked content (preserve the .git - # dir), then repopulate from source via the agent's build script. + # Rewrite the whole tree from the already validated artifact. No build + # tool or third-party package executes while GH_TOKEN is in scope. git -C "$WORKTREE" rm -rfq --ignore-unmatch . - "src/plugins/${AGENT}/build.sh" "$WORKTREE" - - # Validate the built tree against the agent's schema/validator before - # it can be deployed. - "src/plugins/${AGENT}/validate.sh" "$WORKTREE" + rsync -a --delete --exclude='.git/' artifact/ "$WORKTREE/" # Commit only if something changed. git -C "$WORKTREE" add -A diff --git a/.github/workflows/smoke-installer.yml b/.github/workflows/smoke-installer.yml index 2ce3726e..4220a210 100644 --- a/.github/workflows/smoke-installer.yml +++ b/.github/workflows/smoke-installer.yml @@ -4,8 +4,8 @@ # (--no-interactive) so it installs the Sentry plugin for every detected agent # without a prompt, and verifies each plugin 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 Pi ship cross-platform npm CLIs, so they get a +# real install against their package sources. 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 @@ -57,8 +57,8 @@ 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 Pi CLIs + run: npm install -g @anthropic-ai/claude-code @openai/codex @xai-official/grok @earendil-works/pi-coding-agent - name: Put a Cursor stub on PATH run: | @@ -82,18 +82,26 @@ jobs: set -euo pipefail echo "== claude ==" - claude plugin list - claude plugin list | grep -iq sentry || { echo "claude: sentry plugin missing"; exit 1; } + claude_plugins=$(claude plugin list) + printf '%s\n' "$claude_plugins" + printf '%s\n' "$claude_plugins" | grep -iq sentry || { echo "claude: sentry plugin missing"; exit 1; } echo "== codex ==" - codex plugin list - codex plugin list | grep -iq sentry || { echo "codex: sentry plugin missing"; exit 1; } + codex_plugins=$(codex plugin list) + printf '%s\n' "$codex_plugins" + printf '%s\n' "$codex_plugins" | grep -iq sentry || { echo "codex: sentry plugin missing"; exit 1; } echo "== grok ==" - grok plugin list - grok plugin list | grep -iq sentry || { echo "grok: sentry plugin missing"; exit 1; } + grok_plugins=$(grok plugin list) + printf '%s\n' "$grok_plugins" + printf '%s\n' "$grok_plugins" | grep -iq sentry || { echo "grok: sentry plugin missing"; exit 1; } 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 "== pi ==" + pi_plugins=$(pi list --no-approve) + printf '%s\n' "$pi_plugins" + printf '%s\n' "$pi_plugins" | grep -iq 'getsentry/plugin-pi' || { echo "pi: sentry package missing"; exit 1; } + echo "All agents have the Sentry plugin." 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..f1136328 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Overview -Sentry plugin for AI coding assistants (Claude Code, Cursor, Codex, and Grok). +Sentry plugin for AI coding assistants (Claude Code, Cursor, Codex, Grok, and Pi). Provides MCP server integration and skills. ## Commit Attribution @@ -26,6 +26,8 @@ src/SKILL_TREE.md # Generated skill index (see Skill Tree Navigation belo 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. +Pi is packaged as a native Pi package (`package.json` with a `pi` manifest) and includes +a small `pi-mcp-adapter` extension because Pi has no built-in MCP client. Skill frontmatter is `name`, `description`, and `license`. Nothing else is required, and the retired router fields (`category`, `parent`, `role`, `disable-model-invocation`) are @@ -52,6 +54,8 @@ emit it as `.mcp.json` (Codex’s validator requires the dotted name; Grok auto- it). Claude declares the server inline in its `plugin.json` (`mcpServers`), so the Claude build ships no MCP file. +Pi declares the same server in `src/plugins/pi/extensions/sentry-mcp.ts`, which adapts +it into native Pi tools. ## Releasing the Plugins diff --git a/README.md b/README.md index 86a289e6..f5930ace 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ > skills here are built from this source into installable plugins 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), and +> [Pi](https://github.com/getsentry/plugin-pi) — 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 +23,9 @@ 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), and +[**Pi**](https://github.com/getsentry/plugin-pi). ## What You Can Do @@ -77,6 +79,7 @@ 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) | +| Pi | [`getsentry/plugin-pi`](https://github.com/getsentry/plugin-pi) | These repositories are generated; do not edit them. Each one’s README has the install instructions for that agent. @@ -94,11 +97,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,pi} ``` To build any target locally, run `src/plugins//build.sh ` (`claude`, -`cursor`, `codex`, or `grok`). +`cursor`, `codex`, `grok`, or `pi`). ## Skills diff --git a/packages/installer/README.md b/packages/installer/README.md index 0ceef24d..ce3562bb 100644 --- a/packages/installer/README.md +++ b/packages/installer/README.md @@ -8,7 +8,7 @@ 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**, and **Pi**. ```bash npx @sentry/ai install @@ -48,6 +48,7 @@ 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` | +| Pi | `pi install git:github.com/getsentry/plugin-pi` | Each per-agent plugin is built and published from the [`sentry-for-ai`](https://github.com/getsentry/sentry-for-ai) repository, which is the diff --git a/packages/installer/src/__tests__/harnesses.test.ts b/packages/installer/src/__tests__/harnesses.test.ts index 4639bacf..1240fcbd 100644 --- a/packages/installer/src/__tests__/harnesses.test.ts +++ b/packages/installer/src/__tests__/harnesses.test.ts @@ -5,6 +5,7 @@ import { createClaude } from "../harnesses/claude"; import { createCodex } from "../harnesses/codex"; import { createCursor } from "../harnesses/cursor"; import { createGrok } from "../harnesses/grok"; +import { createPi } from "../harnesses/pi"; import { fakeSystem } from "./fake-system"; const ok: ShellResult = { ok: true }; @@ -382,6 +383,91 @@ describe("grok harness", () => { }); }); +describe("pi harness", () => { + it("detects when the pi binary is on PATH", async () => { + const harness = createPi(fakeSystem({ run: () => ok })); + expect(await harness.detect()).toBe(true); + }); + + it("does not detect when which fails", async () => { + const harness = createPi(fakeSystem({ run: () => notFound })); + expect(await harness.detect()).toBe(false); + }); + + it("reports installed when pi list includes our git package", async () => { + const harness = createPi( + fakeSystem({ + run: () => ({ + ok: true, + stdout: "User packages:\n git:github.com/getsentry/plugin-pi", + }), + }), + ); + expect(await harness.isInstalled()).toBe(true); + }); + + it("reports not installed when pi list lacks our package", async () => { + const harness = createPi( + fakeSystem({ run: () => ({ ok: true, stdout: "User packages:\n npm:other-package" }) }), + ); + expect(await harness.isInstalled()).toBe(false); + }); + + it("reports not installed when pi list fails", async () => { + const harness = createPi(fakeSystem({ run: () => notFound })); + expect(await harness.isInstalled()).toBe(false); + }); + + it("installs from the Pi distribution repository without trusting project resources", async () => { + const system = fakeSystem({ run: () => ok }); + const outcome = await createPi(system).install(); + + expect(outcome).toMatchObject({ + kind: "done", + command: "pi install git:github.com/getsentry/plugin-pi --no-approve", + }); + expect(system.run).toHaveBeenCalledWith( + "pi install git:github.com/getsentry/plugin-pi --no-approve", + ); + }); + + it("updates through Pi's package manager without trusting project resources", async () => { + const system = fakeSystem({ run: () => ok }); + const outcome = await createPi(system).update(); + + expect(outcome).toMatchObject({ + kind: "done", + command: "pi update git:github.com/getsentry/plugin-pi --no-approve", + }); + expect(system.run).toHaveBeenCalledWith( + "pi update git:github.com/getsentry/plugin-pi --no-approve", + ); + }); + + it("removes through Pi's package manager without trusting project resources", async () => { + const system = fakeSystem({ run: () => ok }); + const outcome = await createPi(system).remove(); + + expect(outcome).toMatchObject({ + kind: "done", + command: "pi remove git:github.com/getsentry/plugin-pi --no-approve", + }); + expect(system.run).toHaveBeenCalledWith( + "pi remove git:github.com/getsentry/plugin-pi --no-approve", + ); + }); + + it("forwards the output sink to package commands", async () => { + const system = fakeSystem({ run: () => ok }); + const sink = {} as NodeJS.WritableStream; + await createPi(system).install(sink); + expect(system.run).toHaveBeenCalledWith( + "pi install git:github.com/getsentry/plugin-pi --no-approve", + sink, + ); + }); +}); + 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..0f160a2a 100644 --- a/packages/installer/src/harnesses/index.ts +++ b/packages/installer/src/harnesses/index.ts @@ -3,17 +3,18 @@ import { createClaude } from "./claude"; import { createCodex } from "./codex"; import { createCursor } from "./cursor"; import { createGrok } from "./grok"; +import { createPi } from "./pi"; import type { Harness } from "./types"; export type { Harness, InstallOutcome } from "./types"; -export { createClaude, createCodex, createCursor, createGrok }; +export { createClaude, createCodex, createCursor, createGrok, createPi }; /** * 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 +23,6 @@ export function buildHarnesses(): Harness[] { createCodex(realSystem), createCursor(realSystem), createGrok(realSystem), + createPi(realSystem), ]; } diff --git a/packages/installer/src/harnesses/pi.ts b/packages/installer/src/harnesses/pi.ts new file mode 100644 index 00000000..d189faf6 --- /dev/null +++ b/packages/installer/src/harnesses/pi.ts @@ -0,0 +1,52 @@ +import type { OutputSink, SystemDeps } from "../system"; +import type { Harness, InstallOutcome } from "./types"; +import { detectOnPath, runCommand } from "./shell"; + +const PACKAGE_SOURCE = "git:github.com/getsentry/plugin-pi"; +const INSTALL_COMMAND = `pi install ${PACKAGE_SOURCE} --no-approve`; +const UPDATE_COMMAND = `pi update ${PACKAGE_SOURCE} --no-approve`; +const REMOVE_COMMAND = `pi remove ${PACKAGE_SOURCE} --no-approve`; + +function listsSentryPiPackage(output: string | undefined): boolean { + if (!output) { + return false; + } + + const normalized = output.toLowerCase(); + return ( + normalized.includes(PACKAGE_SOURCE) || + normalized.includes("github.com/getsentry/plugin-pi") || + normalized.includes("@sentry/pi-plugin") + ); +} + +export function createPi(system: SystemDeps): Harness { + return { + id: "pi", + name: "Pi", + + detect: async () => detectOnPath(system, "pi"), + + isInstalled: async () => { + const result = await system.run("pi list --no-approve"); + return result.ok && listsSentryPiPackage(result.stdout); + }, + + canInstall: async () => ({ ok: true }), + + install: async (output): Promise => { + await runCommand(system, INSTALL_COMMAND, output); + return { kind: "done", command: INSTALL_COMMAND }; + }, + + update: async (output): Promise => { + await runCommand(system, UPDATE_COMMAND, output); + return { kind: "done", command: UPDATE_COMMAND }; + }, + + remove: async (output): Promise => { + await runCommand(system, REMOVE_COMMAND, output); + return { kind: "done", command: REMOVE_COMMAND }; + }, + }; +} diff --git a/src/plugins/pi/README.md b/src/plugins/pi/README.md new file mode 100644 index 00000000..a8b03d3a --- /dev/null +++ b/src/plugins/pi/README.md @@ -0,0 +1,48 @@ +# Sentry for Pi + +The Sentry package for [Pi](https://pi.dev). +It teaches Pi how to use Sentry: SDK setup for supported platforms, production issue +debugging through the Sentry MCP server, and monitoring configuration. + +> [!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 +pi install git:github.com/getsentry/plugin-pi +``` + +Restart Pi after installation. +The first Sentry MCP operation starts browser OAuth; if you need to authenticate +explicitly, run: + +```text +/sentry-mcp-auth sentry +``` + +## What’s included + +- The full Sentry skill library for SDK setup, debugging workflows, and feature + configuration. +- A Pi extension that connects to the hosted [Sentry MCP server](https://mcp.sentry.dev) + through `pi-mcp-adapter`. It can coexist with a separately installed copy of the + adapter; package-private commands use the `/sentry-mcp*` namespace. + +Pi honors `disable-model-invocation`, so only the router and standalone skills are +advertised initially. +Routed leaf skills load on demand instead of crowding the model’s context. +The MCP extension follows the same approach: one `sentry_mcp` gateway discovers and +calls Sentry operations on demand, so the full MCP tool catalog does not crowd the +model’s context. + +## Update or remove + +```bash +pi update git:github.com/getsentry/plugin-pi +pi remove git:github.com/getsentry/plugin-pi +``` diff --git a/src/plugins/pi/build.sh b/src/plugins/pi/build.sh new file mode 100755 index 00000000..012859ed --- /dev/null +++ b/src/plugins/pi/build.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# build.sh — Build the Pi distribution of the Sentry plugin. +# +# Pi packages put their resources at the package root and declare them in the +# `pi` section of package.json. This build ships the shared skills as-authored: +# Pi honors `disable-model-invocation`, so the skill-tree routers work natively. +# +# Pi does not have built-in MCP support. The package therefore includes a small +# extension backed by pi-mcp-adapter. It connects to Sentry's hosted MCP server +# through one namespaced `sentry_mcp` gateway, keeping the MCP catalog's tool +# schemas out of the model context until they are needed. Adapter-private tools, +# commands, and flags are namespaced so this package can coexist with a +# separately installed adapter. Runtime dependencies are +# installed by `pi install` from package.json. +# +# Skill content (skills/, references/, SKILL_TREE.md) is read from CONTENT_ROOT, +# defaulting to the repo's src/ directory. Override CONTENT_ROOT to build a +# different content tree with the same steps. +# +# 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" + +mkdir -p "$TARGET_DIR/extensions" + +cp "$SRC_DIR/package.json" "$TARGET_DIR/package.json" +cp "$SRC_DIR/extensions/sentry-mcp.ts" "$TARGET_DIR/extensions/sentry-mcp.ts" +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" + +echo "Built Pi package into $TARGET_DIR (root package, content from $CONTENT_ROOT)." diff --git a/src/plugins/pi/extensions/sentry-mcp.ts b/src/plugins/pi/extensions/sentry-mcp.ts new file mode 100644 index 00000000..c0024258 --- /dev/null +++ b/src/plugins/pi/extensions/sentry-mcp.ts @@ -0,0 +1,138 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { createMcpAdapter } from "pi-mcp-adapter"; + +const SENTRY_MCP_URL = "https://mcp.sentry.dev/mcp?utm_source=plugin"; +const ADAPTER_PROXY_TOOL_NAME = "mcp"; +const SENTRY_PROXY_TOOL_NAME = "sentry_mcp"; +const EXTERNAL_MCP_TOOL_SENTINEL = "__sentry_adapter_external_mcp"; +const SENTRY_MCP_PROMPT_GUIDELINE = + "Use sentry_mcp for Sentry operations. Call search_sentry_tools and execute_sentry_tool through sentry_mcp instead of looking for those tools directly."; + +/** + * Give this package's adapter-private surfaces Sentry-specific names so it can + * coexist with a user's separately installed pi-mcp-adapter. Sentry's actual + * MCP tools keep their upstream names (for example, search_sentry_tools). + */ +function namespaceSentryMcpAdapter(pi: ExtensionAPI): ExtensionAPI { + return new Proxy(pi as ExtensionAPI & { unregisterTool?: (name: string) => boolean }, { + get(target, property, receiver) { + if (property === "registerTool") { + return (tool: { + name: string; + label?: string; + description?: string; + promptSnippet?: string; + promptGuidelines?: string[]; + }) => { + if (tool.name === ADAPTER_PROXY_TOOL_NAME) { + const namespacedDescription = tool.description + ?.replaceAll("mcp({", "sentry_mcp({") + .replaceAll("/mcp-auth", "/sentry-mcp-auth") + .replaceAll("/mcp ", "/sentry-mcp "); + return target.registerTool({ + ...tool, + name: SENTRY_PROXY_TOOL_NAME, + label: "Sentry MCP", + description: namespacedDescription, + promptSnippet: "Connect to Sentry and call Sentry MCP tools", + promptGuidelines: [SENTRY_MCP_PROMPT_GUIDELINE], + } as Parameters[0]); + } + + return target.registerTool(tool as Parameters[0]); + }; + } + + if (property === "registerCommand") { + return (name: string, options: unknown) => { + const namespacedName = + name === "mcp" + ? "sentry-mcp" + : name === "mcp-auth" + ? "sentry-mcp-auth" + : name.startsWith("mcp__") + ? `sentry_${name}` + : name; + return target.registerCommand( + namespacedName, + options as Parameters[1], + ); + }; + } + + if (property === "registerFlag") { + return (name: string, options: unknown) => + target.registerFlag( + name === "mcp-config" ? "sentry-mcp-config" : name, + options as Parameters[1], + ); + } + + if (property === "getAllTools") { + return () => + target + .getAllTools() + .filter((tool) => tool.name !== ADAPTER_PROXY_TOOL_NAME) + .map((tool) => + tool.name === SENTRY_PROXY_TOOL_NAME + ? { ...tool, name: ADAPTER_PROXY_TOOL_NAME } + : tool, + ); + } + + if (property === "getActiveTools") { + return () => + target.getActiveTools().map((name) => { + if (name === ADAPTER_PROXY_TOOL_NAME) return EXTERNAL_MCP_TOOL_SENTINEL; + if (name === SENTRY_PROXY_TOOL_NAME) return ADAPTER_PROXY_TOOL_NAME; + return name; + }); + } + + if (property === "setActiveTools") { + return (names: string[]) => + target.setActiveTools( + names.map((name) => { + if (name === EXTERNAL_MCP_TOOL_SENTINEL) return ADAPTER_PROXY_TOOL_NAME; + if (name === ADAPTER_PROXY_TOOL_NAME) return SENTRY_PROXY_TOOL_NAME; + return name; + }), + ); + } + + if (property === "unregisterTool") { + return (name: string) => + target.unregisterTool?.( + name === ADAPTER_PROXY_TOOL_NAME ? SENTRY_PROXY_TOOL_NAME : name, + ) ?? false; + } + + const value = Reflect.get(target, property, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as ExtensionAPI; +} + +const installSentryMcp = createMcpAdapter({ + config: { + mcpServers: { + sentry: { + url: SENTRY_MCP_URL, + auth: "oauth", + directTools: false, + }, + }, + settings: { + autoAuth: true, + disableProxyTool: false, + showStatusIcon: false, + toolPrefix: "none", + authRequiredMessage: + 'Sentry authentication required. Run /sentry-mcp-auth sentry, then retry the tool call.', + }, + }, +}); + +export default function sentryMcpExtension(pi: ExtensionAPI) { + installSentryMcp(namespaceSentryMcpAdapter(pi)); +} diff --git a/src/plugins/pi/package.json b/src/plugins/pi/package.json new file mode 100644 index 00000000..692868f2 --- /dev/null +++ b/src/plugins/pi/package.json @@ -0,0 +1,52 @@ +{ + "name": "@sentry/pi-plugin", + "version": "1.2.0", + "description": "Sentry skills and MCP integration for Pi", + "type": "module", + "license": "MIT", + "author": { + "name": "Sentry", + "url": "https://sentry.io" + }, + "repository": { + "type": "git", + "url": "https://github.com/getsentry/plugin-pi.git" + }, + "keywords": [ + "pi-package", + "sentry", + "debugging", + "monitoring", + "error-tracking" + ], + "engines": { + "node": ">=20" + }, + "dependencies": { + "pi-mcp-adapter": "2.15.0" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*" + }, + "peerDependenciesMeta": { + "@earendil-works/pi-coding-agent": { + "optional": true + } + }, + "pi": { + "extensions": [ + "./extensions/sentry-mcp.ts" + ], + "skills": [ + "./skills" + ] + }, + "files": [ + "assets", + "extensions", + "skills", + "SKILL_TREE.md", + "README.md", + "LICENSE" + ] +} diff --git a/src/plugins/pi/validate.sh b/src/plugins/pi/validate.sh new file mode 100755 index 00000000..58ee83bd --- /dev/null +++ b/src/plugins/pi/validate.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# +# validate.sh — Validate the built Pi distribution before publishing. +# +# Pi packages have no separate plugin or marketplace schema. Validate the npm +# package manifest, required resources, skill-tree metadata, installable tarball, +# and extension load path. The extension smoke test runs Pi in offline print +# mode without sending a prompt, so it loads resources but makes no model or MCP +# request. +# +# 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)" + +for f in \ + "$TARGET_DIR/package.json" \ + "$TARGET_DIR/extensions/sentry-mcp.ts" \ + "$TARGET_DIR/README.md" \ + "$TARGET_DIR/LICENSE"; do + [ -f "$f" ] || { echo "missing required file: $f" >&2; exit 1; } +done + +jq -e ' + (.keywords | index("pi-package")) and + (.pi.extensions == ["./extensions/sentry-mcp.ts"]) and + (.pi.skills == ["./skills"]) and + (.dependencies["pi-mcp-adapter"] | type == "string") and + (.peerDependencies["@earendil-works/pi-coding-agent"] == "*") and + (.peerDependenciesMeta["@earendil-works/pi-coding-agent"].optional == true) +' "$TARGET_DIR/package.json" >/dev/null + +[ -f "$TARGET_DIR/SKILL_TREE.md" ] || { + echo "missing required skill tree: $TARGET_DIR/SKILL_TREE.md" >&2 + exit 1 +} +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: list[str] = [] +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 + +SMOKE_DIR="$(mktemp -d)" +trap 'rm -rf "$SMOKE_DIR"' EXIT +rsync -a "$TARGET_DIR/" "$SMOKE_DIR/" +( + cd "$SMOKE_DIR" + npm pack --dry-run --json >/dev/null + npm install --ignore-scripts --no-audit --no-fund --no-package-lock + PI_OFFLINE=1 PI_CODING_AGENT_DIR="$(mktemp -d)" \ + pi --no-context-files --no-skills --extension ./extensions/sentry-mcp.ts --no-session --print + PI_OFFLINE=1 PI_CODING_AGENT_DIR="$(mktemp -d)" \ + pi --no-context-files --no-skills \ + --extension ./node_modules/pi-mcp-adapter/index.ts \ + --extension ./extensions/sentry-mcp.ts \ + --no-session --print + + cat > assert-sentry-extension.ts <<'TS' +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +export default function assertSentryExtension(pi: ExtensionAPI) { + pi.on("session_start", () => { + const toolNames = pi.getAllTools().map((tool) => tool.name); + for (const expected of ["mcp", "sentry_mcp"]) { + if (!toolNames.includes(expected)) { + throw new Error(`missing ${expected}: ${toolNames.join(",")}`); + } + } + }); +} +TS + PI_OFFLINE=1 PI_CODING_AGENT_DIR="$(mktemp -d)" \ + pi --no-context-files --no-skills \ + --extension ./node_modules/pi-mcp-adapter/index.ts \ + --extension ./extensions/sentry-mcp.ts \ + --extension ./assert-sentry-extension.ts \ + --no-session --print +) + +echo "Validated Pi package at $TARGET_DIR."