Skip to content

Commit e896da6

Browse files
authored
fix: ship extmgr improvements (#6)
* fix(extmgr): handle rpc mode without custom tui assumptions * fix(extmgr): align package discovery rules with pi package loading semantics * fix(extmgr): honor project precedence for duplicate installed packages * fix(extmgr): respect auto-update schedules and track updates by package identity * fix(extmgr): batch package extension config writes and surface settings errors earlier * fix(extmgr): unify platform-sensitive package command execution * fix(extmgr): tighten cache history and command ux semantics * refactor(extmgr): simplify ui and package orchestration boundaries * docs(extmgr): align documentation with verified behavior * fix(extmgr): handle degraded custom ui and cleanup temp files * fix(extmgr): address review follow-ups * fix(install): use allSettled for temp cleanup
1 parent 8b7d21f commit e896da6

31 files changed

Lines changed: 2139 additions & 579 deletions

README.md

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Requires Node.js `>=22.5.0`.
2626
- Scope indicators (global/project), status indicators, update badges
2727
- **Package extension configuration panel**
2828
- Configure individual extension entrypoints inside an installed package (`c` on package row)
29+
- Works with manifest-declared entrypoints and conventional `extensions/` package layouts
2930
- Persists to package filters in `settings.json` (no manual JSON editing)
3031
- **Safe staged local extension toggles**
3132
- Toggle with `Space/Enter`, apply with `S`
@@ -36,15 +37,16 @@ Requires Node.js `>=22.5.0`.
3637
- **Remote discovery and install**
3738
- npm search/browse with pagination
3839
- Install by source (`npm:`, `git:`, `https://`, `ssh://`, `git@...`, local path)
39-
- Supports direct GitHub `.ts` installs and local standalone install mode
40+
- Supports direct GitHub `.ts` installs and standalone local install for self-contained packages
4041
- **Auto-update**
4142
- Interactive wizard (`t` in manager, or `/extensions auto-update`)
4243
- Persistent schedule restored on startup and session switch
43-
- Background checks + status bar updates
44+
- Background checks + status bar updates for installed npm packages
4445
- **Operational visibility**
4546
- Session history (`/extensions history`)
46-
- Cache controls (`/extensions clear-cache`)
47+
- Cache controls (`/extensions clear-cache` clears persistent + runtime extmgr caches)
4748
- Status line summary (`pkg count • auto-update • known updates`)
49+
- History now records local extension deletions and auto-update configuration changes
4850
- **Interactive + non-interactive support**
4951
- Works in TUI and non-UI modes
5052
- Non-interactive commands for list/install/remove/update/auto-update
@@ -91,9 +93,9 @@ Open the manager:
9193
/extensions remove [source] # Remove package
9294
/extensions uninstall [source] # Alias: remove
9395
/extensions update [source] # Update one package (or all when omitted)
94-
/extensions auto-update [every] # No arg opens wizard in UI; accepts 1d, 1w, never, etc.
96+
/extensions auto-update [every] # No arg opens wizard in UI; accepts 1d, 1w, 1mo, never, etc.
9597
/extensions history [options] # View change history (supports filters)
96-
/extensions clear-cache # Clear metadata cache
98+
/extensions clear-cache # Clear persistent + runtime extmgr caches
9799
```
98100

99101
### Non-interactive mode
@@ -107,23 +109,33 @@ When Pi is running without UI, extmgr still supports command-driven workflows:
107109
- `/extensions update [source]`
108110
- `/extensions history [options]`
109111
- `/extensions auto-update <duration>`
112+
- Use `1mo` for monthly schedules (`/extensions history --since <duration>` also accepts `1mo`; `30m`/`24h` are just lookback examples)
110113

111-
Remote browsing/search menus require interactive mode.
114+
Remote browsing/search menus require the full interactive TUI.
115+
116+
### RPC / limited-UI mode
117+
118+
In RPC mode, dialog-based commands still work, but the custom TUI panels do not:
119+
120+
- `/extensions` falls back to read-only local/package lists
121+
- `/extensions installed` lists packages directly
122+
- remote browsing/search panels require the full interactive TUI
123+
- package extension configuration requires the full interactive TUI
112124

113125
History options (works in non-interactive mode too):
114126

115127
- `--limit <n>`
116-
- `--action <extension_toggle|package_install|package_update|package_remove|cache_clear>`
128+
- `--action <extension_toggle|extension_delete|package_install|package_update|package_remove|cache_clear|auto_update_config>`
117129
- `--success` / `--failed`
118130
- `--package <query>`
119-
- `--since <duration>` (e.g. `30m`, `24h`, `7d`, `1mo`)
120-
- `--global` (non-interactive mode only; reads all persisted sessions)
131+
- `--since <duration>` (e.g. `30m`, `24h`, `7d`, `1mo`; `1mo` is supported for monthly lookbacks)
132+
- `--global` (non-interactive mode only; reads all persisted sessions under `~/.pi/agent/sessions`)
121133

122134
Examples:
123135

124136
- `/extensions history --failed --limit 50`
125137
- `/extensions history --action package_update --since 7d`
126-
- `/extensions history --global --package extmgr --since 24h`
138+
- `/extensions history --global --package extmgr --since 1mo`
127139

128140
### Install sources
129141

@@ -144,9 +156,10 @@ Examples:
144156
- **Package extension config**: Select a package and press `c` (or Enter/A → Configure) to enable/disable individual package entrypoints.
145157
- After saving package extension config, restart pi to fully apply changes.
146158
- **Two install modes**:
147-
- **Managed** (npm): Auto-updates with `pi update`, stored in pi's package cache
148-
- **Local** (standalone): Copies to `~/.pi/agent/extensions/{package}/`, supports multi-file extensions
159+
- **Managed** (npm): Auto-updates with `pi update`, stored in pi's package cache, supports Pi package manifest/convention loading
160+
- **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
149161
- **Auto-update schedule is persistent**: `/extensions auto-update 1d` stays active across future Pi sessions and is restored when switching sessions.
162+
- **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.
150163
- **Settings/cache writes are hardened**: extmgr serializes writes and uses safe file replacement to reduce JSON corruption issues.
151164
- **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.
152165
- **Reload is built-in**: When extmgr asks to reload, it calls `ctx.reload()` directly.

src/commands/auto-update.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export async function handleAutoUpdateSubcommand(
5454
" 3d - Check every 3 days",
5555
" 1w - Check weekly",
5656
" 2w - Check every 2 weeks",
57-
" 1m - Check monthly",
57+
" 1mo - Check monthly (1m also works)",
5858
" daily - Check daily (alias)",
5959
" weekly - Check weekly (alias)",
6060
];

src/commands/cache.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2+
import { clearSearchCache } from "../packages/discovery.js";
3+
import { clearRemotePackageInfoCache } from "../ui/remote.js";
24
import { clearCache } from "../utils/cache.js";
35
import { logCacheClear } from "../utils/history.js";
46
import { notify } from "../utils/notify.js";
@@ -10,8 +12,10 @@ export async function clearMetadataCacheCommand(
1012
): Promise<void> {
1113
try {
1214
await clearCache();
15+
clearSearchCache();
16+
clearRemotePackageInfoCache();
1317
logCacheClear(pi, true);
14-
notify(ctx, "Metadata cache cleared.", "info");
18+
notify(ctx, "Metadata and in-memory extmgr caches cleared.", "info");
1519
} catch (error) {
1620
const message = error instanceof Error ? error.message : String(error);
1721
logCacheClear(pi, false, message);

src/commands/history.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ import { formatListOutput } from "../utils/ui-helpers.js";
1111

1212
const HISTORY_ACTIONS: ChangeAction[] = [
1313
"extension_toggle",
14+
"extension_delete",
1415
"package_install",
1516
"package_update",
1617
"package_remove",
1718
"cache_clear",
19+
"auto_update_config",
1820
];
1921

2022
interface ParsedHistoryArgs {
@@ -187,12 +189,12 @@ function showHistoryHelp(ctx: ExtensionCommandContext): void {
187189
"Options:",
188190
" --limit <n> Maximum entries to show (default: 20)",
189191
" --action <type> Filter by action",
190-
" extension_toggle | package_install | package_update | package_remove | cache_clear",
192+
` ${HISTORY_ACTIONS.join(" | ")}`,
191193
" --success Show only successful entries",
192194
" --failed Show only failed entries",
193195
" --package <q> Filter by package/source/extension id",
194196
" --since <d> Show only entries newer than duration (e.g. 30m, 24h, 7d, 1mo)",
195-
" --global Read all persisted sessions (non-interactive mode only)",
197+
" --global Read all persisted sessions from ~/.pi/agent/sessions (non-interactive mode only)",
196198
"",
197199
"Examples:",
198200
" /extensions history --failed --limit 50",

src/commands/registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ function showNonInteractiveHelp(ctx: ExtensionCommandContext): void {
3434
" /extensions remove <source> - Remove a package",
3535
" /extensions update [source] - Update one package or all packages",
3636
" /extensions history [opts] - Show history (supports filters)",
37-
" /extensions auto-update <d> - Configure auto-update (e.g. 1d, 1w, never)",
37+
" /extensions auto-update <d> - Configure auto-update (e.g. 1d, 1w, 1mo, never)",
3838
"",
3939
"History examples:",
4040
" /extensions history --failed --limit 50",

src/packages/discovery.ts

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import { readSummary } from "../utils/fs.js";
1414
import { parseNpmSource } from "../utils/format.js";
1515
import {
1616
getPackageSourceKind,
17-
normalizeLocalSourceIdentity,
17+
normalizePackageIdentity,
1818
splitGitRepoAndRef,
19+
stripGitSourcePrefix,
1920
} from "../utils/package-source.js";
2021
import { execNpm } from "../utils/npm-exec.js";
2122

@@ -121,15 +122,11 @@ function sanitizeListSourceSuffix(source: string): string {
121122
.trim();
122123
}
123124

124-
function normalizeSourceIdentity(source: string): string {
125-
const sanitized = sanitizeListSourceSuffix(source);
126-
const kind = getPackageSourceKind(sanitized);
127-
128-
if (kind === "local") {
129-
return normalizeLocalSourceIdentity(sanitized);
130-
}
131-
132-
return sanitized.replace(/\\/g, "/").toLowerCase();
125+
function getInstalledPackageIdentity(pkg: InstalledPackage): string {
126+
return normalizePackageIdentity(
127+
pkg.source,
128+
pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : undefined
129+
);
133130
}
134131

135132
function isScopeHeader(lowerTrimmed: string, scope: "global" | "project"): boolean {
@@ -181,12 +178,8 @@ function parseResolvedPathLine(line: string): string | undefined {
181178
return undefined;
182179
}
183180

184-
function parseInstalledPackagesOutputInternal(
185-
text: string,
186-
options?: { dedupeBySource?: boolean }
187-
): InstalledPackage[] {
181+
function parseInstalledPackagesOutputInternal(text: string): InstalledPackage[] {
188182
const packages: InstalledPackage[] = [];
189-
const seenSources = new Set<string>();
190183

191184
const lines = text.split("\n");
192185
let currentScope: "global" | "project" = "global";
@@ -222,15 +215,6 @@ function parseInstalledPackagesOutputInternal(
222215
if (!looksLikePackageSource(candidate)) continue;
223216

224217
const source = sanitizeListSourceSuffix(candidate);
225-
if (options?.dedupeBySource !== false) {
226-
const sourceIdentity = normalizeSourceIdentity(source);
227-
if (seenSources.has(sourceIdentity)) {
228-
currentPackage = undefined;
229-
continue;
230-
}
231-
seenSources.add(sourceIdentity);
232-
}
233-
234218
const { name, version } = parsePackageNameAndVersion(source);
235219

236220
const pkg: InstalledPackage = { source, name, scope: currentScope };
@@ -244,8 +228,34 @@ function parseInstalledPackagesOutputInternal(
244228
return packages;
245229
}
246230

231+
function shouldReplaceInstalledPackage(
232+
current: InstalledPackage | undefined,
233+
candidate: InstalledPackage
234+
): boolean {
235+
if (!current) {
236+
return true;
237+
}
238+
239+
if (current.scope !== candidate.scope) {
240+
return candidate.scope === "project";
241+
}
242+
243+
return false;
244+
}
245+
247246
export function parseInstalledPackagesOutput(text: string): InstalledPackage[] {
248-
return parseInstalledPackagesOutputInternal(text, { dedupeBySource: true });
247+
const parsed = parseInstalledPackagesOutputInternal(text);
248+
const deduped = new Map<string, InstalledPackage>();
249+
250+
for (const pkg of parsed) {
251+
const identity = getInstalledPackageIdentity(pkg);
252+
const current = deduped.get(identity);
253+
if (shouldReplaceInstalledPackage(current, pkg)) {
254+
deduped.set(identity, pkg);
255+
}
256+
}
257+
258+
return Array.from(deduped.values());
249259
}
250260

251261
/**
@@ -263,10 +273,10 @@ export async function isSourceInstalled(
263273
if (res.code !== 0) return false;
264274

265275
const installed = parseInstalledPackagesOutputAllScopes(res.stdout || "");
266-
const expected = normalizeSourceIdentity(source);
276+
const expected = normalizePackageIdentity(source);
267277

268278
return installed.some((pkg) => {
269-
if (normalizeSourceIdentity(pkg.source) !== expected) {
279+
if (getInstalledPackageIdentity(pkg) !== expected) {
270280
return false;
271281
}
272282
return options?.scope ? pkg.scope === options.scope : true;
@@ -276,8 +286,14 @@ export async function isSourceInstalled(
276286
}
277287
}
278288

289+
/**
290+
* parseInstalledPackagesOutputAllScopes returns the raw parsed entries from
291+
* parseInstalledPackagesOutputInternal without deduplication or scope merging.
292+
* Prefer parseInstalledPackagesOutput for user-facing lists, since it applies
293+
* deduplication and normalized scope selection.
294+
*/
279295
export function parseInstalledPackagesOutputAllScopes(text: string): InstalledPackage[] {
280-
return parseInstalledPackagesOutputInternal(text, { dedupeBySource: false });
296+
return parseInstalledPackagesOutputInternal(text);
281297
}
282298

283299
function extractGitPackageName(repoSpec: string): string {
@@ -316,7 +332,7 @@ function parsePackageNameAndVersion(fullSource: string): {
316332

317333
const sourceKind = getPackageSourceKind(fullSource);
318334
if (sourceKind === "git") {
319-
const gitSpec = fullSource.startsWith("git:") ? fullSource.slice(4) : fullSource;
335+
const gitSpec = stripGitSourcePrefix(fullSource);
320336
const { repo } = splitGitRepoAndRef(gitSpec);
321337
return { name: extractGitPackageName(repo) };
322338
}

0 commit comments

Comments
 (0)