repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/intellij/index.ts
Rawimport { stat } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import type {
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { closeDebug, dbg } from "./src/debug.ts";
import {
resolveSetting,
type SettingDeclaration,
} from "./src/pi-ext-settings.ts";
const SERVER_NAME = "intellij";
const TOOL_NAMESPACE = "mcp__intellij__";
const MAX_ANCESTORS = 128;
export const INTELLIJ_URL_SETTING: SettingDeclaration<string | undefined> = {
key: "intellij.url",
parse: (raw) =>
typeof raw === "string" ? normalizeIntellijUrl(raw) : undefined,
default: undefined,
};
export function normalizeIntellijUrl(value: string): string {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error("IntelliJ MCP URL is malformed.");
}
if (url.protocol !== "http:")
throw new Error("IntelliJ MCP URL must use HTTP.");
if (url.username || url.password)
throw new Error("IntelliJ MCP URL must not contain credentials.");
if (url.hash)
throw new Error("IntelliJ MCP URL must not contain a fragment.");
if (!isLoopbackHost(url.hostname))
throw new Error("IntelliJ MCP URL must use a loopback host.");
return url.href;
}
function isLoopbackHost(hostname: string): boolean {
const host = hostname.toLowerCase();
if (host === "localhost" || host === "[::1]") return true;
const parts = host.split(".");
return (
parts.length === 4 &&
parts[0] === "127" &&
parts.every((part) => /^\d+$/.test(part) && Number(part) <= 255)
);
}
/** Find nearest ancestor containing IntelliJ's project metadata. */
export async function findIdeaProjectRoot(
cwd: string,
): Promise<string | undefined> {
let current = resolve(cwd);
for (let depth = 0; depth < MAX_ANCESTORS; depth++) {
try {
if ((await stat(resolve(current, ".idea"))).isDirectory()) return current;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
}
const parent = dirname(current);
if (parent === current) return undefined;
current = parent;
}
return undefined;
}
export default function intellijExtension(pi: ExtensionAPI): void {
let generation = 0;
let started = false;
let registered = false;
pi.on("session_start", (_event, ctx) => {
if (started) return;
started = true;
const current = ++generation;
dbg?.("session.start", { generation: current });
void activate(ctx, current).catch(() => {
if (current !== generation) return;
dbg?.("activation.result", { generation: current, status: "failed" });
ctx.ui.notify(
"IntelliJ MCP activation failed; inspect /mcp and intellij.url.",
"warning",
);
});
});
pi.on("tool_call", (event) => {
if (
registered &&
event.toolName.startsWith(TOOL_NAMESPACE) &&
projectRoot !== undefined
)
Object.assign(event.input, { projectPath: projectRoot });
});
let projectRoot: string | undefined;
async function activate(
ctx: ExtensionContext,
current: number,
): Promise<void> {
const root = await findIdeaProjectRoot(ctx.cwd);
if (current !== generation || ctx.signal?.aborted) {
dbg?.("activation.result", { generation: current, status: "stale" });
return;
}
if (!root) {
dbg?.("activation.result", { generation: current, status: "no_project" });
return;
}
const setting = resolveSetting(pi, ctx, INTELLIJ_URL_SETTING);
if (!setting.ok || !setting.value) {
dbg?.("activation.result", {
generation: current,
status: setting.ok ? "missing_url" : "invalid_url",
hasIdea: true,
});
ctx.ui.notify(
setting.ok
? "IntelliJ project detected. Set intellij.url to IntelliJ's loopback HTTP Stream URL."
: `${setting.error}. Set intellij.url to IntelliJ's loopback HTTP Stream URL.`,
"warning",
);
return;
}
if (current !== generation || ctx.signal?.aborted) {
dbg?.("activation.result", { generation: current, status: "stale" });
return;
}
projectRoot = root;
pi.registerMcpServer(SERVER_NAME, {
url: setting.value,
exposure: "deferred",
description:
"IntelliJ project tools. Pi injects the detected project root.",
});
registered = true;
dbg?.("activation.result", {
generation: current,
status: "registered",
hasIdea: true,
hasUrl: true,
});
}
pi.on("session_shutdown", () => {
generation++;
started = false;
if (registered) pi.unregisterMcpServer(SERVER_NAME);
registered = false;
projectRoot = undefined;
dbg?.("session.shutdown", { generation });
closeDebug();
});
}