import { constants } from "node:fs"; import { access, chmod, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { delimiter, join } from "node:path"; import { randomUUID } from "node:crypto"; import defaults from "./default.json" with { type: "json" }; // Built-in CLI candidates from the community AXI catalog. const managedCliCandidates = [...defaults.candidateCli]; export const MANAGED_CLI_CANDIDATES: readonly string[] = managedCliCandidates; const CANDIDATE_NAMES = new Set(MANAGED_CLI_CANDIDATES); export interface ManagedSettings { managedCli: string[]; managedPlugin: string[]; candidateCli?: string[]; } export interface DetectedCliTool { name: string; path: string; detected: boolean; managed: boolean; } export interface ManagedToolPolicy { getManagedCli(): string[]; setManagedCli(names: Iterable): void; getManagedPlugin(): string[]; setManagedPlugin(names: Iterable): void; isManagedCommand(command: string): boolean; isManagedPlugin(name: string): boolean; } export const AXI_RECOVERY_TOOLS = new Set(["axi_read"]); function toolName(name: string): string { return name.trim().toLowerCase().replace(/\.(?:exe|cmd|bat|com)$/i, ""); } function normalizeCandidates(names: Iterable): string[] { return [...new Set([...names] .map(toolName) .filter((name) => /^[a-z0-9][a-z0-9._-]*$/.test(name)))]; } function normalizeCli(names: Iterable, candidates = CANDIDATE_NAMES): string[] { return [...new Set([...names] .map(toolName) .filter((name) => candidates.has(name)))]; } function normalizePlugins(names: Iterable): string[] { return [...new Set([...names] .map((name) => name.trim()) .filter((name) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)))]; } function candidateNames(settings: Pick): string[] { return settings.candidateCli === undefined ? [...MANAGED_CLI_CANDIDATES] : normalizeCandidates(settings.candidateCli); } async function refreshDefaultCandidates(): Promise { try { const value: unknown = JSON.parse(await readFile(new URL("./default.json", import.meta.url), "utf8")); const raw = value && typeof value === "object" ? (value as { candidateCli?: unknown }).candidateCli : undefined; if (!Array.isArray(raw)) return; const candidates = normalizeCandidates(raw.filter((name): name is string => typeof name === "string")); managedCliCandidates.splice(0, managedCliCandidates.length, ...candidates); CANDIDATE_NAMES.clear(); candidates.forEach((name) => CANDIDATE_NAMES.add(name)); } catch { /* keep the packaged defaults */ } } function arrayOfStrings(value: unknown): string[] | undefined { return Array.isArray(value) ? value.filter((name): name is string => typeof name === "string") : undefined; } export function commandParts(command: string): string[] | undefined { const trimmed = command.trim(); if (!trimmed || ["|", "&", ";", "`", "$", "(", ")", "<", ">", "\n", "\r", "\0"].some((token) => trimmed.includes(token))) return undefined; const parts = trimmed.split(/\s+/); let first = 0; while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(parts[first] ?? "")) first++; return parts.slice(first); } function executableName(command: string): string | undefined { const parts = commandParts(command); const executable = parts?.[0]; const name = executable?.split(/[\\/]/).pop(); return name === undefined ? undefined : toolName(name); } export function isManagedCommand(command: string, settings: ManagedSettings | ManagedToolPolicy): boolean { const name = executableName(command); if (!name) return false; const managed = "getManagedCli" in settings ? settings.getManagedCli() : settings.managedCli; return managed.includes(name); } export function createManagedToolPolicy(settings: ManagedSettings = { managedCli: [], managedPlugin: [] }): ManagedToolPolicy { const candidates = new Set(candidateNames(settings)); let managedCli = normalizeCli(settings.managedCli, candidates); let managedPlugin = normalizePlugins(settings.managedPlugin); return { getManagedCli: () => [...managedCli], setManagedCli(names) { managedCli = normalizeCli(names, candidates); }, getManagedPlugin: () => [...managedPlugin], setManagedPlugin(names) { managedPlugin = normalizePlugins(names); }, isManagedCommand(command) { return isManagedCommand(command, { managedCli, managedPlugin: [] }); }, isManagedPlugin(name) { return !AXI_RECOVERY_TOOLS.has(name) && managedPlugin.includes(name); }, }; } function settingsDirectory(env: NodeJS.ProcessEnv = process.env): string { const configured = env.PI_CODING_AGENT_DIR; if (!configured) return join(homedir(), ".pi", "agent"); if (configured === "~") return homedir(); if (configured.startsWith("~/") || configured.startsWith("~\\")) return join(homedir(), configured.slice(2)); return configured; } export function managedSettingsPath(env: NodeJS.ProcessEnv = process.env): string { return join(settingsDirectory(env), "pi-managed-axi.json"); } export async function loadManagedSettings(env: NodeJS.ProcessEnv = process.env): Promise { await refreshDefaultCandidates(); try { const value: unknown = JSON.parse(await readFile(managedSettingsPath(env), "utf8")); if (!value || typeof value !== "object") return { managedCli: [], managedPlugin: [] }; const raw = value as Record; const rawCandidateCli = Object.hasOwn(raw, "candidateCli") ? arrayOfStrings(raw.candidateCli) ?? [] : arrayOfStrings(raw.candidateTools); const candidateCli = rawCandidateCli === undefined ? undefined : normalizeCandidates(rawCandidateCli); const rawManagedCli = Object.hasOwn(raw, "managedCli") ? arrayOfStrings(raw.managedCli) ?? [] : arrayOfStrings(raw.managedTools) ?? []; const rawManagedPlugin = Object.hasOwn(raw, "managedPlugin") ? arrayOfStrings(raw.managedPlugin) ?? [] : []; const candidates = new Set(candidateCli ?? MANAGED_CLI_CANDIDATES); return { ...(candidateCli === undefined ? {} : { candidateCli }), managedCli: normalizeCli(rawManagedCli, candidates), managedPlugin: normalizePlugins(rawManagedPlugin), }; } catch { return { managedCli: [], managedPlugin: [] }; } } export async function saveManagedSettings(settings: ManagedSettings, env: NodeJS.ProcessEnv = process.env): Promise { const path = managedSettingsPath(env); const directory = settingsDirectory(env); await mkdir(directory, { recursive: true, mode: 0o700 }); await chmod(directory, 0o700); const temporary = join(directory, ".pi-managed-axi-" + randomUUID() + ".tmp"); const candidateCli = settings.candidateCli === undefined ? undefined : normalizeCandidates(settings.candidateCli); const candidates = new Set(candidateCli ?? MANAGED_CLI_CANDIDATES); const value = { ...(candidateCli === undefined ? {} : { candidateCli }), managedCli: normalizeCli(settings.managedCli, candidates), managedPlugin: normalizePlugins(settings.managedPlugin), }; try { await writeFile(temporary, JSON.stringify(value, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" }); await chmod(temporary, 0o600); await rename(temporary, path); await chmod(path, 0o600); } finally { await unlink(temporary).catch(() => undefined); } } async function isExecutable(path: string): Promise { try { await access(path, constants.X_OK); return (await stat(path)).isFile(); } catch { return false; } } async function findOnPath(name: string, env: NodeJS.ProcessEnv): Promise { for (const directory of (env.PATH ?? "").split(delimiter).filter(Boolean)) { const candidates = process.platform === "win32" ? [name, ...(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").map((extension) => name + extension)] : [name]; for (const candidate of candidates) { const path = join(directory, candidate); if (await isExecutable(path)) return path; } } return undefined; } export async function discoverManagedCli(env: NodeJS.ProcessEnv = process.env): Promise { const tools: DetectedCliTool[] = []; const settings = await loadManagedSettings(env); for (const name of candidateNames(settings)) { const path = await findOnPath(name, env); if (path) tools.push({ name, path, detected: true, managed: settings.managedCli.includes(name) }); } return tools; }