Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/intellij/index.ts

Raw
import { 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();
	});
}