feat(coding-agent): add PI_SELF_UPDATE_COMMAND and selfUpdateCommand for pi update (#11629)

Refs #7756, #5607.

Problem: when a wrapper or another package manager provides Pi (mise, a
`PNPM_HOME` wrapper such as nub, Nix), `pi update` cannot update it and
prints "pi cannot self-update this installation." Users with several
machines have to remember which tool updates Pi on which machine.

Example:

```sh
export PI_SELF_UPDATE_COMMAND="mise use -g npm:{packageName}@{version}"
pi update
# Updating pi with mise use -g npm:@earendil-works/pi-coding-agent@1.4.0...
# Updated pi from 1.3.0 to 1.4.0
```

or in `~/.pi/agent/settings.json`:

```json
{ "selfUpdateCommand": ["nix", "profile", "upgrade", "pi"] }
```

Behavior:

- `pi update` still checks pi.dev first. The command runs only when a
newer release exists, or with `--force`, and the release note prints
first.
- Placeholders come from that release: `{packageName}`, `{version}`,
`{installSpec}` (`<packageName>@<version>`). A package rename announced
by pi.dev flows into them.
- `PI_SELF_UPDATE_COMMAND` takes precedence and is split on whitespace
(no quoting). The setting is argv, for arguments with spaces, and is
read only from global settings, so a project's `.pi/settings.json`
cannot inject a command.
- The configured command replaces Pi's own update logic for every
install method, including installer-managed installs: it is explicit
user intent.
- Exit code 0 does not prove an update. Pi reports "Updated pi from X to
Y" when its own `package.json` now shows Y (in-place updates) or when
`pi --version` from PATH reports Y (wrappers such as mise or Nix that
install each version into a new directory). Otherwise it warns: ``Ran
<cmd>, but `pi --version` reports X, not Y.``
- "cannot self-update this installation" now mentions the env var and
setting.

Docs: `docs/cli.md` (new "Custom update command" section),
`docs/settings.md`, `docs/environment-variables.md`, `--help` env list,
regenerated `schemas/settings.schema.json`.

Tests: unit tests for argv resolution and placeholders in
`test/config.test.ts`; CLI tests in `test/package-command-paths.test.ts`
for env precedence over the setting and the managed install, global-only
setting gated by the version check, and command failure. `npm run check`
passes.

Manual end-to-end check with real mise 2026.10.23 in a sandboxed HOME,
running this branch as the mise-installed 1.3.0 (`PI_PACKAGE_DIR`
pointing at mise's install):

| Scenario | Result |
|---|---|
| `mise use -g npm:{packageName}@{version}` | mise installs 1.4.0;
`Updated pi from 1.3.0 to 1.4.0` |
| Already 1.4.0 | `pi is already up to date (v1.4.0)`, mise not run |
| In-place `npm install -g --prefix <dir> {installSpec}` via the setting
| `Updated pi from 1.3.0 to 1.4.0` |
| Command `true` (exit 0, installs nothing) | ``Ran true, but `pi
--version` reports 1.3.0, not 1.4.0.`` |

This PR is AI-generated.
This commit is contained in:
Petr Houška 2026-10-30 22:30:39 +01:00 • committed by GitHub
parent 9e4868a605
commit 8bc94b19ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 299 additions and 1 deletions

View file

@ -7,6 +7,7 @@
- Extension packages can now show their changelog after an update. Pi reads `pi.changelogPath` from the package's `package.json`, or `CHANGELOG.md` in the package root, and on the next fresh launch shows entries newer than the last seen version under "Extension Updates". The `collapseChangelog` setting condenses this to one line per package ([#5958](https://github.com/earendil-works/pi/issues/5958), [#8790](https://github.com/earendil-works/pi/pull/8790))
- `pi.exec()` accepts a `maxBuffer` option that limits stdout and stderr combined, and its result has a `truncated` field that is true when output exceeded the limit ([#11360](https://github.com/earendil-works/pi/issues/11360))
- Device-code login (for example GitHub Copilot) now opens the verification page and copies the code when you press Enter. Providers can turn either action off with `openBrowserOnConfirm: false` or `copyCodeOnConfirm: false` ([#9282](https://github.com/earendil-works/pi/issues/9282), [#9301](https://github.com/earendil-works/pi/pull/9301))
- `pi update` can update Pi through a wrapper or another package manager. Set `PI_SELF_UPDATE_COMMAND` (for example `mise use -g npm:{packageName}@{version}`) or the global `selfUpdateCommand` setting; `pi update` runs it when pi.dev reports a newer release and fills `{packageName}`, `{version}`, and `{installSpec}` from that release. See [Custom update command](docs/cli.md#custom-update-command) ([#7756](https://github.com/earendil-works/pi/issues/7756), [#5607](https://github.com/earendil-works/pi/issues/5607))
### Changed

View file

@ -282,6 +282,16 @@ Add `--force` to reinstall Pi when the selected update includes Pi.
`pi update` cannot update Pi when another package manager provides it, such as Nix. Update Pi with that package manager, for example `nix profile upgrade pi`. Package and model catalog updates still work.
#### Custom update command
If a wrapper or another package manager provides Pi, set `PI_SELF_UPDATE_COMMAND` so `pi update` runs its update command:
```sh
export PI_SELF_UPDATE_COMMAND="mise use -g npm:{packageName}@{version}"
```
`pi update` runs it only when pi.dev has a newer release, replacing `{packageName}`, `{version}`, and `{installSpec}` (`{packageName}@{version}`) with that release. The value is split on spaces. For arguments with spaces, use the `selfUpdateCommand` setting in `~/.pi/agent/settings.json` instead, an argv array such as `["nix", "profile", "upgrade", "pi"]`.
### Aliases and command options
- `pi uninstall <source>` is an alias for `pi remove <source>`.

View file

@ -83,6 +83,7 @@ These variables are read by Pi itself:
| `PI_PACKAGE_DIR` | Override the package directory, useful for Nix/Guix store paths |
| `PI_OFFLINE` | Disable automatic network activity, including model catalog refreshes |
| `PI_SKIP_VERSION_CHECK` | Disable the `pi.dev` latest-version request |
| `PI_SELF_UPDATE_COMMAND` | Command `pi update` runs to update Pi itself, split on whitespace, with `{packageName}`, `{version}`, and `{installSpec}` placeholders. Takes precedence over the `selfUpdateCommand` setting. See [Custom update command](cli.md#custom-update-command) |
| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no` |
| `PI_CODEMODE_MAX_WORKERS` | Maximum codemode workers alive at once across all sessions in the process; default `2`. Each worker's script may use up to 256 MB. Raise it when one process runs several sessions, such as in-process subagents. Further scripts wait for a free worker. See [Codemode](codemode.md) |
| `PI_CACHE_RETENTION` | Set to `long` for extended provider prompt caching where supported |

View file

@ -142,6 +142,7 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are requir
| `shellPath` | string | Platform default | Custom shell executable path. Supports a leading `~`. |
| `shellCommandPrefix` | string | None | Prefix prepended to every shell command. |
| `npmCommand` | `string[]` | `npm` | Command and arguments used for npm package lookup and installation. |
| `selfUpdateCommand` | `string[]` | None | Command and arguments `pi update` runs to update Pi itself, with `{packageName}`, `{version}`, and `{installSpec}` placeholders. `PI_SELF_UPDATE_COMMAND` takes precedence. **Can only be set in agent-directory settings.** See [Custom update command](cli.md#custom-update-command). |
See [Shell aliases](shell-aliases.md) for shell setup and [Pi Packages](packages.md) for package-manager behavior.

View file

@ -267,6 +267,14 @@
},
"description": "Command used for npm package lookup and installation, in argv form such as [\"mise\", \"exec\", \"node@20\", \"--\", \"npm\"]."
},
"selfUpdateCommand": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"description": "Command `pi update` runs to update Pi itself, in argv form such as [\"mise\", \"use\", \"-g\", \"npm:{packageName}@{version}\"]. Placeholders: {packageName}, {version}, {installSpec}. PI_SELF_UPDATE_COMMAND takes precedence. Global setting only."
},
"collapseChangelog": {
"type": "boolean",
"description": "Show the condensed changelog after update; use /changelog for the full changelog.",

View file

@ -463,6 +463,7 @@ ${chalk.bold("Environment Variables:")}
${ENV_SESSION_DIR.padEnd(32)} - Session storage directory (overridden by --session-dir)
PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths)
PI_OFFLINE - Disable startup network operations when set to 1/true/yes
PI_SELF_UPDATE_COMMAND - Command pi update runs to update pi itself ({packageName}, {version}, {installSpec})
PI_TELEMETRY - Override install telemetry when set to 1/true/yes or 0/false/no
PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/)

View file

@ -334,6 +334,32 @@ export function getSelfUpdateCommand(
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: string[] | undefined): string[] | undefined {
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: string[],
release: { packageName: string; version: string },
): SelfUpdateCommand {
const values: Record<string, string> = {
packageName: release.packageName,
version: release.version,
installSpec: `${release.packageName}@${release.version}`,
};
const [command = "", ...args] = argv.map((arg) =>
arg.replace(/\{(packageName|version|installSpec)\}/g, (_, name: string) => values[name]),
);
return makeSelfUpdateCommandStep(command, args);
}
export function getSelfUpdateUnavailableInstruction(
packageName: string,
npmCommand?: string[],

View file

@ -283,6 +283,13 @@ export const SettingsSchema = Type.Object(
'Command used for npm package lookup and installation, in argv form such as ["mise", "exec", "node@20", "--", "npm"].',
}),
),
selfUpdateCommand: Type.Optional(
Type.Array(Type.String(), {
minItems: 1,
description:
'Command `pi update` runs to update Pi itself, in argv form such as ["mise", "use", "-g", "npm:{packageName}@{version}"]. Placeholders: {packageName}, {version}, {installSpec}. PI_SELF_UPDATE_COMMAND takes precedence. Global setting only.',
}),
),
collapseChangelog: Type.Optional(
Type.Boolean({
description: "Show the condensed changelog after update; use /changelog for the full changelog.",

View file

@ -17,11 +17,14 @@ import { createProjectTrustContext } from "./cli/project-trust.ts";
import {
APP_NAME,
CONFIG_DIR_NAME,
detectInstallChange,
detectInstallMethod,
getAgentDir,
getConfiguredSelfUpdateArgv,
getPackageDir,
getSelfUpdateCommand,
getSelfUpdateUnavailableInstruction,
makeConfiguredSelfUpdateCommand,
PACKAGE_NAME,
type SelfUpdateCommand,
type SelfUpdatePackageTarget,
@ -638,6 +641,9 @@ function printSelfUpdateUnavailable(
): void {
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) {
@ -735,6 +741,48 @@ async function runSelfUpdate(command: SelfUpdateCommand): Promise<void> {
}
}
/** Run the user-configured self-update command, then report the version installed on disk. */
async function runConfiguredSelfUpdate(argv: string[], plan: SelfUpdatePlan): Promise<void> {
const command = makeConfiguredSelfUpdateCommand(argv, plan);
if (plan.note) {
printSelfUpdateNote(plan.note);
}
try {
await runSelfUpdate(command);
} catch (error: unknown) {
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(): string | undefined {
// 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(): void {
if (process.platform !== "win32") {
return;
@ -1058,7 +1106,11 @@ export async function handlePackageCommand(
}
}
if (updateTargetIncludesSelf(target)) {
const managedInstallRoot = getActiveManagedInstallRoot();
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(
@ -1072,6 +1124,10 @@ export async function handlePackageCommand(
if (!selfUpdatePlan.shouldRun) {
return true;
}
if (configuredSelfUpdateArgv) {
await runConfiguredSelfUpdate(configuredSelfUpdateArgv, selfUpdatePlan);
return true;
}
if (managedInstallRoot) {
if (selfUpdatePlan.note) {
printSelfUpdateNote(selfUpdatePlan.note);

View file

@ -6,9 +6,11 @@ import {
detectInstallChange,
detectInstallMethod,
findNodePackageDir,
getConfiguredSelfUpdateArgv,
getSelfUpdateCommand,
getSelfUpdateUnavailableInstruction,
getUpdateInstruction,
makeConfiguredSelfUpdateCommand,
} from "../src/config.ts";
const execPathDescriptor = Object.getOwnPropertyDescriptor(process, "execPath");
@ -489,3 +491,54 @@ describe("detectInstallChange", () => {
expect(detectInstallChange(join(installDir, "package.json"))).toEqual({ kind: "removed" });
});
});
describe("configured self-update command", () => {
const originalSelfUpdateCommand = process.env.PI_SELF_UPDATE_COMMAND;
afterEach(() => {
if (originalSelfUpdateCommand === undefined) {
delete process.env.PI_SELF_UPDATE_COMMAND;
} else {
process.env.PI_SELF_UPDATE_COMMAND = originalSelfUpdateCommand;
}
});
test("prefers PI_SELF_UPDATE_COMMAND over the setting and splits it on whitespace", () => {
process.env.PI_SELF_UPDATE_COMMAND = " nub upgrade {installSpec} ";
expect(getConfiguredSelfUpdateArgv(["mise", "upgrade"])).toEqual(["nub", "upgrade", "{installSpec}"]);
});
test("uses the setting when PI_SELF_UPDATE_COMMAND is unset or blank", () => {
process.env.PI_SELF_UPDATE_COMMAND = " ";
expect(getConfiguredSelfUpdateArgv(["mise", "upgrade", "npm:{packageName}"])).toEqual([
"mise",
"upgrade",
"npm:{packageName}",
]);
expect(getConfiguredSelfUpdateArgv([])).toBeUndefined();
expect(getConfiguredSelfUpdateArgv(undefined)).toBeUndefined();
});
test("fills placeholders in every argument", () => {
const command = makeConfiguredSelfUpdateCommand(
["wrapper", "install", "{installSpec}", "--name={packageName}", "--version", "{version}", "{unknown}"],
{ packageName: "@earendil-works/pi-coding-agent", version: "1.2.3" },
);
expect(command).toEqual({
command: "wrapper",
args: [
"install",
"@earendil-works/pi-coding-agent@1.2.3",
"--name=@earendil-works/pi-coding-agent",
"--version",
"1.2.3",
"{unknown}",
],
display:
"wrapper install @earendil-works/pi-coding-agent@1.2.3 --name=@earendil-works/pi-coding-agent --version 1.2.3 {unknown}",
});
});
});

View file

@ -774,6 +774,140 @@ if (process.platform !== "win32") fs.chmodSync(piPath, 0o755);
expect(process.exitCode).toBe(1);
});
function writeNodeShim(binDir: string, name: string, scriptPath: string): void {
const shimPath = join(binDir, process.platform === "win32" ? `${name}.cmd` : name);
writeFileSync(
shimPath,
process.platform === "win32"
? `@echo off\r\n"${originalExecPath}" "${scriptPath}" %*\r\n`
: `#!/bin/sh\nexec "${originalExecPath}" "${scriptPath}" "$@"\n`,
);
chmodSync(shimPath, 0o755);
}
/** Put a `pi-wrapper` update command and a `pi` that prints `piVersion` first on PATH. */
function prepareSelfUpdateCommand(piVersion: string, exitCode = 0): { scriptPath: string; recordPath: string } {
const binDir = join(tempDir, "self-update-bin");
const scriptPath = join(tempDir, "self-update.cjs");
const piScriptPath = join(tempDir, "fake-pi.cjs");
const recordPath = join(tempDir, "self-update-record.json");
mkdirSync(binDir, { recursive: true });
writeFileSync(
scriptPath,
`require("node:fs").writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(process.argv.slice(2)));
process.exit(${exitCode});
`,
);
writeFileSync(piScriptPath, `console.log(${JSON.stringify(piVersion)});\n`);
writeNodeShim(binDir, "pi-wrapper", scriptPath);
writeNodeShim(binDir, "pi", piScriptPath);
process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`;
return { scriptPath, recordPath };
}
it("runs PI_SELF_UPDATE_COMMAND instead of the managed update and the selfUpdateCommand setting", async () => {
const targetVersion = getNewerPatchVersion();
const { npmRecordPath } = prepareManagedInstall(targetVersion);
const { scriptPath, recordPath } = prepareSelfUpdateCommand(targetVersion);
const settingRecordPath = join(tempDir, "setting-record.json");
writeFileSync(
join(agentDir, "settings.json"),
JSON.stringify({ selfUpdateCommand: [originalExecPath, scriptPath, settingRecordPath] }),
);
vi.stubEnv("PI_SELF_UPDATE_COMMAND", "pi-wrapper upgrade {installSpec} {version}");
vi.stubGlobal(
"fetch",
vi.fn(async () => Response.json({ packageName: "@new-scope/pi", version: targetVersion, note: "Read me" })),
);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined();
expect(JSON.parse(readFileSync(recordPath, "utf8")) as string[]).toEqual([
"upgrade",
`@new-scope/pi@${targetVersion}`,
targetVersion,
]);
expect(existsSync(npmRecordPath)).toBe(false);
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("Read me");
// Like mise, the command leaves this installation untouched; the pi on PATH reports the new version.
expect(stdout).toContain(`Updated pi from ${VERSION} to ${targetVersion}`);
expect(errorSpy).not.toHaveBeenCalled();
expect(process.exitCode).toBeUndefined();
});
it("runs the global selfUpdateCommand setting only when a newer release exists", async () => {
const targetVersion = getNewerPatchVersion();
const { scriptPath, recordPath } = prepareSelfUpdateCommand(targetVersion);
const projectRecordPath = join(tempDir, "project-record.json");
writeFileSync(
join(agentDir, "settings.json"),
JSON.stringify({ selfUpdateCommand: [originalExecPath, scriptPath, "{packageName}"] }),
);
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(
join(projectDir, ".pi", "settings.json"),
JSON.stringify({ selfUpdateCommand: [originalExecPath, scriptPath, projectRecordPath] }),
);
vi.stubEnv("PI_SELF_UPDATE_COMMAND", "");
const fetchMock = vi.fn(async () => Response.json({ version: VERSION }));
vi.stubGlobal("fetch", fetchMock);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined();
expect(existsSync(recordPath)).toBe(false);
expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain(
`pi is already up to date (v${VERSION})`,
);
fetchMock.mockImplementation(async () => Response.json({ version: targetVersion }));
await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined();
expect(JSON.parse(readFileSync(recordPath, "utf8")) as string[]).toEqual([PACKAGE_NAME]);
expect(process.exitCode).toBeUndefined();
});
it("warns when the pi on PATH does not report the new version", async () => {
const targetVersion = getNewerPatchVersion();
prepareSelfUpdateCommand(VERSION);
vi.stubEnv("PI_SELF_UPDATE_COMMAND", "pi-wrapper upgrade");
vi.stubGlobal(
"fetch",
vi.fn(async () => Response.json({ version: targetVersion })),
);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain(
`Ran pi-wrapper upgrade, but \`pi --version\` reports ${VERSION}, not ${targetVersion}.`,
);
expect(stdout).not.toContain("Updated pi from");
expect(process.exitCode).toBeUndefined();
});
it("fails when the configured self-update command fails", async () => {
prepareSelfUpdateCommand(VERSION, 23);
vi.stubEnv("PI_SELF_UPDATE_COMMAND", "pi-wrapper upgrade");
vi.stubGlobal(
"fetch",
vi.fn(async () => Response.json({ version: getNewerPatchVersion() })),
);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined();
expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain(
"pi-wrapper upgrade exited with code 23",
);
expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).not.toContain("Ran pi-wrapper");
expect(process.exitCode).toBe(1);
});
it("keeps the managed release active when its update fails", async () => {
const targetVersion = getNewerPatchVersion();
const { managedRoot } = prepareManagedInstall(targetVersion, 23);