@danypops/pi-packed 0.5.1 → 0.5.3
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 +11 -1
- package/extension/src/packed.ts +20 -26
- package/extension/src/tool-output.ts +269 -0
- package/extension/src/tools.ts +60 -32
- package/package.json +4 -1
- package/src/cli.ts +13 -5
- package/src/client.ts +19 -7
- package/src/constants.ts +10 -0
- package/src/install.ts +19 -4
- package/src/installed.ts +30 -0
- package/src/ports.ts +20 -1
- package/src/service.ts +2 -2
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Package service for the [Pi](https://github.com/earendil-works/pi) agent —
|
|
4
4
|
DNF-style package management that both **you** and the **agent** can use.
|
|
5
5
|
|
|
6
|
-
The agent gets native tools (`pkg_search`, `pkg_info`, `pkg_install`); you get
|
|
6
|
+
The agent gets native tools (`pkg_search`, `pkg_info`, `pkg_install`, `pkg_update`, and `pkg_remove`); you get
|
|
7
7
|
the `packed` CLI and an interactive `/packages` TUI. The extension is a thin,
|
|
8
8
|
Node-compatible client. Registry access, SQLite, and package execution remain
|
|
9
9
|
inside the supervised Bun daemon.
|
|
@@ -66,6 +66,16 @@ Guarded CLI mutations require `--approve` under the secure default. This is pi-p
|
|
|
66
66
|
Failures use exit code 1 and `{ "ok": false, ... , "error": "..." }` with
|
|
67
67
|
credential-safe diagnostics. Usage errors use exit code 2.
|
|
68
68
|
|
|
69
|
+
## Native tool presentation
|
|
70
|
+
|
|
71
|
+
Native tools keep three output contracts independent:
|
|
72
|
+
|
|
73
|
+
- model-facing `content` is concise, credential-safe, and capped at 2,000 characters;
|
|
74
|
+
- renderer-facing `details` uses a versioned bounded package DTO and never retains raw npm metadata or manifest values;
|
|
75
|
+
- CLI human output and `--json` remain presenters over daemon DTOs and do not parse either native-tool channel.
|
|
76
|
+
|
|
77
|
+
Calls and results have themed collapsed and expanded renderers. Missing or legacy details fall back to model content, while daemon and execution failures are thrown through Pi's native error channel. Package approval refusal remains a normal `cancelled` or `denied` outcome.
|
|
78
|
+
|
|
69
79
|
## Service API
|
|
70
80
|
|
|
71
81
|
Every route requires the bearer token stored in the private state directory.
|
package/extension/src/packed.ts
CHANGED
|
@@ -3,14 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Pi's extension runtime is Node-compatible and does not guarantee a global
|
|
5
5
|
* `Bun`. All registry reads, SQLite access, and package mutations therefore
|
|
6
|
-
* stay inside the supervised Bun daemon.
|
|
7
|
-
*
|
|
6
|
+
* stay inside the supervised Bun daemon. A restarted daemon binds a new
|
|
7
|
+
* port/token, which would otherwise leave a stale cached client behind for
|
|
8
|
+
* the rest of the Pi session -- daemon-kit's createRetryingClient detects
|
|
9
|
+
* that on the failing call itself and reconnects, so the happy path reuses
|
|
10
|
+
* one connected client instead of reconnecting on every single operation.
|
|
8
11
|
*/
|
|
12
|
+
import { createRetryingClient } from "@danypops/daemon-kit/pi-client";
|
|
9
13
|
import type { PackageDaemonPort as ClientPackageDaemonPort } from "../../src/client.ts";
|
|
10
|
-
import type { InstalledPkg, Pkg, PkgInfo, UpdateEntry } from "../../src/ports.ts";
|
|
14
|
+
import type { InstalledPkg, Pkg, PkgInfo, UpdateEntry, UpdateOutcome } from "../../src/ports.ts";
|
|
11
15
|
import type { MutationApproval, SecuritySettings } from "../../src/security.ts";
|
|
12
16
|
|
|
13
|
-
export type { InstalledPkg, UpdateEntry };
|
|
17
|
+
export type { InstalledPkg, UpdateEntry, UpdateOutcome };
|
|
14
18
|
export type PackageInfo = PkgInfo;
|
|
15
19
|
export type PackageDaemonPort = ClientPackageDaemonPort;
|
|
16
20
|
|
|
@@ -30,7 +34,7 @@ export interface Natives {
|
|
|
30
34
|
setMutationApproval(value: MutationApproval, approved?: boolean): Promise<SecuritySettings>;
|
|
31
35
|
install(source: string, approved?: boolean): Promise<string>;
|
|
32
36
|
remove(name: string, approved?: boolean): Promise<string>;
|
|
33
|
-
update(source: string, approved?: boolean): Promise<
|
|
37
|
+
update(source: string, approved?: boolean): Promise<UpdateOutcome>;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
export type PackageDaemonConnector = () => Promise<PackageDaemonPort>;
|
|
@@ -44,28 +48,18 @@ async function connectDefaultDaemon(): Promise<PackageDaemonPort> {
|
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
export async function createNatives(connect: PackageDaemonConnector = connectDefaultDaemon): Promise<Natives> {
|
|
47
|
-
|
|
48
|
-
let lastError: unknown;
|
|
49
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
50
|
-
try {
|
|
51
|
-
return await operation(await connect());
|
|
52
|
-
} catch (error) {
|
|
53
|
-
lastError = error;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
57
|
-
}
|
|
51
|
+
const client = createRetryingClient(connect, { label: "pi-packed" });
|
|
58
52
|
|
|
59
53
|
return {
|
|
60
|
-
search: (query, limit) => call((daemon) => daemon.search(query, limit)),
|
|
61
|
-
searchOffline: (query, limit) => call((daemon) => daemon.search(query, limit, true)),
|
|
62
|
-
info: (name) => call((daemon) => daemon.info(name)),
|
|
63
|
-
installed: () => call((daemon) => daemon.installed()),
|
|
64
|
-
updates: () => call((daemon) => daemon.updates()),
|
|
65
|
-
security: () => call((daemon) => daemon.security()),
|
|
66
|
-
setMutationApproval: (value, approved) => call((daemon) => daemon.setMutationApproval(value, approved)),
|
|
67
|
-
install: (source, approved) => call((daemon) => daemon.install(source, approved)),
|
|
68
|
-
remove: (name, approved) => call((daemon) => daemon.remove(name, approved)),
|
|
69
|
-
update: (source, approved) => call((daemon) => daemon.update(source, approved)),
|
|
54
|
+
search: (query, limit) => client.call((daemon) => daemon.search(query, limit)),
|
|
55
|
+
searchOffline: (query, limit) => client.call((daemon) => daemon.search(query, limit, true)),
|
|
56
|
+
info: (name) => client.call((daemon) => daemon.info(name)),
|
|
57
|
+
installed: () => client.call((daemon) => daemon.installed()),
|
|
58
|
+
updates: () => client.call((daemon) => daemon.updates()),
|
|
59
|
+
security: () => client.call((daemon) => daemon.security()),
|
|
60
|
+
setMutationApproval: (value, approved) => client.call((daemon) => daemon.setMutationApproval(value, approved)),
|
|
61
|
+
install: (source, approved) => client.call((daemon) => daemon.install(source, approved)),
|
|
62
|
+
remove: (name, approved) => client.call((daemon) => daemon.remove(name, approved)),
|
|
63
|
+
update: (source, approved) => client.call((daemon) => daemon.update(source, approved)),
|
|
70
64
|
};
|
|
71
65
|
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type { AgentToolResult, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
TOOL_COLLAPSED_PACKAGE_PREVIEW,
|
|
5
|
+
TOOL_DETAILS_MAX_CAPABILITIES,
|
|
6
|
+
TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS,
|
|
7
|
+
TOOL_DETAILS_MAX_KEYWORDS,
|
|
8
|
+
TOOL_DETAILS_MAX_OUTPUT_CHARACTERS,
|
|
9
|
+
TOOL_DETAILS_MAX_PACKAGES,
|
|
10
|
+
TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS,
|
|
11
|
+
TOOL_MODEL_CONTENT_MAX_CHARACTERS,
|
|
12
|
+
} from "../../src/constants.ts";
|
|
13
|
+
import type { Pkg, PkgInfo } from "../../src/ports.ts";
|
|
14
|
+
|
|
15
|
+
const DETAILS_VERSION = 1 as const;
|
|
16
|
+
const MUTATION_OPERATIONS = new Set(["install", "update", "remove"]);
|
|
17
|
+
const MUTATION_STATUSES = new Set(["succeeded", "cancelled", "denied"]);
|
|
18
|
+
|
|
19
|
+
export interface PackageSummaryDetails {
|
|
20
|
+
name: string;
|
|
21
|
+
version: string;
|
|
22
|
+
description: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SearchToolDetails {
|
|
26
|
+
version: typeof DETAILS_VERSION;
|
|
27
|
+
kind: "search";
|
|
28
|
+
operation: "search";
|
|
29
|
+
query: string;
|
|
30
|
+
total: number;
|
|
31
|
+
items: PackageSummaryDetails[];
|
|
32
|
+
truncated: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface InfoToolDetails {
|
|
36
|
+
version: typeof DETAILS_VERSION;
|
|
37
|
+
kind: "info";
|
|
38
|
+
operation: "info";
|
|
39
|
+
package: PackageSummaryDetails & {
|
|
40
|
+
homepage?: string;
|
|
41
|
+
repository?: string;
|
|
42
|
+
license?: string;
|
|
43
|
+
keywords: string[];
|
|
44
|
+
capabilities: string[];
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MutationToolDetails {
|
|
49
|
+
version: typeof DETAILS_VERSION;
|
|
50
|
+
kind: "mutation";
|
|
51
|
+
operation: "install" | "update" | "remove";
|
|
52
|
+
target: string;
|
|
53
|
+
status: "succeeded" | "cancelled" | "denied";
|
|
54
|
+
output: string;
|
|
55
|
+
reloadRequired: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type PackageToolDetails = SearchToolDetails | InfoToolDetails | MutationToolDetails;
|
|
59
|
+
|
|
60
|
+
export interface BoundedModelContent {
|
|
61
|
+
text: string;
|
|
62
|
+
truncated: boolean;
|
|
63
|
+
omitted: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function bounded(value: unknown, maximum: number): string {
|
|
67
|
+
const text = typeof value === "string" ? value : "";
|
|
68
|
+
return text.length <= maximum ? text : text.slice(0, maximum);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function safePackageTarget(value: unknown): string {
|
|
72
|
+
const target = bounded(value, TOOL_DETAILS_MAX_OUTPUT_CHARACTERS);
|
|
73
|
+
const gitPrefix = target.startsWith("git+http://") || target.startsWith("git+https://") ? "git+" : "";
|
|
74
|
+
const candidate = gitPrefix ? target.slice(gitPrefix.length) : target;
|
|
75
|
+
if (!candidate.startsWith("http://") && !candidate.startsWith("https://")) return target;
|
|
76
|
+
try {
|
|
77
|
+
const url = new URL(candidate);
|
|
78
|
+
url.username = "";
|
|
79
|
+
url.password = "";
|
|
80
|
+
url.search = "";
|
|
81
|
+
url.hash = "";
|
|
82
|
+
const safe = url.toString().replace(/\/$/, candidate.endsWith("/") ? "/" : "");
|
|
83
|
+
return `${gitPrefix}${safe}`;
|
|
84
|
+
} catch {
|
|
85
|
+
return `${gitPrefix}https://[invalid-package-source]`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function safeDisplayText(value: unknown, maximum: number): string {
|
|
90
|
+
const text = bounded(value, maximum);
|
|
91
|
+
return text.replace(/(?:git\+)?https?:\/\/[^\s]+/gu, (url) => safePackageTarget(url));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createModelContent(value: string): BoundedModelContent {
|
|
95
|
+
const safe = safeDisplayText(value, value.length);
|
|
96
|
+
if (safe.length <= TOOL_MODEL_CONTENT_MAX_CHARACTERS) {
|
|
97
|
+
return { text: safe, truncated: false, omitted: 0 };
|
|
98
|
+
}
|
|
99
|
+
let omitted = safe.length - TOOL_MODEL_CONTENT_MAX_CHARACTERS;
|
|
100
|
+
let marker = "";
|
|
101
|
+
let kept = 0;
|
|
102
|
+
for (let iteration = 0; iteration < 5; iteration += 1) {
|
|
103
|
+
const nextMarker = `\n[truncated ${omitted} characters]`;
|
|
104
|
+
const nextKept = Math.max(0, TOOL_MODEL_CONTENT_MAX_CHARACTERS - nextMarker.length);
|
|
105
|
+
const nextOmitted = safe.length - nextKept;
|
|
106
|
+
marker = nextMarker;
|
|
107
|
+
kept = nextKept;
|
|
108
|
+
if (nextOmitted === omitted) break;
|
|
109
|
+
omitted = nextOmitted;
|
|
110
|
+
}
|
|
111
|
+
return { text: `${safe.slice(0, kept)}${marker}`, truncated: true, omitted: safe.length - kept };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function packageSummary(pkg: Pkg): PackageSummaryDetails {
|
|
115
|
+
return {
|
|
116
|
+
name: bounded(pkg.name, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS),
|
|
117
|
+
version: bounded(pkg.version, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS),
|
|
118
|
+
description: bounded(pkg.description, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createSearchDetails(query: string, total: number, packages: Pkg[]): SearchToolDetails {
|
|
123
|
+
return {
|
|
124
|
+
version: DETAILS_VERSION,
|
|
125
|
+
kind: "search",
|
|
126
|
+
operation: "search",
|
|
127
|
+
query: bounded(query, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS),
|
|
128
|
+
total: Number.isFinite(total) ? Math.max(0, Math.floor(total)) : packages.length,
|
|
129
|
+
items: packages.slice(0, TOOL_DETAILS_MAX_PACKAGES).map(packageSummary),
|
|
130
|
+
truncated: packages.length > TOOL_DETAILS_MAX_PACKAGES || total > packages.length,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createInfoDetails(info: PkgInfo): InfoToolDetails {
|
|
135
|
+
return {
|
|
136
|
+
version: DETAILS_VERSION,
|
|
137
|
+
kind: "info",
|
|
138
|
+
operation: "info",
|
|
139
|
+
package: {
|
|
140
|
+
...packageSummary(info),
|
|
141
|
+
...(info.homepage ? { homepage: safePackageTarget(info.homepage) } : {}),
|
|
142
|
+
...(info.repository ? { repository: safePackageTarget(info.repository) } : {}),
|
|
143
|
+
...(info.license ? { license: bounded(info.license, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS) } : {}),
|
|
144
|
+
keywords: (info.keywords ?? []).slice(0, TOOL_DETAILS_MAX_KEYWORDS).map((keyword) => bounded(keyword, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS)),
|
|
145
|
+
capabilities: Object.keys(info.pi ?? {}).slice(0, TOOL_DETAILS_MAX_CAPABILITIES).map((name) => bounded(name, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS)),
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createMutationDetails(
|
|
151
|
+
operation: MutationToolDetails["operation"],
|
|
152
|
+
target: string,
|
|
153
|
+
status: MutationToolDetails["status"],
|
|
154
|
+
output: string,
|
|
155
|
+
// update's reloadRequired is not always "succeeded implies true" -- pi
|
|
156
|
+
// update exits 0 whether or not a pinned/already-latest package actually
|
|
157
|
+
// changed. Callers that know the real outcome (updatePackageWithPolicy)
|
|
158
|
+
// pass it explicitly; install/remove keep the old succeeded-implies-true
|
|
159
|
+
// default, which is accurate for them.
|
|
160
|
+
reloadRequired: boolean = operation === "update" && status === "succeeded",
|
|
161
|
+
): MutationToolDetails {
|
|
162
|
+
return {
|
|
163
|
+
version: DETAILS_VERSION,
|
|
164
|
+
kind: "mutation",
|
|
165
|
+
operation,
|
|
166
|
+
target: safePackageTarget(target),
|
|
167
|
+
status,
|
|
168
|
+
output: safeDisplayText(output, TOOL_DETAILS_MAX_OUTPUT_CHARACTERS),
|
|
169
|
+
reloadRequired,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isShortString(value: unknown, maximum = TOOL_DETAILS_MAX_OUTPUT_CHARACTERS): value is string {
|
|
174
|
+
return typeof value === "string" && value.length <= maximum;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function parsePackageToolDetails(value: unknown): PackageToolDetails | undefined {
|
|
178
|
+
try {
|
|
179
|
+
if (!value || typeof value !== "object") return undefined;
|
|
180
|
+
if (JSON.stringify(value).length > TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS) return undefined;
|
|
181
|
+
const candidate = value as Record<string, unknown>;
|
|
182
|
+
if (candidate.version !== DETAILS_VERSION || typeof candidate.kind !== "string") return undefined;
|
|
183
|
+
if (candidate.kind === "search") {
|
|
184
|
+
if (candidate.operation !== "search" || !isShortString(candidate.query, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS) || typeof candidate.total !== "number" || !Number.isSafeInteger(candidate.total) || candidate.total < 0 || typeof candidate.truncated !== "boolean") return undefined;
|
|
185
|
+
if (!Array.isArray(candidate.items) || candidate.items.length > TOOL_DETAILS_MAX_PACKAGES) return undefined;
|
|
186
|
+
if (!candidate.items.every((item) => isPackageSummary(item))) return undefined;
|
|
187
|
+
return value as SearchToolDetails;
|
|
188
|
+
}
|
|
189
|
+
if (candidate.kind === "info") {
|
|
190
|
+
if (candidate.operation !== "info" || !candidate.package || typeof candidate.package !== "object") return undefined;
|
|
191
|
+
const pkg = candidate.package as Record<string, unknown>;
|
|
192
|
+
if (!isPackageSummary(pkg) || !Array.isArray(pkg.keywords) || pkg.keywords.length > TOOL_DETAILS_MAX_KEYWORDS || !pkg.keywords.every((item) => isShortString(item, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS))) return undefined;
|
|
193
|
+
if (!Array.isArray(pkg.capabilities) || pkg.capabilities.length > TOOL_DETAILS_MAX_CAPABILITIES || !pkg.capabilities.every((item) => isShortString(item, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS))) return undefined;
|
|
194
|
+
for (const field of ["homepage", "repository", "license"] as const) {
|
|
195
|
+
if (pkg[field] !== undefined && !isShortString(pkg[field])) return undefined;
|
|
196
|
+
}
|
|
197
|
+
return value as InfoToolDetails;
|
|
198
|
+
}
|
|
199
|
+
if (candidate.kind === "mutation") {
|
|
200
|
+
if (typeof candidate.operation !== "string" || !MUTATION_OPERATIONS.has(candidate.operation)) return undefined;
|
|
201
|
+
if (typeof candidate.status !== "string" || !MUTATION_STATUSES.has(candidate.status)) return undefined;
|
|
202
|
+
if (!isShortString(candidate.target) || !isShortString(candidate.output) || typeof candidate.reloadRequired !== "boolean") return undefined;
|
|
203
|
+
return value as MutationToolDetails;
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isPackageSummary(value: unknown): boolean {
|
|
212
|
+
if (!value || typeof value !== "object") return false;
|
|
213
|
+
const item = value as Record<string, unknown>;
|
|
214
|
+
return isShortString(item.name, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS)
|
|
215
|
+
&& isShortString(item.version, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS)
|
|
216
|
+
&& isShortString(item.description, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function contentFallback(result: AgentToolResult<unknown>): string {
|
|
220
|
+
return result.content.filter((item) => item.type === "text").map((item) => item.text).join("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function renderPackageToolCall(label: string, args: Record<string, unknown>, theme: Theme) {
|
|
224
|
+
const target = safePackageTarget(args.query ?? args.name ?? args.source ?? "");
|
|
225
|
+
return new Text(`${theme.fg("accent", label)}${target ? theme.fg("muted", ` · ${target}`) : ""}`, 0, 0);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function renderPackageToolResult(
|
|
229
|
+
result: AgentToolResult<unknown>,
|
|
230
|
+
options: { expanded: boolean; isPartial: boolean },
|
|
231
|
+
theme: Theme,
|
|
232
|
+
context: { isPartial: boolean },
|
|
233
|
+
) {
|
|
234
|
+
if (options.isPartial || context.isPartial) return new Text(theme.fg("muted", "Working…"), 0, 0);
|
|
235
|
+
const details = parsePackageToolDetails(result.details);
|
|
236
|
+
if (!details) return new Text(contentFallback(result), 0, 0);
|
|
237
|
+
return {
|
|
238
|
+
render(width: number): string[] {
|
|
239
|
+
const safeWidth = Math.max(1, width);
|
|
240
|
+
if (details.kind === "search") {
|
|
241
|
+
const shown = options.expanded ? details.items : details.items.slice(0, TOOL_COLLAPSED_PACKAGE_PREVIEW);
|
|
242
|
+
const heading = `${theme.bold(String(details.total))} packages · showing ${details.items.length}${details.truncated ? " · bounded" : ""}`;
|
|
243
|
+
const rows = shown.map((pkg) => truncateToWidth(`${theme.fg("accent", `${pkg.name}@${pkg.version}`)}${options.expanded && pkg.description ? ` — ${pkg.description}` : ""}`, safeWidth));
|
|
244
|
+
if (!options.expanded && details.items.length > shown.length) rows.push(theme.fg("muted", `… ${details.items.length - shown.length} more`));
|
|
245
|
+
return [truncateToWidth(heading, safeWidth), ...rows];
|
|
246
|
+
}
|
|
247
|
+
if (details.kind === "info") {
|
|
248
|
+
const pkg = details.package;
|
|
249
|
+
const lines = [theme.bold(`${pkg.name}@${pkg.version}`), pkg.description];
|
|
250
|
+
if (options.expanded) {
|
|
251
|
+
if (pkg.license) lines.push(`license: ${pkg.license}`);
|
|
252
|
+
if (pkg.repository) lines.push(`repository: ${pkg.repository}`);
|
|
253
|
+
if (pkg.homepage) lines.push(`homepage: ${pkg.homepage}`);
|
|
254
|
+
if (pkg.capabilities.length) lines.push(`provides: ${pkg.capabilities.join(", ")}`);
|
|
255
|
+
if (pkg.keywords.length) lines.push(`keywords: ${pkg.keywords.join(", ")}`);
|
|
256
|
+
}
|
|
257
|
+
return lines.filter(Boolean).map((line) => truncateToWidth(line, safeWidth));
|
|
258
|
+
}
|
|
259
|
+
const statusColor = details.status === "succeeded" ? "success" : "warning";
|
|
260
|
+
const lines = [
|
|
261
|
+
`${theme.fg(statusColor, details.status === "succeeded" ? "✓" : "○")} ${details.operation} ${details.target}`,
|
|
262
|
+
];
|
|
263
|
+
if (options.expanded && details.output) lines.push(details.output);
|
|
264
|
+
if (details.reloadRequired) lines.push(theme.fg("warning", "Reload Pi with /reload to activate the update."));
|
|
265
|
+
return lines.map((line) => truncateToWidth(line, safeWidth));
|
|
266
|
+
},
|
|
267
|
+
invalidate() {},
|
|
268
|
+
};
|
|
269
|
+
}
|
package/extension/src/tools.ts
CHANGED
|
@@ -3,9 +3,18 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { packagePermissionDecision, type PackageOperation } from "../../src/security.ts";
|
|
5
5
|
import type { Natives } from "./packed.js";
|
|
6
|
+
import {
|
|
7
|
+
createInfoDetails,
|
|
8
|
+
createModelContent,
|
|
9
|
+
createMutationDetails,
|
|
10
|
+
createSearchDetails,
|
|
11
|
+
renderPackageToolCall,
|
|
12
|
+
renderPackageToolResult,
|
|
13
|
+
type PackageToolDetails,
|
|
14
|
+
} from "./tool-output.js";
|
|
6
15
|
|
|
7
|
-
function text(
|
|
8
|
-
return { content: [{ type: "text" as const, text:
|
|
16
|
+
function text(value: string, details: PackageToolDetails) {
|
|
17
|
+
return { content: [{ type: "text" as const, text: createModelContent(value).text }], details };
|
|
9
18
|
}
|
|
10
19
|
|
|
11
20
|
type ApprovalContext = {
|
|
@@ -18,7 +27,7 @@ export async function approvePackageOperation(
|
|
|
18
27
|
command: string,
|
|
19
28
|
natives: Pick<Natives, "security">,
|
|
20
29
|
ctx: ApprovalContext,
|
|
21
|
-
): Promise<{ allowed: boolean; approved: boolean; message?: string }> {
|
|
30
|
+
): Promise<{ allowed: boolean; approved: boolean; reason?: "cancelled" | "denied"; message?: string }> {
|
|
22
31
|
const settings = await natives.security();
|
|
23
32
|
const decision = packagePermissionDecision(settings, operation);
|
|
24
33
|
if (!decision.approvalRequired) return { allowed: true, approved: false };
|
|
@@ -26,6 +35,7 @@ export async function approvePackageOperation(
|
|
|
26
35
|
return {
|
|
27
36
|
allowed: false,
|
|
28
37
|
approved: false,
|
|
38
|
+
reason: "denied",
|
|
29
39
|
message: `${operation} requires interactive approval; change mutationApproval in /packed only to deliberately opt out.`,
|
|
30
40
|
};
|
|
31
41
|
}
|
|
@@ -33,7 +43,7 @@ export async function approvePackageOperation(
|
|
|
33
43
|
`${operation[0]!.toUpperCase()}${operation.slice(1)} Pi package`,
|
|
34
44
|
`Run: ${command}\n\nThis operation can execute package code or mutate Pi settings/install roots. Continue?`,
|
|
35
45
|
);
|
|
36
|
-
return approved ? { allowed: true, approved: true } : { allowed: false, approved: false, message: `${operation} cancelled by user.` };
|
|
46
|
+
return approved ? { allowed: true, approved: true } : { allowed: false, approved: false, reason: "cancelled", message: `${operation} cancelled by user.` };
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
export async function installPackageWithPolicy(
|
|
@@ -41,14 +51,13 @@ export async function installPackageWithPolicy(
|
|
|
41
51
|
natives: Pick<Natives, "security" | "install">,
|
|
42
52
|
ctx: ApprovalContext,
|
|
43
53
|
) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return text(out || `Installed ${source}. Reload with /reload to activate.`);
|
|
49
|
-
} catch (error) {
|
|
50
|
-
return text(`install failed: ${error instanceof Error ? error.message : error}`);
|
|
54
|
+
const approval = await approvePackageOperation("install", `pi install ${source}`, natives, ctx);
|
|
55
|
+
if (!approval.allowed) {
|
|
56
|
+
const output = approval.message ?? "install denied";
|
|
57
|
+
return text(output, createMutationDetails("install", source, approval.reason ?? "denied", output));
|
|
51
58
|
}
|
|
59
|
+
const output = await natives.install(source, approval.approved) || `Installed ${source}. Reload with /reload to activate.`;
|
|
60
|
+
return text(output, createMutationDetails("install", source, "succeeded", output));
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
export async function updatePackageWithPolicy(
|
|
@@ -56,14 +65,23 @@ export async function updatePackageWithPolicy(
|
|
|
56
65
|
natives: Pick<Natives, "security" | "update">,
|
|
57
66
|
ctx: ApprovalContext,
|
|
58
67
|
) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return text(`${out || `Updated ${source}.`} Reload with /reload to activate.`);
|
|
64
|
-
} catch (error) {
|
|
65
|
-
return text(`update failed: ${error instanceof Error ? error.message : error}`);
|
|
68
|
+
const approval = await approvePackageOperation("update", `pi update --extension ${source}`, natives, ctx);
|
|
69
|
+
if (!approval.allowed) {
|
|
70
|
+
const output = approval.message ?? "update denied";
|
|
71
|
+
return text(output, createMutationDetails("update", source, approval.reason ?? "denied", output));
|
|
66
72
|
}
|
|
73
|
+
const outcome = await natives.update(source, approval.approved);
|
|
74
|
+
if (outcome.alreadyUpToDate) {
|
|
75
|
+
const version = outcome.currentVersion ?? outcome.previousVersion;
|
|
76
|
+
const reason = outcome.pinned
|
|
77
|
+
? `pinned to ${version ?? "an exact version"} -- pi update intentionally leaves pinned packages unchanged; reinstall with a different (or no) version to move off the pin`
|
|
78
|
+
: `already up to date${version ? ` at ${version}` : ""}`;
|
|
79
|
+
const message = `${source} is ${reason}.`;
|
|
80
|
+
return text(message, createMutationDetails("update", source, "succeeded", outcome.output || message, false));
|
|
81
|
+
}
|
|
82
|
+
const transition = outcome.previousVersion && outcome.currentVersion ? ` (${outcome.previousVersion} → ${outcome.currentVersion})` : "";
|
|
83
|
+
const message = `${outcome.output || `Updated ${source}.`}${transition} Reload with /reload to activate.`;
|
|
84
|
+
return text(message, createMutationDetails("update", source, "succeeded", outcome.output || message, true));
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
export async function removePackageWithPolicy(
|
|
@@ -71,14 +89,13 @@ export async function removePackageWithPolicy(
|
|
|
71
89
|
natives: Pick<Natives, "security" | "remove">,
|
|
72
90
|
ctx: ApprovalContext,
|
|
73
91
|
) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return text(out || `Removed ${name}. Reload with /reload to deactivate.`);
|
|
79
|
-
} catch (error) {
|
|
80
|
-
return text(`remove failed: ${error instanceof Error ? error.message : error}`);
|
|
92
|
+
const approval = await approvePackageOperation("remove", `pi remove npm:${name}`, natives, ctx);
|
|
93
|
+
if (!approval.allowed) {
|
|
94
|
+
const output = approval.message ?? "remove denied";
|
|
95
|
+
return text(output, createMutationDetails("remove", name, approval.reason ?? "denied", output));
|
|
81
96
|
}
|
|
97
|
+
const output = await natives.remove(name, approval.approved) || `Removed ${name}. Reload with /reload to deactivate.`;
|
|
98
|
+
return text(output, createMutationDetails("remove", name, "succeeded", output));
|
|
82
99
|
}
|
|
83
100
|
|
|
84
101
|
export function registerTools(pi: ExtensionAPI, natives: Natives): void {
|
|
@@ -90,14 +107,17 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
|
|
|
90
107
|
query: Type.String({ description: "Search terms, e.g. 'lsp' or 'telegram'" }),
|
|
91
108
|
limit: Type.Optional(Type.Number({ description: "Max results (default 10, max 50)" })),
|
|
92
109
|
}),
|
|
110
|
+
renderCall(args, theme) { return renderPackageToolCall("Search packages", args, theme); },
|
|
111
|
+
renderResult(result, options, theme, context) { return renderPackageToolResult(result, options, theme, context); },
|
|
93
112
|
async execute(_id, params) {
|
|
94
113
|
try {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
114
|
+
const response = await natives.search(params.query, params.limit ?? 10);
|
|
115
|
+
const details = createSearchDetails(params.query, response.total, response.results);
|
|
116
|
+
if (response.results.length === 0) return text(`No Pi packages found for "${params.query}".`, details);
|
|
117
|
+
const lines = response.results.map((pkg, index) => `${index + 1}. ${pkg.name}@${pkg.version}\n ${pkg.description ?? ""}`);
|
|
118
|
+
return text(`Found ${response.total} pi package(s) (showing ${response.results.length}):\n\n${lines.join("\n")}`, details);
|
|
99
119
|
} catch (error) {
|
|
100
|
-
|
|
120
|
+
throw new Error(`pkg_search failed: ${error instanceof Error ? error.message : error}`);
|
|
101
121
|
}
|
|
102
122
|
},
|
|
103
123
|
});
|
|
@@ -107,6 +127,8 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
|
|
|
107
127
|
label: "Pi Package Info",
|
|
108
128
|
description: "Show bounded metadata and declared Pi resources for one package.",
|
|
109
129
|
parameters: Type.Object({ name: Type.String({ description: "npm package name" }) }),
|
|
130
|
+
renderCall(args, theme) { return renderPackageToolCall("Package info", args, theme); },
|
|
131
|
+
renderResult(result, options, theme, context) { return renderPackageToolResult(result, options, theme, context); },
|
|
110
132
|
async execute(_id, params) {
|
|
111
133
|
try {
|
|
112
134
|
const info = await natives.info(params.name);
|
|
@@ -119,9 +141,9 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
|
|
|
119
141
|
info.unpackedSize ? `size: ${(info.unpackedSize / 1024).toFixed(0)} KB` : "",
|
|
120
142
|
info.modified ? `modified: ${info.modified}` : "",
|
|
121
143
|
].filter(Boolean);
|
|
122
|
-
return text(lines.join("\n"),
|
|
144
|
+
return text(lines.join("\n"), createInfoDetails(info));
|
|
123
145
|
} catch (error) {
|
|
124
|
-
|
|
146
|
+
throw new Error(`pkg_info failed: ${error instanceof Error ? error.message : error}`);
|
|
125
147
|
}
|
|
126
148
|
},
|
|
127
149
|
});
|
|
@@ -131,6 +153,8 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
|
|
|
131
153
|
label: "Pi Package Install",
|
|
132
154
|
description: "Install a Pi package through the authenticated daemon. Operation-aware approval is secure by default.",
|
|
133
155
|
parameters: Type.Object({ source: Type.String({ description: "npm:, git:, or https source" }) }),
|
|
156
|
+
renderCall(args, theme) { return renderPackageToolCall("Install package", args, theme); },
|
|
157
|
+
renderResult(result, options, theme, context) { return renderPackageToolResult(result, options, theme, context); },
|
|
134
158
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
135
159
|
return installPackageWithPolicy(params.source, natives, ctx);
|
|
136
160
|
},
|
|
@@ -141,6 +165,8 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
|
|
|
141
165
|
label: "Pi Package Update",
|
|
142
166
|
description: "Update one configured Pi package through Pi's documented update command. Operation-aware approval is secure by default.",
|
|
143
167
|
parameters: Type.Object({ source: Type.String({ description: "configured npm:, git:, or https source" }) }),
|
|
168
|
+
renderCall(args, theme) { return renderPackageToolCall("Update package", args, theme); },
|
|
169
|
+
renderResult(result, options, theme, context) { return renderPackageToolResult(result, options, theme, context); },
|
|
144
170
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
145
171
|
return updatePackageWithPolicy(params.source, natives, ctx);
|
|
146
172
|
},
|
|
@@ -151,6 +177,8 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
|
|
|
151
177
|
label: "Pi Package Remove",
|
|
152
178
|
description: "Remove an installed npm Pi package through the authenticated daemon. Operation-aware approval is secure by default.",
|
|
153
179
|
parameters: Type.Object({ name: Type.String({ description: "bare npm name, e.g. pi-lsp or @scope/pkg" }) }),
|
|
180
|
+
renderCall(args, theme) { return renderPackageToolCall("Remove package", args, theme); },
|
|
181
|
+
renderResult(result, options, theme, context) { return renderPackageToolResult(result, options, theme, context); },
|
|
154
182
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
155
183
|
return removePackageWithPolicy(params.name, natives, ctx);
|
|
156
184
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-packed",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Package service for the Pi agent: search/info/install/updates + /packages TUI, backed by a long-running Bun service",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
"extension/src/index.ts"
|
|
21
21
|
]
|
|
22
22
|
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@danypops/daemon-kit": "^0.4.0"
|
|
25
|
+
},
|
|
23
26
|
"peerDependencies": {
|
|
24
27
|
"@earendil-works/pi-coding-agent": "*",
|
|
25
28
|
"@earendil-works/pi-tui": "*",
|
package/src/cli.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { buildSearchQuery, clampLimit } from "./ports.ts";
|
|
9
9
|
import type { Installer, Registry } from "./ports.ts";
|
|
10
|
-
import { readInstalledPackages } from "./installed.ts";
|
|
10
|
+
import { npmPackageName, readInstalledPackages } from "./installed.ts";
|
|
11
11
|
import { checkUpdates } from "./watcher.ts";
|
|
12
12
|
import { syncCatalog } from "./catalog.ts";
|
|
13
13
|
import { openDb, searchLocal, catalogList, getSyncMeta, latestVersion, dbPath } from "./db.ts";
|
|
@@ -246,10 +246,18 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
246
246
|
const source = pos[0] ?? "";
|
|
247
247
|
if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands["update"]!.usage}\n`);
|
|
248
248
|
try {
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
249
|
+
const outcome = await d.inst.update(source, { approved: flags.approved });
|
|
250
|
+
if (flags.json) return ok(`${JSON.stringify({ ok: true, source, ...outcome })}\n`);
|
|
251
|
+
if (outcome.alreadyUpToDate) {
|
|
252
|
+
const version = outcome.currentVersion ?? outcome.previousVersion;
|
|
253
|
+
const reason = outcome.pinned
|
|
254
|
+
? `is pinned to ${version ?? "an exact version"} — pi update intentionally leaves pinned packages unchanged; run \`packed install npm:${npmPackageName(source) ?? source}\` to move off the pin`
|
|
255
|
+
: `is already up to date${version ? ` at ${version}` : ""}`;
|
|
256
|
+
return ok(`${source} ${reason}\n`);
|
|
257
|
+
}
|
|
258
|
+
const transition =
|
|
259
|
+
outcome.previousVersion && outcome.currentVersion ? ` (${outcome.previousVersion} → ${outcome.currentVersion})` : "";
|
|
260
|
+
return ok(`${outcome.output}${transition}\nReload Pi with /reload to activate the updated package.\n`);
|
|
253
261
|
} catch (error) {
|
|
254
262
|
const message = error instanceof Error ? error.message : String(error);
|
|
255
263
|
return flags.json
|
package/src/client.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import type { InstalledPkg, Installer, PkgInfo, Registry, SearchPage, UpdateEntry, UpdatesSnapshot } from "./ports.ts";
|
|
8
|
+
import type { InstalledPkg, Installer, PkgInfo, Registry, SearchPage, UpdateEntry, UpdateOutcome, UpdatesSnapshot } from "./ports.ts";
|
|
9
9
|
import { HttpRegistry } from "./registry.ts";
|
|
10
10
|
import { DAEMON_HOST, PROBE_TIMEOUT_MS, REGISTRY_FETCH_TIMEOUT_MS, PORT_FILE, TOKEN_FILE } from "./constants.ts";
|
|
11
11
|
import type { MutationApproval, SecuritySettings } from "./security.ts";
|
|
@@ -21,7 +21,7 @@ export interface PackageDaemonPort {
|
|
|
21
21
|
setMutationApproval(value: MutationApproval, approved?: boolean): Promise<SecuritySettings>;
|
|
22
22
|
install(source: string, approved?: boolean): Promise<string>;
|
|
23
23
|
remove(name: string, approved?: boolean): Promise<string>;
|
|
24
|
-
update(source: string, approved?: boolean): Promise<
|
|
24
|
+
update(source: string, approved?: boolean): Promise<UpdateOutcome>;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
interface MutationResponse {
|
|
@@ -29,6 +29,8 @@ interface MutationResponse {
|
|
|
29
29
|
output: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
interface UpdateMutationResponse extends MutationResponse, Partial<Omit<UpdateOutcome, "output">> {}
|
|
33
|
+
|
|
32
34
|
export class PackageDaemonError extends Error {
|
|
33
35
|
constructor(
|
|
34
36
|
message: string,
|
|
@@ -115,13 +117,23 @@ export class PackageDaemonClient implements PackageDaemonPort {
|
|
|
115
117
|
return result.output;
|
|
116
118
|
}
|
|
117
119
|
|
|
118
|
-
async update(source: string, approved = false): Promise<
|
|
119
|
-
const result = await this.request<
|
|
120
|
+
async update(source: string, approved = false): Promise<UpdateOutcome> {
|
|
121
|
+
const result = await this.request<UpdateMutationResponse>("/update", {
|
|
120
122
|
method: "POST",
|
|
121
123
|
body: JSON.stringify({ source, approved }),
|
|
122
124
|
});
|
|
123
125
|
if (!result.ok) throw new PackageDaemonError(result.output || `failed to update ${source}`, "update");
|
|
124
|
-
|
|
126
|
+
// Older daemons (pre-honest-update) only ever sent {ok, output}; default
|
|
127
|
+
// to the historical "assume it changed" signal rather than claiming
|
|
128
|
+
// certainty the response doesn't actually contain.
|
|
129
|
+
return {
|
|
130
|
+
output: result.output,
|
|
131
|
+
reloadRequired: result.reloadRequired ?? true,
|
|
132
|
+
alreadyUpToDate: result.alreadyUpToDate ?? false,
|
|
133
|
+
pinned: result.pinned ?? false,
|
|
134
|
+
previousVersion: result.previousVersion,
|
|
135
|
+
currentVersion: result.currentVersion,
|
|
136
|
+
};
|
|
125
137
|
}
|
|
126
138
|
}
|
|
127
139
|
|
|
@@ -139,7 +151,7 @@ export class PackageDaemonInstaller implements Installer {
|
|
|
139
151
|
return this.client.remove(source.slice(4), options?.approved);
|
|
140
152
|
}
|
|
141
153
|
|
|
142
|
-
update(source: string, options?: { approved?: boolean }): Promise<
|
|
154
|
+
update(source: string, options?: { approved?: boolean }): Promise<UpdateOutcome> {
|
|
143
155
|
return this.client.update(source, options?.approved);
|
|
144
156
|
}
|
|
145
157
|
}
|
|
@@ -165,7 +177,7 @@ export class DaemonBackedInstaller implements Installer {
|
|
|
165
177
|
return new PackageDaemonInstaller(await connectPackageDaemon(this.stateDirectory)).remove(source, options);
|
|
166
178
|
}
|
|
167
179
|
|
|
168
|
-
async update(source: string, options?: { approved?: boolean }): Promise<
|
|
180
|
+
async update(source: string, options?: { approved?: boolean }): Promise<UpdateOutcome> {
|
|
169
181
|
return (await connectPackageDaemon(this.stateDirectory)).update(source, options?.approved);
|
|
170
182
|
}
|
|
171
183
|
}
|
package/src/constants.ts
CHANGED
|
@@ -12,6 +12,16 @@ export const SEARCH_MAX_LIMIT = 50;
|
|
|
12
12
|
export const SEARCH_PAGE_SIZE = 250; // npm registry max page size
|
|
13
13
|
export const PI_PACKAGE_KEYWORD = "keywords:pi-package";
|
|
14
14
|
|
|
15
|
+
// --- Native tool presentation bounds ---
|
|
16
|
+
export const TOOL_MODEL_CONTENT_MAX_CHARACTERS = 2_000;
|
|
17
|
+
export const TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS = 32_000;
|
|
18
|
+
export const TOOL_DETAILS_MAX_PACKAGES = 50;
|
|
19
|
+
export const TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS = 240;
|
|
20
|
+
export const TOOL_DETAILS_MAX_OUTPUT_CHARACTERS = 1_000;
|
|
21
|
+
export const TOOL_DETAILS_MAX_KEYWORDS = 20;
|
|
22
|
+
export const TOOL_DETAILS_MAX_CAPABILITIES = 12;
|
|
23
|
+
export const TOOL_COLLAPSED_PACKAGE_PREVIEW = 3;
|
|
24
|
+
|
|
15
25
|
// --- Upstream etiquette (429s) ---
|
|
16
26
|
export const RETRY_MAX_ATTEMPTS = 6;
|
|
17
27
|
export const RETRY_BASE_DELAY_MS = 2_000; // 2+4+8+16+32s spans npm's ~60s search window
|
package/src/install.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** install.ts — driven adapter: pi CLI mutations via Bun.spawn. */
|
|
2
|
-
import
|
|
2
|
+
import { defaultPiHome, isPinnedNpmSource, readResolvedVersion } from "./installed.ts";
|
|
3
|
+
import type { Installer, UpdateOutcome } from "./ports.ts";
|
|
3
4
|
|
|
4
5
|
/** Bare npm package name (for `packed remove`). */
|
|
5
6
|
export const NAME_RE = /^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/;
|
|
@@ -9,7 +10,10 @@ export function defaultPiBin(): string {
|
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export class ExecInstaller implements Installer {
|
|
12
|
-
constructor(
|
|
13
|
+
constructor(
|
|
14
|
+
private bin = defaultPiBin(),
|
|
15
|
+
private piHome = defaultPiHome(),
|
|
16
|
+
) {}
|
|
13
17
|
|
|
14
18
|
private async run(args: string[]): Promise<string> {
|
|
15
19
|
const proc = Bun.spawn([this.bin, ...args], { stdout: "pipe", stderr: "pipe" });
|
|
@@ -31,7 +35,18 @@ export class ExecInstaller implements Installer {
|
|
|
31
35
|
return this.run(["remove", source]);
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
update(source: string, _options?: { approved?: boolean }): Promise<
|
|
35
|
-
|
|
38
|
+
async update(source: string, _options?: { approved?: boolean }): Promise<UpdateOutcome> {
|
|
39
|
+
const pinned = isPinnedNpmSource(source);
|
|
40
|
+
const previousVersion = readResolvedVersion(this.piHome, source);
|
|
41
|
+
const output = await this.run(["update", "--extension", source]);
|
|
42
|
+
const currentVersion = readResolvedVersion(this.piHome, source);
|
|
43
|
+
// Only trust a "nothing changed" conclusion when we actually read a
|
|
44
|
+
// real version both before and after (npm source, resolvable in
|
|
45
|
+
// node_modules). Otherwise (git:/https: sources, or an unreadable
|
|
46
|
+
// node_modules entry) fall back to the traditional "assume it may
|
|
47
|
+
// have changed" signal instead of falsely claiming it didn't.
|
|
48
|
+
const knowsBoth = previousVersion !== undefined && currentVersion !== undefined;
|
|
49
|
+
const changed = !knowsBoth || previousVersion !== currentVersion;
|
|
50
|
+
return { output, reloadRequired: changed, alreadyUpToDate: !changed, pinned, previousVersion, currentVersion };
|
|
36
51
|
}
|
|
37
52
|
}
|
package/src/installed.ts
CHANGED
|
@@ -30,6 +30,36 @@ function nodeModulesVersion(piHome: string, name: string): string | undefined {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* True when a configured npm: source pins an exact version, e.g.
|
|
35
|
+
* "npm:@scope/pkg@1.2.3" vs. the floating "npm:@scope/pkg". `pi update`
|
|
36
|
+
* intentionally leaves pinned sources unchanged (see readInstalledPackages)
|
|
37
|
+
* but still exits 0 and prints "Updated <source>" either way -- callers
|
|
38
|
+
* must not treat that text as proof anything changed.
|
|
39
|
+
*/
|
|
40
|
+
export function isPinnedNpmSource(source: string): boolean {
|
|
41
|
+
if (!source.startsWith("npm:")) return false;
|
|
42
|
+
const [, pinned] = splitNpmSource(source.slice(4));
|
|
43
|
+
return pinned !== "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The bare npm package name for a configured npm: source, pinned or not.
|
|
47
|
+
* undefined for git:/https: sources -- there is no npm-registry name to read. */
|
|
48
|
+
export function npmPackageName(source: string): string | undefined {
|
|
49
|
+
if (!source.startsWith("npm:")) return undefined;
|
|
50
|
+
const [name] = splitNpmSource(source.slice(4));
|
|
51
|
+
return name;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reads a single npm package's real on-disk resolved version, regardless of
|
|
55
|
+
* whether its configured source is pinned -- ground truth for detecting
|
|
56
|
+
* whether an update actually changed anything. undefined for non-npm
|
|
57
|
+
* sources or when node_modules has no matching package.json to read. */
|
|
58
|
+
export function readResolvedVersion(piHome: string, source: string): string | undefined {
|
|
59
|
+
const name = npmPackageName(source);
|
|
60
|
+
return name ? nodeModulesVersion(piHome, name) : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
export function readInstalledPackages(piHome: string): InstalledPkg[] {
|
|
34
64
|
let settings: { packages?: unknown[] };
|
|
35
65
|
try {
|
package/src/ports.ts
CHANGED
|
@@ -37,11 +37,30 @@ export interface Registry {
|
|
|
37
37
|
info(name: string): Promise<PkgInfo>;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* `pi update --extension <source>` exits 0 and prints "Updated <source>"
|
|
42
|
+
* whether or not anything actually changed -- verified empirically against
|
|
43
|
+
* both a pinned, already-current source and an unpinned, already-latest
|
|
44
|
+
* one. reloadRequired/alreadyUpToDate are ground truth (on-disk resolved
|
|
45
|
+
* version, before vs. after), not that text. previousVersion/currentVersion
|
|
46
|
+
* are omitted when unknown (git:/https: sources, or no node_modules entry
|
|
47
|
+
* to read) -- reloadRequired then conservatively stays true rather than
|
|
48
|
+
* guessing.
|
|
49
|
+
*/
|
|
50
|
+
export interface UpdateOutcome {
|
|
51
|
+
output: string;
|
|
52
|
+
reloadRequired: boolean;
|
|
53
|
+
alreadyUpToDate: boolean;
|
|
54
|
+
pinned: boolean;
|
|
55
|
+
previousVersion?: string;
|
|
56
|
+
currentVersion?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
/** Driven port: pi CLI mutations. */
|
|
41
60
|
export interface Installer {
|
|
42
61
|
install(source: string, options?: { approved?: boolean }): Promise<string>;
|
|
43
62
|
remove(source: string, options?: { approved?: boolean }): Promise<string>;
|
|
44
|
-
update(source: string, options?: { approved?: boolean }): Promise<
|
|
63
|
+
update(source: string, options?: { approved?: boolean }): Promise<UpdateOutcome>;
|
|
45
64
|
}
|
|
46
65
|
|
|
47
66
|
export interface InstalledPkg {
|
package/src/service.ts
CHANGED
|
@@ -182,8 +182,8 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
182
182
|
const denied = authorize("update", approved);
|
|
183
183
|
if (denied) return denied;
|
|
184
184
|
try {
|
|
185
|
-
const
|
|
186
|
-
return json({ ok: true, source,
|
|
185
|
+
const outcome = await deps.inst.update(source, { approved });
|
|
186
|
+
return json({ ok: true, source, ...outcome });
|
|
187
187
|
} catch (error) {
|
|
188
188
|
return json({ ok: false, source, output: error instanceof Error ? error.message : String(error), reloadRequired: false });
|
|
189
189
|
}
|