@danypops/pi-packed 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -0
- package/extension/src/index.ts +41 -0
- package/extension/src/model.ts +50 -0
- package/extension/src/packed.ts +90 -0
- package/extension/src/tools.ts +98 -0
- package/extension/src/tui.ts +222 -0
- package/package.json +36 -0
- package/src/cache.ts +19 -0
- package/src/catalog.ts +63 -0
- package/src/cli.ts +239 -0
- package/src/client.ts +80 -0
- package/src/daemon.ts +63 -0
- package/src/install.ts +29 -0
- package/src/installed.ts +56 -0
- package/src/ports.ts +74 -0
- package/src/registry.ts +94 -0
- package/src/service.ts +112 -0
- package/src/state.ts +48 -0
- package/src/watcher.ts +70 -0
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# pi-packed
|
|
2
|
+
|
|
3
|
+
Package service for the [Pi](https://github.com/earendil-works/pi-coding-agent) agent —
|
|
4
|
+
DNF-style package management that both **you** and the **agent** can use.
|
|
5
|
+
|
|
6
|
+
The agent gets tools (`pkg_search`, `pkg_info`, `pkg_install`); you get a CLI
|
|
7
|
+
(`packed`) and an interactive TUI (`/packages`). All logic lives in a
|
|
8
|
+
long-running Bun service — the extension is a thin seam.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
┌─ Pi extension seam (extension/src) ───────────┐
|
|
12
|
+
│ pkg_search/pkg_info/pkg_install tools │
|
|
13
|
+
│ /packages TUI · session_start notify │
|
|
14
|
+
│ thin: exec `bun src/cli.ts …`, confirm gates │
|
|
15
|
+
└───────────────┬───────────────────────────────┘
|
|
16
|
+
│ CLI (same hexagon ports)
|
|
17
|
+
┌─ Bun service (src/) ──────────────────────────┼──────────┐
|
|
18
|
+
│ cli.ts (command table) · service.ts (fetch) │ │
|
|
19
|
+
│ daemon: watcher (update drift) + catalogSync │ │
|
|
20
|
+
│ npm registry adapter · pi install exec adapter│ │
|
|
21
|
+
└───────────────────────────────────────────────┴──────────┘
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bun test # 52 tests
|
|
28
|
+
bun src/cli.ts search lsp # one-shot, direct to npm
|
|
29
|
+
bun src/cli.ts serve & # long-running service (warm cache + watcher)
|
|
30
|
+
bun src/cli.ts updates # diff installed vs latest
|
|
31
|
+
|
|
32
|
+
# In Pi:
|
|
33
|
+
pi -e /path/to/pi-packed/extension/src/index.ts # ephemeral
|
|
34
|
+
# or: pi install git:github.com/DanyPops/pi-packed
|
|
35
|
+
/packages # interactive panel
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## CLI
|
|
39
|
+
|
|
40
|
+
| Command | What |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `packed search <q> [--limit N] [--json]` | npm search scoped to `keywords:pi-package` |
|
|
43
|
+
| `packed info <name> [--json]` | version, repo, pi manifest, size |
|
|
44
|
+
| `packed updates [--cached]` | drift vs dist-tags.latest (`--cached` = watcher snapshot) |
|
|
45
|
+
| `packed installed` | parse `~/.pi/agent/settings.json` (+ node_modules versions) |
|
|
46
|
+
| `packed catalog` | full pi-package snapshot (hot browse) |
|
|
47
|
+
| `packed install <source>` | allowlisted: `npm:` / `git:` / `https://` |
|
|
48
|
+
| `packed remove <name>` | bare npm name |
|
|
49
|
+
| `packed serve` | daemon: HTTP API + watcher + catalog sync + idle self-exit |
|
|
50
|
+
|
|
51
|
+
## Service API (loopback + bearer token)
|
|
52
|
+
|
|
53
|
+
`GET /health` · `GET /search?q=&limit=` · `GET /info?name=` · `POST /install`
|
|
54
|
+
· `GET /updates` · `GET /catalog`
|
|
55
|
+
|
|
56
|
+
State in `~/.cache/pi-packed/` (`PI_PACKED_HOME`): `token`, `port`,
|
|
57
|
+
`updates.json`, `catalog.json`. Env knobs: `PI_PACKED_WATCH_SECS` (default
|
|
58
|
+
30min), `PI_PACKED_CATALOG_SECS` (6h), `PI_PACKED_IDLE_SECS` (10min),
|
|
59
|
+
`PI_PACKED_PI_HOME`, `PACKED_CLI` / `PACKED_BIN` (seam overrides).
|
|
60
|
+
|
|
61
|
+
## Architecture (patterns)
|
|
62
|
+
|
|
63
|
+
| Pattern | Where |
|
|
64
|
+
|---|---|
|
|
65
|
+
| Ports & Adapters | `ports.ts` interfaces; drivers: HTTP, CLI, watcher, tests; driven: npm, pi exec |
|
|
66
|
+
| Proxy | daemon = caching/protection proxy of npm; `DaemonRegistry` = remote proxy |
|
|
67
|
+
| Facade | lean JSON over npm's verbose documents |
|
|
68
|
+
| Event-driven | watcher + catalogSync produce snapshots; seam consumes on `session_start` → `ctx.ui.notify` (event-carried state, no callbacks) |
|
|
69
|
+
| Command | `cli.ts` command table (go-tool/Cobra convention, zero deps) |
|
|
70
|
+
| Deep module | tiny API surface, rich internals — one package, no sprawl |
|
|
71
|
+
|
|
72
|
+
## Decisions
|
|
73
|
+
|
|
74
|
+
- **Bun/TS over Go/Rust**: one language end-to-end, no build step (Bun runs
|
|
75
|
+
TS directly), `pi install git:…` works without binaries. Service is
|
|
76
|
+
IO-bound; runtime perf is irrelevant.
|
|
77
|
+
- **No Cobra/urfave**: agent-first CLI; stdlib-style flag parsing with
|
|
78
|
+
flags-anywhere support (LLMs emit flags in random positions).
|
|
79
|
+
- **npm registry is the source of truth** (5,500+ pkgs): pi.dev has no API
|
|
80
|
+
(`/api/*` → "reserved for future features"), its gallery is curated HTML
|
|
81
|
+
(~50/page, server-side `?name=` filter). Catalog sync paginates
|
|
82
|
+
`keywords:pi-package` (250/page) into `catalog.json`, TTL 6h — npm's
|
|
83
|
+
search API has no ETag, so conditional revalidation is impossible.
|
|
84
|
+
- **Install validation** is an allowlist regex (defense-in-depth on top of
|
|
85
|
+
the bearer token): no shell metacharacters, ever.
|
|
86
|
+
|
|
87
|
+
## Roadmap
|
|
88
|
+
|
|
89
|
+
- `pi.dev` JSON API when it ships (currently 501 reserved)
|
|
90
|
+
- `replicate.npmjs.com` changes feed for near-real-time catalog
|
|
91
|
+
- Unix socket transport; `bun build --compile` single-binary distribution
|
|
92
|
+
- `/packages` remote-browse view fed by `catalog.json`
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-packed — Pi extension seam.
|
|
3
|
+
*
|
|
4
|
+
* Thin by design: registers agent tools (pkg_search/pkg_info/pkg_install),
|
|
5
|
+
* the /packages command, and a session_start update notification. ALL logic
|
|
6
|
+
* lives in the Bun service (src/): registry access, caching, watcher,
|
|
7
|
+
* catalog sync, install execution.
|
|
8
|
+
*
|
|
9
|
+
* Install: pi install git:github.com/DanyPops/pi-packed
|
|
10
|
+
*/
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { registerTools } from "./tools.js";
|
|
13
|
+
import { showPackages } from "./tui.js";
|
|
14
|
+
import { runPacked } from "./packed.js";
|
|
15
|
+
import { formatUpdateNotice } from "./model.js";
|
|
16
|
+
import type { UpdatesSnapshot } from "./packed.js";
|
|
17
|
+
|
|
18
|
+
export default function (pi: ExtensionAPI) {
|
|
19
|
+
registerTools(pi);
|
|
20
|
+
|
|
21
|
+
pi.registerCommand("packages", {
|
|
22
|
+
description: "Browse and manage installed Pi packages (pi-packed)",
|
|
23
|
+
handler: async (_args, ctx) => {
|
|
24
|
+
await showPackages(ctx);
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// The watcher (daemon side) produces the snapshot; the seam consumes it
|
|
29
|
+
// on Pi's own lifecycle event — that's the whole event-driven story.
|
|
30
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
31
|
+
if (!ctx.hasUI) return;
|
|
32
|
+
try {
|
|
33
|
+
const snap = await runPacked<UpdatesSnapshot>(["updates", "--cached"], 3_000);
|
|
34
|
+
if (snap.updates?.length) {
|
|
35
|
+
ctx.ui.notify(`${formatUpdateNotice(snap.updates)} — /packages to review`, "info");
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// packed missing or daemon down — stay silent, never block startup.
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model.ts — pure row logic for the /packages panel. No I/O: vitest drives
|
|
3
|
+
* this directly (the TUI component is a thin shell over these functions).
|
|
4
|
+
*/
|
|
5
|
+
import type { InstalledPkg, UpdateEntry } from "./packed.js";
|
|
6
|
+
|
|
7
|
+
export interface Row {
|
|
8
|
+
name: string;
|
|
9
|
+
version: string; // pinned ?? installed
|
|
10
|
+
latest?: string;
|
|
11
|
+
hasUpdate: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ViewMode = "all" | "updates";
|
|
15
|
+
|
|
16
|
+
export function mergeRows(installed: InstalledPkg[], updates: UpdateEntry[]): Row[] {
|
|
17
|
+
const byName = new Map(updates.map((u) => [u.name, u]));
|
|
18
|
+
return installed
|
|
19
|
+
.map((p) => {
|
|
20
|
+
const u = byName.get(p.name);
|
|
21
|
+
return {
|
|
22
|
+
name: p.name,
|
|
23
|
+
version: p.pinned ?? p.installed ?? "?",
|
|
24
|
+
latest: u?.latest,
|
|
25
|
+
hasUpdate: u !== undefined,
|
|
26
|
+
};
|
|
27
|
+
})
|
|
28
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function visibleRows(rows: Row[], mode: ViewMode): Row[] {
|
|
32
|
+
if (mode === "updates") return rows.filter((r) => r.hasUpdate);
|
|
33
|
+
return rows;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function nextMode(mode: ViewMode): ViewMode {
|
|
37
|
+
return mode === "all" ? "updates" : "all";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function filterRows(rows: Row[], query: string): Row[] {
|
|
41
|
+
const q = query.trim().toLowerCase();
|
|
42
|
+
if (!q) return rows;
|
|
43
|
+
return rows.filter((r) => r.name.toLowerCase().includes(q));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function formatUpdateNotice(updates: UpdateEntry[]): string {
|
|
47
|
+
const names = updates.slice(0, 3).map((u) => `${u.name} ${u.installed}→${u.latest}`);
|
|
48
|
+
const more = updates.length > 3 ? ` +${updates.length - 3} more` : "";
|
|
49
|
+
return `${updates.length} package update(s): ${names.join(", ")}${more}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packed.ts — the ONLY place the seam touches the service side.
|
|
3
|
+
* Thin exec wrapper: every call is `bun <pi-packed>/src/cli.ts <cmd>`.
|
|
4
|
+
* No registry, daemon, or npm knowledge lives here.
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
export interface InstalledPkg {
|
|
9
|
+
name: string;
|
|
10
|
+
pinned?: string;
|
|
11
|
+
installed?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UpdateEntry {
|
|
15
|
+
name: string;
|
|
16
|
+
installed: string;
|
|
17
|
+
latest: string;
|
|
18
|
+
detectedAt?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UpdatesSnapshot {
|
|
22
|
+
checkedAt?: string;
|
|
23
|
+
updates: UpdateEntry[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SearchResult {
|
|
27
|
+
name: string;
|
|
28
|
+
version: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
date?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SearchResponse {
|
|
34
|
+
query: string;
|
|
35
|
+
total: number;
|
|
36
|
+
results: SearchResult[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PackageInfo {
|
|
40
|
+
name: string;
|
|
41
|
+
version: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
homepage?: string;
|
|
44
|
+
repository?: string;
|
|
45
|
+
license?: string;
|
|
46
|
+
keywords?: string[];
|
|
47
|
+
pi?: Record<string, unknown>;
|
|
48
|
+
modified?: string;
|
|
49
|
+
unpackedSize?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Path to the pi-packed CLI entry (resolved relative to this file). */
|
|
53
|
+
export function cliPath(): string {
|
|
54
|
+
if (process.env["PACKED_CLI"]) return process.env["PACKED_CLI"];
|
|
55
|
+
return new URL("../../src/cli.ts", import.meta.url).pathname;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function packedCmd(): { bin: string; prefix: string[] } {
|
|
59
|
+
if (process.env["PACKED_BIN"]) return { bin: process.env["PACKED_BIN"], prefix: [] };
|
|
60
|
+
return { bin: process.env["PACKED_BUN"] ?? "bun", prefix: [cliPath()] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function exec(cmd: string, args: string[], timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
66
|
+
if (err) {
|
|
67
|
+
reject(new Error(stderr?.trim() || err.message));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
resolve({ stdout, stderr });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function runPacked<T>(args: string[], timeoutMs = 15_000): Promise<T> {
|
|
76
|
+
const { bin, prefix } = packedCmd();
|
|
77
|
+
const { stdout } = await exec(bin, [...prefix, ...args, "--json"], timeoutMs);
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(stdout) as T;
|
|
80
|
+
} catch {
|
|
81
|
+
throw new Error(`packed ${args[0]}: invalid JSON: ${stdout.slice(0, 200)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Text output variant for install/remove (human-readable pi output). */
|
|
86
|
+
export async function runPackedText(args: string[], timeoutMs = 180_000): Promise<string> {
|
|
87
|
+
const { bin, prefix } = packedCmd();
|
|
88
|
+
const { stdout, stderr } = await exec(bin, [...prefix, ...args], timeoutMs);
|
|
89
|
+
return [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
|
|
90
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools.ts — agent-facing tools. Thin by design: descriptions teach the
|
|
3
|
+
* agent the packed CLI surface; logic lives in the Bun service.
|
|
4
|
+
*/
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
import { runPacked, runPackedText } from "./packed.js";
|
|
8
|
+
import type { PackageInfo, SearchResponse } from "./packed.js";
|
|
9
|
+
|
|
10
|
+
function text(t: string, details: Record<string, unknown> = {}) {
|
|
11
|
+
return { content: [{ type: "text" as const, text: t }], details };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function registerTools(pi: ExtensionAPI): void {
|
|
15
|
+
pi.registerTool({
|
|
16
|
+
name: "pkg_search",
|
|
17
|
+
label: "Pi Package Search",
|
|
18
|
+
description:
|
|
19
|
+
"Search Pi packages (extensions, skills, themes, prompts) on the npm registry. " +
|
|
20
|
+
"Scoped to the pi-package keyword automatically. Returns name, version, description.",
|
|
21
|
+
parameters: Type.Object({
|
|
22
|
+
query: Type.String({ description: "Search terms, e.g. 'lsp' or 'telegram'" }),
|
|
23
|
+
limit: Type.Optional(Type.Number({ description: "Max results (default 10, max 50)" })),
|
|
24
|
+
}),
|
|
25
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
26
|
+
try {
|
|
27
|
+
const r = await runPacked<SearchResponse>(
|
|
28
|
+
["search", params.query, "--limit", String(params.limit ?? 10)],
|
|
29
|
+
);
|
|
30
|
+
if (r.results.length === 0) return text(`No Pi packages found for "${params.query}".`);
|
|
31
|
+
const lines = r.results.map(
|
|
32
|
+
(p, i) => `${i + 1}. ${p.name}@${p.version}\n ${p.description ?? ""}`,
|
|
33
|
+
);
|
|
34
|
+
return text(
|
|
35
|
+
`Found ${r.total} pi package(s) (showing ${r.results.length}):\n\n${lines.join("\n")}`,
|
|
36
|
+
{ results: r.results, total: r.total },
|
|
37
|
+
);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return text(`pkg_search failed: ${e instanceof Error ? e.message : e}`);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
pi.registerTool({
|
|
45
|
+
name: "pkg_info",
|
|
46
|
+
label: "Pi Package Info",
|
|
47
|
+
description:
|
|
48
|
+
"Show details for a Pi package: latest version, description, repository, " +
|
|
49
|
+
"declared pi resources (extensions/skills/themes/prompts), size, license.",
|
|
50
|
+
parameters: Type.Object({
|
|
51
|
+
name: Type.String({ description: "npm package name, e.g. 'pi-lsp' or '@scope/pkg'" }),
|
|
52
|
+
}),
|
|
53
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
54
|
+
try {
|
|
55
|
+
const info = await runPacked<PackageInfo>(["info", params.name]);
|
|
56
|
+
const lines = [
|
|
57
|
+
`${info.name}@${info.version}`,
|
|
58
|
+
info.description ?? "",
|
|
59
|
+
info.repository ? `repo: ${info.repository}` : "",
|
|
60
|
+
info.license ? `license: ${info.license}` : "",
|
|
61
|
+
info.pi ? `provides: ${Object.keys(info.pi).join(", ")}` : "",
|
|
62
|
+
info.unpackedSize ? `size: ${(info.unpackedSize / 1024).toFixed(0)} KB` : "",
|
|
63
|
+
info.modified ? `modified: ${info.modified}` : "",
|
|
64
|
+
].filter(Boolean);
|
|
65
|
+
return text(lines.join("\n"), { info });
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return text(`pkg_info failed: ${e instanceof Error ? e.message : e}`);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
pi.registerTool({
|
|
73
|
+
name: "pkg_install",
|
|
74
|
+
label: "Pi Package Install",
|
|
75
|
+
description:
|
|
76
|
+
"Install a Pi package (pi install). Supports npm:<pkg>[@ver], git:<host>/<owner>/<repo>[@ref], " +
|
|
77
|
+
"or https:// URLs. Packages execute arbitrary code — the user confirms every install.",
|
|
78
|
+
parameters: Type.Object({
|
|
79
|
+
source: Type.String({ description: "e.g. 'npm:pi-lsp', 'npm:@scope/pkg@1.2.3', 'git:github.com/u/r@v1'" }),
|
|
80
|
+
}),
|
|
81
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
82
|
+
if (!ctx.hasUI) {
|
|
83
|
+
return text("pkg_install requires an interactive session (the user must confirm).");
|
|
84
|
+
}
|
|
85
|
+
const ok = await ctx.ui.confirm(
|
|
86
|
+
"Install Pi package",
|
|
87
|
+
`Run: pi install ${params.source}\n\nPackages execute arbitrary code. Continue?`,
|
|
88
|
+
);
|
|
89
|
+
if (!ok) return text("Install cancelled by user.");
|
|
90
|
+
try {
|
|
91
|
+
const out = await runPackedText(["install", params.source]);
|
|
92
|
+
return text(out || `Installed ${params.source}. Reload with /reload to activate.`);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return text(`install failed: ${e instanceof Error ? e.message : e}`);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tui.ts — /packages interactive panel. Follows the pi-extension-manager
|
|
3
|
+
* idiom: ctx.ui.custom with Container/DynamicBorder layout, header hints,
|
|
4
|
+
* type-to-filter (/), Tab view modes, Enter → actions, r refresh, esc close.
|
|
5
|
+
* All data flows through the packed CLI (thin seam).
|
|
6
|
+
*/
|
|
7
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { filterRows, mergeRows, nextMode, visibleRows } from "./model.js";
|
|
11
|
+
import type { Row, ViewMode } from "./model.js";
|
|
12
|
+
import { runPacked, runPackedText } from "./packed.js";
|
|
13
|
+
import type { InstalledPkg, UpdatesSnapshot } from "./packed.js";
|
|
14
|
+
|
|
15
|
+
interface PanelAction {
|
|
16
|
+
type: "menu" | "refresh";
|
|
17
|
+
row?: Row;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function loadRows(): Promise<{ rows: Row[]; error?: string }> {
|
|
21
|
+
try {
|
|
22
|
+
const [installed, snap] = await Promise.all([
|
|
23
|
+
runPacked<InstalledPkg[]>(["installed"]),
|
|
24
|
+
runPacked<UpdatesSnapshot>(["updates", "--cached"], 5_000).catch(() => ({ updates: [] }) as UpdatesSnapshot),
|
|
25
|
+
]);
|
|
26
|
+
return { rows: mergeRows(installed, snap.updates ?? []) };
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return { rows: [], error: e instanceof Error ? e.message : String(e) };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function showPackages(ctx: ExtensionCommandContext): Promise<void> {
|
|
33
|
+
if (!ctx.hasUI) {
|
|
34
|
+
ctx.ui.notify("/packages requires interactive mode", "warning");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let { rows, error } = await loadRows();
|
|
39
|
+
if (error) {
|
|
40
|
+
ctx.ui.notify(`packed unavailable: ${error}`, "error");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Panel loop: actions resolve the component, run outside it, then reopen.
|
|
45
|
+
for (;;) {
|
|
46
|
+
const action = await renderPanel(ctx, rows);
|
|
47
|
+
if (!action) return; // closed
|
|
48
|
+
|
|
49
|
+
if (action.type === "refresh") {
|
|
50
|
+
({ rows, error } = await loadRows());
|
|
51
|
+
if (error) ctx.ui.notify(`refresh failed: ${error}`, "error");
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const row = action.row;
|
|
56
|
+
if (!row) continue;
|
|
57
|
+
|
|
58
|
+
const choice = await ctx.ui.select(
|
|
59
|
+
`${row.name}@${row.version}${row.hasUpdate ? ` → ${row.latest}` : ""}`,
|
|
60
|
+
[
|
|
61
|
+
...(row.hasUpdate ? [`Update to ${row.latest}`] : []),
|
|
62
|
+
"Remove",
|
|
63
|
+
"Cancel",
|
|
64
|
+
],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (choice?.startsWith("Update")) {
|
|
68
|
+
ctx.ui.notify(`Updating ${row.name}…`, "info");
|
|
69
|
+
try {
|
|
70
|
+
await runPackedText(["install", `npm:${row.name}@${row.latest}`]);
|
|
71
|
+
ctx.ui.notify(`Updated ${row.name} to ${row.latest} (takes effect after /reload)`, "info");
|
|
72
|
+
row.version = row.latest ?? row.version;
|
|
73
|
+
row.hasUpdate = false;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
ctx.ui.notify(`update failed: ${e instanceof Error ? e.message : e}`, "error");
|
|
76
|
+
}
|
|
77
|
+
} else if (choice === "Remove") {
|
|
78
|
+
const sure = await ctx.ui.confirm("Remove package", `pi remove npm:${row.name}?`);
|
|
79
|
+
if (sure) {
|
|
80
|
+
try {
|
|
81
|
+
await runPackedText(["remove", row.name]);
|
|
82
|
+
ctx.ui.notify(`Removed ${row.name}`, "info");
|
|
83
|
+
rows = rows.filter((r) => r.name !== row.name);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
ctx.ui.notify(`remove failed: ${e instanceof Error ? e.message : e}`, "error");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderPanel(ctx: ExtensionCommandContext, rows: Row[]): Promise<PanelAction | undefined> {
|
|
93
|
+
return ctx.ui.custom<PanelAction | undefined>((tui, theme, _kb, done) => {
|
|
94
|
+
let mode: ViewMode = "all";
|
|
95
|
+
const searchInput = new Input();
|
|
96
|
+
let searchActive = false;
|
|
97
|
+
let filtered = visibleRows(rows, mode);
|
|
98
|
+
let selectedIndex = 0;
|
|
99
|
+
|
|
100
|
+
const maxVisible = 20;
|
|
101
|
+
|
|
102
|
+
function applyFilter(): void {
|
|
103
|
+
filtered = filterRows(visibleRows(rows, mode), searchInput.getValue());
|
|
104
|
+
selectedIndex = 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const header = {
|
|
108
|
+
invalidate() {},
|
|
109
|
+
render(width: number): string[] {
|
|
110
|
+
const title = theme.bold("Packages");
|
|
111
|
+
const outdated = rows.filter((r) => r.hasUpdate).length;
|
|
112
|
+
const badge = outdated > 0 ? theme.fg("warning", ` ${outdated} update(s)`) : "";
|
|
113
|
+
const hint = searchActive
|
|
114
|
+
? rawKeyHint("esc", "clear")
|
|
115
|
+
: rawKeyHint("enter", "actions") +
|
|
116
|
+
theme.fg("muted", " · ") +
|
|
117
|
+
rawKeyHint("/", "filter") +
|
|
118
|
+
theme.fg("muted", " · ") +
|
|
119
|
+
rawKeyHint("tab", "view") +
|
|
120
|
+
theme.fg("muted", " · ") +
|
|
121
|
+
rawKeyHint("esc", "close");
|
|
122
|
+
const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(badge) - visibleWidth(hint));
|
|
123
|
+
const line1 = truncateToWidth(`${title}${badge}${" ".repeat(spacing)}${hint}`, width, "");
|
|
124
|
+
const dot = "·";
|
|
125
|
+
const line2 = truncateToWidth(
|
|
126
|
+
theme.fg("muted", `view: ${mode} ${dot} r refresh ${dot} ${rows.length} installed`),
|
|
127
|
+
width,
|
|
128
|
+
"",
|
|
129
|
+
);
|
|
130
|
+
return [line1, line2];
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const list = {
|
|
135
|
+
invalidate() {},
|
|
136
|
+
render(width: number): string[] {
|
|
137
|
+
const lines: string[] = [];
|
|
138
|
+
if (searchActive) lines.push(...searchInput.render(width));
|
|
139
|
+
lines.push("");
|
|
140
|
+
if (filtered.length === 0) {
|
|
141
|
+
lines.push(theme.fg("muted", " No packages"));
|
|
142
|
+
return lines;
|
|
143
|
+
}
|
|
144
|
+
const start = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), filtered.length - maxVisible));
|
|
145
|
+
const end = Math.min(start + maxVisible, filtered.length);
|
|
146
|
+
for (let i = start; i < end; i++) {
|
|
147
|
+
const row = filtered[i]!;
|
|
148
|
+
const selected = i === selectedIndex;
|
|
149
|
+
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
150
|
+
const name = selected ? theme.bold(row.name) : row.name;
|
|
151
|
+
const ver = theme.fg("dim", `@${row.version}`);
|
|
152
|
+
const upd = row.hasUpdate ? theme.fg("warning", ` ↑${row.latest}`) : "";
|
|
153
|
+
lines.push(truncateToWidth(`${cursor} ${name}${ver}${upd}`, width, ""));
|
|
154
|
+
}
|
|
155
|
+
const hasScroll = start > 0 || end < filtered.length;
|
|
156
|
+
lines.push(theme.fg("dim", ` ${hasScroll ? `${selectedIndex + 1}/${filtered.length} ` : ""}${mode}`));
|
|
157
|
+
return lines;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const container = new Container();
|
|
162
|
+
container.addChild(new Spacer(1));
|
|
163
|
+
container.addChild(new DynamicBorder());
|
|
164
|
+
container.addChild(new Spacer(1));
|
|
165
|
+
container.addChild(header);
|
|
166
|
+
container.addChild(new Spacer(1));
|
|
167
|
+
container.addChild(list);
|
|
168
|
+
container.addChild(new Spacer(1));
|
|
169
|
+
container.addChild(new DynamicBorder());
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
render: (width: number) => container.render(width),
|
|
173
|
+
invalidate: () => container.invalidate(),
|
|
174
|
+
handleInput(data: string) {
|
|
175
|
+
if (searchActive) {
|
|
176
|
+
if (data === "\x1b") {
|
|
177
|
+
searchActive = false;
|
|
178
|
+
searchInput.setValue?.("");
|
|
179
|
+
applyFilter();
|
|
180
|
+
} else if (data === "\r") {
|
|
181
|
+
searchActive = false;
|
|
182
|
+
} else {
|
|
183
|
+
searchInput.handleInput(data);
|
|
184
|
+
applyFilter();
|
|
185
|
+
}
|
|
186
|
+
tui.requestRender();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
switch (data) {
|
|
191
|
+
case "\x1b[A": // up
|
|
192
|
+
selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(filtered.length, 1);
|
|
193
|
+
break;
|
|
194
|
+
case "\x1b[B": // down
|
|
195
|
+
selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1);
|
|
196
|
+
break;
|
|
197
|
+
case "\t":
|
|
198
|
+
mode = nextMode(mode);
|
|
199
|
+
applyFilter();
|
|
200
|
+
break;
|
|
201
|
+
case "/":
|
|
202
|
+
searchActive = true;
|
|
203
|
+
break;
|
|
204
|
+
case "r":
|
|
205
|
+
done({ type: "refresh" });
|
|
206
|
+
return;
|
|
207
|
+
case "\r": {
|
|
208
|
+
const row = filtered[selectedIndex];
|
|
209
|
+
if (row) done({ type: "menu", row });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
case "\x1b":
|
|
213
|
+
done(undefined);
|
|
214
|
+
return;
|
|
215
|
+
default:
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
tui.requestRender();
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@danypops/pi-packed",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Package service for the Pi agent: search/info/install/updates + /packages TUI, backed by a long-running Bun service",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "bun test",
|
|
11
|
+
"cli": "bun src/cli.ts",
|
|
12
|
+
"serve": "bun src/cli.ts serve"
|
|
13
|
+
},
|
|
14
|
+
"pi": {
|
|
15
|
+
"extensions": [
|
|
16
|
+
"extension/src/index.ts"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
21
|
+
"@earendil-works/pi-tui": "*",
|
|
22
|
+
"typebox": "*"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"bun-types": "latest"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/DanyPops/pi-packed.git"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"extension",
|
|
34
|
+
"README.md"
|
|
35
|
+
]
|
|
36
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** TTLCache — the smart-proxy concern, nothing more. */
|
|
2
|
+
export class TTLCache {
|
|
3
|
+
private m = new Map<string, { body: string; expires: number }>();
|
|
4
|
+
constructor(private ttlMs = 5 * 60_000) {}
|
|
5
|
+
|
|
6
|
+
get(key: string): string | undefined {
|
|
7
|
+
const e = this.m.get(key);
|
|
8
|
+
if (!e) return undefined;
|
|
9
|
+
if (Date.now() > e.expires) {
|
|
10
|
+
this.m.delete(key);
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
return e.body;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
set(key: string, body: string): void {
|
|
17
|
+
this.m.set(key, { body, expires: Date.now() + this.ttlMs });
|
|
18
|
+
}
|
|
19
|
+
}
|