feat(coding-agent): Add built-in update command (#3680)
Some checks are pending
CI / build-check-test (push) Waiting to run

This commit is contained in:
Armin Ronacher 2026-04-25 13:09:33 +02:00 committed by GitHub
parent 0b271a2c4f
commit dcf2651631
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 372 additions and 35 deletions

View file

@ -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 <source> [-l] # Install package, -l for project-local
pi remove <source> [-l] # Remove package
pi uninstall <source> [-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 <src> # Update one package
pi list # List installed packages
pi config # Enable/disable package resources
```

View file

@ -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/<host>/<path>` (global) or `.pi/git/<host>/<path>` (project).
- Runs `npm install` after clone or pull if `package.json` exists.

View file

@ -203,7 +203,7 @@ ${chalk.bold("Commands:")}
${APP_NAME} install <source> [-l] Install extension source and add to settings
${APP_NAME} remove <source> [-l] Remove extension source from settings
${APP_NAME} uninstall <source> [-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} <command> --help Show help for install/remove/uninstall/update/list

View file

@ -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`;

View file

@ -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",

View file

@ -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 <source> [-l]`;
case "update":
return `${APP_NAME} update [source]`;
return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>]`;
case "list":
return `${APP_NAME} list`;
}
@ -78,8 +91,17 @@ Examples:
console.log(`${chalk.bold("Usage:")}
${getPackageCommandUsage("update")}
Update installed packages.
If <source> is provided, only that package is updated.
Update pi and installed packages.
Options:
--self Update pi only
--extensions Update installed packages only
--extension <source> Update one package only
Short forms:
${APP_NAME} update Update pi and all extensions
${APP_NAME} update <source> 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<void> {
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<void>((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<boolean> {
@ -178,6 +337,27 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
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<boolean> {
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<boolean> {
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";

View file

@ -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.",
);
});
});