Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions .github/workflows/jetbrains.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
name: JetBrains Plugin

# Checks: Kotlin unit tests, the plugin verifier, and the editor-independent
# conformance suite against IntelliJ IDEA (Java fixture) through the real MCP
# server, on every push and pull request that touches the plugin.
#
# Release: bump pluginVersion in editors/jetbrains/gradle.properties, add an
# editors/jetbrains/CHANGELOG.md entry, merge, then push a tag
# `jetbrains-v<version>`. This checks the tag, version and changelog agree,
# runs the checks, builds and signs the plugin once, creates the GitHub
# Release (notes from CHANGELOG.md, plugin zip attached) and publishes that
# zip to the JetBrains Marketplace ("eap" channel for pre-releases).

on:
push:
branches: [ main, 'feat/**' ]
tags: [ 'jetbrains-v*' ]
paths: [ 'editors/jetbrains/**', 'editors/conformance/**', 'editors/vscode/src/**', 'editors/vscode/scripts/**', 'schema/**', '.github/workflows/jetbrains.yml' ]
pull_request:
paths: [ 'editors/jetbrains/**', 'editors/conformance/**', 'editors/vscode/src/**', 'editors/vscode/scripts/**', 'schema/**', '.github/workflows/jetbrains.yml' ]
workflow_dispatch:

permissions:
contents: read

defaults:
run:
working-directory: editors/jetbrains
shell: bash

jobs:
check:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.meta.outputs.version }}
prerelease: ${{ steps.meta.outputs.prerelease }}
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: 22

- name: Check version, tag and changelog
id: meta
run: |
version=$(sed -n 's/^pluginVersion=//p' gradle.properties)
prerelease=false
# Only a release needs its tag and changelog entry; other runs just test.
if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
if [[ "$GITHUB_REF_NAME" != "jetbrains-v$version" ]]; then
echo "::error::Tag $GITHUB_REF_NAME does not match gradle.properties pluginVersion $version (expected jetbrains-v$version)"
exit 1
fi
export CHANGELOG="$PWD/CHANGELOG.md"
node ../vscode/scripts/release-notes.js "$version" > /dev/null
prerelease=$(node ../vscode/scripts/release-notes.js "$version" --prerelease)
fi
{
echo "version=$version"
echo "prerelease=$prerelease"
} >> "$GITHUB_OUTPUT"

test:
needs: check
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest, macos-latest, windows-latest ]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21

- uses: actions/setup-node@v7
with:
node-version: 22

- uses: gradle/actions/setup-gradle@v5

# The plugin bundles the MCP server; the conformance suite runs it too.
- name: Build the MCP server
working-directory: editors/vscode
run: (npm ci || (npm cache clean --force && npm ci)) && npm run compile

- name: Unit tests and build
run: ./gradlew --console=plain test buildPlugin

- name: Plugin verifier
if: runner.os == 'Linux'
run: ./gradlew --console=plain verifyPlugin

- name: Conformance (IntelliJ IDEA + Java)
if: runner.os == 'Linux'
working-directory: editors/conformance
run: npm ci && xvfb-run -a npm run test:jetbrains

release:
needs: [ check, test ]
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21

- uses: actions/setup-node@v7
with:
node-version: 22

- uses: gradle/actions/setup-gradle@v5

- name: Build the MCP server
working-directory: editors/vscode
run: npm ci

- name: Build and sign
run: ./gradlew --console=plain signPlugin
env:
CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}

- name: Create GitHub Release
run: |
CHANGELOG="$PWD/CHANGELOG.md" node ../vscode/scripts/release-notes.js "${{ needs.check.outputs.version }}" > release-notes.md
gh release create "$GITHUB_REF_NAME" build/distributions/*-signed.zip \
--repo "$GITHUB_REPOSITORY" --verify-tag \
--title "Blackbox for JetBrains IDEs ${{ needs.check.outputs.version }}" \
--notes-file release-notes.md \
${{ needs.check.outputs.prerelease == 'true' && '--prerelease' || '' }}
env:
GH_TOKEN: ${{ github.token }}

- name: Publish to JetBrains Marketplace
run: ./gradlew --console=plain publishPlugin ${{ needs.check.outputs.prerelease == 'true' && '-PpublishChannel=eap' || '' }}
env:
CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
PUBLISH_TOKEN: ${{ secrets.JETBRAINS_PUBLISH_TOKEN }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ Thumbs.db

# Compiled output (monorepo-wide)
editors/vscode/out/
editors/vscode/dist-mcp/

# Vite logs files
vite.config.js.timestamp-*
Expand Down
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ Each VS Code extension version is a tag `vscode-v<version>`, a [GitHub Release](

To try the pipeline on a branch without releasing anything, run the workflow by hand from the Actions tab: it builds, tests and packages only.

### JetBrains plugin

Same flow with tag `jetbrains-v<version>`: bump `pluginVersion` in `editors/jetbrains/gradle.properties`, rename `## Unreleased` in `editors/jetbrains/CHANGELOG.md` (check it with `CHANGELOG=editors/jetbrains/CHANGELOG.md node editors/vscode/scripts/release-notes.js <x.y.z>`), merge, then push the tag. The **JetBrains Plugin** workflow runs the unit tests, the plugin verifier and the conformance suite, signs the plugin, creates the GitHub Release and publishes to the JetBrains Marketplace (pre-releases to the `eap` channel).

It needs these repository secrets: `JETBRAINS_PUBLISH_TOKEN` (a Marketplace token), and `JETBRAINS_CERTIFICATE_CHAIN`, `JETBRAINS_PRIVATE_KEY`, `JETBRAINS_PRIVATE_KEY_PASSWORD` for [plugin signing](https://plugins.jetbrains.com/docs/intellij/plugin-signing.html). The Marketplace accepts automated uploads only after the first version was uploaded by hand on [plugins.jetbrains.com](https://plugins.jetbrains.com/plugin/add).

The plugin bundles the MCP server from `editors/vscode`, so a server change reaches JetBrains users with the next plugin release.

## Reporting Issues

- Use the GitHub issue tracker
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ This project contains implementations for the following editors:
| IDE | Status | Directory |
|-----|--------|-----------|
| **VS Code** | Active | [`editors/vscode/`](editors/vscode/) |
| **JetBrains** | Planned | [`editors/jetbrains/`](editors/jetbrains/) |
| **JetBrains** (PhpStorm, IntelliJ IDEA, GoLand, …) | Working, not yet published ([build from this repo](editors/jetbrains/README.md)) | [`editors/jetbrains/`](editors/jetbrains/) |
| **Neovim** | Working, not yet published ([install from this repo](editors/neovim/README.md)) | [`editors/neovim/`](editors/neovim/) |


Expand Down
7 changes: 6 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,16 @@ Layers on the MCP side, each depending only on the ones below:
## Neovim Implementation
`editors/neovim/` is the same window side in Lua on top of nvim-dap. It writes the same registry entry (`app: "Neovim"`; `pid` and `appPid` are both the Neovim process, so an AI tool in `:terminal` is matched to it), listens on the same kind of socket, and answers the same requests. The MCP server needs no changes. Pause state, output and waits port `debugEvents.ts` onto `dap.listeners`. nvim-dap has no function or disabled breakpoints, so the plugin keeps those itself.

## JetBrains Implementation
`editors/jetbrains/` is the window side in Kotlin on the IntelliJ debugger platform (XDebugger), so one plugin works in every IntelliJ-based IDE; language-specific parts (method and exception breakpoints) are optional extensions, e.g. for Java. An IDE serves all its projects from one process, so **each open project is a window**: id `<pid>-<hash of the project folder>`, `pid` and `appPid` both the IDE's. The MCP server then treats the IDE process as the *editor* an AI tool was started from, not a window; the IDE's terminal sets `BLACKBOX_WINDOW` to pick the project. Run configurations are started by name in the debugger. Hit conditions and logpoints, which the platform doesn't have for every language, are applied by the plugin when such a breakpoint pauses. The plugin also ships a bundled MCP server (`editors/vscode/scripts/bundle-mcp.js`) and installs the launcher with the same rules as the VS Code extension.

On Windows, Node can't connect to Java's Unix domain sockets (it only knows named pipes there), so the plugin listens on TCP loopback: the registry entry's socket is `tcp://127.0.0.1:<port>/<token>`, and every request carries the token. The registry folder is private, so only this user can read it.

## Extension Guide: Adding New IDEs
A new editor only needs the **window side**; the MCP server is shared.

1. **Namespace:** Create a new directory under `editors/<ide>/`.
2. **Window protocol:** Per window, listen on `<ipcDir>/<id>.sock` (a randomly named pipe on Windows) in the private per-user folder (`/tmp/blackbox-<uid>/`, mode 0700, checked for ownership), and write `<ipcDir>/<id>.json` with the `RegistryEntry` fields from `editors/vscode/src/ipc/protocol.ts` (`app`, `appPid`, `protocol: 2`, …). Answer newline-delimited JSON requests `{id, tool, args}` with `{id, result}` or `{id, error}`.
2. **Window protocol:** Per window, listen on `<ipcDir>/<id>.sock` (a randomly named pipe on Windows, or `tcp://127.0.0.1:<port>/<token>` where named pipes aren't available) in the private per-user folder (`/tmp/blackbox-<uid>/`, mode 0700, checked for ownership), and write `<ipcDir>/<id>.json` with the `RegistryEntry` fields from `editors/vscode/src/ipc/protocol.ts` (`app`, `appPid`, `protocol: 2`, …). Answer newline-delimited JSON requests `{id, tool, args}` with `{id, result}` or `{id, error}`.
3. **Compliance:** Implement every tool in `schema/tools.json` except `ide_*` (answered by the MCP server), plus the internal `window_status`.
4. **Conformance:** Add a harness to `editors/conformance/harness/` that starts the editor, and run `editors/conformance/test` against it. It drives the editor through the real MCP server.
5. **Automation:** Register a CI workflow in `.github/workflows/<ide>.yml`.
19 changes: 19 additions & 0 deletions editors/conformance/fixtures/java/App.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Fixture for the Blackbox conformance suite (editors/conformance/test).
// The tests refer to these line numbers; keep them in step with app.go.

public class App {
static int add(int a, int b) {
return a + b;
}

public static void main(String[] args) {
int total = 0;
for (int i = 1; i <= 2; i++) {
// Same lines as app.go: breakpoint on 14, logpoint on 15.

total = add(total, i); // line 14: breakpoint
System.out.println("total " + total); // line 15: logpoint
}
System.out.println("done " + total);
}
}
134 changes: 134 additions & 0 deletions editors/conformance/harness/jetbrains.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Starts an IntelliJ-platform IDE (IntelliJ IDEA Community, through Gradle's
// runIdeForConformance task) with the Blackbox plugin, on a copy of the Java
// fixture, for the conformance suite.
//
// The fixture is a prebuilt jar run by a "JAR Application" configuration, so
// the IDE needs no JDK set up and no build: javac and java come from
// JAVA_HOME (or PATH). Linux CI runs this under xvfb-run.

const { spawn, spawnSync, execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');

const PLUGIN = path.resolve(__dirname, '../../jetbrains');
const FIXTURE = path.resolve(__dirname, '../fixtures/java');
const isWindows = process.platform === 'win32';
// The first start builds the plugin and may download the IDE.
const START_TIMEOUT_MS = 10 * 60_000;

function javaHome() {
if (process.env.JAVA_HOME) { return process.env.JAVA_HOME; }
const { stderr } = spawnSync('java', ['-XshowSettings:properties', '-version'], { encoding: 'utf8' });
const match = /java\.home = (.+)/.exec(stderr || '');
if (!match) { throw new Error('set JAVA_HOME to a JDK: the fixture is built with javac and jar'); }
return match[1].trim();
}

const xml = s => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');

function runConfiguration(name, jar, jre) {
return `<component name="ProjectRunConfigurationManager">
<configuration default="false" name="${xml(name)}" type="JarApplication">
<option name="JAR_PATH" value="${xml(jar)}" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
<option name="ALTERNATIVE_JRE_PATH" value="${xml(jre)}" />
<method v="2" />
</configuration>
</component>
`;
}

// A project around the fixture: one module with the source, the compiled jar,
// and the run configurations the tests start by name.
function prepareWorkspace(dir) {
const workspace = path.join(dir, 'fixture');
fs.mkdirSync(path.join(workspace, '.idea', 'runConfigurations'), { recursive: true });
fs.copyFileSync(path.join(FIXTURE, 'App.java'), path.join(workspace, 'App.java'));

const jdk = javaHome();
const bin = name => path.join(jdk, 'bin', isWindows ? `${name}.exe` : name);
const classes = path.join(dir, 'classes');
execFileSync(bin('javac'), ['-g', '--release', '17', '-d', classes, path.join(workspace, 'App.java')]);
execFileSync(bin('jar'), ['--create', '--file', path.join(workspace, 'app.jar'), '--main-class', 'App', '-C', classes, '.']);

const idea = path.join(workspace, '.idea');
fs.writeFileSync(path.join(idea, 'modules.xml'), `<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/fixture.iml" filepath="$PROJECT_DIR$/fixture.iml" />
</modules>
</component>
</project>
`);
fs.writeFileSync(path.join(workspace, 'fixture.iml'), `<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
</content>
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
`);
const configs = path.join(idea, 'runConfigurations');
fs.writeFileSync(path.join(configs, 'Fixture.xml'), `<!-- Used by the Blackbox conformance suite. -->\n` +
runConfiguration('Fixture', '$PROJECT_DIR$/app.jar', jdk));
// The IDE refuses it (no such JRE), to check that the error is reported.
fs.writeFileSync(path.join(configs, 'Fixture_broken.xml'),
runConfiguration('Fixture (broken)', '$PROJECT_DIR$/app.jar', path.join(dir, 'no-such-jre')));
return fs.realpathSync(workspace);
}

async function startJetBrains({ ipcDir }) {
const workspace = prepareWorkspace(ipcDir);
// Where the plugin installs its MCP launcher (not the user's ~/.blackbox).
const home = path.join(ipcDir, 'home');
const gradlew = path.join(PLUGIN, isWindows ? 'gradlew.bat' : 'gradlew');
const gradle = spawn(gradlew, ['--console=plain', 'runIdeForConformance', `-PblackboxProject=${workspace}`], {
cwd: PLUGIN,
env: { ...process.env, BLACKBOX_IPC_DIR: ipcDir, BLACKBOX_HOME: home },
stdio: ['ignore', 'pipe', 'pipe'],
});
let log = '';
gradle.stdout.on('data', chunk => { log += chunk; });
gradle.stderr.on('data', chunk => { log += chunk; });

// Ready once the project's registry entry exists.
const started = Date.now();
let entry;
while (!entry && Date.now() - started < START_TIMEOUT_MS && gradle.exitCode === null) {
const file = fs.readdirSync(ipcDir).find(f => f.endsWith('.json'));
if (file) {
try { entry = JSON.parse(fs.readFileSync(path.join(ipcDir, file), 'utf8')); } catch { /* being written */ }
}
if (!entry) { await new Promise(r => setTimeout(r, 500)); }
}
if (!entry) {
gradle.kill();
throw new Error(`the IDE did not register within ${START_TIMEOUT_MS / 1000}s.\n${log.slice(-5000)}`);
}

return {
name: entry.app.name,
workspace,
file: path.join(workspace, 'App.java'),
functionName: 'App.add',
// The plugin ships the MCP server and installs ~/.blackbox/blackbox-mcp.js.
home,
// Its run configurations can't stall the way a debug adapter can.
skip: ['stalled start'],
stop: () => new Promise(resolve => {
if (gradle.exitCode !== null || gradle.signalCode !== null) { return resolve(); }
gradle.once('exit', resolve);
// Ends the IDE (Gradle's task then finishes); Gradle itself as a fallback.
try { process.kill(entry.pid, 'SIGTERM'); } catch { gradle.kill(); }
setTimeout(() => gradle.kill(), 30_000).unref();
}),
stderr: () => log.slice(-20000),
};
}

module.exports = { startJetBrains };
7 changes: 6 additions & 1 deletion editors/conformance/harness/nvim.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const os = require('os');
const path = require('path');

const PLUGIN = path.resolve(__dirname, '../../neovim');
const GO_FIXTURE = fs.realpathSync(path.resolve(__dirname, '../fixtures/go'));
const NVIM_DAP = process.env.NVIM_DAP_PATH || path.join(os.homedir(), '.local/share/nvim/lazy/nvim-dap');

function initScript(workspace) {
Expand All @@ -30,7 +31,7 @@ require('blackbox').setup()
`;
}

async function startNvim({ ipcDir, workspace }) {
async function startNvim({ ipcDir, workspace = GO_FIXTURE }) {
const init = path.join(ipcDir, 'init.lua');
fs.writeFileSync(init, initScript(workspace));
const nvim = spawn('nvim', ['--headless', '-u', init], {
Expand All @@ -52,6 +53,10 @@ async function startNvim({ ipcDir, workspace }) {
}
return {
name: 'Neovim',
workspace,
file: path.join(workspace, 'app.go'),
functionName: 'main.add',
skip: [],
// Resolves at once if Neovim already exited (e.g. crashed during a test).
stop: () => new Promise(resolve => {
if (nvim.exitCode !== null || nvim.signalCode !== null) { return resolve(); }
Expand Down
3 changes: 2 additions & 1 deletion editors/conformance/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"private": true,
"description": "Editor-independent checks that an editor's Blackbox window side implements the tool contract, driven through the real MCP server.",
"scripts": {
"test:nvim": "mocha --ui tdd --timeout 120000 \"test/**/*.test.js\""
"test:nvim": "mocha --ui tdd --timeout 120000 \"test/**/*.test.js\"",
"test:jetbrains": "BLACKBOX_EDITOR=jetbrains mocha --ui tdd --timeout 120000 \"test/**/*.test.js\""
},
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
Expand Down
Loading
Loading