Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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