@aliou/pi-dev-kit 0.4.9 → 0.5.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.
@@ -0,0 +1,596 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { ToolBody, ToolCallHeader, ToolFooter } from "@aliou/pi-utils-ui";
4
+ import type {
5
+ AgentToolResult,
6
+ ExtensionAPI,
7
+ ExtensionContext,
8
+ Theme,
9
+ ToolRenderResultOptions,
10
+ } from "@mariozechner/pi-coding-agent";
11
+ import { keyHint, VERSION } from "@mariozechner/pi-coding-agent";
12
+ import { Text } from "@mariozechner/pi-tui";
13
+ import { type Static, Type } from "@sinclair/typebox";
14
+ import { findPiInstallation } from "./utils";
15
+
16
+ const GITHUB_RAW_CHANGELOG_URL =
17
+ "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/CHANGELOG.md";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Params
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const ChangelogParams = Type.Object({
24
+ version: Type.Optional(
25
+ Type.String({
26
+ description:
27
+ "Specific version to get changelog for. If not provided, returns latest version.",
28
+ }),
29
+ ),
30
+ });
31
+
32
+ type ChangelogParamsType = Static<typeof ChangelogParams>;
33
+
34
+ const ChangelogVersionsParams = Type.Object({});
35
+ type ChangelogVersionsParamsType = Record<string, never>;
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Types
39
+ // ---------------------------------------------------------------------------
40
+
41
+ interface ChangelogEntry {
42
+ version: string;
43
+ content: string;
44
+ }
45
+
46
+ interface ChangelogDetails {
47
+ success: boolean;
48
+ message: string;
49
+ changelog?: ChangelogEntry;
50
+ source?: "local" | "github";
51
+ }
52
+
53
+ interface ChangelogVersionsDetails {
54
+ success: boolean;
55
+ message: string;
56
+ versions?: string[];
57
+ source?: "local" | "github";
58
+ }
59
+
60
+ type ExecuteResult = AgentToolResult<ChangelogDetails>;
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Parsing
64
+ // ---------------------------------------------------------------------------
65
+
66
+ interface ParsedChangelog {
67
+ entries: Array<{ version: string; content: string }>;
68
+ }
69
+
70
+ function parseChangelogEntries(changelogContent: string): ParsedChangelog {
71
+ const lines = changelogContent.split("\n");
72
+ const entries: Array<{
73
+ version: string;
74
+ content: string;
75
+ lineStart: number;
76
+ lineEnd: number;
77
+ }> = [];
78
+
79
+ for (let i = 0; i < lines.length; i++) {
80
+ const line = lines[i];
81
+ if (!line) continue;
82
+ const versionMatch = line.trim().match(/^#+\s*(?:\[([^\]]+)\]|([^[\s]+))/);
83
+ if (versionMatch) {
84
+ const version = versionMatch[1] || versionMatch[2];
85
+ if (version && /^v?\d+\.\d+/.test(version)) {
86
+ entries.push({ version, content: "", lineStart: i, lineEnd: -1 });
87
+ }
88
+ }
89
+ }
90
+
91
+ for (let i = 0; i < entries.length; i++) {
92
+ const entry = entries[i];
93
+ if (!entry) continue;
94
+ const nextEntry = entries[i + 1];
95
+ const nextStart = nextEntry ? nextEntry.lineStart : lines.length;
96
+ entry.lineEnd = nextStart;
97
+
98
+ const contentLines = lines.slice(entry.lineStart + 1, entry.lineEnd);
99
+ const rawContent = contentLines.join("\n").trim();
100
+
101
+ const cleanContent = rawContent
102
+ .replace(/^-+$|^=+$|^\*+$|^#+$/gm, "")
103
+ .trim();
104
+ if (!cleanContent || cleanContent.length < 10) {
105
+ entry.content =
106
+ "[Empty changelog entry - no details provided for this version]";
107
+ } else {
108
+ entry.content = rawContent;
109
+ }
110
+ }
111
+
112
+ return { entries };
113
+ }
114
+
115
+ function findChangelogEntry(
116
+ changelogContent: string,
117
+ requestedVersion?: string,
118
+ ): {
119
+ success: boolean;
120
+ changelog?: ChangelogEntry;
121
+ message: string;
122
+ } {
123
+ try {
124
+ const { entries } = parseChangelogEntries(changelogContent);
125
+ if (entries.length === 0) {
126
+ return { success: false, message: "No version entries found" };
127
+ }
128
+
129
+ if (requestedVersion) {
130
+ const normalizedRequested = requestedVersion.replace(/^v/, "");
131
+ const entry = entries.find(
132
+ (e) =>
133
+ e.version === requestedVersion ||
134
+ e.version === `v${normalizedRequested}` ||
135
+ e.version.replace(/^v/, "") === normalizedRequested,
136
+ );
137
+
138
+ if (entry) {
139
+ return {
140
+ success: true,
141
+ changelog: { version: entry.version, content: entry.content },
142
+ message: `Found changelog for version ${entry.version}`,
143
+ };
144
+ }
145
+
146
+ const allVersions = entries.map((e) => e.version);
147
+ return {
148
+ success: false,
149
+ message: `Version ${requestedVersion} not found. Available: ${allVersions.join(", ")}`,
150
+ };
151
+ }
152
+
153
+ const latest = entries[0];
154
+ if (!latest) {
155
+ return { success: false, message: "No version entries found" };
156
+ }
157
+ return {
158
+ success: true,
159
+ changelog: { version: latest.version, content: latest.content },
160
+ message: `Latest changelog entry: ${latest.version}`,
161
+ };
162
+ } catch (error) {
163
+ return {
164
+ success: false,
165
+ message: `Error parsing changelog: ${error instanceof Error ? error.message : String(error)}`,
166
+ };
167
+ }
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Helpers
172
+ // ---------------------------------------------------------------------------
173
+
174
+ function isNewerThanInstalled(requestedVersion: string): boolean {
175
+ const normalize = (v: string) => v.replace(/^v/, "");
176
+ const req = normalize(requestedVersion);
177
+ const installed = normalize(VERSION);
178
+ if (req === installed) return false;
179
+
180
+ const reqParts = req.split(".").map(Number);
181
+ const instParts = installed.split(".").map(Number);
182
+ for (let i = 0; i < Math.max(reqParts.length, instParts.length); i++) {
183
+ const r = reqParts[i] ?? 0;
184
+ const inst = instParts[i] ?? 0;
185
+ if (r > inst) return true;
186
+ if (r < inst) return false;
187
+ }
188
+ return false;
189
+ }
190
+
191
+ async function fetchGithubChangelog(): Promise<string | null> {
192
+ try {
193
+ const res = await fetch(GITHUB_RAW_CHANGELOG_URL);
194
+ if (!res.ok) return null;
195
+ return await res.text();
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ function readLocalChangelog(): { content: string; piPath: string } | null {
202
+ const piPath = findPiInstallation();
203
+ if (!piPath) return null;
204
+ const changelogPath = path.join(piPath, "CHANGELOG.md");
205
+ if (!fs.existsSync(changelogPath)) return null;
206
+ return { content: fs.readFileSync(changelogPath, "utf-8"), piPath };
207
+ }
208
+
209
+ /** Max lines shown when collapsed. */
210
+ const COLLAPSED_LINES = 8;
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Render helpers
214
+ // ---------------------------------------------------------------------------
215
+
216
+ function renderChangelogContent(
217
+ content: string,
218
+ theme: Theme,
219
+ maxLines?: number,
220
+ ): string[] {
221
+ const allLines = content.split("\n");
222
+ const truncated = maxLines != null && allLines.length > maxLines;
223
+ const linesToRender = truncated ? allLines.slice(0, maxLines) : allLines;
224
+
225
+ const out: string[] = [];
226
+ for (const line of linesToRender) {
227
+ if (line.trim().startsWith("###")) {
228
+ out.push(theme.fg("warning", line));
229
+ } else if (line.trim().startsWith("##")) {
230
+ out.push(theme.fg("accent", line));
231
+ } else if (line.trim().startsWith("#")) {
232
+ out.push(theme.fg("accent", theme.bold(line)));
233
+ } else if (line.trim().startsWith("-") || line.trim().startsWith("*")) {
234
+ out.push(theme.fg("dim", line));
235
+ } else {
236
+ out.push(line);
237
+ }
238
+ }
239
+
240
+ if (truncated) {
241
+ out.push(theme.fg("muted", "..."));
242
+ }
243
+
244
+ return out;
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // pi_changelog
249
+ // ---------------------------------------------------------------------------
250
+
251
+ export function setupChangelogTool(pi: ExtensionAPI) {
252
+ pi.registerTool<typeof ChangelogParams, ChangelogDetails>({
253
+ name: "pi_changelog",
254
+ label: "Pi Changelog",
255
+ description:
256
+ "Get changelog entry for a Pi version. Returns latest by default. Use pi_changelog_versions to list all available versions.",
257
+
258
+ parameters: ChangelogParams,
259
+
260
+ async execute(
261
+ _toolCallId: string,
262
+ params: ChangelogParamsType,
263
+ _signal: AbortSignal | undefined,
264
+ _onUpdate: unknown,
265
+ _ctx: ExtensionContext,
266
+ ): Promise<ExecuteResult> {
267
+ try {
268
+ // Newer than installed -> fetch from GitHub
269
+ if (params.version && isNewerThanInstalled(params.version)) {
270
+ const githubContent = await fetchGithubChangelog();
271
+ if (!githubContent) {
272
+ return {
273
+ content: [
274
+ {
275
+ type: "text",
276
+ text: `Version ${params.version} is newer than installed (${VERSION}) and GitHub fetch failed.`,
277
+ },
278
+ ],
279
+ details: {
280
+ success: false,
281
+ message: `Version ${params.version} is newer than installed (${VERSION}) and GitHub fetch failed.`,
282
+ },
283
+ };
284
+ }
285
+
286
+ const result = findChangelogEntry(githubContent, params.version);
287
+ if (!result.success || !result.changelog) {
288
+ return {
289
+ content: [{ type: "text", text: result.message }],
290
+ details: {
291
+ success: false,
292
+ message: result.message,
293
+ source: "github",
294
+ },
295
+ };
296
+ }
297
+
298
+ const message = `${result.message} (from GitHub)\n\n## ${result.changelog.version}\n\n${result.changelog.content}`;
299
+ return {
300
+ content: [{ type: "text", text: message }],
301
+ details: {
302
+ success: true,
303
+ message: `${result.message} (from GitHub)`,
304
+ changelog: result.changelog,
305
+ source: "github",
306
+ },
307
+ };
308
+ }
309
+
310
+ // Local
311
+ const local = readLocalChangelog();
312
+ if (!local) {
313
+ return {
314
+ content: [
315
+ {
316
+ type: "text",
317
+ text: "Could not locate Pi installation or CHANGELOG.md",
318
+ },
319
+ ],
320
+ details: {
321
+ success: false,
322
+ message: "Could not locate Pi installation or CHANGELOG.md",
323
+ },
324
+ };
325
+ }
326
+
327
+ const result = findChangelogEntry(local.content, params.version);
328
+ if (!result.success || !result.changelog) {
329
+ return {
330
+ content: [{ type: "text", text: result.message }],
331
+ details: { success: false, message: result.message },
332
+ };
333
+ }
334
+
335
+ const { changelog } = result;
336
+ const message = `${result.message}\n\n## ${changelog.version}\n\n${changelog.content}`;
337
+ return {
338
+ content: [{ type: "text", text: message }],
339
+ details: {
340
+ success: true,
341
+ message: result.message,
342
+ changelog,
343
+ source: "local",
344
+ },
345
+ };
346
+ } catch (error) {
347
+ const message = `Error reading Pi changelog: ${error instanceof Error ? error.message : String(error)}`;
348
+ return {
349
+ content: [{ type: "text", text: message }],
350
+ details: { success: false, message },
351
+ };
352
+ }
353
+ },
354
+
355
+ renderCall(args: ChangelogParamsType, theme: Theme) {
356
+ return new ToolCallHeader(
357
+ {
358
+ toolName: "Pi Changelog",
359
+ mainArg: args.version ? `v${args.version}` : "latest",
360
+ },
361
+ theme,
362
+ );
363
+ },
364
+
365
+ renderResult(
366
+ result: AgentToolResult<ChangelogDetails>,
367
+ options: ToolRenderResultOptions,
368
+ theme: Theme,
369
+ ) {
370
+ const { details } = result;
371
+
372
+ if (!details) {
373
+ const text = result.content[0];
374
+ return new Text(
375
+ text?.type === "text" && text.text ? text.text : "No result",
376
+ 0,
377
+ 0,
378
+ );
379
+ }
380
+
381
+ const fields: Array<
382
+ { label: string; value: string; showCollapsed?: boolean } | Text
383
+ > = [];
384
+
385
+ if (!details.success) {
386
+ fields.push({
387
+ label: "Error",
388
+ value: theme.fg("error", details.message),
389
+ showCollapsed: true,
390
+ });
391
+ } else if (!details.changelog) {
392
+ fields.push({
393
+ label: "Result",
394
+ value: theme.fg("success", details.message),
395
+ showCollapsed: true,
396
+ });
397
+ } else {
398
+ const lines: string[] = [];
399
+ const sourceTag =
400
+ details.source === "github" ? theme.fg("muted", " (github)") : "";
401
+ lines.push(theme.fg("success", details.message) + sourceTag, "");
402
+ lines.push(
403
+ theme.fg("accent", `Version: ${details.changelog.version}`),
404
+ "",
405
+ );
406
+ lines.push(
407
+ ...renderChangelogContent(
408
+ details.changelog.content,
409
+ theme,
410
+ options.expanded ? undefined : COLLAPSED_LINES,
411
+ ),
412
+ );
413
+
414
+ if (!options.expanded) {
415
+ lines.push(
416
+ "",
417
+ theme.fg("muted", `${keyHint("app.tools.expand", "to expand")}`),
418
+ );
419
+ }
420
+
421
+ fields.push(new Text(lines.join("\n"), 0, 0));
422
+ }
423
+
424
+ return new ToolBody(
425
+ {
426
+ fields,
427
+ footer: new ToolFooter(theme, {
428
+ items: [
429
+ {
430
+ label: "status",
431
+ value: details.success ? "ok" : "error",
432
+ tone: details.success ? "success" : "error",
433
+ },
434
+ {
435
+ label: "source",
436
+ value: details.source ?? "local",
437
+ tone: "accent",
438
+ },
439
+ ],
440
+ }),
441
+ },
442
+ options,
443
+ theme,
444
+ );
445
+ },
446
+ });
447
+
448
+ // -------------------------------------------------------------------------
449
+ // pi_changelog_versions
450
+ // -------------------------------------------------------------------------
451
+
452
+ pi.registerTool<typeof ChangelogVersionsParams, ChangelogVersionsDetails>({
453
+ name: "pi_changelog_versions",
454
+ label: "Pi Changelog Versions",
455
+ description: "List all available Pi changelog versions",
456
+
457
+ parameters: ChangelogVersionsParams,
458
+
459
+ async execute(
460
+ _toolCallId: string,
461
+ _params: ChangelogVersionsParamsType,
462
+ _signal: AbortSignal | undefined,
463
+ _onUpdate: unknown,
464
+ _ctx: ExtensionContext,
465
+ ): Promise<AgentToolResult<ChangelogVersionsDetails>> {
466
+ try {
467
+ const local = readLocalChangelog();
468
+ if (!local) {
469
+ return {
470
+ content: [
471
+ {
472
+ type: "text",
473
+ text: "Could not locate Pi installation or CHANGELOG.md",
474
+ },
475
+ ],
476
+ details: {
477
+ success: false,
478
+ message: "Could not locate Pi installation or CHANGELOG.md",
479
+ },
480
+ };
481
+ }
482
+
483
+ const { entries } = parseChangelogEntries(local.content);
484
+ if (entries.length === 0) {
485
+ return {
486
+ content: [
487
+ { type: "text", text: "No version entries found in changelog" },
488
+ ],
489
+ details: {
490
+ success: false,
491
+ message: "No version entries found in changelog",
492
+ },
493
+ };
494
+ }
495
+
496
+ const versions = entries.map((e) => e.version);
497
+ const message = `${versions.length} versions available:\n${versions.join(", ")}`;
498
+
499
+ return {
500
+ content: [{ type: "text", text: message }],
501
+ details: {
502
+ success: true,
503
+ message: `Found ${versions.length} versions`,
504
+ versions,
505
+ source: "local",
506
+ },
507
+ };
508
+ } catch (error) {
509
+ const message = `Error reading changelog: ${error instanceof Error ? error.message : String(error)}`;
510
+ return {
511
+ content: [{ type: "text", text: message }],
512
+ details: { success: false, message },
513
+ };
514
+ }
515
+ },
516
+
517
+ renderCall(_args: ChangelogVersionsParamsType, theme: Theme) {
518
+ return new ToolCallHeader({ toolName: "Pi Changelog Versions" }, theme);
519
+ },
520
+
521
+ renderResult(
522
+ result: AgentToolResult<ChangelogVersionsDetails>,
523
+ options: ToolRenderResultOptions,
524
+ theme: Theme,
525
+ ) {
526
+ const { details } = result;
527
+
528
+ if (!details) {
529
+ const text = result.content[0];
530
+ return new Text(
531
+ text?.type === "text" && text.text ? text.text : "No result",
532
+ 0,
533
+ 0,
534
+ );
535
+ }
536
+
537
+ const fields: Array<
538
+ { label: string; value: string; showCollapsed?: boolean } | Text
539
+ > = [];
540
+
541
+ if (!details.success) {
542
+ fields.push({
543
+ label: "Error",
544
+ value: theme.fg("error", details.message),
545
+ showCollapsed: true,
546
+ });
547
+ } else if (!details.versions || details.versions.length === 0) {
548
+ fields.push({
549
+ label: "Result",
550
+ value: theme.fg("warning", "No versions found"),
551
+ showCollapsed: true,
552
+ });
553
+ } else {
554
+ const lines: string[] = [
555
+ theme.fg("accent", `${details.versions.length} versions available:`),
556
+ "",
557
+ ];
558
+ const cols = 6;
559
+ const maxLen = Math.max(
560
+ ...details.versions.map((version) => version.length),
561
+ );
562
+ const colWidth = maxLen + 2;
563
+ for (let i = 0; i < details.versions.length; i += cols) {
564
+ const row = details.versions
565
+ .slice(i, i + cols)
566
+ .map((version) => version.padEnd(colWidth))
567
+ .join("");
568
+ lines.push(theme.fg("dim", row));
569
+ }
570
+ fields.push(new Text(lines.join("\n"), 0, 0));
571
+ }
572
+
573
+ return new ToolBody(
574
+ {
575
+ fields,
576
+ footer: new ToolFooter(theme, {
577
+ items: [
578
+ {
579
+ label: "status",
580
+ value: details.success ? "ok" : "error",
581
+ tone: details.success ? "success" : "error",
582
+ },
583
+ {
584
+ label: "versions",
585
+ value: String(details.versions?.length ?? 0),
586
+ tone: "accent",
587
+ },
588
+ ],
589
+ }),
590
+ },
591
+ options,
592
+ theme,
593
+ );
594
+ },
595
+ });
596
+ }