repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/runtime/index.ts
Raw/**
* Runtime detection footer segment producer.
*
* Detects project language runtime from config files in cwd or parents
* under $HOME and emits a footer:segment with zone "workspace".
*/
import { execFile } from "node:child_process";
import { readdir, realpath } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve } from "node:path";
import { promisify } from "node:util";
import type {
ExtensionAPI,
ExtensionContext,
ThemeColor,
} from "@earendil-works/pi-coding-agent";
import { closeDebug, dbg, span } from "./src/debug.ts";
import {
type FooterSegment,
offerFooterSegment,
removeFooterSegment,
} from "./src/pi-ext-footer-segment.ts";
import {
MAX_TIMER_MS,
parseNumberSetting,
resolveSetting,
type SettingDeclaration,
} from "./src/pi-ext-settings.ts";
import { isStaleContextError } from "./src/pi-ext-stale-context.ts";
const execFileAsync = promisify(execFile);
const VERSION_TIMEOUT_SETTING: SettingDeclaration<number> = {
key: "runtime.versionTimeoutMs",
env: "PI_RUNTIME_VERSION_TIMEOUT_MS",
default: 2500,
parse(raw, source) {
const value = parseNumberSetting(raw, source);
if (value === undefined) return undefined;
if (!Number.isInteger(value) || value <= 0 || value > MAX_TIMER_MS)
throw new Error(`expected an integer from 1 to ${MAX_TIMER_MS}`);
return value;
},
};
// Resolved per session_start; shared by all version lookups.
let versionTimeoutMs = VERSION_TIMEOUT_SETTING.default;
const SEGMENT_ID = "runtime";
const STATUS_ID = "runtime";
const SEGMENT_ZONE = "workspace";
const SEGMENT_ORDER = 3;
const RUNTIME_FILES = {
bun: ["bun.lock", "bun.lockb"],
deno: ["deno.json", "deno.jsonc", "deno.lock"],
lua: [
"stylua.toml",
".stylua.toml",
".luarc.json",
".luarc.jsonc",
"init.lua",
],
nodejs: ["package.json", ".nvmrc", ".node-version"],
python: [
"pyproject.toml",
"requirements.txt",
"setup.py",
"setup.cfg",
"Pipfile",
".python-version",
],
golang: ["go.mod"],
rust: ["Cargo.toml"],
java: ["pom.xml", "build.gradle", "build.gradle.kts"],
ruby: ["Gemfile", ".ruby-version"],
php: ["composer.json"],
} as const;
type RuntimeName = keyof typeof RUNTIME_FILES;
type DirEntries = { names: readonly string[]; set: ReadonlySet<string> };
type RuntimeInfo = { name: RuntimeName; icon: string; version?: string };
type RuntimeDef = {
name: RuntimeName;
icon: string;
files: readonly string[];
entries?: readonly string[];
suffixes?: readonly string[];
version: () => Promise<string | undefined>;
};
async function commandOutput(
cmd: string,
args: readonly string[] = [],
): Promise<string | undefined> {
try {
const { stdout, stderr } = await execFileAsync(cmd, [...args], {
timeout: versionTimeoutMs,
});
const text = `${stdout}\n${stderr}`.trim();
return text || undefined;
} catch {
return undefined;
}
}
function prefixedVersion(version?: string): string | undefined {
if (!version) return undefined;
return version.startsWith("v") ? version : `v${version}`;
}
async function exactVersion(
cmd: string,
args: readonly string[] = [],
): Promise<string | undefined> {
return prefixedVersion(await commandOutput(cmd, args));
}
async function matchedVersion(
cmd: string,
args: readonly string[],
regex: RegExp,
): Promise<string | undefined> {
return prefixedVersion((await commandOutput(cmd, args))?.match(regex)?.[1]);
}
async function firstMatchedVersion(
commands: readonly [string, readonly string[], RegExp][],
): Promise<string | undefined> {
for (const [cmd, args, regex] of commands) {
const version = await matchedVersion(cmd, args, regex);
if (version) return version;
}
return undefined;
}
async function canonicalPath(path: string): Promise<string> {
try {
return await realpath(path);
} catch {
return resolve(path);
}
}
function isInside(path: string, root: string): boolean {
const rel = relative(root, path);
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
}
async function searchDirs(cwd: string): Promise<string[]> {
const start = await canonicalPath(cwd);
const homeRaw = process.env.HOME;
if (!homeRaw) return [start];
const home = await canonicalPath(homeRaw);
if (!isInside(start, home)) return [start];
const dirs: string[] = [];
let dir = start;
while (true) {
dirs.push(dir);
if (dir === home) return dirs;
const parent = dirname(dir);
if (parent === dir || !isInside(parent, home)) return dirs;
dir = parent;
}
}
const runtimes: readonly RuntimeDef[] = [
{
name: "bun",
icon: "",
files: RUNTIME_FILES.bun,
version: () => exactVersion("bun", ["--version"]),
},
{
name: "deno",
icon: "",
files: RUNTIME_FILES.deno,
version: () =>
matchedVersion("deno", ["--version"], /deno\s+([0-9][^\s]*)/i),
},
{
name: "lua",
icon: "",
files: RUNTIME_FILES.lua,
entries: ["lua"],
suffixes: [".lua"],
version: () =>
firstMatchedVersion([
["lua", ["-v"], /Lua\s+([0-9][^\s]*)/i],
["luajit", ["-v"], /LuaJIT\s+([0-9][^\s]*)/i],
]),
},
{
name: "nodejs",
icon: "",
files: RUNTIME_FILES.nodejs,
version: () => Promise.resolve(process.version),
},
{
name: "python",
icon: "",
files: RUNTIME_FILES.python,
version: () =>
firstMatchedVersion([
["python3", ["--version"], /Python\s+([0-9][^\s]*)/i],
["python", ["--version"], /Python\s+([0-9][^\s]*)/i],
]),
},
{
name: "golang",
icon: "",
files: RUNTIME_FILES.golang,
version: () =>
matchedVersion("go", ["version"], /go version go([0-9][^\s]*)/i),
},
{
name: "rust",
icon: "",
files: RUNTIME_FILES.rust,
version: () =>
matchedVersion("rustc", ["--version"], /rustc\s+([0-9][^\s]*)/i),
},
{
name: "java",
icon: "",
files: RUNTIME_FILES.java,
version: () => matchedVersion("java", ["-version"], /"([0-9][^"]*)"/),
},
{
name: "ruby",
icon: "",
files: RUNTIME_FILES.ruby,
version: () =>
matchedVersion("ruby", ["--version"], /ruby\s+([0-9][^\s]*)/i),
},
{
name: "php",
icon: "",
files: RUNTIME_FILES.php,
version: () => matchedVersion("php", ["--version"], /PHP\s+([0-9][^\s]*)/i),
},
];
const RUNTIME_COLORS: Record<RuntimeName, ThemeColor> = {
nodejs: "success",
deno: "syntaxType",
golang: "syntaxType",
bun: "warning",
python: "warning",
java: "warning",
rust: "error",
ruby: "error",
lua: "accent",
php: "accent",
};
async function entriesFor(dir: string): Promise<DirEntries> {
try {
const names = await readdir(dir);
return { names, set: new Set(names) };
} catch {
return { names: [], set: new Set() };
}
}
function matchesRuntime(rt: RuntimeDef, entries: DirEntries): boolean {
if (rt.files.some((file) => entries.set.has(file))) return true;
if (rt.entries?.some((entry) => entries.set.has(entry))) return true;
if (
rt.suffixes?.some((suffix) =>
entries.names.some((entry) => entry.endsWith(suffix)),
)
)
return true;
return false;
}
async function detectInDir(dir: string): Promise<RuntimeInfo | undefined> {
const entries = await entriesFor(dir);
for (const rt of runtimes) {
if (!matchesRuntime(rt, entries)) continue;
return { name: rt.name, icon: rt.icon, version: await rt.version() };
}
return undefined;
}
async function detect(cwd: string): Promise<RuntimeInfo | undefined> {
for (const dir of await searchDirs(cwd)) {
const rt = await detectInDir(dir);
if (rt) return rt;
}
return undefined;
}
function ignoreStaleContext(fn: () => void) {
try {
fn();
} catch (error) {
if (!isStaleContextError(error)) throw error;
}
}
export default function runtimeExtension(pi: ExtensionAPI) {
let generation = 0;
let hasUI = false;
function clearSegment() {
ignoreStaleContext(() => removeFooterSegment(pi, SEGMENT_ID));
}
function offerSegment(segment: FooterSegment): boolean {
let accepted = false;
ignoreStaleContext(() => {
accepted = offerFooterSegment(pi, segment);
});
return accepted;
}
async function refresh(ctx: ExtensionContext) {
const ticket = generation;
let cwd: string;
try {
cwd = ctx.cwd;
} catch (error) {
if (isStaleContextError(error)) return;
throw error;
}
if (!hasUI) return;
const finish = span?.("runtime.detect");
let rt: RuntimeInfo | undefined;
try {
rt = await detect(cwd);
} catch (error) {
finish?.("error");
throw error;
}
if (ticket !== generation) {
finish?.("finish", { kind: rt?.name ?? "none", outcome: "superseded" });
return;
}
finish?.("finish", {
kind: rt?.name ?? "none",
outcome: rt ? "detected" : "none",
});
if (!rt) {
clearSegment();
ignoreStaleContext(() => ctx.ui.setStatus(STATUS_ID, undefined));
return;
}
const text = rt.version ?? rt.name;
const accepted =
ctx.mode === "tui" &&
offerSegment({
id: SEGMENT_ID,
icon: rt.icon,
text,
color: RUNTIME_COLORS[rt.name],
zone: SEGMENT_ZONE,
order: SEGMENT_ORDER,
});
if (!accepted) clearSegment();
ignoreStaleContext(() =>
ctx.ui.setStatus(STATUS_ID, accepted ? undefined : `${rt.icon} ${text}`),
);
}
pi.on("session_start", async (_e, ctx) => {
dbg?.("session.start");
generation++;
hasUI = ctx.hasUI;
const setting = resolveSetting(pi, ctx, VERSION_TIMEOUT_SETTING);
versionTimeoutMs = setting.ok
? setting.value
: VERSION_TIMEOUT_SETTING.default;
if (!setting.ok && hasUI)
ctx.ui.notify(
`${setting.error}; using ${VERSION_TIMEOUT_SETTING.default} ms`,
"warning",
);
void refresh(ctx);
});
pi.on("agent_end", async (_e, ctx) => {
if (hasUI) void refresh(ctx);
});
pi.on("session_shutdown", async (_e, ctx) => {
generation++;
clearSegment();
ignoreStaleContext(() => {
if (ctx.hasUI) ctx.ui.setStatus(STATUS_ID, undefined);
});
dbg?.("session.shutdown");
closeDebug();
});
}