diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 2c3495ba..1f00df53 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -381,7 +381,10 @@ pi install ssh://git@github.com/user/repo@v1 # tag or commit pi remove npm:@foo/pi-tools pi uninstall npm:@foo/pi-tools # alias for remove pi list -pi update # skips pinned packages +pi update # update pi and packages (skips pinned packages) +pi update --extensions # update packages only +pi update --self # update pi only +pi update npm:@foo/pi-tools # update one package pi config # enable/disable extensions, skills, prompts, themes ``` @@ -476,7 +479,10 @@ pi [options] [@files...] [messages...] pi install [-l] # Install package, -l for project-local pi remove [-l] # Remove package pi uninstall [-l] # Alias for remove -pi update [source] # Update packages (skips pinned) +pi update [source|self|pi] # Update pi and packages (skips pinned packages) +pi update --extensions # Update packages only +pi update --self # Update pi only +pi update --extension # Update one package pi list # List installed packages pi config # Enable/disable package resources ``` diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 23413c18..6370dab1 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -27,8 +27,12 @@ pi install /absolute/path/to/package pi install ./relative/path/to/package pi remove npm:@foo/bar -pi list # show installed packages from settings -pi update # update all non-pinned packages +pi list # show installed packages from settings +pi update # update pi and all non-pinned packages +pi update --extensions # update all non-pinned packages only +pi update --self # update pi only +pi update npm:@foo/bar # update one package +pi update --extension npm:@foo/bar ``` By default, `install` and `remove` write to global settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup. @@ -51,7 +55,7 @@ npm:@scope/pkg@1.2.3 npm:pkg ``` -- Versioned specs are pinned and skipped by `pi update`. +- Versioned specs are pinned and skipped by package updates (`pi update`, `pi update --extensions`). - Global installs use `npm install -g`. - Project installs go under `.pi/npm/`. - Set `npmCommand` in `settings.json` to pin npm package lookup and install operations to a specific wrapper command such as `mise` or `asdf`. @@ -78,7 +82,7 @@ ssh://git@github.com/user/repo@v1 - HTTPS and SSH URLs are both supported. - SSH URLs use your configured SSH keys automatically (respects `~/.ssh/config`). - For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0` to disable credential prompts and set `GIT_SSH_COMMAND` (for example `ssh -o BatchMode=yes -o ConnectTimeout=5`) to fail fast. -- Refs pin the package and skip `pi update`. +- Refs pin the package and skip package updates (`pi update`, `pi update --extensions`). - Cloned to `~/.pi/agent/git//` (global) or `.pi/git//` (project). - Runs `npm install` after clone or pull if `package.json` exists. diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index a4233343..b1c84d8c 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -203,7 +203,7 @@ ${chalk.bold("Commands:")} ${APP_NAME} install [-l] Install extension source and add to settings ${APP_NAME} remove [-l] Remove extension source from settings ${APP_NAME} uninstall [-l] Alias for remove - ${APP_NAME} update [source] Update installed extensions (skips pinned sources) + ${APP_NAME} update [source|self|pi] Update pi and installed extensions ${APP_NAME} list List installed extensions from settings ${APP_NAME} config Open TUI to enable/disable package resources ${APP_NAME} --help Show help for install/remove/uninstall/update/list diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 6f13b499..436e3930 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -1,6 +1,7 @@ +import { spawnSync } from "child_process"; import { existsSync, readFileSync } from "fs"; import { homedir } from "os"; -import { dirname, join, resolve } from "path"; +import { dirname, join, resolve, sep } from "path"; import { fileURLToPath } from "url"; // ============================================================================= @@ -26,6 +27,12 @@ export const isBunRuntime = !!process.versions.bun; export type InstallMethod = "bun-binary" | "npm" | "pnpm" | "yarn" | "bun" | "unknown"; +export interface SelfUpdateCommand { + command: string; + args: string[]; + display: string; +} + export function detectInstallMethod(): InstallMethod { if (isBunBinary) { return "bun-binary"; @@ -49,24 +56,125 @@ export function detectInstallMethod(): InstallMethod { return "unknown"; } -export function getUpdateInstruction(packageName: string): string { - const method = detectInstallMethod(); +function getSelfUpdateCommandForMethod(method: InstallMethod, packageName: string): SelfUpdateCommand | undefined { switch (method) { case "bun-binary": - return `Download from: https://github.com/badlogic/pi-mono/releases/latest`; + return undefined; case "pnpm": - return `Run: pnpm install -g ${packageName}`; + return { + command: "pnpm", + args: ["install", "-g", packageName], + display: `pnpm install -g ${packageName}`, + }; case "yarn": - return `Run: yarn global add ${packageName}`; + return { + command: "yarn", + args: ["global", "add", packageName], + display: `yarn global add ${packageName}`, + }; case "bun": - return `Run: bun install -g ${packageName}`; + return { + command: "bun", + args: ["install", "-g", packageName], + display: `bun install -g ${packageName}`, + }; case "npm": - return `Run: npm install -g ${packageName}`; - default: - return `Run: npm install -g ${packageName}`; + return { + command: "npm", + args: ["install", "-g", packageName], + display: `npm install -g ${packageName}`, + }; + case "unknown": + return undefined; } } +function readCommandOutput(command: string, args: string[]): string | undefined { + const result = spawnSync(command, args, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 2000, + }); + if (result.status !== 0) return undefined; + const stdout = result.stdout.trim(); + return stdout || undefined; +} + +function getGlobalPackageRoots(method: InstallMethod): string[] { + switch (method) { + case "npm": { + const root = readCommandOutput("npm", ["root", "-g"]); + return root ? [root] : []; + } + case "pnpm": { + const root = readCommandOutput("pnpm", ["root", "-g"]); + return root ? [root, dirname(root)] : []; + } + 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(dirname(bunBin)), "install", "global", "node_modules")); + } + return roots; + } + case "bun-binary": + case "unknown": + return []; + } +} + +function isManagedByGlobalPackageManager(method: InstallMethod): boolean { + let packageDir = resolve(getPackageDir()); + if (process.platform === "win32") { + packageDir = packageDir.toLowerCase(); + } + return getGlobalPackageRoots(method).some((root) => { + let normalizedRoot = resolve(root); + if (process.platform === "win32") { + normalizedRoot = normalizedRoot.toLowerCase(); + } + return ( + existsSync(normalizedRoot) && + (packageDir === normalizedRoot || + packageDir.startsWith(normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`)) + ); + }); +} + +export function getSelfUpdateCommand(packageName: string): SelfUpdateCommand | undefined { + const method = detectInstallMethod(); + const command = getSelfUpdateCommandForMethod(method, packageName); + if (!command || !isManagedByGlobalPackageManager(method)) { + return undefined; + } + return command; +} + +export function getSelfUpdateUnavailableInstruction(packageName: string): string { + const method = detectInstallMethod(); + if (method === "bun-binary") { + return `Download from: https://github.com/badlogic/pi-mono/releases/latest`; + } + if (getSelfUpdateCommandForMethod(method, packageName)) { + return `This installation is not managed by a global ${method} install. Update it with the package manager, wrapper, or source checkout that provides it.`; + } + return `Update ${packageName} using the package manager, wrapper, or source checkout that provides this installation.`; +} + +export function getUpdateInstruction(packageName: string): string { + const method = detectInstallMethod(); + const command = getSelfUpdateCommandForMethod(method, packageName); + if (command) { + return `Run: ${command.display}`; + } + return getSelfUpdateUnavailableInstruction(packageName); +} + // ============================================================================= // Package Asset Paths (shipped with executable) // ============================================================================= @@ -182,13 +290,23 @@ export function getBundledInteractiveAssetPath(name: string): string { // App Config (from package.json piConfig) // ============================================================================= -const pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")); +interface PackageJson { + name?: string; + version?: string; + piConfig?: { + name?: string; + configDir?: string; + }; +} + +const pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson; const piConfigName: string | undefined = pkg.piConfig?.name; +export const PACKAGE_NAME: string = pkg.name || "@mariozechner/pi-coding-agent"; export const APP_NAME: string = piConfigName || "pi"; export const APP_TITLE: string = piConfigName ? APP_NAME : "π"; export const CONFIG_DIR_NAME: string = pkg.piConfig?.configDir || ".pi"; -export const VERSION: string = pkg.version; +export const VERSION: string = 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`; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1abe8fff..f5c755f7 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -54,7 +54,6 @@ import { getDebugLogPath, getDocsPath, getShareViewerUrl, - getUpdateInstruction, VERSION, } from "../../config.js"; import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js"; @@ -3515,8 +3514,8 @@ export class InteractiveMode { } showNewVersionNotification(newVersion: string): void { - const action = theme.fg("accent", getUpdateInstruction("@mariozechner/pi-coding-agent")); - const updateInstruction = theme.fg("muted", `New version ${newVersion} is available. `) + action; + const action = theme.fg("accent", `${APP_NAME} update`); + const updateInstruction = theme.fg("muted", `New version ${newVersion} is available. Run `) + action; const changelogUrl = theme.fg( "accent", "https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md", diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index d16f225d..cc935fa0 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -1,17 +1,30 @@ import chalk from "chalk"; +import { spawn } from "child_process"; import { selectConfig } from "./cli/config-selector.js"; -import { APP_NAME, getAgentDir } from "./config.js"; +import { + APP_NAME, + getAgentDir, + getSelfUpdateCommand, + getSelfUpdateUnavailableInstruction, + PACKAGE_NAME, +} from "./config.js"; import { DefaultPackageManager } from "./core/package-manager.js"; import { SettingsManager } from "./core/settings-manager.js"; export type PackageCommand = "install" | "remove" | "update" | "list"; +type UpdateTarget = { type: "all" } | { type: "self" } | { type: "extensions"; source?: string }; + interface PackageCommandOptions { command: PackageCommand; source?: string; + updateTarget?: UpdateTarget; local: boolean; help: boolean; invalidOption?: string; + invalidArgument?: string; + missingOptionValue?: string; + conflictingOptions?: string; } function reportSettingsErrors(settingsManager: SettingsManager, context: string): void { @@ -31,7 +44,7 @@ function getPackageCommandUsage(command: PackageCommand): string { case "remove": return `${APP_NAME} remove [-l]`; case "update": - return `${APP_NAME} update [source]`; + return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension ]`; case "list": return `${APP_NAME} list`; } @@ -78,8 +91,17 @@ Examples: console.log(`${chalk.bold("Usage:")} ${getPackageCommandUsage("update")} -Update installed packages. -If is provided, only that package is updated. +Update pi and installed packages. + +Options: + --self Update pi only + --extensions Update installed packages only + --extension Update one package only + +Short forms: + ${APP_NAME} update Update pi and all extensions + ${APP_NAME} update Update one package + ${APP_NAME} update pi Update pi only (self works as alias to pi) `); return; @@ -108,9 +130,16 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined let local = false; let help = false; let invalidOption: string | undefined; + let invalidArgument: string | undefined; + let missingOptionValue: string | undefined; + let conflictingOptions: string | undefined; let source: string | undefined; + let selfFlag = false; + let extensionsFlag = false; + let extensionFlagSource: string | undefined; - for (const arg of rest) { + for (let index = 0; index < rest.length; index++) { + const arg = rest[index]; if (arg === "-h" || arg === "--help") { help = true; continue; @@ -125,6 +154,43 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined 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 === "--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; @@ -132,10 +198,103 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined if (!source) { source = arg; + } else { + invalidArgument = invalidArgument ?? arg; } } - return { command, source, local, help, invalidOption }; + let updateTarget: UpdateTarget | undefined; + if (command === "update") { + if (extensionFlagSource) { + if (selfFlag || extensionsFlag) { + conflictingOptions = conflictingOptions ?? "--extension cannot be combined with --self or --extensions"; + } + 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) { + conflictingOptions = + conflictingOptions ?? "positional update targets cannot be combined with --self or --extensions"; + } + updateTarget = { type: "extensions", source }; + } + } else if (selfFlag && extensionsFlag) { + updateTarget = { type: "all" }; + } else if (selfFlag) { + updateTarget = { type: "self" }; + } else if (extensionsFlag) { + updateTarget = { type: "extensions" }; + } else { + updateTarget = { type: "all" }; + } + } + + return { + command, + source, + updateTarget, + local, + help, + invalidOption, + invalidArgument, + missingOptionValue, + conflictingOptions, + }; +} + +function updateTargetIncludesSelf(target: UpdateTarget): boolean { + return target.type === "all" || target.type === "self"; +} + +function updateTargetIncludesExtensions(target: UpdateTarget): boolean { + return target.type === "all" || target.type === "extensions"; +} + +function canSelfUpdate(): boolean { + return getSelfUpdateCommand(PACKAGE_NAME) !== undefined; +} + +function printSelfUpdateUnavailable(): void { + console.error(`error: ${APP_NAME} cannot self-update this installation.`); + console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME)); + + const entrypoint = process.argv[1]; + if (entrypoint) { + console.error(""); + console.error(`Location of pi executable: ${entrypoint}`); + } +} + +async function runSelfUpdate(): Promise { + const command = getSelfUpdateCommand(PACKAGE_NAME); + if (!command) { + throw new Error( + `${APP_NAME} cannot self-update this installation. ${getSelfUpdateUnavailableInstruction(PACKAGE_NAME)}`, + ); + } + + console.log(chalk.dim(`Updating ${APP_NAME} with ${command.display}...`)); + await new Promise((resolve, reject) => { + const child = spawn(command.command, command.args, { stdio: "inherit" }); + child.on("error", (error) => { + reject(error); + }); + child.on("close", (code, signal) => { + if (code === 0) { + resolve(); + } else if (signal) { + reject(new Error(`${command.display} terminated by signal ${signal}`)); + } else { + reject(new Error(`${command.display} exited with code ${code ?? "unknown"}`)); + } + }); + }); } export async function handleConfigCommand(args: string[]): Promise { @@ -178,6 +337,27 @@ export async function handlePackageCommand(args: string[]): Promise { 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.`)); @@ -186,6 +366,12 @@ export async function handlePackageCommand(args: string[]): Promise { return true; } + if (options.command === "update" && options.updateTarget?.type === "self" && !canSelfUpdate()) { + printSelfUpdateUnavailable(); + process.exitCode = 1; + return true; + } + const cwd = process.cwd(); const agentDir = getAgentDir(); const settingsManager = SettingsManager.create(cwd, agentDir); @@ -252,14 +438,28 @@ export async function handlePackageCommand(args: string[]): Promise { return true; } - case "update": - await packageManager.update(source); - if (source) { - console.log(chalk.green(`Updated ${source}`)); - } else { - console.log(chalk.green("Updated packages")); + case "update": { + const target = options.updateTarget ?? { type: "all" }; + if (updateTargetIncludesExtensions(target)) { + const updateSource = target.type === "extensions" ? target.source : undefined; + await packageManager.update(updateSource); + if (updateSource) { + console.log(chalk.green(`Updated ${updateSource}`)); + } else { + console.log(chalk.green("Updated packages")); + } + } + if (updateTargetIncludesSelf(target)) { + if (canSelfUpdate()) { + await runSelfUpdate(); + console.log(chalk.green(`Updated ${APP_NAME}`)); + } else { + printSelfUpdateUnavailable(); + process.exitCode = 1; + } } return true; + } } } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unknown package command error"; diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index bfa04dfc..fe63b0a5 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "vitest"; -import { detectInstallMethod, getUpdateInstruction } from "../src/config.js"; +import { detectInstallMethod, getSelfUpdateCommand, getUpdateInstruction } from "../src/config.js"; const execPathDescriptor = Object.getOwnPropertyDescriptor(process, "execPath"); @@ -27,4 +27,14 @@ describe("detectInstallMethod", () => { "Run: pnpm install -g @mariozechner/pi-coding-agent", ); }); + + test("does not self-update unknown wrapper installs", () => { + setExecPath("/usr/local/bin/node"); + + expect(detectInstallMethod()).toBe("unknown"); + expect(getSelfUpdateCommand("@mariozechner/pi-coding-agent")).toBeUndefined(); + expect(getUpdateInstruction("@mariozechner/pi-coding-agent")).toBe( + "Update @mariozechner/pi-coding-agent using the package manager, wrapper, or source checkout that provides this installation.", + ); + }); });