Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ Requires Node.js `>=22.5.0`.
- npm search/browse with pagination
- Install by source (`npm:`, `git:`, `https://`, `ssh://`, `git@...`, local path)
- Supports direct GitHub `.ts` installs and standalone local install for self-contained packages
- Long-running discovery/detail screens now show dedicated loading UI, and cancellable reads can be aborted with `Esc`
- **Auto-update**
- Interactive wizard (`t` in manager, or `/extensions auto-update`)
- Persistent schedule restored on startup and session switch
- Background checks + status bar updates for installed npm packages
- Background checks + status bar updates for installed npm + git packages
- **Operational visibility**
- Session history (`/extensions history`)
- Cache controls (`/extensions clear-cache` clears persistent + runtime extmgr caches)
Expand Down Expand Up @@ -159,7 +160,7 @@ Examples:
- **Managed** (npm): Auto-updates with `pi update`, stored in pi's package cache, supports Pi package manifest/convention loading
- **Local** (standalone): Copies to `~/.pi/agent/extensions/{package}/`, so it only accepts runnable standalone layouts (manifest-declared/root entrypoints), requires `tar` on `PATH`, and rejects packages whose runtime `dependencies` are not already bundled with the package contents
- **Auto-update schedule is persistent**: `/extensions auto-update 1d` stays active across future Pi sessions and is restored when switching sessions.
- **Auto-update coverage is npm-only today**: extmgr checks update availability for managed npm packages; git/local installs are not included in the background update badge yet.
- **Auto-update/update badges cover npm + git packages**: extmgr now uses pi's package manager APIs for structured update detection instead of parsing `pi list` output.
- **Settings/cache writes are hardened**: extmgr serializes writes and uses safe file replacement to reduce JSON corruption issues.
- **Invalid JSON is handled safely**: malformed `auto-update.json` / metadata cache files are backed up and reset; invalid `.pi/settings.json` is not overwritten during package-extension toggles.
- **Reload is built-in**: When extmgr asks to reload, it calls `ctx.reload()` directly.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@
"@mariozechner/pi-tui": "*"
},
"devDependencies": {
"@mariozechner/pi-coding-agent": "^0.52.6",
"@mariozechner/pi-tui": "^0.52.6",
"@mariozechner/pi-coding-agent": "^0.62.0",
"@mariozechner/pi-tui": "^0.62.0",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.42.0",
Expand Down
1,728 changes: 842 additions & 886 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

162 changes: 162 additions & 0 deletions src/packages/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import {
DefaultPackageManager,
getAgentDir,
SettingsManager,
type PackageSource,
type ProgressEvent,
} from "@mariozechner/pi-coding-agent";
import type { InstalledPackage, Scope } from "../types/index.js";
import { normalizePackageIdentity, parsePackageNameAndVersion } from "../utils/package-source.js";

type PiScope = "user" | "project";
type PiPackageUpdate = Awaited<
ReturnType<DefaultPackageManager["checkForAvailableUpdates"]>
>[number];

export interface AvailablePackageUpdate {
source: string;
displayName: string;
type: "npm" | "git";
scope: Scope;
}

export interface PackageCatalog {
listInstalledPackages(options?: { dedupe?: boolean }): Promise<InstalledPackage[]>;
checkForAvailableUpdates(): Promise<AvailablePackageUpdate[]>;
install(source: string, scope: Scope, onProgress?: (event: ProgressEvent) => void): Promise<void>;
remove(source: string, scope: Scope, onProgress?: (event: ProgressEvent) => void): Promise<void>;
update(source?: string, onProgress?: (event: ProgressEvent) => void): Promise<void>;
}

type PackageCatalogFactory = (cwd: string) => PackageCatalog;

let packageCatalogFactory: PackageCatalogFactory = createDefaultPackageCatalog;

function toScope(scope: PiScope): Scope {
return scope === "project" ? "project" : "global";
}

function getPackageSource(pkg: PackageSource): string {
return typeof pkg === "string" ? pkg : pkg.source;
}

function createPackageRecord(
source: string,
scope: PiScope,
packageManager: DefaultPackageManager
): InstalledPackage {
const resolvedPath = packageManager.getInstalledPath(source, scope);
const { name, version } = parsePackageNameAndVersion(source);

return {
source,
name,
scope: toScope(scope),
...(version ? { version } : {}),
...(resolvedPath ? { resolvedPath } : {}),
};
}

function dedupeInstalledPackages(packages: InstalledPackage[]): InstalledPackage[] {
const byIdentity = new Map<string, InstalledPackage>();

for (const pkg of packages) {
const identity = normalizePackageIdentity(
pkg.source,
pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : undefined
);

if (!byIdentity.has(identity)) {
byIdentity.set(identity, pkg);
}
}

return [...byIdentity.values()];
}

function setProgressCallback(
packageManager: DefaultPackageManager,
onProgress?: (event: ProgressEvent) => void
): void {
packageManager.setProgressCallback(onProgress);
}

function createDefaultPackageCatalog(cwd: string): PackageCatalog {
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });

return {
listInstalledPackages(options) {
const projectPackages = (settingsManager.getProjectSettings().packages ?? []).map((pkg) =>
createPackageRecord(getPackageSource(pkg), "project", packageManager)
);
const globalPackages = (settingsManager.getGlobalSettings().packages ?? []).map((pkg) =>
createPackageRecord(getPackageSource(pkg), "user", packageManager)
);

const installed = [...projectPackages, ...globalPackages];
return Promise.resolve(
options?.dedupe === false ? installed : dedupeInstalledPackages(installed)
);
},

async checkForAvailableUpdates() {
const updates = await packageManager.checkForAvailableUpdates();
return updates.map((update: PiPackageUpdate) => ({
source: update.source,
displayName: update.displayName,
type: update.type,
scope: toScope(update.scope),
}));
},

async install(source, scope, onProgress) {
setProgressCallback(packageManager, onProgress);

try {
await packageManager.install(source, { local: scope === "project" });
packageManager.addSourceToSettings(source, { local: scope === "project" });
await settingsManager.flush();
} finally {
setProgressCallback(packageManager, undefined);
}
},

async remove(source, scope, onProgress) {
setProgressCallback(packageManager, onProgress);

try {
await packageManager.remove(source, { local: scope === "project" });
const removed = packageManager.removeSourceFromSettings(source, {
local: scope === "project",
});
await settingsManager.flush();

if (!removed) {
throw new Error(`No matching package found for ${source}`);
}
} finally {
setProgressCallback(packageManager, undefined);
}
},

async update(source, onProgress) {
setProgressCallback(packageManager, onProgress);

try {
await packageManager.update(source);
} finally {
setProgressCallback(packageManager, undefined);
}
},
};
}

export function getPackageCatalog(cwd: string): PackageCatalog {
return packageCatalogFactory(cwd);
}

export function setPackageCatalogFactory(factory?: PackageCatalogFactory): void {
packageCatalogFactory = factory ?? createDefaultPackageCatalog;
}
Loading
Loading