@aliou/pi-dev-kit 0.4.9 → 0.6.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,484 @@
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 ChangelogParamsSchema = 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 ChangelogParams = Static<typeof ChangelogParamsSchema>;
33
+
34
+ const ChangelogVersionsParamsSchema = Type.Object({});
35
+ type ChangelogVersionsParams = Record<string, never>;
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Types
39
+ // ---------------------------------------------------------------------------
40
+
41
+ interface ChangelogEntry {
42
+ version: string;
43
+ content: string;
44
+ }
45
+
46
+ interface ChangelogDetails {
47
+ changelog?: ChangelogEntry;
48
+ source?: "local" | "github";
49
+ }
50
+
51
+ interface ChangelogVersionsDetails {
52
+ versions?: string[];
53
+ source?: "local" | "github";
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Parsing
58
+ // ---------------------------------------------------------------------------
59
+
60
+ interface ParsedChangelog {
61
+ entries: Array<{ version: string; content: string }>;
62
+ }
63
+
64
+ function parseChangelogEntries(changelogContent: string): ParsedChangelog {
65
+ const lines = changelogContent.split("\n");
66
+ const entries: Array<{
67
+ version: string;
68
+ content: string;
69
+ lineStart: number;
70
+ lineEnd: number;
71
+ }> = [];
72
+
73
+ for (let i = 0; i < lines.length; i++) {
74
+ const line = lines[i];
75
+ if (!line) continue;
76
+ const versionMatch = line.trim().match(/^#+\s*(?:\[([^\]]+)\]|([^[\s]+))/);
77
+ if (versionMatch) {
78
+ const version = versionMatch[1] || versionMatch[2];
79
+ if (version && /^v?\d+\.\d+/.test(version)) {
80
+ entries.push({ version, content: "", lineStart: i, lineEnd: -1 });
81
+ }
82
+ }
83
+ }
84
+
85
+ for (let i = 0; i < entries.length; i++) {
86
+ const entry = entries[i];
87
+ if (!entry) continue;
88
+ const nextEntry = entries[i + 1];
89
+ const nextStart = nextEntry ? nextEntry.lineStart : lines.length;
90
+ entry.lineEnd = nextStart;
91
+
92
+ const contentLines = lines.slice(entry.lineStart + 1, entry.lineEnd);
93
+ const rawContent = contentLines.join("\n").trim();
94
+
95
+ const cleanContent = rawContent
96
+ .replace(/^-+$|^=+$|^\*+$|^#+$/gm, "")
97
+ .trim();
98
+ if (!cleanContent || cleanContent.length < 10) {
99
+ entry.content =
100
+ "[Empty changelog entry - no details provided for this version]";
101
+ } else {
102
+ entry.content = rawContent;
103
+ }
104
+ }
105
+
106
+ return { entries };
107
+ }
108
+
109
+ function findChangelogEntry(
110
+ changelogContent: string,
111
+ requestedVersion?: string,
112
+ ): ChangelogEntry {
113
+ const { entries } = parseChangelogEntries(changelogContent);
114
+ if (entries.length === 0) {
115
+ throw new Error("No version entries found in changelog");
116
+ }
117
+
118
+ if (requestedVersion) {
119
+ const normalizedRequested = requestedVersion.replace(/^v/, "");
120
+ const entry = entries.find(
121
+ (e) =>
122
+ e.version === requestedVersion ||
123
+ e.version === `v${normalizedRequested}` ||
124
+ e.version.replace(/^v/, "") === normalizedRequested,
125
+ );
126
+
127
+ if (entry) {
128
+ return { version: entry.version, content: entry.content };
129
+ }
130
+
131
+ const allVersions = entries.map((e) => e.version);
132
+ throw new Error(
133
+ `Version ${requestedVersion} not found. Available versions: ${allVersions.join(", ")}`,
134
+ );
135
+ }
136
+
137
+ const latest = entries[0];
138
+ if (!latest) {
139
+ throw new Error("No version entries found in changelog");
140
+ }
141
+ return { version: latest.version, content: latest.content };
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Helpers
146
+ // ---------------------------------------------------------------------------
147
+
148
+ function isNewerThanInstalled(requestedVersion: string): boolean {
149
+ const normalize = (v: string) => v.replace(/^v/, "");
150
+ const req = normalize(requestedVersion);
151
+ const installed = normalize(VERSION);
152
+ if (req === installed) return false;
153
+
154
+ const reqParts = req.split(".").map(Number);
155
+ const instParts = installed.split(".").map(Number);
156
+ for (let i = 0; i < Math.max(reqParts.length, instParts.length); i++) {
157
+ const r = reqParts[i] ?? 0;
158
+ const inst = instParts[i] ?? 0;
159
+ if (r > inst) return true;
160
+ if (r < inst) return false;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ async function fetchGithubChangelog(): Promise<string> {
166
+ try {
167
+ const res = await fetch(GITHUB_RAW_CHANGELOG_URL);
168
+ if (!res.ok) {
169
+ throw new Error(
170
+ `Failed to fetch changelog from GitHub: ${res.status} ${res.statusText}`,
171
+ );
172
+ }
173
+ return await res.text();
174
+ } catch (error) {
175
+ if (error instanceof Error && error.message.includes("Failed to fetch")) {
176
+ throw error;
177
+ }
178
+ throw new Error(
179
+ `Failed to fetch changelog from GitHub: ${error instanceof Error ? error.message : String(error)}`,
180
+ );
181
+ }
182
+ }
183
+
184
+ function readLocalChangelog(): { content: string; piPath: string } {
185
+ const piPath = findPiInstallation();
186
+ if (!piPath) {
187
+ throw new Error("Could not locate Pi installation");
188
+ }
189
+ const changelogPath = path.join(piPath, "CHANGELOG.md");
190
+ if (!fs.existsSync(changelogPath)) {
191
+ throw new Error(`Changelog file not found at ${changelogPath}`);
192
+ }
193
+ return { content: fs.readFileSync(changelogPath, "utf-8"), piPath };
194
+ }
195
+
196
+ /** Max lines shown when collapsed. */
197
+ const COLLAPSED_LINES = 8;
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Render helpers
201
+ // ---------------------------------------------------------------------------
202
+
203
+ function renderChangelogContent(
204
+ content: string,
205
+ theme: Theme,
206
+ maxLines?: number,
207
+ ): string[] {
208
+ const allLines = content.split("\n");
209
+ const truncated = maxLines != null && allLines.length > maxLines;
210
+ const linesToRender = truncated ? allLines.slice(0, maxLines) : allLines;
211
+
212
+ const out: string[] = [];
213
+ for (const line of linesToRender) {
214
+ if (line.trim().startsWith("###")) {
215
+ out.push(theme.fg("warning", line));
216
+ } else if (line.trim().startsWith("##")) {
217
+ out.push(theme.fg("accent", line));
218
+ } else if (line.trim().startsWith("#")) {
219
+ out.push(theme.fg("accent", theme.bold(line)));
220
+ } else if (line.trim().startsWith("-") || line.trim().startsWith("*")) {
221
+ out.push(theme.fg("dim", line));
222
+ } else {
223
+ out.push(line);
224
+ }
225
+ }
226
+
227
+ if (truncated) {
228
+ out.push(theme.fg("muted", "..."));
229
+ }
230
+
231
+ return out;
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // pi_changelog
236
+ // ---------------------------------------------------------------------------
237
+
238
+ export function setupChangelogTool(pi: ExtensionAPI) {
239
+ pi.registerTool<typeof ChangelogParamsSchema, ChangelogDetails>({
240
+ name: "pi_changelog",
241
+ label: "Pi Changelog",
242
+ description:
243
+ "Get changelog entry for a Pi version. Returns latest by default. Use pi_changelog_versions to list all available versions.",
244
+ promptSnippet: `pi_changelog version="1.2.3" // Get changelog for specific version
245
+ pi_changelog // Get latest changelog`,
246
+ promptGuidelines: [
247
+ "Use this tool to check what's new in a Pi version",
248
+ "Use pi_changelog_versions first to list available versions",
249
+ "Leave version empty to get the latest changelog",
250
+ ],
251
+
252
+ parameters: ChangelogParamsSchema,
253
+
254
+ async execute(
255
+ _toolCallId: string,
256
+ params: ChangelogParams,
257
+ _signal: AbortSignal | undefined,
258
+ _onUpdate: unknown,
259
+ _ctx: ExtensionContext,
260
+ ): Promise<AgentToolResult<ChangelogDetails>> {
261
+ // Newer than installed -> fetch from GitHub
262
+ if (params.version && isNewerThanInstalled(params.version)) {
263
+ const githubContent = await fetchGithubChangelog();
264
+ const changelog = findChangelogEntry(githubContent, params.version);
265
+
266
+ const message = `Changelog for ${changelog.version} (from GitHub)\n\n## ${changelog.version}\n\n${changelog.content}`;
267
+ return {
268
+ content: [{ type: "text", text: message }],
269
+ details: {
270
+ changelog,
271
+ source: "github",
272
+ },
273
+ };
274
+ }
275
+
276
+ // Local
277
+ const local = readLocalChangelog();
278
+ const changelog = findChangelogEntry(local.content, params.version);
279
+
280
+ const message = `Changelog for ${changelog.version}\n\n## ${changelog.version}\n\n${changelog.content}`;
281
+ return {
282
+ content: [{ type: "text", text: message }],
283
+ details: {
284
+ changelog,
285
+ source: "local",
286
+ },
287
+ };
288
+ },
289
+
290
+ renderCall(args: ChangelogParams, theme: Theme) {
291
+ return new ToolCallHeader(
292
+ {
293
+ toolName: "Pi Changelog",
294
+ mainArg: args.version ? `v${args.version}` : "latest",
295
+ },
296
+ theme,
297
+ );
298
+ },
299
+
300
+ renderResult(
301
+ result: AgentToolResult<ChangelogDetails>,
302
+ options: ToolRenderResultOptions,
303
+ theme: Theme,
304
+ ) {
305
+ const { details } = result;
306
+
307
+ // Check for missing expected fields to detect errors
308
+ if (!details?.changelog) {
309
+ const text = result.content[0];
310
+ return new Text(
311
+ text?.type === "text" && text.text ? text.text : "No result",
312
+ 0,
313
+ 0,
314
+ );
315
+ }
316
+
317
+ const fields: Array<
318
+ { label: string; value: string; showCollapsed?: boolean } | Text
319
+ > = [];
320
+
321
+ const lines: string[] = [];
322
+
323
+ if (options.expanded) {
324
+ // Expanded view: show full changelog content
325
+ lines.push(
326
+ theme.fg(
327
+ "accent",
328
+ theme.bold(`Version: ${details.changelog.version}`),
329
+ ),
330
+ "",
331
+ );
332
+ lines.push(...renderChangelogContent(details.changelog.content, theme));
333
+ fields.push(new Text(lines.join("\n"), 0, 0));
334
+ } else {
335
+ // Collapsed view: show version + first few lines of changelog + expand hint
336
+ lines.push(
337
+ theme.fg(
338
+ "accent",
339
+ theme.bold(`Version: ${details.changelog.version}`),
340
+ ),
341
+ "",
342
+ );
343
+ lines.push(
344
+ ...renderChangelogContent(
345
+ details.changelog.content,
346
+ theme,
347
+ COLLAPSED_LINES,
348
+ ),
349
+ );
350
+ lines.push(
351
+ "",
352
+ theme.fg("muted", `${keyHint("app.tools.expand", "to expand")}`),
353
+ );
354
+ fields.push(new Text(lines.join("\n"), 0, 0));
355
+ }
356
+
357
+ // Footer: show source tag only
358
+ const footer = new ToolFooter(theme, {
359
+ items: [
360
+ {
361
+ label: "source",
362
+ value: details.source ?? "local",
363
+ tone: "accent",
364
+ },
365
+ ],
366
+ });
367
+
368
+ return new ToolBody(
369
+ {
370
+ fields,
371
+ footer,
372
+ },
373
+ options,
374
+ theme,
375
+ );
376
+ },
377
+ });
378
+
379
+ // -------------------------------------------------------------------------
380
+ // pi_changelog_versions
381
+ // -------------------------------------------------------------------------
382
+
383
+ pi.registerTool<
384
+ typeof ChangelogVersionsParamsSchema,
385
+ ChangelogVersionsDetails
386
+ >({
387
+ name: "pi_changelog_versions",
388
+ label: "Pi Changelog Versions",
389
+ description: "List all available Pi changelog versions",
390
+ promptSnippet: `pi_changelog_versions // List all available versions`,
391
+
392
+ parameters: ChangelogVersionsParamsSchema,
393
+
394
+ async execute(
395
+ _toolCallId: string,
396
+ _params: ChangelogVersionsParams,
397
+ _signal: AbortSignal | undefined,
398
+ _onUpdate: unknown,
399
+ _ctx: ExtensionContext,
400
+ ): Promise<AgentToolResult<ChangelogVersionsDetails>> {
401
+ const local = readLocalChangelog();
402
+ const { entries } = parseChangelogEntries(local.content);
403
+
404
+ if (entries.length === 0) {
405
+ throw new Error("No version entries found in changelog");
406
+ }
407
+
408
+ const versions = entries.map((e) => e.version);
409
+ const message = `${versions.length} versions available:\n${versions.join(", ")}`;
410
+
411
+ return {
412
+ content: [{ type: "text", text: message }],
413
+ details: {
414
+ versions,
415
+ source: "local",
416
+ },
417
+ };
418
+ },
419
+
420
+ renderCall(_args: ChangelogVersionsParams, theme: Theme) {
421
+ return new ToolCallHeader({ toolName: "Pi Changelog Versions" }, theme);
422
+ },
423
+
424
+ renderResult(
425
+ result: AgentToolResult<ChangelogVersionsDetails>,
426
+ options: ToolRenderResultOptions,
427
+ theme: Theme,
428
+ ) {
429
+ const { details } = result;
430
+
431
+ // Check for missing expected fields to detect errors
432
+ if (!details?.versions) {
433
+ const text = result.content[0];
434
+ return new Text(
435
+ text?.type === "text" && text.text ? text.text : "No result",
436
+ 0,
437
+ 0,
438
+ );
439
+ }
440
+
441
+ const fields: Array<
442
+ { label: string; value: string; showCollapsed?: boolean } | Text
443
+ > = [];
444
+
445
+ const lines: string[] = [
446
+ theme.fg("accent", `${details.versions.length} versions available:`),
447
+ "",
448
+ ];
449
+ const cols = 6;
450
+ const maxLen = Math.max(
451
+ ...details.versions.map((version) => version.length),
452
+ );
453
+ const colWidth = maxLen + 2;
454
+ for (let i = 0; i < details.versions.length; i += cols) {
455
+ const row = details.versions
456
+ .slice(i, i + cols)
457
+ .map((version) => version.padEnd(colWidth))
458
+ .join("");
459
+ lines.push(theme.fg("dim", row));
460
+ }
461
+ fields.push(new Text(lines.join("\n"), 0, 0));
462
+
463
+ // Footer: just show version count
464
+ const footer = new ToolFooter(theme, {
465
+ items: [
466
+ {
467
+ label: "count",
468
+ value: String(details.versions.length),
469
+ tone: "accent",
470
+ },
471
+ ],
472
+ });
473
+
474
+ return new ToolBody(
475
+ {
476
+ fields,
477
+ footer,
478
+ },
479
+ options,
480
+ theme,
481
+ );
482
+ },
483
+ });
484
+ }
@@ -0,0 +1,181 @@
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 } from "@mariozechner/pi-coding-agent";
12
+ import { Text } from "@mariozechner/pi-tui";
13
+ import { Type } from "@sinclair/typebox";
14
+ import { findPiInstallation } from "./utils";
15
+
16
+ const DocsParamsSchema = Type.Object({});
17
+ type DocsParams = Record<string, never>;
18
+
19
+ interface DocsDetails {
20
+ /** Relative paths from the pi install root, markdown only. */
21
+ docFiles?: string[];
22
+ installPath?: string;
23
+ }
24
+
25
+ function listFilesRecursive(dir: string, prefix = ""): string[] {
26
+ const results: string[] = [];
27
+ if (!fs.existsSync(dir)) return results;
28
+
29
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
30
+ for (const entry of entries) {
31
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
32
+ if (entry.isDirectory()) {
33
+ results.push(...listFilesRecursive(path.join(dir, entry.name), rel));
34
+ } else {
35
+ results.push(rel);
36
+ }
37
+ }
38
+ return results;
39
+ }
40
+
41
+ export function setupDocsTool(pi: ExtensionAPI) {
42
+ pi.registerTool<typeof DocsParamsSchema, DocsDetails>({
43
+ name: "pi_docs",
44
+ label: "Pi Documentation",
45
+ description:
46
+ "List Pi markdown documentation files (README, docs/, examples/)",
47
+
48
+ promptSnippet: "List Pi documentation files",
49
+ promptGuidelines: [
50
+ "Use to discover available Pi documentation",
51
+ "Returns markdown files from README.md, docs/, and examples/",
52
+ ],
53
+
54
+ parameters: DocsParamsSchema,
55
+
56
+ async execute(
57
+ _toolCallId: string,
58
+ _params: DocsParams,
59
+ _signal: AbortSignal | undefined,
60
+ _onUpdate: unknown,
61
+ _ctx: ExtensionContext,
62
+ ): Promise<AgentToolResult<DocsDetails>> {
63
+ const piPath = findPiInstallation();
64
+ if (!piPath) {
65
+ throw new Error("Could not locate running Pi installation directory");
66
+ }
67
+
68
+ const readmePath = path.join(piPath, "README.md");
69
+ const docsDir = path.join(piPath, "docs");
70
+ const examplesDir = path.join(piPath, "examples");
71
+
72
+ const docFiles: string[] = [];
73
+
74
+ if (fs.existsSync(readmePath)) {
75
+ docFiles.push("README.md");
76
+ }
77
+
78
+ if (fs.existsSync(docsDir)) {
79
+ for (const file of listFilesRecursive(docsDir)) {
80
+ if (file.endsWith(".md")) {
81
+ docFiles.push(`docs/${file}`);
82
+ }
83
+ }
84
+ }
85
+
86
+ if (fs.existsSync(examplesDir)) {
87
+ for (const file of listFilesRecursive(examplesDir)) {
88
+ if (file.endsWith(".md")) {
89
+ docFiles.push(`examples/${file}`);
90
+ }
91
+ }
92
+ }
93
+
94
+ if (docFiles.length === 0) {
95
+ throw new Error(
96
+ `No markdown documentation found in Pi installation at ${piPath}`,
97
+ );
98
+ }
99
+
100
+ // Content sent to LLM: full relative paths so it can read them.
101
+ const lines = docFiles.map((rel) => `${path.join(piPath, rel)} (${rel})`);
102
+ const message = `${docFiles.length} markdown files:\n${lines.join("\n")}`;
103
+
104
+ return {
105
+ content: [{ type: "text", text: message }],
106
+ details: {
107
+ docFiles,
108
+ installPath: piPath,
109
+ },
110
+ };
111
+ },
112
+
113
+ renderCall(_args: DocsParams, theme: Theme) {
114
+ return new ToolCallHeader({ toolName: "Pi Docs" }, theme);
115
+ },
116
+
117
+ renderResult(
118
+ result: AgentToolResult<DocsDetails>,
119
+ options: ToolRenderResultOptions,
120
+ theme: Theme,
121
+ ) {
122
+ const { details } = result;
123
+ const { isPartial } = options;
124
+
125
+ // Handle isPartial first (this tool doesn't stream, but keep the pattern)
126
+ if (isPartial) {
127
+ return new Text(theme.fg("dim", "Loading..."), 0, 0);
128
+ }
129
+
130
+ // Check for missing expected fields in details to detect errors
131
+ if (!details || !details.docFiles) {
132
+ const text = result.content[0];
133
+ return new Text(
134
+ text?.type === "text" && text.text ? text.text : "No result",
135
+ 0,
136
+ 0,
137
+ );
138
+ }
139
+
140
+ const { docFiles } = details;
141
+ const fields: Array<
142
+ { label: string; value: string; showCollapsed?: boolean } | Text
143
+ > = [];
144
+
145
+ if (options.expanded) {
146
+ // Expanded view: show full file list
147
+ const lines: string[] = [];
148
+ lines.push(
149
+ theme.fg("accent", `${docFiles.length} markdown files:`),
150
+ "",
151
+ );
152
+ for (const rel of docFiles) {
153
+ lines.push(theme.fg("dim", ` ${rel}`));
154
+ }
155
+ fields.push(new Text(lines.join("\n"), 0, 0));
156
+ } else {
157
+ // Collapsed view: show file count + expand hint
158
+ fields.push({
159
+ label: "Files",
160
+ value:
161
+ theme.fg("accent", `${docFiles.length} markdown files`) +
162
+ ` (${keyHint("app.tools.expand", "to expand")})`,
163
+ showCollapsed: true,
164
+ });
165
+ }
166
+
167
+ // Only show footer if there are items worth showing
168
+ const footer = new ToolFooter(theme, {
169
+ items: [
170
+ {
171
+ label: "docs",
172
+ value: String(docFiles.length),
173
+ tone: "accent",
174
+ },
175
+ ],
176
+ });
177
+
178
+ return new ToolBody({ fields, footer }, options, theme);
179
+ },
180
+ });
181
+ }
@@ -0,0 +1,12 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { setupChangelogTool } from "./changelog-tool";
3
+ import { setupDocsTool } from "./docs-tool";
4
+ import { setupPackageManagerTool } from "./package-manager-tool";
5
+ import { setupVersionTool } from "./version-tool";
6
+
7
+ export function setupTools(pi: ExtensionAPI) {
8
+ setupPackageManagerTool(pi);
9
+ setupVersionTool(pi);
10
+ setupDocsTool(pi);
11
+ setupChangelogTool(pi);
12
+ }