@trim21/personal-pi-extensions 0.0.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,1264 @@
1
+ /**
2
+ * GitHub Read-Only Tools Extension
3
+ *
4
+ * Provides individual read-only tools for GitHub operations using the system's `gh` CLI.
5
+ *
6
+ * Tools:
7
+ * - read-github-issue: Get issue details
8
+ * - list-github-issues: List issues
9
+ * - read-github-issue-comments: Get issue comments
10
+ * - read-github-pr: Get PR details
11
+ * - list-github-prs: List PRs
12
+ * - read-github-pr-diff: Get PR diff
13
+ * - read-github-pr-status: Get PR status checks
14
+ * - read-github-pr-comments: Get PR comments
15
+ * - read-github-ci-logs: Get CI workflow run logs
16
+ * - read-github-workflow-runs: List workflow runs
17
+ * - read-github-workflow-jobs: Get workflow run jobs
18
+ * - read-github-repo: Get repo info
19
+ * - list-github-releases: List releases
20
+ * - read-github-release: Get release details
21
+ * - wait-github-pr-checks: Watch PR CI checks
22
+ * - watch-github-run: Watch a workflow run
23
+ * - search-github-issues: Search GitHub issues
24
+ * - search-github-prs: Search GitHub pull requests
25
+ *
26
+ * Install:
27
+ * cp gh-readonly.ts ~/.pi/agent/extensions/
28
+ *
29
+ * Or for project-local:
30
+ * cp gh-readonly.ts .pi/extensions/
31
+ */
32
+
33
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
34
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
35
+ import { Type } from "typebox";
36
+ import { spawn } from "node:child_process";
37
+ import { homedir } from "node:os";
38
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
39
+ import { join } from "node:path";
40
+
41
+ interface GhResult {
42
+ stdout: string;
43
+ stderr: string;
44
+ code: number;
45
+ killed: boolean;
46
+ combined: string;
47
+ }
48
+
49
+ // ── helpers ──────────────────────────────────────────────────────────────────
50
+
51
+ function execGh(
52
+ pi: ExtensionAPI,
53
+ args: string[],
54
+ ctx: { cwd?: string; signal?: AbortSignal; timeout?: number },
55
+ ): Promise<GhResult> {
56
+ return new Promise((resolve) => {
57
+ const proc = spawn("gh", args, {
58
+ cwd: ctx.cwd,
59
+ shell: false,
60
+ stdio: ["ignore", "pipe", "pipe"],
61
+ env: { ...process.env, GH_PAGER: "cat" },
62
+ });
63
+
64
+ let stdout = "";
65
+ let stderr = "";
66
+ const combined: string[] = [];
67
+ let killed = false;
68
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
69
+
70
+ const killProcess = () => {
71
+ if (!killed) {
72
+ killed = true;
73
+ proc.kill("SIGTERM");
74
+ setTimeout(() => {
75
+ if (!proc.killed) proc.kill("SIGKILL");
76
+ }, 5000);
77
+ }
78
+ };
79
+
80
+ if (ctx.signal) {
81
+ if (ctx.signal.aborted) {
82
+ killProcess();
83
+ } else {
84
+ ctx.signal.addEventListener("abort", killProcess, { once: true });
85
+ }
86
+ }
87
+
88
+ const timeout = ctx.timeout ?? 30_000;
89
+ if (timeout > 0) {
90
+ timeoutId = setTimeout(killProcess, timeout);
91
+ }
92
+
93
+ proc.stdout?.on("data", (data: Buffer) => {
94
+ const text = data.toString();
95
+ stdout += text;
96
+ combined.push(text);
97
+ });
98
+ proc.stderr?.on("data", (data: Buffer) => {
99
+ const text = data.toString();
100
+ stderr += text;
101
+ combined.push(text);
102
+ });
103
+
104
+ proc.on("close", (code) => {
105
+ if (timeoutId) clearTimeout(timeoutId);
106
+ if (ctx.signal) {
107
+ ctx.signal.removeEventListener("abort", killProcess);
108
+ }
109
+ resolve({ stdout, stderr, code: code ?? 0, killed, combined: combined.join("") });
110
+ });
111
+
112
+ proc.on("error", (_err) => {
113
+ if (timeoutId) clearTimeout(timeoutId);
114
+ if (ctx.signal) {
115
+ ctx.signal.removeEventListener("abort", killProcess);
116
+ }
117
+ resolve({ stdout, stderr, code: 1, killed, combined: combined.join("") });
118
+ });
119
+ });
120
+ }
121
+
122
+ /** Result of a gh invocation. On failure, `args` carries the full raw command input for debugging. */
123
+ export interface GhExecResult {
124
+ ok: boolean;
125
+ stdout: string;
126
+ error: string | null;
127
+ args: string[];
128
+ }
129
+
130
+ async function ghExec(
131
+ pi: ExtensionAPI,
132
+ args: string[],
133
+ ctx: { cwd?: string; signal?: AbortSignal },
134
+ ): Promise<GhExecResult> {
135
+ const result = await execGh(pi, args, ctx);
136
+ if (result.code !== 0) {
137
+ return {
138
+ ok: false,
139
+ stdout: "",
140
+ error: result.combined.trim() || `exit code ${result.code}`,
141
+ args,
142
+ };
143
+ }
144
+ return { ok: true, stdout: result.stdout, error: null, args };
145
+ }
146
+
147
+ function repoArgs(repo?: string): string[] {
148
+ return repo ? ["--repo", repo] : [];
149
+ }
150
+
151
+ function truncate(
152
+ text: string,
153
+ maxLines = 2000,
154
+ maxBytes = 50 * 1024,
155
+ ): { text: string; truncated: boolean } {
156
+ const lines = text.split("\n");
157
+ if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes) {
158
+ return { text, truncated: false };
159
+ }
160
+
161
+ const out: string[] = [];
162
+ let bytes = 0;
163
+ for (const line of lines) {
164
+ if (out.length >= maxLines) break;
165
+ const lineBytes = Buffer.byteLength(line + "\n", "utf-8");
166
+ if (bytes + lineBytes > maxBytes) break;
167
+ out.push(line);
168
+ bytes += lineBytes;
169
+ }
170
+ return { text: out.join("\n"), truncated: true };
171
+ }
172
+
173
+ /**
174
+ * Convert a gh invocation result into a tool result.
175
+ *
176
+ * On failure, returns the error text as content and includes the raw command
177
+ * input (`args`) in `details` so the user can debug what the model actually ran.
178
+ */
179
+ function toToolResult(res: GhExecResult): {
180
+ content: Array<{ type: "text"; text: string }>;
181
+ details: Record<string, unknown>;
182
+ } {
183
+ if (!res.ok) {
184
+ return {
185
+ content: [{ type: "text", text: `gh ${res.args.join(" ")} failed: ${res.error}` }],
186
+ details: { error: res.error, args: res.args },
187
+ };
188
+ }
189
+ const { text, truncated } = truncate(res.stdout);
190
+ return { content: [{ type: "text", text }], details: { truncated } };
191
+ }
192
+
193
+ // ── CI helpers ───────────────────────────────────────────────────────────────
194
+
195
+ export interface StepInfo {
196
+ name: string;
197
+ number: number;
198
+ status: string;
199
+ conclusion: string | null;
200
+ }
201
+
202
+ export interface JobInfo {
203
+ id: number;
204
+ name: string;
205
+ conclusion: string | null;
206
+ steps: StepInfo[];
207
+ }
208
+
209
+ /** Build GitHub-UI-style step list for details, marking expanded steps. */
210
+ export function stepsDetail(
211
+ job: JobInfo,
212
+ expandedSteps?: Set<number>,
213
+ ): Array<{ number: number; name: string; conclusion: string | null; expanded?: boolean }> {
214
+ return job.steps.map((s) => ({
215
+ number: s.number,
216
+ name: s.name,
217
+ conclusion: s.conclusion,
218
+ ...(expandedSteps?.has(s.number) ? { expanded: true } : {}),
219
+ }));
220
+ }
221
+
222
+ /** In-flight dedup map to avoid concurrent fetches of the same log. */
223
+ const inflightLogs = new Map<string, Promise<GhExecResult & { log?: string }>>();
224
+
225
+ async function getJobLog(
226
+ pi: ExtensionAPI,
227
+ runId: string,
228
+ jobId: number,
229
+ effectiveRepo: string,
230
+ signal: AbortSignal | undefined,
231
+ cwd: string | undefined,
232
+ ): Promise<GhExecResult & { log?: string }> {
233
+ const cacheDir = join(homedir(), ".cache", "pi", "ci-logs", runId);
234
+ const cacheFile = join(cacheDir, `${jobId}.log`);
235
+ const key = `${runId}:${jobId}`;
236
+
237
+ // Check in-flight dedup map
238
+ const inflight = inflightLogs.get(key);
239
+ if (inflight) return inflight;
240
+
241
+ const fetchAndCache = async (): Promise<GhExecResult & { log?: string }> => {
242
+ // Check file cache
243
+ try {
244
+ const cached = await readFile(cacheFile, "utf-8");
245
+ return { ok: true, stdout: cached, error: null, args: [], log: cached };
246
+ } catch {
247
+ // Not cached, fetch from GitHub
248
+ }
249
+
250
+ const res = await ghExec(pi, ["api", `/repos/${effectiveRepo}/actions/jobs/${jobId}/logs`], {
251
+ cwd,
252
+ signal,
253
+ });
254
+ if (!res.ok) return res;
255
+
256
+ // Write to cache
257
+ await mkdir(cacheDir, { recursive: true });
258
+ await withFileMutationQueue(cacheFile, async () => {
259
+ await writeFile(cacheFile, res.stdout);
260
+ });
261
+
262
+ return { ...res, log: res.stdout };
263
+ };
264
+
265
+ const promise = fetchAndCache();
266
+ inflightLogs.set(key, promise);
267
+ try {
268
+ return await promise;
269
+ } finally {
270
+ inflightLogs.delete(key);
271
+ }
272
+ }
273
+
274
+ async function resolveRepo(
275
+ pi: ExtensionAPI,
276
+ repo: string | undefined,
277
+ signal: AbortSignal | undefined,
278
+ cwd: string | undefined,
279
+ ): Promise<GhExecResult & { repo?: string }> {
280
+ if (repo) return { ok: true, stdout: "", error: null, args: [], repo };
281
+ const res = await ghExec(pi, ["repo", "view", "--json", "nameWithOwner"], { cwd, signal });
282
+ if (!res.ok) return res;
283
+ return { ...res, repo: JSON.parse(res.stdout).nameWithOwner };
284
+ }
285
+
286
+ export function statusIcon(conclusion: string | null): string {
287
+ switch (conclusion) {
288
+ case "success":
289
+ return "✅";
290
+ case "failure":
291
+ return "❌";
292
+ case "cancelled":
293
+ return "🚫";
294
+ case "skipped":
295
+ return "⏭️";
296
+ case "timed_out":
297
+ return "⏰";
298
+ case "action_required":
299
+ return "⚠️";
300
+ default:
301
+ return "🔄";
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Extract step content from raw job log by matching step names to "Run " groups.
307
+ *
308
+ * User-defined steps (actions, shell commands) each emit a `##[group]Run <name>`
309
+ * at depth 1. We match API step names against these group names by stripping the
310
+ * "Run " / "Post Run " prefix and comparing the action name.
311
+ *
312
+ * This handles composite actions correctly: their internal actions produce extra
313
+ * "Run " groups that don't match any API step name, so they are naturally skipped.
314
+ *
315
+ * Step 1 ("Set up job") maps to everything before the first matched "Run "/"Post Run " group.
316
+ * Steps 2+ map to the "Run "/"Post Run " group whose action name matches the step name.
317
+ * Steps that were skipped and never executed return null.
318
+ *
319
+ * Returns null if no matching group is found.
320
+ */
321
+ export function extractStepFromLog(
322
+ log: string,
323
+ stepNumber: number,
324
+ apiSteps: Array<{ number: number; name: string }>,
325
+ ): string | null {
326
+ const targetStep = apiSteps.find((s) => s.number === stepNumber);
327
+ if (!targetStep) return null;
328
+
329
+ const lines = log.split("\n");
330
+
331
+ // Step 1 ("Set up job"): everything before the first "Run " or "Post Run " group at depth 1
332
+ if (stepNumber === 1) {
333
+ let depth = 0;
334
+ for (let i = 0; i < lines.length; i++) {
335
+ const line = lines[i];
336
+ if (line.includes("##[endgroup]")) {
337
+ if (depth > 0) depth--;
338
+ continue;
339
+ }
340
+ if (line.includes("##[group]")) {
341
+ depth++;
342
+ if (depth === 1) {
343
+ const m = line.match(/##\[group\](.*)/);
344
+ const name = m ? m[1].trim() : "";
345
+ if (name.startsWith("Run ") || name.startsWith("Post Run ")) {
346
+ return lines.slice(0, i).join("\n").trimEnd();
347
+ }
348
+ }
349
+ }
350
+ }
351
+ return lines.join("\n").trimEnd();
352
+ }
353
+
354
+ // Steps 2+: match "Run "/"Post Run " group by comparing the action name
355
+ // (the part after "Run " or "Post Run " prefix)
356
+ const stepAction = targetStep.name.replace(/^(Run |Post Run )/, "").trim();
357
+
358
+ // Collect all "Run "/"Post Run " groups at depth 1
359
+ const groups: Array<{ line: number; action: string }> = [];
360
+ let depth = 0;
361
+ for (let i = 0; i < lines.length; i++) {
362
+ const line = lines[i];
363
+ if (line.includes("##[endgroup]")) {
364
+ if (depth > 0) depth--;
365
+ continue;
366
+ }
367
+ if (line.includes("##[group]")) {
368
+ depth++;
369
+ if (depth === 1) {
370
+ const m = line.match(/##\[group\](.*)/);
371
+ const name = m ? m[1].trim() : "";
372
+ if (name.startsWith("Run ") || name.startsWith("Post Run ")) {
373
+ const action = name.replace(/^(Run |Post Run )/, "").trim();
374
+ groups.push({ line: i, action });
375
+ }
376
+ }
377
+ }
378
+ }
379
+
380
+ // Find the matching group by action name
381
+ const matchedIdx = groups.findIndex((g) => g.action === stepAction);
382
+ if (matchedIdx === -1) return null;
383
+
384
+ const start = groups[matchedIdx].line;
385
+ const end = matchedIdx + 1 < groups.length ? groups[matchedIdx + 1].line : lines.length;
386
+ return lines.slice(start, end).join("\n").trimEnd();
387
+ }
388
+
389
+ // ── tools ────────────────────────────────────────────────────────────────────
390
+
391
+ export default function (pi: ExtensionAPI) {
392
+ // ── read-github-issue ──────────────────────────────────────────────────────
393
+ pi.registerTool({
394
+ name: "read-github-issue",
395
+ label: "GitHub Issue",
396
+ description: "Get details of a GitHub issue by number.",
397
+ promptSnippet: "Read a GitHub issue",
398
+ parameters: Type.Object({
399
+ number: Type.Union([Type.Number(), Type.String()], { description: "Issue number" }),
400
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
401
+ }),
402
+ async execute(_id, params, signal, _onUpdate, ctx) {
403
+ const { number, repo } = params as { number: number | string; repo?: string };
404
+ return toToolResult(
405
+ await ghExec(
406
+ pi,
407
+ [
408
+ "issue",
409
+ "view",
410
+ String(number),
411
+ ...repoArgs(repo),
412
+ "--json",
413
+ "title,state,body,author,createdAt,updatedAt,closedAt,url,labels,assignees,comments,milestone,number",
414
+ ],
415
+ { cwd: ctx.cwd, signal },
416
+ ),
417
+ );
418
+ },
419
+ });
420
+
421
+ // ── list-github-issues ─────────────────────────────────────────────────────
422
+ pi.registerTool({
423
+ name: "list-github-issues",
424
+ label: "GitHub Issues List",
425
+ description: "List GitHub issues with optional filters.",
426
+ promptSnippet: "List GitHub issues",
427
+ parameters: Type.Object({
428
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
429
+ state: Type.Optional(Type.String({ description: "open, closed, all (default: open)" })),
430
+ limit: Type.Optional(Type.Number({ description: "Max results (default 30)" })),
431
+ }),
432
+ async execute(_id, params, signal, _onUpdate, ctx) {
433
+ const { repo, state, limit } = params as { repo?: string; state?: string; limit?: number };
434
+ const args = ["issue", "list", ...repoArgs(repo)];
435
+ if (state) args.push("--state", state);
436
+ if (limit) args.push("--limit", String(limit));
437
+ return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
438
+ },
439
+ });
440
+
441
+ // ── read-github-pr ─────────────────────────────────────────────────────────
442
+ pi.registerTool({
443
+ name: "read-github-pr",
444
+ label: "GitHub PR",
445
+ description: "Get details of a GitHub pull request by number.",
446
+ promptSnippet: "Read a GitHub PR",
447
+ parameters: Type.Object({
448
+ number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
449
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
450
+ }),
451
+ async execute(_id, params, signal, _onUpdate, ctx) {
452
+ const { number, repo } = params as { number: number | string; repo?: string };
453
+ return toToolResult(
454
+ await ghExec(
455
+ pi,
456
+ [
457
+ "pr",
458
+ "view",
459
+ String(number),
460
+ ...repoArgs(repo),
461
+ "--json",
462
+ "title,state,body,author,createdAt,updatedAt,mergedAt,mergedBy,headRefName,baseRefName,url,additions,deletions,changedFiles,labels,assignees,reviewRequests,reviews,comments,number",
463
+ ],
464
+ { cwd: ctx.cwd, signal },
465
+ ),
466
+ );
467
+ },
468
+ });
469
+
470
+ // ── list-github-prs ────────────────────────────────────────────────────────
471
+ pi.registerTool({
472
+ name: "list-github-prs",
473
+ label: "GitHub PRs List",
474
+ description: "List GitHub pull requests with optional filters.",
475
+ promptSnippet: "List GitHub PRs",
476
+ parameters: Type.Object({
477
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
478
+ state: Type.Optional(
479
+ Type.String({ description: "open, closed, merged, all (default: open)" }),
480
+ ),
481
+ limit: Type.Optional(Type.Number({ description: "Max results (default 30)" })),
482
+ }),
483
+ async execute(_id, params, signal, _onUpdate, ctx) {
484
+ const { repo, state, limit } = params as { repo?: string; state?: string; limit?: number };
485
+ const args = ["pr", "list", ...repoArgs(repo)];
486
+ if (state) args.push("--state", state);
487
+ if (limit) args.push("--limit", String(limit));
488
+ return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
489
+ },
490
+ });
491
+
492
+ // ── read-github-pr-diff ────────────────────────────────────────────────────
493
+ pi.registerTool({
494
+ name: "read-github-pr-diff",
495
+ label: "GitHub PR Diff",
496
+ description: "Get the diff of a GitHub pull request.",
497
+ promptSnippet: "Read a GitHub PR diff",
498
+ parameters: Type.Object({
499
+ number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
500
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
501
+ }),
502
+ async execute(_id, params, signal, _onUpdate, ctx) {
503
+ const { number, repo } = params as {
504
+ number: number | string;
505
+ repo?: string;
506
+ };
507
+ const args = ["pr", "diff", String(number), ...repoArgs(repo)];
508
+ return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
509
+ },
510
+ });
511
+
512
+ // ── read-github-pr-status ──────────────────────────────────────────────────
513
+ pi.registerTool({
514
+ name: "read-github-pr-status",
515
+ label: "GitHub PR Status",
516
+ description: "Get status checks and CI results for a GitHub pull request.",
517
+ promptSnippet: "Read GitHub PR status checks",
518
+ parameters: Type.Object({
519
+ number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
520
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
521
+ }),
522
+ async execute(_id, params, signal, _onUpdate, ctx) {
523
+ const { number, repo } = params as { number: number | string; repo?: string };
524
+ return toToolResult(
525
+ await ghExec(pi, ["pr", "checks", String(number), ...repoArgs(repo)], {
526
+ cwd: ctx.cwd,
527
+ signal,
528
+ }),
529
+ );
530
+ },
531
+ });
532
+
533
+ // ── read-github-pr-comments ────────────────────────────────────────────────
534
+ pi.registerTool({
535
+ name: "read-github-pr-comments",
536
+ label: "GitHub PR Comments",
537
+ description:
538
+ "Get review comments on a GitHub pull request. Set reviews=true for inline code review comments with diff_hunk.",
539
+ promptSnippet: "Read GitHub PR comments",
540
+ parameters: Type.Object({
541
+ number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
542
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
543
+ reviews: Type.Optional(
544
+ Type.Boolean({
545
+ description:
546
+ "If true, returns inline code review comments (with diff_hunk, path, line) via API. Default: false (returns issue comments).",
547
+ }),
548
+ ),
549
+ }),
550
+ async execute(_id, params, signal, _onUpdate, ctx) {
551
+ const { number, repo, reviews } = params as {
552
+ number: number | string;
553
+ repo?: string;
554
+ reviews?: boolean;
555
+ };
556
+ let out: string;
557
+ if (reviews) {
558
+ const resolved = await resolveRepo(pi, repo, signal, ctx.cwd);
559
+ if (!resolved.ok) return toToolResult(resolved);
560
+
561
+ const [commentsRes, reviewsRes] = await Promise.all([
562
+ ghExec(pi, ["api", `/repos/${resolved.repo!}/pulls/${String(number)}/comments`], {
563
+ cwd: ctx.cwd,
564
+ signal,
565
+ }),
566
+ ghExec(pi, ["api", `/repos/${resolved.repo!}/pulls/${String(number)}/reviews`], {
567
+ cwd: ctx.cwd,
568
+ signal,
569
+ }),
570
+ ]);
571
+ if (!commentsRes.ok) return toToolResult(commentsRes);
572
+ if (!reviewsRes.ok) return toToolResult(reviewsRes);
573
+
574
+ const reviewComments = JSON.parse(commentsRes.stdout);
575
+ const reviewSummaries = JSON.parse(reviewsRes.stdout);
576
+
577
+ out = JSON.stringify(
578
+ {
579
+ reviews: reviewSummaries,
580
+ comments: reviewComments,
581
+ },
582
+ null,
583
+ 2,
584
+ );
585
+ } else {
586
+ const res = await ghExec(
587
+ pi,
588
+ ["pr", "view", String(number), ...repoArgs(repo), "--json", "comments"],
589
+ {
590
+ cwd: ctx.cwd,
591
+ signal,
592
+ },
593
+ );
594
+ if (!res.ok) return toToolResult(res);
595
+ out = res.stdout;
596
+ }
597
+ const { text, truncated } = truncate(out);
598
+ return {
599
+ content: [{ type: "text", text }],
600
+ details: { truncated },
601
+ };
602
+ },
603
+ });
604
+
605
+ // ── read-github-issue-comments ─────────────────────────────────────────────
606
+ pi.registerTool({
607
+ name: "read-github-issue-comments",
608
+ label: "GitHub Issue Comments",
609
+ description: "Get comments on a GitHub issue.",
610
+ promptSnippet: "Read GitHub issue comments",
611
+ parameters: Type.Object({
612
+ number: Type.Union([Type.Number(), Type.String()], { description: "Issue number" }),
613
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
614
+ }),
615
+ async execute(_id, params, signal, _onUpdate, ctx) {
616
+ const { number, repo } = params as { number: number | string; repo?: string };
617
+ return toToolResult(
618
+ await ghExec(
619
+ pi,
620
+ ["issue", "view", String(number), ...repoArgs(repo), "--json", "comments"],
621
+ { cwd: ctx.cwd, signal },
622
+ ),
623
+ );
624
+ },
625
+ });
626
+
627
+ // ── list-github-workflow-runs ──────────────────────────────────────────────
628
+ pi.registerTool({
629
+ name: "list-github-workflow-runs",
630
+ label: "GitHub Workflow Runs",
631
+ description: "List GitHub Actions workflow runs.",
632
+ promptSnippet: "List GitHub workflow runs",
633
+ parameters: Type.Object({
634
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
635
+ limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
636
+ status: Type.Optional(
637
+ Type.String({ description: "Filter by status: success, failure, cancelled, etc." }),
638
+ ),
639
+ workflow: Type.Optional(Type.String({ description: "Filter by workflow name or file" })),
640
+ }),
641
+ async execute(_id, params, signal, _onUpdate, ctx) {
642
+ const { repo, limit, status, workflow } = params as {
643
+ repo?: string;
644
+ limit?: number;
645
+ status?: string;
646
+ workflow?: string;
647
+ };
648
+ const args = ["run", "list", ...repoArgs(repo)];
649
+ if (limit) args.push("--limit", String(limit));
650
+ if (status) args.push("--status", status);
651
+ if (workflow) args.push("--workflow", workflow);
652
+ return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
653
+ },
654
+ });
655
+
656
+ // ── read-github-ci-logs ────────────────────────────────────────────────────
657
+ pi.registerTool({
658
+ name: "read-github-ci-logs",
659
+ label: "GitHub CI Logs",
660
+ description:
661
+ "Get CI logs from a GitHub Actions workflow run. Without step: shows a summary of jobs and steps with their statuses. With step: returns logs for that specific step only, supports offset/limit for long steps. Use run_id from list-github-workflow-runs. Note: queued jobs have no logs yet; use watch-github-run to wait for completion.",
662
+ promptSnippet: "Read GitHub CI logs",
663
+ parameters: Type.Object({
664
+ run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
665
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
666
+ job: Type.Optional(
667
+ Type.String({
668
+ description:
669
+ "Job name or ID. Required when multiple jobs exist and fetching step logs. Optional when showing summary (filters to that job).",
670
+ }),
671
+ ),
672
+ step: Type.Optional(
673
+ Type.String({
674
+ description:
675
+ "Step name to fetch logs for (from summary table). Omit to show job/step summary instead of raw logs.",
676
+ }),
677
+ ),
678
+ offset: Type.Optional(
679
+ Type.Number({
680
+ description:
681
+ "Line number to start reading from within the step's log (1-indexed). Useful for long steps where the error is at the end. Only meaningful with step.",
682
+ }),
683
+ ),
684
+ limit: Type.Optional(
685
+ Type.Number({
686
+ description:
687
+ "Maximum number of lines to return from the step's log. Only meaningful with step.",
688
+ }),
689
+ ),
690
+ }),
691
+ async execute(_id, params, signal, onUpdate, ctx) {
692
+ const { run_id, repo, job, step, offset, limit } = params as {
693
+ run_id: number | string;
694
+ repo?: string;
695
+ job?: string;
696
+ step?: string;
697
+ offset?: number;
698
+ limit?: number;
699
+ };
700
+
701
+ // ── Fetch specific step logs ───────────────────────────────────────
702
+ if (step !== undefined && step !== null) {
703
+ const resolved = await resolveRepo(pi, repo, signal, ctx.cwd);
704
+ if (!resolved.ok) return toToolResult(resolved);
705
+
706
+ const jobsRes = await ghExec(
707
+ pi,
708
+ ["api", `/repos/${resolved.repo!}/actions/runs/${run_id}/jobs`],
709
+ { cwd: ctx.cwd, signal },
710
+ );
711
+ if (!jobsRes.ok) return toToolResult(jobsRes);
712
+ const { jobs } = JSON.parse(jobsRes.stdout) as {
713
+ jobs: Array<{
714
+ id: number;
715
+ name: string;
716
+ status: string;
717
+ conclusion: string | null;
718
+ steps: Array<{
719
+ name: string;
720
+ number: number;
721
+ status: string;
722
+ conclusion: string | null;
723
+ }>;
724
+ }>;
725
+ };
726
+
727
+ if (!jobs || jobs.length === 0) {
728
+ return {
729
+ content: [{ type: "text", text: `No jobs found for run ${run_id}` }],
730
+ details: {},
731
+ };
732
+ }
733
+
734
+ let targetJob: (typeof jobs)[0] | undefined;
735
+ if (job) {
736
+ const isNumeric = /^\d+$/.test(job);
737
+ targetJob = jobs.find((j) => (isNumeric ? String(j.id) === job : j.name === job));
738
+ if (!targetJob) {
739
+ return {
740
+ content: [
741
+ {
742
+ type: "text",
743
+ text: `Job "${job}" not found. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
744
+ },
745
+ ],
746
+ details: {},
747
+ };
748
+ }
749
+ } else if (jobs.length === 1) {
750
+ targetJob = jobs[0];
751
+ } else {
752
+ return {
753
+ content: [
754
+ {
755
+ type: "text",
756
+ text: `Multiple jobs found. Specify \`job\`: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
757
+ },
758
+ ],
759
+ details: {},
760
+ };
761
+ }
762
+
763
+ if (targetJob.status === "queued") {
764
+ return {
765
+ content: [
766
+ {
767
+ type: "text",
768
+ text: `Job "${targetJob.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
769
+ },
770
+ ],
771
+ details: {},
772
+ };
773
+ }
774
+
775
+ // Resolve step name → number
776
+ const found = targetJob.steps.find((s) => s.name.toLowerCase() === step.toLowerCase());
777
+ if (!found) {
778
+ return {
779
+ content: [
780
+ {
781
+ type: "text",
782
+ text: `Step "${step}" not found. Available: ${targetJob.steps.map((s) => `${s.name} (${s.number})`).join(", ")}`,
783
+ },
784
+ ],
785
+ details: {},
786
+ };
787
+ }
788
+ const stepNum = found.number;
789
+
790
+ if (stepNum < 1 || stepNum > targetJob.steps.length) {
791
+ return {
792
+ content: [
793
+ {
794
+ type: "text",
795
+ text: `Step ${stepNum} out of range. Job "${targetJob.name}" has ${targetJob.steps.length} steps (1-${targetJob.steps.length}).`,
796
+ },
797
+ ],
798
+ details: {},
799
+ };
800
+ }
801
+
802
+ onUpdate?.({
803
+ content: [{ type: "text", text: `Fetching logs for step ${stepNum}...` }],
804
+ details: {},
805
+ });
806
+
807
+ const logRes = await getJobLog(
808
+ pi,
809
+ String(run_id),
810
+ targetJob.id,
811
+ resolved.repo!,
812
+ signal,
813
+ ctx.cwd,
814
+ );
815
+ if (!logRes.ok) return toToolResult(logRes);
816
+ const rawLog = logRes.log!;
817
+
818
+ const stepLog = extractStepFromLog(rawLog, stepNum, targetJob.steps);
819
+ if (stepLog === null) {
820
+ return {
821
+ content: [
822
+ {
823
+ type: "text",
824
+ text: `Could not extract step ${stepNum} from job "${targetJob.name}" logs. The log may be malformed or empty. Try fetching without \`step\` to see the full job log.`,
825
+ },
826
+ ],
827
+ details: {},
828
+ };
829
+ }
830
+
831
+ // Calculate full step stats
832
+ const totalLines = stepLog.split("\n").length;
833
+
834
+ // Apply offset — slice lines before truncation
835
+ let logToShow = stepLog;
836
+ let appliedOffset = false;
837
+ if (offset !== undefined && offset !== null && offset > 1) {
838
+ if (offset > totalLines) {
839
+ return {
840
+ content: [
841
+ {
842
+ type: "text",
843
+ text: `Offset ${offset} exceeds step log length (${totalLines} lines).`,
844
+ },
845
+ ],
846
+ details: {},
847
+ };
848
+ }
849
+ logToShow = stepLog
850
+ .split("\n")
851
+ .slice(offset - 1)
852
+ .join("\n");
853
+ appliedOffset = true;
854
+ }
855
+
856
+ const maxLines = limit ?? 3000;
857
+ const maxBytes = 80 * 1024;
858
+ const { text, truncated: tr } = truncate(logToShow, maxLines, maxBytes);
859
+
860
+ const shownLines = text.split("\n").length;
861
+ const stepName = targetJob.steps[stepNum - 1]?.name ?? `Step ${stepNum}`;
862
+ const offsetNote = appliedOffset ? ` (lines ${offset!}-${offset! + shownLines - 1})` : "";
863
+ const meta = [
864
+ `## ${targetJob.name} / ${stepName} (step ${stepNum}${offsetNote})`,
865
+ `Total: ${totalLines} lines | Shown: ${shownLines} lines${tr ? " (truncated)" : ""}`,
866
+ ].join("\n");
867
+
868
+ return {
869
+ content: [
870
+ { type: "text", text: meta },
871
+ { type: "text", text },
872
+ ],
873
+ details: {
874
+ summary: `Step ${stepNum} — ${targetJob.name} / ${stepName}: ${shownLines} of ${totalLines} lines${tr ? " (truncated)" : ""}`,
875
+ truncated: tr,
876
+ job: {
877
+ name: targetJob.name,
878
+ conclusion: targetJob.conclusion,
879
+ steps: stepsDetail(targetJob, new Set([stepNum])),
880
+ },
881
+ totalLines,
882
+ shownLines,
883
+ offset: appliedOffset ? offset : undefined,
884
+ },
885
+ };
886
+ }
887
+
888
+ // ── Show step summary ──────────────────────────────────────────────
889
+ onUpdate?.({
890
+ content: [{ type: "text", text: `Fetching job list...` }],
891
+ details: {},
892
+ });
893
+
894
+ const resolved = await resolveRepo(pi, repo, signal, ctx.cwd);
895
+ if (!resolved.ok) return toToolResult(resolved);
896
+
897
+ const jobsRes = await ghExec(
898
+ pi,
899
+ ["api", `/repos/${resolved.repo!}/actions/runs/${run_id}/jobs`],
900
+ { cwd: ctx.cwd, signal },
901
+ );
902
+ if (!jobsRes.ok) return toToolResult(jobsRes);
903
+ const { jobs } = JSON.parse(jobsRes.stdout) as {
904
+ jobs: Array<{
905
+ id: number;
906
+ name: string;
907
+ status: string;
908
+ conclusion: string | null;
909
+ steps: Array<{
910
+ name: string;
911
+ number: number;
912
+ status: string;
913
+ conclusion: string | null;
914
+ }>;
915
+ }>;
916
+ };
917
+
918
+ if (!jobs || jobs.length === 0) {
919
+ return {
920
+ content: [{ type: "text", text: `No jobs found for run ${run_id}` }],
921
+ details: {},
922
+ };
923
+ }
924
+
925
+ let targetJobs = jobs;
926
+ if (job) {
927
+ const isNumeric = /^\d+$/.test(job);
928
+ targetJobs = jobs.filter((j) => (isNumeric ? String(j.id) === job : j.name === job));
929
+ if (targetJobs.length === 0) {
930
+ return {
931
+ content: [
932
+ {
933
+ type: "text",
934
+ text: `Job "${job}" not found. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
935
+ },
936
+ ],
937
+ details: {},
938
+ };
939
+ }
940
+ }
941
+
942
+ let output = `## CI Summary for Run ${run_id}\n\n`;
943
+
944
+ for (const j of targetJobs) {
945
+ const jIcon = statusIcon(j.conclusion);
946
+ output += `### ${jIcon} Job: \`${j.name}\` (id: ${j.id}) — ${j.conclusion ?? j.status}\n\n`;
947
+ output += `| Step# | Name | Status |\n|-------|------|--------|\n`;
948
+ for (const s of j.steps) {
949
+ const sIcon = statusIcon(s.conclusion);
950
+ output += `| ${s.number} | ${s.name} | ${sIcon} ${s.conclusion ?? s.status} |\n`;
951
+ }
952
+ output += `\n`;
953
+ }
954
+
955
+ output += `---\n`;
956
+ output += `To view a specific step's logs, call again with \`step=<number>\` (and \`job="<name>"\` if multiple jobs).\n`;
957
+
958
+ // ── Auto-include failed step logs ──────────────────────────────────
959
+ const contents: Array<{ type: "text"; text: string }> = [{ type: "text", text: output }];
960
+ let fetchedCount = 0;
961
+ const maxFailed = 5;
962
+ const expandedSteps = new Map<number, Set<number>>(); // jobId → step numbers
963
+
964
+ for (const j of targetJobs) {
965
+ if (fetchedCount >= maxFailed) {
966
+ contents.push({
967
+ type: "text",
968
+ text: `(... ${maxFailed} failed step logs shown; use \`step\` to fetch more)`,
969
+ });
970
+ break;
971
+ }
972
+
973
+ const failedSteps = j.steps.filter((s) => s.conclusion === "failure");
974
+ if (failedSteps.length === 0) continue;
975
+
976
+ try {
977
+ const logRes = await getJobLog(pi, String(run_id), j.id, resolved.repo!, signal, ctx.cwd);
978
+ if (!logRes.ok) {
979
+ contents.push({
980
+ type: "text",
981
+ text: `\n⚠️ Could not auto-fetch logs for ${j.name}: gh ${logRes.args.join(" ")} failed: ${logRes.error}`,
982
+ });
983
+ continue;
984
+ }
985
+ const rawLog = logRes.log!;
986
+
987
+ for (const fs of failedSteps) {
988
+ if (fetchedCount >= maxFailed) break;
989
+ fetchedCount++;
990
+
991
+ const stepLog = extractStepFromLog(rawLog, fs.number, j.steps);
992
+ if (!stepLog) continue;
993
+
994
+ const totalLines = stepLog.split("\n").length;
995
+ const maxLines = 500; // tighter limit for auto-included logs
996
+ const { text: logText, truncated: logTr } = truncate(stepLog, maxLines, 60 * 1024);
997
+ const shownLines = logText.split("\n").length;
998
+ const trNote = logTr ? " (truncated)" : "";
999
+
1000
+ contents.push({
1001
+ type: "text",
1002
+ text: `\n### ❌ ${j.name} / ${fs.name} (step ${fs.number})\nTotal: ${totalLines} lines | Shown: ${shownLines} lines${trNote}\n`,
1003
+ });
1004
+ contents.push({ type: "text", text: logText });
1005
+ if (!expandedSteps.has(j.id)) expandedSteps.set(j.id, new Set());
1006
+ expandedSteps.get(j.id)!.add(fs.number);
1007
+ }
1008
+ } catch (err: unknown) {
1009
+ const msg = err instanceof Error ? err.message : String(err);
1010
+ contents.push({
1011
+ type: "text",
1012
+ text: `\n⚠️ Could not auto-fetch logs for ${j.name}: ${msg}`,
1013
+ });
1014
+ }
1015
+ }
1016
+
1017
+ const totalJobs = targetJobs.length;
1018
+ const failedJobs = targetJobs.filter((j) => j.conclusion === "failure").length;
1019
+ const totalFailedSteps = targetJobs.reduce(
1020
+ (acc, j) => acc + j.steps.filter((s) => s.conclusion === "failure").length,
1021
+ 0,
1022
+ );
1023
+ return {
1024
+ content: contents,
1025
+ details: {
1026
+ summary: `${totalJobs} job${totalJobs > 1 ? "s" : ""}, ${failedJobs} failed, ${totalFailedSteps} failed step${totalFailedSteps > 1 ? "s" : ""}`,
1027
+ jobs: targetJobs.map((j) => ({
1028
+ name: j.name,
1029
+ conclusion: j.conclusion,
1030
+ steps: stepsDetail(j, expandedSteps.get(j.id)),
1031
+ })),
1032
+ },
1033
+ };
1034
+ },
1035
+ });
1036
+
1037
+ // ── read-github-workflow-jobs ──────────────────────────────────────────────
1038
+ pi.registerTool({
1039
+ name: "read-github-workflow-jobs",
1040
+ label: "GitHub Workflow Jobs",
1041
+ description:
1042
+ "Get structured job data (name, status, conclusion, job ID) for a workflow run. Useful before reading CI logs to identify which job to inspect.",
1043
+ promptSnippet: "Read GitHub workflow run jobs",
1044
+ parameters: Type.Object({
1045
+ run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
1046
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1047
+ }),
1048
+ async execute(_id, params, signal, _onUpdate, ctx) {
1049
+ const { run_id, repo } = params as { run_id: number | string; repo?: string };
1050
+ const resolved = await resolveRepo(pi, repo, signal, ctx.cwd);
1051
+ if (!resolved.ok) return toToolResult(resolved);
1052
+ return toToolResult(
1053
+ await ghExec(pi, ["api", `/repos/${resolved.repo!}/actions/runs/${run_id}/jobs`], {
1054
+ cwd: ctx.cwd,
1055
+ signal,
1056
+ }),
1057
+ );
1058
+ },
1059
+ });
1060
+
1061
+ // ── read-github-repo ───────────────────────────────────────────────────────
1062
+ pi.registerTool({
1063
+ name: "read-github-repo",
1064
+ label: "GitHub Repo",
1065
+ description: "Get repository information.",
1066
+ promptSnippet: "Read GitHub repo info",
1067
+ parameters: Type.Object({
1068
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1069
+ }),
1070
+ async execute(_id, params, signal, _onUpdate, ctx) {
1071
+ const { repo } = params as { repo?: string };
1072
+ const args = ["repo", "view"];
1073
+ if (repo) args.push(repo);
1074
+ return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
1075
+ },
1076
+ });
1077
+
1078
+ // ── list-github-releases ───────────────────────────────────────────────────
1079
+ pi.registerTool({
1080
+ name: "list-github-releases",
1081
+ label: "GitHub Releases List",
1082
+ description: "List GitHub releases.",
1083
+ promptSnippet: "List GitHub releases",
1084
+ parameters: Type.Object({
1085
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1086
+ limit: Type.Optional(Type.Number({ description: "Max results (default 10)" })),
1087
+ }),
1088
+ async execute(_id, params, signal, _onUpdate, ctx) {
1089
+ const { repo, limit } = params as { repo?: string; limit?: number };
1090
+ const args = ["release", "list", ...repoArgs(repo)];
1091
+ if (limit) args.push("--limit", String(limit));
1092
+ return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
1093
+ },
1094
+ });
1095
+
1096
+ // ── read-github-release ────────────────────────────────────────────────────
1097
+ pi.registerTool({
1098
+ name: "read-github-release",
1099
+ label: "GitHub Release",
1100
+ description: "Get details of a specific GitHub release by tag.",
1101
+ promptSnippet: "Read a GitHub release",
1102
+ parameters: Type.Object({
1103
+ tag: Type.String({ description: "Release tag name" }),
1104
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1105
+ }),
1106
+ async execute(_id, params, signal, _onUpdate, ctx) {
1107
+ const { tag, repo } = params as { tag: string; repo?: string };
1108
+ return toToolResult(
1109
+ await ghExec(pi, ["release", "view", tag, ...repoArgs(repo)], {
1110
+ cwd: ctx.cwd,
1111
+ signal,
1112
+ }),
1113
+ );
1114
+ },
1115
+ });
1116
+
1117
+ // ── wait-github-pr-checks ─────────────────────────────────────────────────
1118
+ pi.registerTool({
1119
+ name: "wait-github-pr-checks",
1120
+ label: "Watch GitHub PR Checks",
1121
+ description:
1122
+ "Watch CI status checks for a PR until they complete. Blocks until all checks finish or one fails. " +
1123
+ "Use this when you need to wait for CI to complete and see the final result.",
1124
+ promptSnippet: "Watch and wait for GitHub PR CI checks to complete",
1125
+ parameters: Type.Object({
1126
+ number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
1127
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1128
+ fail_fast: Type.Optional(
1129
+ Type.Boolean({ description: "Exit immediately when any check fails (default: false)" }),
1130
+ ),
1131
+ }),
1132
+ async execute(_id, params, signal, onUpdate, ctx) {
1133
+ const { number, repo, fail_fast } = params as {
1134
+ number: number | string;
1135
+ repo?: string;
1136
+ fail_fast?: boolean;
1137
+ };
1138
+
1139
+ onUpdate?.({
1140
+ content: [{ type: "text", text: `Watching CI checks for PR #${number}...` }],
1141
+ details: {},
1142
+ });
1143
+
1144
+ const args = ["pr", "checks", String(number), ...repoArgs(repo), "--watch"];
1145
+ if (fail_fast) args.push("--fail-fast");
1146
+
1147
+ const result = await execGh(pi, args, { cwd: ctx.cwd, signal, timeout: 600_000 });
1148
+
1149
+ const exitCode = result.code;
1150
+ const stdout = result.stdout;
1151
+ const stderr = result.stderr;
1152
+
1153
+ // Exit code 2 means one or more checks failed
1154
+ if (exitCode === 2) {
1155
+ return {
1156
+ content: [
1157
+ { type: "text", text: `## PR #${number} CI Checks - FAILED\n\n${stdout}\n${stderr}` },
1158
+ ],
1159
+ details: { status: "failure", exitCode },
1160
+ };
1161
+ }
1162
+
1163
+ if (exitCode !== 0) {
1164
+ throw new Error(`gh pr checks --watch failed: ${stderr || `exit code ${exitCode}`}`);
1165
+ }
1166
+
1167
+ return {
1168
+ content: [{ type: "text", text: `## PR #${number} CI Checks - PASSED\n\n${stdout}` }],
1169
+ details: { status: "success", exitCode: 0 },
1170
+ };
1171
+ },
1172
+ });
1173
+
1174
+ // ── watch-github-run ───────────────────────────────────────────────────────
1175
+ pi.registerTool({
1176
+ name: "watch-github-run",
1177
+ label: "Watch GitHub Workflow Run",
1178
+ description:
1179
+ "Watch a GitHub Actions workflow run until it completes. " +
1180
+ "Blocks until the run finishes and shows the final status.",
1181
+ promptSnippet: "Watch and wait for a GitHub Actions run to complete",
1182
+ parameters: Type.Object({
1183
+ run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
1184
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1185
+ }),
1186
+ async execute(_id, params, signal, onUpdate, ctx) {
1187
+ const { run_id, repo } = params as { run_id: number | string; repo?: string };
1188
+
1189
+ onUpdate?.({
1190
+ content: [{ type: "text", text: `Watching workflow run ${run_id}...` }],
1191
+ details: {},
1192
+ });
1193
+
1194
+ const result = await execGh(pi, ["run", "watch", String(run_id), ...repoArgs(repo)], {
1195
+ cwd: ctx.cwd,
1196
+ signal,
1197
+ timeout: 600_000,
1198
+ });
1199
+
1200
+ if (result.code !== 0) {
1201
+ throw new Error(`gh run watch failed: ${result.stderr || `exit code ${result.code}`}`);
1202
+ }
1203
+
1204
+ return {
1205
+ content: [
1206
+ { type: "text", text: `## Workflow Run ${run_id} Completed\n\n${result.stdout}` },
1207
+ ],
1208
+ details: { exitCode: 0 },
1209
+ };
1210
+ },
1211
+ });
1212
+
1213
+ // ── search-github-issues ───────────────────────────────────────────────────
1214
+ pi.registerTool({
1215
+ name: "search-github-issues",
1216
+ label: "GitHub Issue Search",
1217
+ description: "Search GitHub issues using GitHub search syntax.",
1218
+ promptSnippet: "Search GitHub issues",
1219
+ parameters: Type.Object({
1220
+ query: Type.String({
1221
+ description:
1222
+ "GitHub search syntax (e.g. 'repo:owner/name keyword', 'is:open label:bug'). Do NOT include 'type:issue' or 'type:pr' qualifiers.",
1223
+ }),
1224
+ include_prs: Type.Optional(
1225
+ Type.Boolean({
1226
+ description: "Whether to include pull requests in results (default: false)",
1227
+ }),
1228
+ ),
1229
+ limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
1230
+ }),
1231
+ async execute(_id, params, signal, _onUpdate, ctx) {
1232
+ const { query, include_prs, limit } = params as {
1233
+ query: string;
1234
+ include_prs?: boolean;
1235
+ limit?: number;
1236
+ };
1237
+ const args = ["search", "issues", query];
1238
+ if (!include_prs) args.push("--type", "issue");
1239
+ if (limit) args.push("--limit", String(limit));
1240
+ return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
1241
+ },
1242
+ });
1243
+
1244
+ // ── search-github-prs ──────────────────────────────────────────────────────
1245
+ pi.registerTool({
1246
+ name: "search-github-prs",
1247
+ label: "GitHub PR Search",
1248
+ description: "Search GitHub pull requests using GitHub search syntax.",
1249
+ promptSnippet: "Search GitHub PRs",
1250
+ parameters: Type.Object({
1251
+ query: Type.String({
1252
+ description:
1253
+ "GitHub search syntax (e.g. 'repo:owner/name keyword', 'is:open label:bug'). Do NOT include 'type:issue' or 'type:pr' qualifiers.",
1254
+ }),
1255
+ limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
1256
+ }),
1257
+ async execute(_id, params, signal, _onUpdate, ctx) {
1258
+ const { query, limit } = params as { query: string; limit?: number };
1259
+ const args = ["search", "prs", query];
1260
+ if (limit) args.push("--limit", String(limit));
1261
+ return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
1262
+ },
1263
+ });
1264
+ }