UNPKG

@earendil-works/pi-coding-agent

Version:

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

1,084 lines (1,071 loc) • 46.1 kB
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs"; import { join, resolve } from "node:path"; import { ModelsError } from "@earendil-works/pi-ai"; import { Markdown } from "@earendil-works/pi-tui"; import chalk from "chalk"; import lockfile from "proper-lockfile"; import { selectConfig } from "./cli/config-selector.js"; import { createProjectTrustContext } from "./cli/project-trust.js"; import { APP_NAME, CONFIG_DIR_NAME, detectInstallChange, detectInstallMethod, getAgentDir, getConfiguredSelfUpdateArgv, getPackageDir, getSelfUpdateCommand, getSelfUpdateUnavailableInstruction, makeConfiguredSelfUpdateCommand, PACKAGE_NAME, VERSION, } from "./config.js"; import { ModelRuntime } from "./core/model-runtime.js"; import { DefaultPackageManager, isOfflineModeEnabled } from "./core/package-manager.js"; import { resolveProjectTrusted } from "./core/project-trust.js"; import { DefaultResourceLoader, formatPackageInstallWarning, isBuiltinExtension } from "./core/resource-loader.js"; import { SettingsManager } from "./core/settings-manager.js"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.js"; import { spawnProcess, spawnProcessSync, waitForChildProcess } from "./utils/child-process.js"; import { canonicalizePath, getCwdRelativePath } from "./utils/paths.js"; import { getPiUserAgent } from "./utils/pi-user-agent.js"; import { stripBom } from "./utils/text.js"; import { formatVersionCheckError, getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.js"; import { cleanupWindowsSelfUpdateQuarantine, quarantineWindowsNativeDependencies, } from "./utils/windows-self-update.js"; const DEFAULT_INSTALLER_API_BASE = "https://pi.dev/api/installer/releases"; const MANAGED_INSTALL_MARKER = "managed-install.json"; const MANAGED_RELEASE_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; function getActiveManagedInstallRoot() { const configuredRoot = process.env.PI_MANAGED_INSTALL_ROOT?.trim(); if (!configuredRoot) return undefined; const managedRoot = resolve(configuredRoot); const releasesDir = canonicalizePath(join(managedRoot, "releases")); // The launcher environment is inherited by child processes. Do not classify a // source checkout or another Pi installation launched from managed Pi as managed. if (getCwdRelativePath(canonicalizePath(getPackageDir()), releasesDir) === undefined) return undefined; const markerPath = join(managedRoot, MANAGED_INSTALL_MARKER); try { const marker = JSON.parse(stripBom(readFileSync(markerPath, "utf8"))); if (marker.kind !== "pi-managed-install" || marker.schemaVersion !== 1 || marker.layout !== "releases-v1") { throw new Error(); } } catch { throw new Error(`Managed install marker is missing or invalid: ${markerPath}`); } return managedRoot; } async function fetchInstallerArtifact(url, label) { const response = await fetch(url, { headers: { "User-Agent": getPiUserAgent(VERSION) } }); if (!response.ok) { throw new Error(`Could not download managed installer ${label} from ${url}: HTTP ${response.status}`); } return await response.text(); } async function runManagedNpmCi(stageDir) { // No age-gate override: the release lockfile pins every package, so npm never // resolves versions and a configured min-release-age does not apply. const args = [ "ci", "--ignore-scripts", "--omit=dev", "--include=optional", "--no-fund", "--no-audit", "--loglevel=error", "--progress=false", ]; const code = await waitForChildProcess(spawnProcess("npm", args, { cwd: stageDir, stdio: "inherit" })); if (code !== 0) throw new Error(`npm ${args.join(" ")} exited with code ${code ?? "unknown"}`); } function verifyManagedRelease(releaseDir, expectedVersion) { const binPath = join(releaseDir, "node_modules", ".bin", process.platform === "win32" ? `${APP_NAME}.cmd` : APP_NAME); const result = spawnProcessSync(binPath, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); if (result.error || result.status !== 0) { const reason = result.error?.message || result.stderr.trim() || `exit code ${result.status ?? "unknown"}`; throw new Error(`Could not verify managed Pi ${expectedVersion}: ${reason}`); } const installedVersion = result.stdout.trim(); if (installedVersion !== expectedVersion) { throw new Error(`Managed Pi smoke test returned version ${installedVersion}; expected ${expectedVersion}.`); } } function activateManagedRelease(managedRoot, version) { const currentPath = join(managedRoot, "current-version"); const temporaryPath = join(managedRoot, `current-version.tmp.${process.pid}-${Date.now()}`); try { writeFileSync(temporaryPath, `${version}\n`); renameSync(temporaryPath, currentPath); } finally { rmSync(temporaryPath, { force: true }); } } // Keep the active release and the one running this update, which other open // sessions likely still use and which allows rolling back by editing current-version. function pruneManagedReleases(managedRoot, activeVersion) { const releasesRoot = join(managedRoot, "releases"); let entries; try { entries = readdirSync(releasesRoot); } catch { return; } for (const entry of entries) { if (entry === activeVersion || entry === VERSION || !MANAGED_RELEASE_VERSION_RE.test(entry)) continue; try { rmSync(join(releasesRoot, entry), { force: true, recursive: true }); } catch { // Files may be in use (e.g. loaded native modules on Windows); retry on the next update. } } } function cleanupManagedStaging(managedRoot) { const stagingRoot = join(managedRoot, "staging"); try { for (const entry of readdirSync(stagingRoot)) { if (entry.startsWith("update-")) { rmSync(join(stagingRoot, entry), { force: true, recursive: true }); } } } catch { // The staging directory does not exist yet or is not writable. } } export function cleanupManagedInstall() { let managedRoot; try { managedRoot = getActiveManagedInstallRoot(); } catch { return; } if (!managedRoot) return; try { const releaseLock = lockfile.lockSync(join(managedRoot, "update"), { realpath: false }); try { cleanupManagedStaging(managedRoot); } finally { releaseLock(); } } catch { // A live update owns the staging directory, or cleanup is unavailable. } } async function runManagedSelfUpdate(managedRoot, version) { if (!MANAGED_RELEASE_VERSION_RE.test(version)) { throw new Error(`Invalid managed release version: ${version}`); } let releaseLock; try { releaseLock = await lockfile.lock(join(managedRoot, "update"), { realpath: false }); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ELOCKED") { throw new Error("Another managed Pi update is already running."); } throw error; } let stageDir; try { cleanupManagedStaging(managedRoot); const installerApiBase = (process.env.PI_INSTALLER_API_BASE?.trim() || DEFAULT_INSTALLER_API_BASE).replace(/\/+$/, ""); const releaseUrl = `${installerApiBase}/${encodeURIComponent(version)}`; const stagingRoot = join(managedRoot, "staging"); const releasesRoot = join(managedRoot, "releases"); mkdirSync(releasesRoot, { recursive: true }); const releaseDir = join(releasesRoot, version); if (existsSync(releaseDir)) { verifyManagedRelease(releaseDir, version); activateManagedRelease(managedRoot, version); pruneManagedReleases(managedRoot, version); return; } mkdirSync(stagingRoot, { recursive: true }); stageDir = mkdtempSync(join(stagingRoot, "update-")); const [packageJsonContent, packageLockContent] = await Promise.all([ fetchInstallerArtifact(`${releaseUrl}/package.json`, "package.json"), fetchInstallerArtifact(`${releaseUrl}/package-lock.json`, "package-lock.json"), ]); writeFileSync(join(stageDir, "package.json"), packageJsonContent); writeFileSync(join(stageDir, "package-lock.json"), packageLockContent); await runManagedNpmCi(stageDir); verifyManagedRelease(stageDir, version); renameSync(stageDir, releaseDir); activateManagedRelease(managedRoot, version); pruneManagedReleases(managedRoot, version); } finally { if (stageDir) rmSync(stageDir, { force: true, recursive: true }); await releaseLock(); } } const SELF_UPDATE_NOTE_MARKDOWN_THEME = { heading: (text) => chalk.bold(chalk.yellow(text)), link: (text) => chalk.cyan(text), linkUrl: (text) => chalk.dim(text), code: (text) => chalk.yellow(text), codeBlock: (text) => chalk.dim(text), codeBlockBorder: (text) => chalk.dim(text), quote: (text) => chalk.dim(text), quoteBorder: (text) => chalk.dim(text), hr: (text) => chalk.dim(text), listBullet: (text) => chalk.yellow(text), bold: (text) => chalk.bold(text), italic: (text) => chalk.italic(text), strikethrough: (text) => chalk.strikethrough(text), underline: (text) => chalk.underline(text), }; function reportSettingsErrors(settingsManager, context) { const errors = settingsManager.drainErrors(); for (const { scope, error } of errors) { console.error(chalk.yellow(`Warning (${context}, ${scope} settings): ${error.message}`)); if (error.stack) { console.error(chalk.dim(error.stack)); } } } function getPackageCommandUsage(command) { switch (command) { case "install": return `${APP_NAME} install <source> [-l] [--approve|--no-approve]`; case "remove": return `${APP_NAME} remove <source> [-l] [--approve|--no-approve]`; case "update": return `${APP_NAME} update [source|self|pi] [--self|--extensions|--models|--all] [--extension <source>] [--approve|--no-approve] [--force]`; case "list": return `${APP_NAME} list [--approve|--no-approve]`; } } const CONFIG_COMMAND_USAGE = `${APP_NAME} config [-l] [--approve|--no-approve]`; function printConfigCommandHelp() { console.log(`${chalk.bold("Usage:")} ${CONFIG_COMMAND_USAGE} Open the resource configuration TUI to enable or disable package resources. Without -l, starts in global settings (~/${CONFIG_DIR_NAME}/agent/settings.json). Press Tab in the TUI to switch between global and project-local modes. Options: -l, --local Edit project overrides (${CONFIG_DIR_NAME}/settings.json) -a, --approve Trust project-local files for this command with -l -na, --no-approve Ignore project-local files for this command with -l `); } function printPackageCommandHelp(command) { switch (command) { case "install": console.log(`${chalk.bold("Usage:")} ${getPackageCommandUsage("install")} Install a package and add it to settings. Options: -l, --local Install project-locally (${CONFIG_DIR_NAME}/settings.json) -a, --approve Trust project-local files for this command -na, --no-approve Ignore project-local files for this command Examples: ${APP_NAME} install npm:@foo/bar ${APP_NAME} install git:github.com/user/repo ${APP_NAME} install git:git@github.com:user/repo ${APP_NAME} install https://github.com/user/repo ${APP_NAME} install ssh://git@github.com/user/repo ${APP_NAME} install ./local/path `); return; case "remove": console.log(`${chalk.bold("Usage:")} ${getPackageCommandUsage("remove")} Remove a package and its source from settings. Alias: ${APP_NAME} uninstall <source> [-l] Options: -l, --local Remove from project settings (${CONFIG_DIR_NAME}/settings.json) -a, --approve Trust project-local files for this command -na, --no-approve Ignore project-local files for this command Examples: ${APP_NAME} remove npm:@foo/bar ${APP_NAME} uninstall npm:@foo/bar `); return; case "update": console.log(`${chalk.bold("Usage:")} ${getPackageCommandUsage("update")} Update pi, installed packages, or model catalogs. Options: --self Update pi only (default when no target is given) --extensions Update installed packages only --models Refresh model catalogs only --all Update pi and installed packages --extension <source> Update one package only -a, --approve Trust project-local files for this command -na, --no-approve Ignore project-local files for this command --force Reinstall pi even if the current version is latest Short forms: ${APP_NAME} update Update pi only ${APP_NAME} update --all Update pi and all extensions ${APP_NAME} update --models Refresh model catalogs only ${APP_NAME} update <source> Update one package ${APP_NAME} update pi Update pi only (self works as alias to pi) `); return; case "list": console.log(`${chalk.bold("Usage:")} ${getPackageCommandUsage("list")} List installed packages from user and project settings. Options: -a, --approve Trust project-local files for this command -na, --no-approve Ignore project-local files for this command `); return; } } function parsePackageCommand(args) { const [rawCommand, ...rest] = args; let command; if (rawCommand === "uninstall") { command = "remove"; } else if (rawCommand === "install" || rawCommand === "remove" || rawCommand === "update" || rawCommand === "list") { command = rawCommand; } if (!command) { return undefined; } let local = false; let force = false; let projectTrustOverride; let help = false; let invalidOption; let invalidArgument; let missingOptionValue; let conflictingOptions; let source; let selfFlag = false; let extensionsFlag = false; let modelsFlag = false; let allFlag = false; let extensionFlagSource; for (let index = 0; index < rest.length; index++) { const arg = rest[index]; if (arg === "-h" || arg === "--help") { help = true; continue; } if (arg === "-l" || arg === "--local") { if (command === "install" || command === "remove") { local = true; } else { invalidOption = invalidOption ?? arg; } continue; } if (arg === "--self") { if (command === "update") { selfFlag = true; } else { invalidOption = invalidOption ?? arg; } continue; } if (arg === "--extensions") { if (command === "update") { extensionsFlag = true; } else { invalidOption = invalidOption ?? arg; } continue; } if (arg === "--models") { if (command === "update") { modelsFlag = true; } else { invalidOption = invalidOption ?? arg; } continue; } if (arg === "--all") { if (command === "update") { allFlag = true; } else { invalidOption = invalidOption ?? arg; } continue; } if (arg === "--approve" || arg === "-a") { projectTrustOverride = true; continue; } if (arg === "--no-approve" || arg === "-na") { projectTrustOverride = false; continue; } if (arg === "--force") { if (command === "update") { force = true; } else { invalidOption = invalidOption ?? arg; } continue; } if (arg === "--extension") { if (command !== "update") { invalidOption = invalidOption ?? arg; continue; } const value = rest[index + 1]; if (!value || value.startsWith("-")) { missingOptionValue = missingOptionValue ?? arg; } else if (extensionFlagSource) { conflictingOptions = conflictingOptions ?? "--extension can only be provided once"; index++; } else { extensionFlagSource = value; index++; } continue; } if (arg.startsWith("-")) { invalidOption = invalidOption ?? arg; continue; } if (!source) { source = arg; } else { invalidArgument = invalidArgument ?? arg; } } let updateTarget; let showExtensionsSkippedNote = false; if (command === "update") { if (allFlag && (selfFlag || extensionsFlag || modelsFlag || extensionFlagSource)) { conflictingOptions = conflictingOptions ?? "--all cannot be combined with --self, --extensions, --models, or --extension"; } if (allFlag && source) { conflictingOptions = conflictingOptions ?? "--all cannot be combined with a positional source"; } if (modelsFlag) { if (selfFlag || extensionsFlag || allFlag || extensionFlagSource) { conflictingOptions = conflictingOptions ?? "--models cannot be combined with --self, --extensions, --all, or --extension"; } if (source) { conflictingOptions = conflictingOptions ?? "--models cannot be combined with a positional source"; } updateTarget = { type: "models" }; } else if (extensionFlagSource) { if (selfFlag || extensionsFlag || allFlag) { conflictingOptions = conflictingOptions ?? "--extension cannot be combined with --self, --extensions, or --all"; } if (source) { conflictingOptions = conflictingOptions ?? "--extension cannot be combined with a positional source"; } updateTarget = { type: "extensions", source: extensionFlagSource }; } else if (source) { const sourceIsSelf = source === "self" || source === "pi"; if (sourceIsSelf) { updateTarget = extensionsFlag ? { type: "all" } : { type: "self" }; } else { if (extensionsFlag || selfFlag || allFlag) { conflictingOptions = conflictingOptions ?? "positional update targets cannot be combined with --self, --extensions, or --all"; } updateTarget = { type: "extensions", source }; } } else if (allFlag) { updateTarget = { type: "all" }; } else if (selfFlag && extensionsFlag) { updateTarget = { type: "all" }; } else if (selfFlag) { updateTarget = { type: "self" }; } else if (extensionsFlag) { updateTarget = { type: "extensions" }; } else { updateTarget = { type: "self" }; showExtensionsSkippedNote = true; } } return { command, source, updateTarget, showExtensionsSkippedNote, local, force, projectTrustOverride, help, invalidOption, invalidArgument, missingOptionValue, conflictingOptions, }; } function updateTargetIncludesSelf(target) { return target.type === "all" || target.type === "self"; } function updateTargetIncludesExtensions(target) { return target.type === "all" || target.type === "extensions"; } async function refreshModelCatalogs(agentDir) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15_000); try { const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json"), allowModelNetwork: false, signal: controller.signal, }); const result = await modelRuntime.refresh({ allowNetwork: true, force: true, signal: controller.signal, }); if (result.aborted) { throw new Error("Model catalog refresh timed out."); } const failures = []; for (const [provider, error] of result.errors) { // A stale login only affects that provider's catalog; the others are still worth refreshing. if (error instanceof ModelsError && error.code === "oauth") { console.warn(chalk.yellow(`Warning: skipped model catalog for ${provider}: ${error.message}\nLog in again with \`${APP_NAME} auth login ${provider}\` to refresh it.`)); } else { failures.push(`${provider}: ${error.message}`); } } if (failures.length > 0) { throw new Error(`Could not refresh model catalogs: ${failures.join("; ")}`); } } finally { clearTimeout(timeout); } console.log(chalk.green("Model catalogs refreshed")); } function printSelfUpdateUnavailable(npmCommand, updatePackageTarget = PACKAGE_NAME) { console.error(`error: ${APP_NAME} cannot self-update this installation.`); console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME, npmCommand, updatePackageTarget)); console.error(`To make \`${APP_NAME} update\` run your own update command, set PI_SELF_UPDATE_COMMAND or the selfUpdateCommand setting.`); const entrypoint = process.argv[1]; if (entrypoint) { console.error(""); console.error(`Location of ${APP_NAME} executable: ${entrypoint}`); } } function printSelfUpdateFallback(command) { console.error(chalk.dim(`If this keeps failing, run this command yourself: ${command.display}`)); } function printPnpmSelfUpdateMetadataHint() { console.error(chalk.yellow("If pnpm reports missing package versions, its cached registry metadata may be stale.")); console.error(chalk.yellow(`Run \`pnpm store prune\` and retry \`${APP_NAME} update --self\`.`)); } function printSelfUpdateNote(note) { const trimmedNote = note.trim(); if (!trimmedNote) { return; } console.log(); console.log(chalk.bold(chalk.yellow("Update note"))); try { const width = Math.max(20, process.stdout.columns ?? 80); const renderedLines = new Markdown(trimmedNote, 0, 0, SELF_UPDATE_NOTE_MARKDOWN_THEME) .render(width) .map((line) => line.trimEnd()); console.log(renderedLines.join("\n")); } catch { console.log(trimmedNote); } console.log(); } async function getSelfUpdatePlan(force) { let latestRelease; try { latestRelease = await getLatestPiRelease(VERSION, { retry: true }); } catch (error) { throw new Error(`Could not determine latest ${APP_NAME} version: ${formatVersionCheckError(error)}`, { cause: error, }); } if (!latestRelease) { throw new Error(`Could not determine latest ${APP_NAME} version.`); } const packageName = latestRelease.packageName ?? PACKAGE_NAME; const installSpec = `${packageName}@${latestRelease.version}`; if (force || packageName !== PACKAGE_NAME || isNewerPackageVersion(latestRelease.version, VERSION)) { return { packageName, installSpec, version: latestRelease.version, ...(latestRelease.note ? { note: latestRelease.note } : {}), shouldRun: true, }; } console.log(chalk.green(`${APP_NAME} is already up to date (v${VERSION})`)); return { packageName, installSpec, version: latestRelease.version, shouldRun: false }; } async function runSelfUpdate(command) { console.log(chalk.dim(`Updating ${APP_NAME} with ${command.display}...`)); for (const step of command.steps ?? [command]) { await new Promise((resolve, reject) => { const child = spawnProcess(step.command, step.args, { stdio: "inherit", }); child.on("error", (error) => { reject(error); }); child.on("close", (code, signal) => { if (code === 0) { resolve(); } else if (signal) { reject(new Error(`${step.display} terminated by signal ${signal}`)); } else { reject(new Error(`${step.display} exited with code ${code ?? "unknown"}`)); } }); }); } } /** Run the user-configured self-update command, then report the version installed on disk. */ async function runConfiguredSelfUpdate(argv, plan) { const command = makeConfiguredSelfUpdateCommand(argv, plan); if (plan.note) { printSelfUpdateNote(plan.note); } try { await runSelfUpdate(command); } catch (error) { const message = error instanceof Error ? error.message : "Unknown self-update command error"; console.error(chalk.red(`Error: ${message}`)); process.exitCode = 1; return; } // Exit code 0 does not prove an update. Wrappers such as mise or Nix install each version into a // new directory and leave this installation untouched, so fall back to the pi on PATH. const change = detectInstallChange(); const installedVersion = change?.kind === "updated" && change.version === plan.version ? change.version : readVersionOnPath(); if (installedVersion === plan.version) { console.log(chalk.green(`Updated ${APP_NAME} from ${VERSION} to ${plan.version}`)); return; } console.log(chalk.yellow(`Ran ${command.display}, but \`${APP_NAME} --version\` reports ${installedVersion ?? "no version"}, not ${plan.version}.`)); } /** Version printed by `pi --version` as resolved from PATH, the pi the user runs next. */ function readVersionOnPath() { // PI_PACKAGE_DIR points at this installation and would make the new pi report the old version. const { PI_PACKAGE_DIR: _, ...env } = process.env; const result = spawnProcessSync(APP_NAME, ["--version"], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env, }); return result.status === 0 ? result.stdout.trim() || undefined : undefined; } function prepareWindowsNpmSelfUpdate() { if (process.platform !== "win32") { return; } const packageDir = getPackageDir(); cleanupWindowsSelfUpdateQuarantine(packageDir); quarantineWindowsNativeDependencies(packageDir); } function getCommandAppMode() { return process.stdin.isTTY && process.stdout.isTTY ? "interactive" : "print"; } function reportProjectTrustWarnings(warnings) { for (const warning of warnings) { console.error(chalk.yellow(`Warning: ${warning}`)); } } async function createCommandSettingsManager(options) { const settingsManager = SettingsManager.create(options.cwd, options.agentDir, { projectTrusted: false }); const projectTrustWarnings = []; const trustStore = new ProjectTrustStore(options.agentDir); if (options.useSavedProjectTrustOnly) { const savedProjectTrusted = trustStore.get(options.cwd) === true; settingsManager.setProjectTrusted(options.projectTrustOverride ?? savedProjectTrusted); return { settingsManager, projectTrustWarnings }; } const appMode = getCommandAppMode(); const extensionsResult = options.projectTrustOverride === undefined && hasTrustRequiringProjectResources(options.cwd) ? await new DefaultResourceLoader({ cwd: options.cwd, agentDir: options.agentDir, settingsManager, extensionFactories: options.extensionFactories, }).loadProjectTrustExtensions() : undefined; for (const error of extensionsResult?.errors ?? []) { projectTrustWarnings.push(`Failed to load extension "${error.path}": ${error.error}`); } const projectTrusted = await resolveProjectTrusted({ cwd: options.cwd, trustStore, trustOverride: options.projectTrustOverride, defaultProjectTrust: settingsManager.getDefaultProjectTrust(), extensionsResult, projectTrustContext: createProjectTrustContext({ cwd: options.cwd, mode: appMode, settingsManager, hasUI: appMode === "interactive", }), onExtensionError: (message) => projectTrustWarnings.push(message), }); settingsManager.setProjectTrusted(projectTrusted); return { settingsManager, projectTrustWarnings }; } export async function handleConfigCommand(args, runtimeOptions = {}) { const [command, ...rest] = args; if (command !== "config") { return false; } if (rest.includes("-h") || rest.includes("--help")) { printConfigCommandHelp(); return true; } let local = false; let projectTrustOverride; for (const arg of rest) { if (arg === "-l" || arg === "--local") { local = true; } else if (arg === "-a" || arg === "--approve") { projectTrustOverride = true; } else if (arg === "-na" || arg === "--no-approve") { projectTrustOverride = false; } else if (arg.startsWith("-")) { console.error(chalk.red(`Unknown option ${arg} for "config".`)); console.error(chalk.dim(`Use "${APP_NAME} --help" or "${CONFIG_COMMAND_USAGE}".`)); process.exitCode = 1; return true; } else { console.error(chalk.red(`Unexpected argument ${arg}.`)); console.error(chalk.dim(`Usage: ${CONFIG_COMMAND_USAGE}`)); process.exitCode = 1; return true; } } const cwd = process.cwd(); const agentDir = getAgentDir(); const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({ cwd, agentDir, projectTrustOverride, extensionFactories: runtimeOptions.extensionFactories, }); reportProjectTrustWarnings(projectTrustWarnings); if (local && !settingsManager.isProjectTrusted()) { console.error(chalk.red("Project is not trusted. Use --approve to modify local resource config.")); process.exitCode = 1; return true; } reportSettingsErrors(settingsManager, "config command"); const builtinExtensions = (runtimeOptions.extensionFactories ?? []) .filter(isBuiltinExtension) .map((input) => input.name); const globalSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false }); const globalResolvedPaths = await new DefaultPackageManager({ cwd, agentDir, settingsManager: globalSettingsManager, builtinExtensions, }).resolve(); const projectResolvedPaths = settingsManager.isProjectTrusted() ? await new DefaultPackageManager({ cwd, agentDir, settingsManager, builtinExtensions }).resolve() : globalResolvedPaths; const installWarnings = new Set([...(globalResolvedPaths.installFailures ?? []), ...(projectResolvedPaths.installFailures ?? [])].map(formatPackageInstallWarning)); for (const warning of installWarnings) { console.error(chalk.yellow(`Warning: ${warning}`)); } await selectConfig({ resolvedPaths: { global: globalResolvedPaths, project: projectResolvedPaths }, settingsManager, cwd, agentDir, writeScope: local ? "project" : "global", projectModeAvailable: settingsManager.isProjectTrusted(), }); process.exit(0); } export async function handlePackageCommand(args, runtimeOptions = {}) { const options = parsePackageCommand(args); if (!options) { return false; } if (options.help) { printPackageCommandHelp(options.command); return true; } if (options.invalidOption) { console.error(chalk.red(`Unknown option ${options.invalidOption} for "${options.command}".`)); console.error(chalk.dim(`Use "${APP_NAME} --help" or "${getPackageCommandUsage(options.command)}".`)); process.exitCode = 1; return true; } if (options.missingOptionValue) { console.error(chalk.red(`Missing value for ${options.missingOptionValue}.`)); console.error(chalk.dim(`Usage: ${getPackageCommandUsage(options.command)}`)); process.exitCode = 1; return true; } if (options.invalidArgument) { console.error(chalk.red(`Unexpected argument ${options.invalidArgument}.`)); console.error(chalk.dim(`Usage: ${getPackageCommandUsage(options.command)}`)); process.exitCode = 1; return true; } if (options.conflictingOptions) { console.error(chalk.red(options.conflictingOptions)); console.error(chalk.dim(`Usage: ${getPackageCommandUsage(options.command)}`)); process.exitCode = 1; return true; } const source = options.source; if ((options.command === "install" || options.command === "remove") && !source) { console.error(chalk.red(`Missing ${options.command} source.`)); console.error(chalk.dim(`Usage: ${getPackageCommandUsage(options.command)}`)); process.exitCode = 1; return true; } if (options.command === "update" && options.updateTarget?.type === "models") { try { await refreshModelCatalogs(getAgentDir()); } catch (error) { const message = error instanceof Error ? error.message : "Unknown model catalog refresh error"; console.error(chalk.red(`Error: ${message}`)); process.exitCode = 1; } return true; } const cwd = process.cwd(); const agentDir = getAgentDir(); const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local; const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({ cwd, agentDir, projectTrustOverride: options.projectTrustOverride, useSavedProjectTrustOnly: options.command === "update", extensionFactories: runtimeOptions.extensionFactories, }); reportProjectTrustWarnings(projectTrustWarnings); if (!settingsManager.isProjectTrusted() && writesProjectPackageConfig) { console.error(chalk.red("Project is not trusted. Use --approve to modify local package config.")); process.exitCode = 1; return true; } reportSettingsErrors(settingsManager, "package command"); const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand; const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); // Update result events describe a changed package, e.g. "npm:foo 1.2.0 -> 1.3.0". const updatedPackages = []; // Progress messages are warnings, e.g. npm reinstalled the old version from stale registry metadata. let warningCount = 0; packageManager.setProgressCallback((event) => { if (event.type === "result" && event.action === "update" && event.message) { updatedPackages.push(event.message); } else if (event.type === "start") { process.stdout.write(chalk.dim(`${event.message}\n`)); } else if (event.type === "progress" && event.message) { warningCount++; process.stdout.write(chalk.yellow(`${event.message}\n`)); } }); try { switch (options.command) { case "install": await packageManager.installAndPersist(source, { local: options.local }); console.log(chalk.green(`Installed ${source}`)); return true; case "remove": { const removed = await packageManager.removeAndPersist(source, { local: options.local }); if (!removed) { console.error(chalk.red(`No matching package found for ${source}`)); process.exitCode = 1; return true; } console.log(chalk.green(`Removed ${source}`)); return true; } case "list": { const configuredPackages = packageManager.listConfiguredPackages(); const userPackages = configuredPackages.filter((pkg) => pkg.scope === "user"); const projectPackages = configuredPackages.filter((pkg) => pkg.scope === "project"); if (configuredPackages.length === 0) { console.log(chalk.dim("No packages installed.")); return true; } const formatPackage = (pkg) => { const display = pkg.filtered ? `${pkg.source} (filtered)` : pkg.source; console.log(` ${display}`); if (pkg.installedPath) { console.log(chalk.dim(` ${pkg.installedPath}`)); } }; if (userPackages.length > 0) { console.log(chalk.bold("User packages:")); for (const pkg of userPackages) { formatPackage(pkg); } } if (projectPackages.length > 0) { if (userPackages.length > 0) console.log(); console.log(chalk.bold("Project packages:")); for (const pkg of projectPackages) { formatPackage(pkg); } } return true; } case "update": { const target = options.updateTarget ?? { type: "self" }; if (options.showExtensionsSkippedNote) { console.log(chalk.dim(`Extensions are skipped. Run ${APP_NAME} update --extensions to update extensions.`)); } if (updateTargetIncludesExtensions(target)) { const updateSource = target.type === "extensions" ? target.source : undefined; await packageManager.update(updateSource); // Only claim "up to date" when the update actually checked and nothing warned. if (isOfflineModeEnabled()) { console.log(chalk.yellow("Skipped package updates (offline mode)")); } else if (updatedPackages.length === 0 && warningCount > 0) { console.log(chalk.yellow(updateSource ? `${updateSource} was not updated, see the warnings above` : "No packages were updated, see the warnings above")); } else if (updatedPackages.length === 0) { console.log(chalk.green(updateSource ? `${updateSource} is already up to date` : "Packages are already up to date")); } else { const count = updatedPackages.length; console.log(chalk.green(`Updated ${count} package${count === 1 ? "" : "s"}:`)); for (const message of updatedPackages) { console.log(` ${message}`); } } } if (updateTargetIncludesSelf(target)) { const configuredSelfUpdateArgv = getConfiguredSelfUpdateArgv(settingsManager.getGlobalSettings().selfUpdateCommand); // A configured command is explicit user intent, so it replaces the managed install path too. const managedInstallRoot = configuredSelfUpdateArgv ? undefined : getActiveManagedInstallRoot(); if (managedInstallRoot && options.force) { console.error(chalk.red(`Managed ${APP_NAME} installations do not support --force; rerun the installer to repair this installation.`)); process.exitCode = 1; return true; } const selfUpdatePlan = await getSelfUpdatePlan(options.force); if (!selfUpdatePlan.shouldRun) { return true; } if (configuredSelfUpdateArgv) { await runConfiguredSelfUpdate(configuredSelfUpdateArgv, selfUpdatePlan); return true; } if (managedInstallRoot) { if (selfUpdatePlan.note) { printSelfUpdateNote(selfUpdatePlan.note); } try { console.log(chalk.dim(`Updating managed ${APP_NAME} installation...`)); await runManagedSelfUpdate(managedInstallRoot, selfUpdatePlan.version); } catch (error) { const message = error instanceof Error ? error.message : "Unknown managed update error"; console.error(chalk.red(`Error: ${message}`)); process.exitCode = 1; return true; } console.log(chalk.green(`Updated ${APP_NAME} from ${VERSION} to ${selfUpdatePlan.version}`)); return true; } const installMethod = detectInstallMethod(); if (process.platform === "win32" && installMethod !== "npm" && installMethod !== "pnpm") { console.error(chalk.red(`${APP_NAME} self-update on Windows is only supported for npm and pnpm installs.`)); console.error(chalk.dim(`Detected install method: ${installMethod}. Update ${APP_NAME} manually.`)); process.exitCode = 1; return true; } const selfUpdateTarget = { packageName: selfUpdatePlan.packageName, installSpec: selfUpdatePlan.installSpec, }; const selfUpdateCommand = getSelfUpdateCommand(PACKAGE_NAME, selfUpdateNpmCommand, selfUpdateTarget); if (!selfUpdateCommand) { printSelfUpdateUnavailable(selfUpdateNpmCommand, selfUpdateTarget); process.exitCode = 1; return true; } if (selfUpdatePlan.note) { printSelfUpdateNote(selfUpdatePlan.note); } try { if (installMethod === "npm") { prepareWindowsNpmSelfUpdate(); } await runSelfUpdate(selfUpdateCommand); } catch (error) { const message = error instanceof Error ? error.message : "Unknown package command error"; console.error(chalk.red(`Error: ${message}`)); if (installMethod === "pnpm") { printPnpmSelfUpdateMetadataHint(); } printSelfUpdateFallback(selfUpdateCommand); process.exitCode = 1; return true; } console.log(chalk.green(`Updated ${APP_NAME} from ${VERSION} to ${selfUpdatePlan.version}`)); // The pi.dev installer migrates global npm installs to a managed install // that pins all dependencies. It does not migrate pnpm, yarn, or bun installs. if (installMethod === "npm") { const installerCommand = process.platform === "win32" ? 'powershell -c "irm https://pi.dev/install.ps1 | iex"' : "curl -fsSL https://pi.dev/install.sh | sh"; console.log(); console.log(chalk.yellow(`This npm installation of ${APP_NAME} does not pin its dependencies.`)); console.log(chalk.yellow("Run the installer to migrate to a managed installation that does:")); console.log(); console.log(` ${chalk.bold(installerCommand)}`); } } return true; } } } catch (error) { const message = error instanceof Error ? error.message : "Unknown package command error"; console.error(chalk.red(`Error: ${message}`)); process.exitCode = 1; return true; } } //# sourceMappingURL=package-manager-cli.js.map