feat(coding-agent): add Nix flake (#9137)

Add a Nix flake that builds Pi from the checkout on aarch64/x86_64 Linux
and macOS. The build is offline: provider model data is hydrated from a
pi.dev model catalog revision pinned in nix/model-catalog.json, and the
runtime dependency tree comes from packages/coding-agent/install-lock with
internal packages built from source.

The Nix workflow keeps the catalog pin current on main, builds release
tags on all systems, and fast-forwards the stable branch to each release
that builds. The release script refreshes the pin before tagging.
This commit is contained in:
Armin Ronacher 2026-10-02 18:30:51 +02:00 • committed by GitHub
parent 4665fafb4d
commit c71a496ff9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 997 additions and 19 deletions

185
.github/workflows/nix.yml vendored Normal file
View file

@ -0,0 +1,185 @@
name: Nix
# The flake builds with the model catalog revision pinned in
# nix/model-catalog.json. This workflow keeps that pin usable independently of
# releases: after pi.dev publishes a catalog from main, it replaces the pin only
# if the current one no longer hydrates main (for example after a provider was
# added) or lacks a model type the live catalog has for one of main's providers
# (for example after a provider gained classifier models), builds the flake,
# and commits the new pin to main.
#
# Release tags are built exactly as tagged, with their committed pin. Once all
# systems build, the stable branch is fast-forwarded to the tag so that
# `nix run github:earendil-works/pi/stable` runs the latest release.
on:
push:
tags:
- 'v*.*.*'
pull_request:
branches: [main]
paths:
- '.github/workflows/nix.yml'
- 'flake.lock'
- 'flake.nix'
- 'nix/**'
- 'package-lock.json'
- 'packages/*/package.json'
- 'packages/coding-agent/install-lock/**'
- 'packages/ai/scripts/hydrate-model-catalog.ts'
- 'scripts/update-model-catalog-pin.mjs'
workflow_run:
workflows:
- Publish Model Catalog
types:
- completed
# Catches packaging drift from changes pushed to main without a pull request.
schedule:
- cron: '43 5 * * *'
workflow_dispatch:
permissions: {}
concurrency:
group: nix-${{ (github.event_name == 'pull_request' || github.ref_type == 'tag') && github.ref || 'main' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
pin:
if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }}
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
revision: ${{ steps.pin.outputs.revision }}
updated: ${{ steps.pin.outputs.updated }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
- name: Refresh the model catalog pin if stale
id: pin
run: |
set -euo pipefail
# A release tag must build with its committed pin; it cannot be changed.
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
node scripts/update-model-catalog-pin.mjs --if-stale
fi
revision="$(node -p 'require("./nix/model-catalog.json").revision')"
echo "revision=${revision}" >> "${GITHUB_OUTPUT}"
if git diff --quiet -- nix/model-catalog.json; then
echo "updated=false" >> "${GITHUB_OUTPUT}"
else
echo "updated=true" >> "${GITHUB_OUTPUT}"
if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then
echo "::warning::The model catalog pin on main is stale; this build uses ${revision}. The pin is refreshed on main after pi.dev publishes a catalog."
fi
fi
build:
needs: pin
# After a catalog publication, only build when the pin changed.
if: ${{ github.event_name != 'workflow_run' || needs.pin.outputs.updated == 'true' }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
system: x86_64-linux
- runner: ubuntu-24.04-arm
system: aarch64-linux
- runner: macos-latest
system: aarch64-darwin
runs-on: ${{ matrix.runner }}
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Use the resolved model catalog pin
env:
REVISION: ${{ needs.pin.outputs.revision }}
run: |
printf '{\n "revision": "%s"\n}\n' "${REVISION}" > nix/model-catalog.json
- name: Install Nix
uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
github_access_token: ${{ github.token }}
- name: Evaluate all systems
if: ${{ matrix.system == 'x86_64-linux' }}
run: nix flake check --all-systems --no-build
- name: Build
run: nix build -L .
- name: Smoke-test
run: |
./result/bin/pi --version
./result/bin/pi --help > /dev/null
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
test "$(./result/bin/pi --version)" = "${GITHUB_REF_NAME#v}"
fi
update-stable:
needs: build
if: ${{ github.ref_type == 'tag' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Without --force, the push fails unless it is a fast-forward, so stable
# never moves back to an older release.
- name: Fast-forward stable to the release
run: git push origin "${GITHUB_SHA}:refs/heads/stable"
commit-pin:
needs:
- pin
- build
if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && needs.pin.outputs.updated == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
- name: Commit and push the model catalog pin
env:
REVISION: ${{ needs.pin.outputs.revision }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
printf '{\n "revision": "%s"\n}\n' "${REVISION}" > nix/model-catalog.json
git add nix/model-catalog.json
if git diff --staged --quiet; then
echo "Model catalog pin is already ${REVISION}."
exit 0
fi
git commit -m "chore: update Nix model catalog pin"
for attempt in 1 2 3; do
if git push origin HEAD:main; then
exit 0
fi
sleep $((attempt * 5))
git pull --rebase origin main
done
exit 1

View file

@ -41,8 +41,10 @@ Run repository commands from the repo root (two directories above this skill), u
```
Use `npm_config_min_release_age=0` only for the release command. The repo's normal npm age gate can otherwise block the release lockfile refresh when the current workspace package version was published recently. Review any lockfile or install lock diffs the release creates before push.
The release script bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed.
The release script refreshes the Nix model catalog pin (`nix/model-catalog.json`) if stale, bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed.
4. **CI verifies and announces the npm release**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required. After publishing, `announce-pi-dev-release` verifies every public workspace package resolves at the exact release version and that its npm tarball is available, then writes the verified release marker to R2. `pi.dev/api/latest-version` reads that marker; it must never announce a release from npm before this job succeeds.
5. **If CI publish or announcement fails**: inspect the failed job. The publish helper is idempotent and skips package versions already present on npm; the announcement job rechecks availability before updating the R2 marker. Rerun the failed job or workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version.
5. **CI builds the Nix release**: the tag push also triggers `.github/workflows/nix.yml`, which builds the tagged flake on Linux and macOS and then fast-forwards the `stable` branch to the release commit. `nix run github:earendil-works/pi/stable` runs the latest release. If the Nix build fails, `stable` stays on the previous release; the tag cannot be fixed, so fix `main` and ship the fix in the next release.
6. **If CI publish or announcement fails**: inspect the failed job. The publish helper is idempotent and skips package versions already present on npm; the announcement job rechecks availability before updating the R2 marker. Rerun the failed job or workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version.

View file

@ -53,6 +53,22 @@ For a built-in AI provider, run `/login` inside Pi to connect a subscription or
See the [documentation](https://pi.dev/docs/latest) for full setup and usage instructions, or [visit pi.dev](https://pi.dev) for demos.
## Run with Nix
```bash
nix run github:earendil-works/pi/stable
```
`stable` points at the latest release. Install it with `nix profile add github:earendil-works/pi/stable` and update with `nix profile upgrade pi`. Use a release tag such as `github:earendil-works/pi/v1.0.0` to pin a version, or `github:earendil-works/pi` for unreleased changes on `main`. Nix builds Pi from source.
Supports ARM64 and x86-64 on Linux and macOS. Use `nix build .` or `nix run .` to build or run your checkout.
Nix builds are offline, so the bundled model data comes from a pi.dev model catalog revision pinned in `nix/model-catalog.json`. At runtime, Pi still overlays newer catalog data from pi.dev as usual. The Nix workflow replaces the pin on `main` when it no longer matches the checkout, for example after a provider is added or gains a new model type. To refresh it by hand:
```bash
npm run update:model-catalog-pin
```
## Packages
This monorepo contains the Pi CLI and its supporting libraries.

44
flake.lock generated Normal file
View file

@ -0,0 +1,44 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1788316716,
"narHash": "sha256-bc7rSpXIdn9QWGNqfWcPZWOhEVF8NoeAZkWq0XWnf/k=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3ed67ec0a4d3c7ab4ae1f04f8ee8df07bfa506a2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-darwin-x64": {
"locked": {
"lastModified": 1788334797,
"narHash": "sha256-nskSa8kDimM7F0VFSP8IdoSNo4G0vKzOtxwvOFpoGDI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c19db427a1fdfc7591c0b0baeb4665dcef2c61da",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-26.05-darwin",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"nixpkgs-darwin-x64": "nixpkgs-darwin-x64"
}
}
},
"root": "root",
"version": 7
}

54
flake.nix Normal file
View file

@ -0,0 +1,54 @@
{
description = "Pi coding agent";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# nixpkgs unstable no longer supports Intel macOS. Keep using the final
# Darwin branch that does so for pi's x86_64-darwin package.
nixpkgs-darwin-x64.url = "github:NixOS/nixpkgs/nixpkgs-26.05-darwin";
};
outputs =
{
self,
nixpkgs,
nixpkgs-darwin-x64,
}:
let
systems = [
"aarch64-darwin"
"aarch64-linux"
"x86_64-darwin"
"x86_64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
nixpkgsFor = system: if system == "x86_64-darwin" then nixpkgs-darwin-x64 else nixpkgs;
packageFor =
system:
let
pkgs = import (nixpkgsFor system) { inherit system; };
in
pkgs.callPackage ./nix/package.nix { source = self; };
in
{
packages = forAllSystems (system: {
default = packageFor system;
pi = packageFor system;
});
apps = forAllSystems (system: {
default = {
type = "app";
program = "${self.packages.${system}.default}/bin/pi";
meta.description = "Pi coding agent";
};
pi = self.apps.${system}.default;
});
overlays.default = final: _previous: {
pi = final.callPackage ./nix/package.nix { source = self; };
};
};
}

3
nix/model-catalog.json Normal file
View file

@ -0,0 +1,3 @@
{
"revision": "sha256-d28b6de6985826060b6e2ccf589d16800d9fdbc40681ae4c698421c92d2ff86f"
}

180
nix/package.nix Normal file
View file

@ -0,0 +1,180 @@
{
autoPatchelfHook,
fd,
fetchurl,
importNpmLock,
lib,
libxcb,
makeWrapper,
nodejs_22,
ripgrep,
source,
stdenv,
wl-clipboard,
xclip,
}:
let
nodejs = nodejs_22;
packageJson = lib.importJSON (source + "/packages/coding-agent/package.json");
# Lockfile root used by the pi.dev installer. It pins the coding agent's
# runtime dependency tree and is kept in sync with package-lock.json by
# `npm run check`.
installLock = source + "/packages/coding-agent/install-lock";
modelCatalogPin = lib.importJSON ./model-catalog.json;
modelCatalog = fetchurl {
name = "pi-model-catalog.json";
# The typed catalog is the representation whose bytes the revision hashes.
url = "https://pi.dev/api/models/revisions/${modelCatalogPin.revision}?types=chat,image,classifier";
sha256 = lib.removePrefix "sha256-" modelCatalogPin.revision;
};
workspacePackages = stdenv.mkDerivation {
pname = "pi-workspace-packages";
inherit (packageJson) version;
src = source;
npmDeps = importNpmLock { npmRoot = source; };
npmRebuildFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [
nodejs
importNpmLock.npmConfigHook
];
buildPhase = ''
runHook preBuild
node packages/ai/scripts/hydrate-model-catalog.ts ${modelCatalog}
npm run build:offline
runHook postBuild
'';
installPhase = ''
runHook preInstall
pack_package() {
local package_dir="$1"
local output_name="$2"
local tarball
tarball="$(cd "$package_dir" && npm pack --ignore-scripts --silent --pack-destination "$TMPDIR")"
mv "$TMPDIR/$tarball" "$out/$output_name.tgz"
}
mkdir -p "$out"
pack_package packages/chord chord
pack_package packages/telemetry telemetry
pack_package packages/ai ai
pack_package packages/tui tui
pack_package packages/agent agent
pack_package packages/codemode codemode
pack_package packages/mcp mcp
pack_package packages/coding-agent coding-agent
runHook postInstall
'';
};
npmDeps = importNpmLock {
npmRoot = installLock;
# The install lock points internal packages at registry releases. Replace
# them with the packages built from this checkout.
packageSourceOverrides = {
"node_modules/@earendil-works/chord" = workspacePackages + "/chord.tgz";
"node_modules/@earendil-works/pi-agent-core" = workspacePackages + "/agent.tgz";
"node_modules/@earendil-works/pi-ai" = workspacePackages + "/ai.tgz";
"node_modules/@earendil-works/pi-codemode" = workspacePackages + "/codemode.tgz";
"node_modules/@earendil-works/pi-coding-agent" = workspacePackages + "/coding-agent.tgz";
"node_modules/@earendil-works/pi-mcp" = workspacePackages + "/mcp.tgz";
"node_modules/@earendil-works/pi-telemetry" = workspacePackages + "/telemetry.tgz";
"node_modules/@earendil-works/pi-tui" = workspacePackages + "/tui.tgz";
};
};
in
stdenv.mkDerivation {
pname = "pi";
inherit (packageJson) version;
src = installLock;
inherit npmDeps;
npmRebuildFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [
nodejs
importNpmLock.npmConfigHook
makeWrapper
]
++ lib.optionals stdenv.hostPlatform.isLinux [ autoPatchelfHook ];
buildInputs = [ nodejs ] ++ lib.optionals stdenv.hostPlatform.isLinux [
stdenv.cc.cc.lib
libxcb
];
dontBuild = true;
dontStrip = true;
installPhase = ''
runHook preInstall
mkdir -p "$out/lib/pi" "$out/bin"
cp -R . "$out/lib/pi"
makeWrapper ${nodejs}/bin/node "$out/bin/pi" \
--add-flags "$out/lib/pi/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" \
--prefix PATH : ${
lib.makeBinPath (
[
nodejs
fd
ripgrep
]
++ lib.optionals stdenv.hostPlatform.isLinux [
wl-clipboard
xclip
]
)
}
runHook postInstall
'';
doInstallCheck = true;
installCheckPhase = ''
runHook preInstallCheck
test "$("$out/bin/pi" --version)" = "${packageJson.version}"
${nodejs}/bin/node -e \
"require('$out/lib/pi/node_modules/esbuild').transformSync('const value: number = 1', { loader: 'ts' })"
# Load host-platform TUI helpers directly so missing native dependencies
# fail the build rather than silently disabling clipboard support.
${nodejs}/bin/node -e \
"const fs = require('node:fs');
const path = require('node:path');
const dir = '$out/lib/pi/node_modules/@earendil-works/pi-tui/native/' + process.platform + '/prebuilds/' + process.platform + '-' + process.arch;
if (fs.existsSync(dir)) {
for (const file of fs.readdirSync(dir)) {
if (file.endsWith('.node')) require(path.join(dir, file));
}
}"
${nodejs}/bin/node -e \
"require('$out/lib/pi/node_modules/@silvia-odwyer/photon-node')"
runHook postInstallCheck
'';
meta = {
description = packageJson.description;
homepage = "https://pi.dev";
license = lib.licenses.mit;
mainProgram = "pi";
platforms = [
"aarch64-darwin"
"aarch64-linux"
"x86_64-darwin"
"x86_64-linux"
];
sourceProvenance = with lib.sourceTypes; [
fromSource
binaryNativeCode
];
};
}

View file

@ -30,6 +30,7 @@
"check:model-data": "npm --prefix packages/ai run check:model-data",
"generate:model-catalog": "npm --prefix packages/ai run generate-model-catalog",
"diff:model-catalog": "node scripts/diff-model-catalog.mjs",
"update:model-catalog-pin": "node scripts/update-model-catalog-pin.mjs",
"eval": "npm run eval --workspace=@earendil-works/pi-evals --",
"check:model-catalog": "node scripts/publish-model-catalog.mjs --input .artifacts/model-catalog --dry-run",
"profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui",

View file

@ -30,6 +30,7 @@ import type {
import {
assertExactModelIds,
createModelDataManifest,
groupProviderModelData,
type ModelDataStructure,
MODEL_DATA_MANIFEST_FILE,
readModelDataProviderIds,
@ -3520,22 +3521,9 @@ async function generateModels() {
const generatedDataProviders: Record<string, Record<string, Record<string, AnyModel>>> = {};
const modelDataStructure: ModelDataStructure = {};
for (const providerId of generatedDataProviderIds) {
const models = jsonAllProviders[providerId];
generatedDataProviders[providerId] = {};
modelDataStructure[providerId] = {};
const apiIds = Array.from(new Set(models.map((model) => model.api))).sort();
for (const api of apiIds) {
generatedDataProviders[providerId][api] = {};
for (const model of models) {
if (model.api !== api) continue;
const identity = `${model.type}:${model.id}`;
if (generatedDataProviders[providerId][api][identity]) {
throw new Error(`${providerId}/${identity} has duplicate ${api} catalog entries`);
}
generatedDataProviders[providerId][api][identity] = model;
modelDataStructure[providerId][identity] = api;
}
}
const { groups, structure } = groupProviderModelData(providerId, jsonAllProviders[providerId]);
generatedDataProviders[providerId] = groups;
modelDataStructure[providerId] = structure;
}
const generatedAt = new Date().toISOString();

View file

@ -0,0 +1,86 @@
#!/usr/bin/env node
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
createModelDataManifest,
groupProviderModelData,
MODEL_DATA_MANIFEST_FILE,
type ModelCatalogEntry,
type ModelDataStructure,
readModelDataProviderIds,
validateModelDataDirectory,
} from "./model-data.ts";
type CatalogEntry = ModelCatalogEntry & Record<string, unknown>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isCatalogEntry(value: unknown): value is CatalogEntry {
return (
isRecord(value) &&
typeof value.type === "string" &&
typeof value.id === "string" &&
typeof value.api === "string" &&
value.api.length > 0
);
}
/**
* Hydrate the checkout's provider data files from a published typed catalog
* (`models.all.json`) without network access. With `validateOnly`, stage and
* validate the data without replacing the checkout's current data.
*/
export function hydrateModelCatalog(
packageRoot: string,
catalogPath: string,
options: { validateOnly?: boolean } = {},
): void {
const catalog: unknown = JSON.parse(readFileSync(catalogPath, "utf8"));
if (!isRecord(catalog)) throw new Error("Model catalog must be an object");
const files: Record<string, string> = {};
const structure: ModelDataStructure = {};
for (const provider of readModelDataProviderIds(packageRoot)) {
const models = catalog[provider];
if (models === undefined) throw new Error(`Model catalog is missing provider: ${provider}`);
if (!Array.isArray(models) || models.length === 0) {
throw new Error(`Model catalog has no typed model list for provider: ${provider}`);
}
const entries: CatalogEntry[] = [];
for (const model of models) {
if (!isCatalogEntry(model)) throw new Error(`Model catalog has an invalid entry for provider ${provider}`);
entries.push(model);
}
const grouped = groupProviderModelData(provider, entries);
structure[provider] = grouped.structure;
files[`${provider}.json`] = `${JSON.stringify(grouped.groups)}\n`;
}
// Public catalogs have no generation timestamp. Use a fixed stamp so hydration
// produces identical bytes for identical input, regardless of build time.
const manifest = createModelDataManifest(structure, files, "1970-01-01T00:00:00.000Z");
const providersDir = join(packageRoot, "src", "providers");
const stagingRoot = mkdtempSync(join(providersDir, ".model-hydration-"));
const stagedData = join(stagingRoot, "data");
try {
mkdirSync(stagedData);
for (const [name, content] of Object.entries(files)) writeFileSync(join(stagedData, name), content);
writeFileSync(join(stagedData, MODEL_DATA_MANIFEST_FILE), `${JSON.stringify(manifest)}\n`);
validateModelDataDirectory(structure, stagedData);
if (options.validateOnly) return;
const dataDir = join(providersDir, "data");
rmSync(dataDir, { recursive: true, force: true });
renameSync(stagedData, dataDir);
} finally {
rmSync(stagingRoot, { recursive: true, force: true });
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
if (process.argv.length !== 3) throw new Error("Usage: node hydrate-model-catalog.ts <models.all.json>");
hydrateModelCatalog(join(dirname(fileURLToPath(import.meta.url)), ".."), resolve(process.argv[2]));
}

View file

@ -304,3 +304,33 @@ export function validateGeneratedModelData(packageRoot: string): void {
const structure = readModelDataStructure(packageRoot);
validateModelDataDirectory(structure, join(packageRoot, "src", "providers", "data"));
}
export interface ModelCatalogEntry {
type: string;
id: string;
api: string;
}
/**
* Group one provider's typed catalog entries by API and key them by `type:id`,
* the layout of `src/providers/data/<provider>.json`.
*/
export function groupProviderModelData<T extends ModelCatalogEntry>(
providerId: string,
models: readonly T[],
): { groups: Record<string, Record<string, T>>; structure: Record<string, string> } {
const groups: Record<string, Record<string, T>> = {};
const structure: Record<string, string> = {};
for (const api of Array.from(new Set(models.map((model) => model.api))).sort()) {
const group: Record<string, T> = {};
for (const model of models) {
if (model.api !== api) continue;
const identity = `${model.type}:${model.id}`;
if (group[identity]) throw new Error(`${providerId}/${identity} has duplicate ${api} catalog entries`);
group[identity] = model;
structure[identity] = api;
}
groups[api] = group;
}
return { groups, structure };
}

View file

@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { hydrateModelCatalog } from "../scripts/hydrate-model-catalog.ts";
import {
assertExactModelIds,
createModelDataManifest,
@ -78,6 +79,104 @@ function writeFixtureData(
writeFileSync(join(dataDir, MODEL_DATA_MANIFEST_FILE), `${JSON.stringify(manifest)}\n`);
}
describe("published model catalog hydration", () => {
function chatModel(values: Record<string, unknown>): Record<string, unknown> {
return values["chat:model-a"] as Record<string, unknown>;
}
it("hydrates a clean checkout deterministically without changing generated TypeScript", () => {
const { dataDir, packageRoot, structure, values } = createFixture();
const aggregatorPath = join(packageRoot, "src", "models.generated.ts");
const aggregator = readFileSync(aggregatorPath, "utf8");
const catalogPath = join(packageRoot, "catalog.json");
const models = [chatModel(values)];
writeFileSync(catalogPath, JSON.stringify({ "test-provider": models, "extra-provider": models }));
rmSync(dataDir, { recursive: true });
hydrateModelCatalog(packageRoot, catalogPath);
expect(() => validateModelDataDirectory(structure, dataDir)).not.toThrow();
expect(readModelDataStructure(packageRoot)).toEqual(structure);
expect(readFileSync(aggregatorPath, "utf8")).toBe(aggregator);
const manifest = readFileSync(join(dataDir, MODEL_DATA_MANIFEST_FILE), "utf8");
const provider = readFileSync(join(dataDir, "test-provider.json"), "utf8");
hydrateModelCatalog(packageRoot, catalogPath);
expect(readFileSync(join(dataDir, MODEL_DATA_MANIFEST_FILE), "utf8")).toBe(manifest);
expect(readFileSync(join(dataDir, "test-provider.json"), "utf8")).toBe(provider);
});
it("groups models by API and keys them by type and id", () => {
const { packageRoot, values } = createFixture();
const catalogPath = join(packageRoot, "catalog.json");
const model = chatModel(values);
writeFileSync(
catalogPath,
JSON.stringify({
"test-provider": [model, { ...model, id: "model-b", api: "anthropic-messages" }],
}),
);
hydrateModelCatalog(packageRoot, catalogPath);
expect(readModelDataStructure(packageRoot)).toEqual({
"test-provider": { "chat:model-a": "openai-completions", "chat:model-b": "anthropic-messages" },
});
});
it("keys chat and image models with the same id separately", () => {
const { packageRoot, values } = createFixture();
const image = {
type: "image",
id: "model-a",
name: "Model A Image",
api: "openrouter-images",
provider: "test-provider",
baseUrl: "https://example.test/v1",
input: ["text"],
output: ["image"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
};
const catalogPath = join(packageRoot, "catalog.json");
writeFileSync(catalogPath, JSON.stringify({ "test-provider": [chatModel(values), image] }));
hydrateModelCatalog(packageRoot, catalogPath);
expect(readModelDataStructure(packageRoot)).toEqual({
"test-provider": { "chat:model-a": "openai-completions", "image:model-a": "openrouter-images" },
});
});
it("validates without replacing existing data", () => {
const { dataDir, packageRoot, values } = createFixture();
const original = readFileSync(join(dataDir, "test-provider.json"), "utf8");
const catalogPath = join(packageRoot, "catalog.json");
const model = chatModel(values);
writeFileSync(catalogPath, JSON.stringify({ "test-provider": [model, { ...model, id: "model-b" }] }));
hydrateModelCatalog(packageRoot, catalogPath, { validateOnly: true });
expect(readFileSync(join(dataDir, "test-provider.json"), "utf8")).toBe(original);
writeFileSync(catalogPath, JSON.stringify({ "other-provider": [model] }));
expect(() => hydrateModelCatalog(packageRoot, catalogPath, { validateOnly: true })).toThrow(/missing provider/);
});
it.each([null, [], {}, { "test-provider": [] }, { "test-provider": {} }, { "test-provider": [{ id: "x" }] }])(
"rejects incomplete catalogs without replacing existing data: %j",
(catalog) => {
const { dataDir, packageRoot } = createFixture();
const catalogPath = join(packageRoot, "catalog.json");
const original = readFileSync(join(dataDir, MODEL_DATA_MANIFEST_FILE), "utf8");
writeFileSync(catalogPath, JSON.stringify(catalog));
expect(() => hydrateModelCatalog(packageRoot, catalogPath)).toThrow();
expect(readFileSync(join(dataDir, MODEL_DATA_MANIFEST_FILE), "utf8")).toBe(original);
},
);
it.each(["api", "provider", "cost"])("validates model %s before replacing existing data", (field) => {
const { dataDir, packageRoot, values } = createFixture();
const original = readFileSync(join(dataDir, "test-provider.json"), "utf8");
const model = chatModel(values);
model[field] = null;
const catalogPath = join(packageRoot, "catalog.json");
writeFileSync(catalogPath, JSON.stringify({ "test-provider": [model] }));
expect(() => hydrateModelCatalog(packageRoot, catalogPath)).toThrow();
expect(readFileSync(join(dataDir, "test-provider.json"), "utf8")).toBe(original);
});
});
describe("generated model data validation", () => {
it("rejects a missing upstream model from an exact generated allowlist", () => {
expect(() => assertExactModelIds("qwen-token-plan-individual", ["model-a", "model-b"], ["model-a"])).toThrow(

View file

@ -42,6 +42,8 @@ npm install -g --ignore-scripts @earendil-works/pi-coding-agent
Pi requires Node.js 22.19 or newer. The macOS, Linux, and Windows installers can install it if needed. Pi does not require dependency lifecycle scripts for a normal npm installation.
On macOS and Linux, Nix users can install the latest release with `nix profile add github:earendil-works/pi/stable`. See the [quickstart](docs/quickstart.md#1-install-pi) for updating and pinning releases.
Start Pi in the directory where you want it to work:
```bash

View file

@ -268,6 +268,8 @@ Running `pi update` without a target updates Pi itself.
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.
### Aliases and command options
- `pi uninstall <source>` is an alias for `pi remove <source>`.

View file

@ -20,6 +20,14 @@ npm install -g --ignore-scripts @earendil-works/pi-coding-agent
Pi does not require dependency lifecycle scripts for a normal npm installation.
With Nix on macOS or Linux, install the latest release from Pi's flake. Nix builds Pi from source:
```bash
nix profile add github:earendil-works/pi/stable
```
Older Nix versions use `nix profile install` instead. Update with `nix profile upgrade pi`; `pi update` cannot update a Nix installation. To pin a release, use a tag such as `github:earendil-works/pi/v1.0.0`.
Verify the installation:
```bash
@ -119,4 +127,10 @@ If you used the installer, run it again and choose **Uninstall Pi**:
curl -fsSL https://pi.dev/install.sh | sh
```
Neither method removes configuration, credentials, sessions, or installed Pi packages from `~/.pi/agent/`.
If you installed Pi with Nix, run:
```bash
nix profile remove pi
```
None of these methods removes configuration, credentials, sessions, or installed Pi packages from `~/.pi/agent/`.

View file

@ -9,6 +9,7 @@
* Steps:
* 1. Check for uncommitted changes
* 2. Verify every public workspace package is registered on npm
* and refresh the Nix model catalog pin if it is stale
* 3. Bump version via npm run version:xxx or set an explicit version
* 4. Update CHANGELOG.md files: [Unreleased] -> [version] - date
* 5. Regenerate release artifacts
@ -224,6 +225,13 @@ console.log(" Working directory clean\n");
// 2. Verify npm package registration before modifying the worktree.
assertPackagesAreRegisteredWithNpm();
// Release tags are immutable, so the tagged Nix pin must already build the
// release. Refresh it before the version bump, while pi.dev still knows the
// current version, so it lands in the release commit.
console.log("Refreshing the Nix model catalog pin if stale...");
run("node scripts/update-model-catalog-pin.mjs --if-stale");
console.log();
// 3. Bump or set version
const version = bumpOrSetVersion(RELEASE_TARGET);
console.log(` New version: ${version}\n`);

View file

@ -0,0 +1,115 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { hydrateModelCatalog } from "../packages/ai/scripts/hydrate-model-catalog.ts";
import { readModelDataProviderIds } from "../packages/ai/scripts/model-data.ts";
// Request the typed catalog; its bytes are what a revision hashes.
const MODEL_TYPES = "types=chat,image,classifier";
const REVISION_RE = /^sha256-[0-9a-f]{64}$/;
function sha256(bytes) {
return `sha256-${createHash("sha256").update(bytes).digest("hex")}`;
}
async function fetchRevision(revision) {
const response = await fetch(`https://pi.dev/api/models/revisions/${revision}?${MODEL_TYPES}`);
if (!response.ok) throw new Error(`Catalog revision ${revision} is unavailable: HTTP ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
if (sha256(bytes) !== revision) throw new Error(`Catalog revision ${revision} does not match its content`);
return bytes;
}
async function fetchLiveCatalog(root) {
const { version } = JSON.parse(readFileSync(join(root, "packages/coding-agent/package.json"), "utf8"));
const response = await fetch(`https://pi.dev/api/models?pi-version=${encodeURIComponent(version)}&${MODEL_TYPES}`);
if (!response.ok) throw new Error(`Catalog discovery failed: HTTP ${response.status}`);
const revision = response.headers.get("x-pi-model-catalog-revision");
if (typeof revision !== "string" || !REVISION_RE.test(revision)) {
throw new Error("Catalog discovery returned an invalid or missing revision");
}
if (sha256(Buffer.from(await response.arrayBuffer())) !== revision) {
throw new Error("Catalog discovery body does not match its revision");
}
return revision;
}
/**
* Return the `provider/type` model groups the catalog provides for this
* checkout's providers. The generated `*.models.ts` types are derived from the
* data, so code that uses a group (e.g. OPENCODE_CLASSIFIER_MODELS) only
* type-checks when the catalog has models of that type for the provider.
*/
function modelTypeGroups(root, bytes) {
const catalog = JSON.parse(bytes.toString("utf8"));
const groups = new Set();
for (const provider of readModelDataProviderIds(join(root, "packages/ai"))) {
for (const model of catalog[provider] ?? []) groups.add(`${provider}/${model.type}`);
}
return groups;
}
/** Return why the catalog cannot hydrate this checkout, or undefined if it can. */
function hydrationProblem(root, bytes) {
const directory = mkdtempSync(join(tmpdir(), "pi-model-catalog-pin-"));
try {
const catalogPath = join(directory, "models.all.json");
writeFileSync(catalogPath, bytes);
hydrateModelCatalog(join(root, "packages/ai"), catalogPath, { validateOnly: true });
return undefined;
} catch (error) {
return error instanceof Error ? error.message : String(error);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
/**
* Pin the live model catalog for Nix builds. With `ifStale`, keep the current
* pin while it still hydrates this checkout and has every model type the live
* catalog has for the checkout's providers.
*/
export async function updateModelCatalogPin(root, { ifStale = false } = {}) {
const pinPath = join(root, "nix/model-catalog.json");
const current = JSON.parse(readFileSync(pinPath, "utf8")).revision;
let currentBytes;
if (ifStale) {
if (typeof current !== "string" || !REVISION_RE.test(current)) throw new Error(`Invalid pin in ${pinPath}`);
currentBytes = await fetchRevision(current);
const problem = hydrationProblem(root, currentBytes);
if (problem) {
console.error(`Pinned model catalog is stale: ${problem}`);
currentBytes = undefined;
}
}
const revision = await fetchLiveCatalog(root);
// Do not record a pin until its immutable URL serves a catalog this checkout can build with.
const bytes = await fetchRevision(revision);
if (currentBytes) {
// The live catalog is generated from main. Main's code may use a model type
// the pin lacks, e.g. after a provider gained classifier models.
const pinned = modelTypeGroups(root, currentBytes);
const missing = [...modelTypeGroups(root, bytes)].filter((group) => !pinned.has(group));
if (missing.length === 0) return { revision: current, updated: false };
console.error(`Pinned model catalog is stale: missing model types ${missing.sort().join(", ")}`);
}
const problem = hydrationProblem(root, bytes);
if (problem) throw new Error(`Live model catalog ${revision} cannot hydrate this checkout: ${problem}`);
writeFileSync(pinPath, `${JSON.stringify({ revision }, null, 2)}\n`);
return { revision, updated: revision !== current };
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const args = process.argv.slice(2);
if (args.length > 1 || (args.length === 1 && args[0] !== "--if-stale")) {
throw new Error("Usage: node scripts/update-model-catalog-pin.mjs [--if-stale]");
}
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const { revision, updated } = await updateModelCatalogPin(root, { ifStale: args[0] === "--if-stale" });
console.log(`${updated ? "Pinned" : "Kept"} model catalog ${revision}`);
}

View file

@ -0,0 +1,149 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, mock, test } from "node:test";
import { updateModelCatalogPin } from "./update-model-catalog-pin.mjs";
const model = {
type: "chat",
id: "model-a",
name: "Model A",
api: "openai-completions",
provider: "test-provider",
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
const catalog = (models) => `${JSON.stringify({ "test-provider": models })}\n`;
const revisionOf = (body) => `sha256-${createHash("sha256").update(body).digest("hex")}`;
const pinFile = (revision) => `${JSON.stringify({ revision }, null, 2)}\n`;
const liveBody = catalog([model, { ...model, id: "model-b" }]);
const liveRevision = revisionOf(liveBody);
const currentBody = catalog([model]);
const currentRevision = revisionOf(currentBody);
// Hydration fails when a checkout provider is missing, e.g. after main adds one.
const staleBody = `${JSON.stringify({ "other-provider": [model] })}\n`;
const staleRevision = revisionOf(staleBody);
// Hydrates, but lacks a model type the live catalog (generated from main) has.
const classifier = { ...model, type: "classifier", id: "classifier-a", name: "Classifier A" };
delete classifier.reasoning;
delete classifier.maxTokens;
const classifierBody = catalog([model, classifier]);
const classifierRevision = revisionOf(classifierBody);
const bodies = new Map([
[liveRevision, liveBody],
[currentRevision, currentBody],
[staleRevision, staleBody],
[classifierRevision, classifierBody],
]);
const typed = "types=chat,image,classifier";
let root;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "pi-catalog-pin-"));
mkdirSync(join(root, "packages/coding-agent"), { recursive: true });
mkdirSync(join(root, "packages/ai/src/providers"), { recursive: true });
mkdirSync(join(root, "nix"));
writeFileSync(join(root, "packages/coding-agent/package.json"), JSON.stringify({ version: "0.85.1" }));
writeFileSync(
join(root, "packages/ai/src/models.generated.ts"),
'import { TEST_PROVIDER_CLASSIFIER_MODELS, TEST_PROVIDER_IMAGE_MODELS, TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\n',
);
writeFileSync(join(root, "packages/ai/src/providers/test-provider.models.ts"), "");
writeFileSync(join(root, "nix/model-catalog.json"), pinFile(currentRevision));
});
afterEach(() => {
mock.restoreAll();
rmSync(root, { recursive: true, force: true });
});
function readPin() {
return readFileSync(join(root, "nix/model-catalog.json"), "utf8");
}
function mockPiDev({ live = liveRevision, failure } = {}) {
const requests = [];
mock.method(globalThis, "fetch", async (url) => {
requests.push(url);
const revision = /\/revisions\/(sha256-[0-9a-f]+)\?/.exec(url)?.[1];
if (revision) {
if (failure === "revision-http") return new Response(null, { status: 503 });
return new Response(failure === "revision-hash" ? "wrong" : bodies.get(revision));
}
if (failure === "discovery-http") return new Response(null, { status: 503 });
return new Response(failure === "discovery-hash" ? "wrong" : bodies.get(live), {
headers: { "x-pi-model-catalog-revision": failure === "discovery-revision" ? "latest" : live },
});
});
return requests;
}
test("pins the live typed catalog after verifying its immutable URL", async () => {
const requests = mockPiDev();
assert.deepEqual(await updateModelCatalogPin(root), { revision: liveRevision, updated: true });
assert.deepEqual(requests, [
`https://pi.dev/api/models?pi-version=0.85.1&${typed}`,
`https://pi.dev/api/models/revisions/${liveRevision}?${typed}`,
]);
assert.equal(readPin(), pinFile(liveRevision));
});
test("keeps a pin that still hydrates the checkout and has the live model types", async () => {
const requests = mockPiDev();
assert.deepEqual(await updateModelCatalogPin(root, { ifStale: true }), {
revision: currentRevision,
updated: false,
});
assert.deepEqual(requests, [
`https://pi.dev/api/models/revisions/${currentRevision}?${typed}`,
`https://pi.dev/api/models?pi-version=0.85.1&${typed}`,
`https://pi.dev/api/models/revisions/${liveRevision}?${typed}`,
]);
assert.equal(readPin(), pinFile(currentRevision));
});
test("replaces a pin that lacks a model type of the live catalog", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
mockPiDev({ live: classifierRevision });
assert.deepEqual(await updateModelCatalogPin(root, { ifStale: true }), {
revision: classifierRevision,
updated: true,
});
assert.equal(readPin(), pinFile(classifierRevision));
assert.deepEqual(errors, ["Pinned model catalog is stale: missing model types test-provider/classifier"]);
});
test("replaces a pin that no longer hydrates the checkout", async (t) => {
t.mock.method(console, "error", () => {});
writeFileSync(join(root, "nix/model-catalog.json"), pinFile(staleRevision));
mockPiDev();
assert.deepEqual(await updateModelCatalogPin(root, { ifStale: true }), { revision: liveRevision, updated: true });
assert.equal(readPin(), pinFile(liveRevision));
});
test("refuses to pin a live catalog that cannot hydrate the checkout", async () => {
mockPiDev({ live: staleRevision });
await assert.rejects(updateModelCatalogPin(root), /cannot hydrate this checkout/);
assert.equal(readPin(), pinFile(currentRevision));
});
for (const failure of ["discovery-http", "discovery-revision", "discovery-hash", "revision-http", "revision-hash"]) {
test(`keeps the old pin on ${failure} failure`, async () => {
mockPiDev({ failure });
await assert.rejects(updateModelCatalogPin(root));
assert.equal(readPin(), pinFile(currentRevision));
});
}
test("does not treat an unreachable pinned revision as stale", async () => {
mockPiDev({ failure: "revision-http" });
await assert.rejects(updateModelCatalogPin(root, { ifStale: true }), /unavailable/);
assert.equal(readPin(), pinFile(currentRevision));
});