UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

553 lines • 23.7 kB
import { accessSync, constants, existsSync, readFileSync, realpathSync } from "fs"; import { createRequire } from "module"; import { homedir } from "os"; import { basename, dirname, join, resolve, sep, win32 } from "path"; import { fileURLToPath } from "url"; import { spawnProcessSync } from "./utils/child-process.js"; import { normalizePath } from "./utils/paths.js"; import { stripBom } from "./utils/text.js"; // ============================================================================= // Package Detection // ============================================================================= const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** * Detect if we're running as a Bun compiled binary. * Bun binaries have import.meta.url containing "$bunfs", "~BUN", or "%7EBUN" (Bun's virtual filesystem path) */ export const isBunBinary = import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN"); /** Detect if Bun is the runtime (compiled binary or bun run) */ export const isBunRuntime = !!process.versions.bun; export const isBundledNode = typeof PI_BUNDLED_NODE !== "undefined" && PI_BUNDLED_NODE; function normalizeSelfUpdatePackageTarget(target) { if (typeof target === "string") { return { packageName: target, installSpec: target }; } return { packageName: target.packageName, installSpec: target.installSpec ?? target.packageName }; } function makeSelfUpdateCommand(installStep, uninstallStep) { if (!uninstallStep) return installStep; return { ...installStep, display: `${uninstallStep.display} && ${installStep.display}`, steps: [uninstallStep, installStep], }; } function makeSelfUpdateCommandStep(command, args) { return { command, args, display: [command, ...args].map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" "), }; } export function detectInstallMethod() { if (isBunBinary) { return "bun-binary"; } const resolvedPath = `${__dirname}\0${process.execPath || ""}`.toLowerCase().replace(/\\/g, "/"); if (resolvedPath.includes("/pnpm/") || resolvedPath.includes("/.pnpm/")) { return "pnpm"; } if (resolvedPath.includes("/yarn/") || resolvedPath.includes("/.yarn/")) { return "yarn"; } if (isBunRuntime || resolvedPath.includes("/install/global/node_modules/")) { return "bun"; } if (resolvedPath.includes("/npm/") || resolvedPath.includes("/node_modules/")) { return "npm"; } return "unknown"; } function getInferredNpmInstall() { const packageDir = getPackageDir(); const path = process.platform === "win32" || packageDir.includes("\\") ? win32 : { basename, dirname }; const parent = path.dirname(packageDir); let root; if (path.basename(parent).startsWith("@") && path.basename(path.dirname(parent)) === "node_modules") { root = path.dirname(parent); } else if (path.basename(parent) === "node_modules") { root = parent; } if (!root) return undefined; const rootParent = path.dirname(root); if (path.basename(rootParent) === "lib") return { root, prefix: path.dirname(rootParent) }; // Windows global npm prefixes use `<prefix>\\node_modules`, which is // indistinguishable from local project installs by path shape alone. Do not // infer unsupported Windows custom prefixes without `npm root -g` evidence. return undefined; } function getSelfUpdateCommandForMethod(method, installedPackageName, updatePackageTarget = installedPackageName, npmCommand) { const target = normalizeSelfUpdatePackageTarget(updatePackageTarget); switch (method) { case "bun-binary": return undefined; case "pnpm": { const match = readCommandOutput("pnpm", ["root", "-g"]) ? undefined : /^(.*[\\/]global[\\/][^\\/]+)[\\/]\.pnpm[\\/]/.exec(getPackageDir()); const binDirArgs = match ? [`--config.global-bin-dir=${process.env.PNPM_HOME || dirname(dirname(match[1]))}`] : []; return makeSelfUpdateCommand(makeSelfUpdateCommandStep("pnpm", [ "install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", ...binDirArgs, target.installSpec, ]), target.packageName === installedPackageName ? undefined : makeSelfUpdateCommandStep("pnpm", ["remove", "-g", ...binDirArgs, installedPackageName])); } case "yarn": return makeSelfUpdateCommand(makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", target.installSpec]), target.packageName === installedPackageName ? undefined : makeSelfUpdateCommandStep("yarn", ["global", "remove", installedPackageName])); case "bun": return makeSelfUpdateCommand(makeSelfUpdateCommandStep("bun", [ "install", "-g", "--ignore-scripts", "--minimum-release-age=0", target.installSpec, ]), target.packageName === installedPackageName ? undefined : makeSelfUpdateCommandStep("bun", ["uninstall", "-g", installedPackageName])); case "npm": { const [command = "npm", ...npmArgs] = npmCommand ?? []; const inferred = npmCommand?.length ? undefined : getInferredNpmInstall(); const prefixArgs = [...npmArgs, ...(inferred ? ["--prefix", inferred.prefix] : [])]; // pi.dev advertises releases immediately, so a configured npm age gate would // block the update. npm has no per-package age gate, so this also lets new // transitive dependency releases through. Managed installs avoid this. const installStep = makeSelfUpdateCommandStep(command, [ ...prefixArgs, "install", "-g", "--ignore-scripts", "--min-release-age=0", target.installSpec, ]); const uninstallStep = target.packageName === installedPackageName ? undefined : makeSelfUpdateCommandStep(command, [...prefixArgs, "uninstall", "-g", installedPackageName]); return makeSelfUpdateCommand(installStep, uninstallStep); } case "unknown": return undefined; } } function readCommandOutput(command, args, options = {}) { const result = spawnProcessSync(command, args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], }); if (result.status === 0) return result.stdout.trim() || undefined; if (options.requireSuccess) { const reason = result.error?.message || result.stderr.trim() || `exit code ${result.status ?? "unknown"}`; throw new Error(`Failed to run ${[command, ...args].join(" ")}: ${reason}`); } return undefined; } function getGlobalPackageRoots(method, _packageName, npmCommand) { switch (method) { case "npm": { const configured = !!npmCommand?.length; const [command = "npm", ...npmArgs] = npmCommand ?? []; if (configured && command === "bun") { const bunBin = readCommandOutput(command, [...npmArgs, "pm", "bin", "-g"], { requireSuccess: true, }); const roots = [join(homedir(), ".bun", "install", "global", "node_modules")]; if (bunBin) { roots.push(join(dirname(bunBin), "install", "global", "node_modules")); } return roots; } const root = readCommandOutput(command, [...npmArgs, "root", "-g"], { requireSuccess: configured, }); const inferred = configured ? undefined : getInferredNpmInstall(); return [root, inferred?.root].filter((x) => !!x); } case "pnpm": { const root = readCommandOutput("pnpm", ["root", "-g"]); if (root) return [root, dirname(root)]; const match = /^(.*[\\/]global[\\/][^\\/]+)[\\/]\.pnpm[\\/]/.exec(getPackageDir()); return match ? [match[1]] : []; } case "yarn": { const dir = readCommandOutput("yarn", ["global", "dir"]); return dir ? [dir, join(dir, "node_modules")] : []; } case "bun": { const bunBin = readCommandOutput("bun", ["pm", "bin", "-g"]); const roots = [join(homedir(), ".bun", "install", "global", "node_modules")]; if (bunBin) { roots.push(join(dirname(bunBin), "install", "global", "node_modules")); } return roots; } case "bun-binary": case "unknown": return []; } } function normalizeExistingPathForComparison(path, resolveSymlinks) { const resolvedPath = resolve(path); if (!existsSync(resolvedPath)) { return undefined; } let normalizedPath = resolvedPath; if (resolveSymlinks) { try { normalizedPath = realpathSync(resolvedPath); } catch { return undefined; } } if (process.platform === "win32") { normalizedPath = normalizedPath.toLowerCase(); } return normalizedPath; } function getPathComparisonCandidates(path) { return Array.from(new Set([normalizeExistingPathForComparison(path, false), normalizeExistingPathForComparison(path, true)].filter((candidate) => !!candidate))); } function getEntrypointPackageDir() { const entrypoint = process.argv[1]; if (!entrypoint) return undefined; let dir = dirname(entrypoint); while (dir !== dirname(dir)) { if (existsSync(join(dir, "package.json"))) { return dir; } dir = dirname(dir); } return undefined; } function isSelfUpdatePathWritable() { const packageDir = getPackageDir(); try { accessSync(packageDir, constants.W_OK); accessSync(dirname(packageDir), constants.W_OK); return true; } catch { return false; } } function isManagedByGlobalPackageManager(method, packageName, npmCommand) { const packageDirs = [getPackageDir(), getEntrypointPackageDir()].filter((dir) => !!dir); const packageDirCandidates = packageDirs.flatMap((dir) => getPathComparisonCandidates(dir)); return getGlobalPackageRoots(method, packageName, npmCommand).some((root) => { return getPathComparisonCandidates(root).some((normalizedRoot) => { const rootPrefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; return packageDirCandidates.some((packageDir) => packageDir.startsWith(rootPrefix)); }); }); } export function getSelfUpdateCommand(packageName, npmCommand, updatePackageTarget = packageName) { const method = detectInstallMethod(); const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageTarget, npmCommand); if (!command || !isManagedByGlobalPackageManager(method, packageName, npmCommand) || !isSelfUpdatePathWritable()) { return undefined; } return command; } /** * The self-update command configured by the user: `PI_SELF_UPDATE_COMMAND` (whitespace-separated * argv, no quoting), else the global `selfUpdateCommand` setting. Overrides install-method detection. */ export function getConfiguredSelfUpdateArgv(setting) { const fromEnv = process.env.PI_SELF_UPDATE_COMMAND?.trim().split(/\s+/).filter(Boolean); if (fromEnv?.length) return fromEnv; return setting?.length ? setting : undefined; } /** Fill the `{packageName}`, `{version}`, and `{installSpec}` placeholders of a configured self-update command. */ export function makeConfiguredSelfUpdateCommand(argv, release) { const values = { packageName: release.packageName, version: release.version, installSpec: `${release.packageName}@${release.version}`, }; const [command = "", ...args] = argv.map((arg) => arg.replace(/\{(packageName|version|installSpec)\}/g, (_, name) => values[name])); return makeSelfUpdateCommandStep(command, args); } export function getSelfUpdateUnavailableInstruction(packageName, npmCommand, updatePackageTarget = packageName) { const method = detectInstallMethod(); const target = normalizeSelfUpdatePackageTarget(updatePackageTarget); if (method === "bun-binary") { return `Download from: https://github.com/earendil-works/pi/releases/latest`; } const command = getSelfUpdateCommandForMethod(method, packageName, target, npmCommand); // Path-based detection also matches installs that only live under a package manager's directory, // such as a wrapper under PNPM_HOME, so name the package manager only when it owns the install. if (command && isManagedByGlobalPackageManager(method, packageName, npmCommand) && !isSelfUpdatePathWritable()) { return `This installation is managed by a global ${method} install, but the install path is not writable. Update it yourself with: ${command.display}`; } return `Update ${target.installSpec} using the package manager, wrapper, or source checkout that provides this installation.`; } export function getUpdateInstruction(packageName) { const method = detectInstallMethod(); const command = getSelfUpdateCommandForMethod(method, packageName); if (command) { return `Run: ${command.display}`; } return getSelfUpdateUnavailableInstruction(packageName); } // ============================================================================= // Package Asset Paths (shipped with executable) // ============================================================================= /** * Get the base directory for resolving package assets (themes, package.json, README.md, CHANGELOG.md). * - For Bun binary: returns the directory containing the executable * - For Node.js: returns the package root containing package.json * - Ignores Bun binary metadata copied into dist/ when the package root is available */ export function findNodePackageDir(startDir) { let dir = startDir; while (dir !== dirname(dir)) { if (existsSync(join(dir, "package.json"))) { const parent = dirname(dir); // build:binary places Bun's metadata inside dist/. Node still needs the // package root so its dist-relative asset paths do not become dist/dist/. if (basename(dir) === "dist" && existsSync(join(parent, "package.json"))) { return parent; } return dir; } dir = dirname(dir); } return startDir; } export function getPackageDir() { // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly) const envDir = process.env.PI_PACKAGE_DIR; if (envDir) { return normalizePath(envDir); } if (isBunBinary) { // Bun binary: process.execPath points to the compiled executable return dirname(process.execPath); } return findNodePackageDir(__dirname); } /** * Get path to built-in themes directory (shipped with package) * - For Bun binary: theme/ next to executable * - For Node.js (dist/): dist/modes/interactive/theme/ * - For source (src/): src/modes/interactive/theme/ */ export function getThemesDir() { if (isBunBinary) { return join(getPackageDir(), "theme"); } // Theme is in modes/interactive/theme/ relative to src/ or dist/ const packageDir = getPackageDir(); const srcOrDist = existsSync(join(packageDir, "src")) ? "src" : "dist"; return join(packageDir, srcOrDist, "modes", "interactive", "theme"); } /** * Get path to HTML export template directory (shipped with package) * - For Bun binary: export-html/ next to executable * - For Node.js (dist/): dist/core/export-html/ * - For source (src/): src/core/export-html/ */ export function getExportTemplateDir() { if (isBunBinary) { return join(getPackageDir(), "export-html"); } const packageDir = getPackageDir(); const srcOrDist = existsSync(join(packageDir, "src")) ? "src" : "dist"; return join(packageDir, srcOrDist, "core", "export-html"); } /** Get path to package.json */ export function getPackageJsonPath() { return join(getPackageDir(), "package.json"); } /** Get path to README.md */ export function getReadmePath() { return resolve(join(getPackageDir(), "README.md")); } /** Get path to docs directory */ export function getDocsPath() { return resolve(join(getPackageDir(), "docs")); } /** Get path to examples directory */ export function getExamplesPath() { return resolve(join(getPackageDir(), "examples")); } /** Get path to CHANGELOG.md */ export function getChangelogPath() { return resolve(join(getPackageDir(), "CHANGELOG.md")); } /** * Get path to built-in interactive assets directory. * - For Bun binary: assets/ next to executable * - For Node.js (dist/): dist/modes/interactive/assets/ * - For source (src/): src/modes/interactive/assets/ */ export function getInteractiveAssetsDir() { if (isBunBinary) { return join(getPackageDir(), "assets"); } const packageDir = getPackageDir(); const srcOrDist = existsSync(join(packageDir, "src")) ? "src" : "dist"; return join(packageDir, srcOrDist, "modes", "interactive", "assets"); } /** Get path to a bundled interactive asset */ export function getBundledInteractiveAssetPath(name) { return join(getInteractiveAssetsDir(), name); } let quickJSWasmPath; /** Called by the Bun entry with the path of the QuickJS wasm file embedded in the compiled executable. */ export function setEmbeddedQuickJSWasmPath(path) { quickJSWasmPath = path; } /** * Get path to `quickjs-wasi/quickjs.wasm`, the VM that runs codemode scripts. Resolved once so the * compiled module cached per path keeps working after an update removes this install (#10439). */ export function getQuickJSWasmPath() { quickJSWasmPath ??= createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm"); return quickJSWasmPath; } /** Resolve the codemode worker entry for a release runtime. */ export function resolveCodemodeWorkerSpecifier(runtime, moduleUrl) { // Bun embeds explicit source entrypoints, but on Windows Bun 1.3 cannot map an absolute // B:\~BUN URL back to one. A relative string with the original source extension works on // every Bun platform. if (runtime === "bun-binary") return "./src/extensions/codemode/worker.ts"; if (runtime === "bundled-node") return new URL("./codemode-worker.js", moduleUrl); return undefined; } let codemodeWorkerDataUrl; /** * Get the codemode worker entry, or undefined to use the worker that ships next to pi-codemode. * The Bun and Node release builds both pass the worker as an extra entrypoint. */ export function getCodemodeWorkerSpecifier() { const runtime = isBunBinary ? "bun-binary" : isBundledNode ? "bundled-node" : "unbundled"; const specifier = resolveCodemodeWorkerSpecifier(runtime, import.meta.url); if (runtime !== "bundled-node" || !(specifier instanceof URL)) return specifier; // Spawn workers from an in-memory copy. An update replaces or deletes the file while this // process keeps running (#10439). The bundle build keeps the worker free of relative imports // and import.meta, so it runs from a data: URL. codemodeWorkerDataUrl ??= new URL(`data:text/javascript;base64,${readFileSync(specifier).toString("base64")}`); return codemodeWorkerDataUrl; } /** * Detect that the package this process runs from changed on disk, for example after `pi update` * in another terminal. Code loaded on demand can then be missing or from another version. * * Checks the package.json read at startup. Resolving it again would walk up past a deleted install * and could find an unrelated package.json, such as one in the home directory. */ export function detectInstallChange(packageJsonPath = startupPackageJsonPath) { // The Bun binary embeds its code, so replacing the executable does not affect this process. if (isBunBinary || !packageJsonPath) return undefined; let installed; try { installed = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))); } catch (error) { return error.code === "ENOENT" ? { kind: "removed" } : undefined; } return installed.version && installed.version !== VERSION ? { kind: "updated", version: installed.version } : undefined; } let pkg = {}; /** The package.json this process started from, if one existed. */ let startupPackageJsonPath; try { const packageJsonPath = getPackageJsonPath(); pkg = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))); startupPackageJsonPath = packageJsonPath; } catch (e) { const err = e; if (err.code !== "ENOENT") throw e; } const piConfigName = pkg.piConfig?.name; export const PACKAGE_NAME = pkg.name || "@earendil-works/pi-coding-agent"; export const APP_NAME = piConfigName || "pi"; export const APP_TITLE = piConfigName ? APP_NAME : "π"; export const CONFIG_DIR_NAME = pkg.piConfig?.configDir || ".pi"; export const VERSION = pkg.version || "0.0.0"; // e.g., PI_CODING_AGENT_DIR or TAU_CODING_AGENT_DIR export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`; export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`; export function expandTildePath(path) { return normalizePath(path); } const DEFAULT_SHARE_VIEWER_URL = "https://pi.dev/session/"; /** Get the share viewer URL for a gist ID. */ export function getShareViewerUrl(gistId) { const baseUrl = process.env.PI_SHARE_VIEWER_URL || DEFAULT_SHARE_VIEWER_URL; return `${baseUrl}#${gistId}`; } // ============================================================================= // User Config Paths (~/.pi/agent/*) // ============================================================================= /** Get the agent config directory (e.g., ~/.pi/agent/) */ export function getAgentDir() { const envDir = process.env[ENV_AGENT_DIR]; if (envDir) { return expandTildePath(envDir); } return join(homedir(), CONFIG_DIR_NAME, "agent"); } /** Get path to user's custom themes directory */ export function getCustomThemesDir() { return join(getAgentDir(), "themes"); } /** Get path to models.json */ export function getModelsPath() { return join(getAgentDir(), "models.json"); } /** Get path to auth.json */ export function getAuthPath() { return join(getAgentDir(), "auth.json"); } /** Get path to settings.json */ export function getSettingsPath() { return join(getAgentDir(), "settings.json"); } /** Get path to tools directory */ export function getToolsDir() { return join(getAgentDir(), "tools"); } /** Get path to managed binaries directory (fd, rg) */ export function getBinDir() { return join(getAgentDir(), "bin"); } /** Get path to prompt templates directory */ export function getPromptsDir() { return join(getAgentDir(), "prompts"); } /** Get path to sessions directory */ export function getSessionsDir() { return join(getAgentDir(), "sessions"); } /** Get path to debug log file */ export function getDebugLogPath() { return join(getAgentDir(), `${APP_NAME}-debug.log`); } //# sourceMappingURL=config.js.map