pi-agent-toolkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/dotfiles/AGENTS.md +197 -0
  2. package/dist/dotfiles/APPEND_SYSTEM.md +78 -0
  3. package/dist/dotfiles/agent-modes.json +12 -0
  4. package/dist/dotfiles/agent-skills/exa-search/.env.example +4 -0
  5. package/dist/dotfiles/agent-skills/exa-search/SKILL.md +234 -0
  6. package/dist/dotfiles/agent-skills/exa-search/scripts/exa-api.cjs +197 -0
  7. package/dist/dotfiles/auth.json.template +5 -0
  8. package/dist/dotfiles/damage-control-rules.yaml +318 -0
  9. package/dist/dotfiles/extensions/btw.ts +1031 -0
  10. package/dist/dotfiles/extensions/commit-approval.ts +590 -0
  11. package/dist/dotfiles/extensions/context.ts +578 -0
  12. package/dist/dotfiles/extensions/control.ts +1748 -0
  13. package/dist/dotfiles/extensions/damage-control/index.ts +543 -0
  14. package/dist/dotfiles/extensions/damage-control/node_modules/.package-lock.json +22 -0
  15. package/dist/dotfiles/extensions/damage-control/package-lock.json +28 -0
  16. package/dist/dotfiles/extensions/damage-control/package.json +7 -0
  17. package/dist/dotfiles/extensions/dirty-repo-guard.ts +56 -0
  18. package/dist/dotfiles/extensions/exa-enforce.ts +51 -0
  19. package/dist/dotfiles/extensions/exa-search-tool.ts +384 -0
  20. package/dist/dotfiles/extensions/execute-command/index.ts +82 -0
  21. package/dist/dotfiles/extensions/files.ts +1112 -0
  22. package/dist/dotfiles/extensions/loop.ts +446 -0
  23. package/dist/dotfiles/extensions/pr-approval.ts +730 -0
  24. package/dist/dotfiles/extensions/qna-interactive.ts +532 -0
  25. package/dist/dotfiles/extensions/question-mode.ts +242 -0
  26. package/dist/dotfiles/extensions/require-session-name-on-exit.ts +141 -0
  27. package/dist/dotfiles/extensions/review.ts +2091 -0
  28. package/dist/dotfiles/extensions/session-breakdown.ts +1629 -0
  29. package/dist/dotfiles/extensions/term-notify.ts +150 -0
  30. package/dist/dotfiles/extensions/tilldone.ts +527 -0
  31. package/dist/dotfiles/extensions/todos.ts +2082 -0
  32. package/dist/dotfiles/extensions/tools.ts +146 -0
  33. package/dist/dotfiles/extensions/uv.ts +123 -0
  34. package/dist/dotfiles/global-skills/brainstorm/SKILL.md +10 -0
  35. package/dist/dotfiles/global-skills/cli-detector/SKILL.md +192 -0
  36. package/dist/dotfiles/global-skills/gh-issue-creator/SKILL.md +173 -0
  37. package/dist/dotfiles/global-skills/google-chat-cards-v2/SKILL.md +237 -0
  38. package/dist/dotfiles/global-skills/google-chat-cards-v2/references/bridge_tap_implementation.md +466 -0
  39. package/dist/dotfiles/global-skills/technical-docs/SKILL.md +204 -0
  40. package/dist/dotfiles/global-skills/technical-docs/references/diagrams.md +168 -0
  41. package/dist/dotfiles/global-skills/technical-docs/references/examples.md +449 -0
  42. package/dist/dotfiles/global-skills/technical-docs/scripts/validate_docs.py +352 -0
  43. package/dist/dotfiles/global-skills/whats-new/SKILL.md +159 -0
  44. package/dist/dotfiles/intercepted-commands/pip +7 -0
  45. package/dist/dotfiles/intercepted-commands/pip3 +7 -0
  46. package/dist/dotfiles/intercepted-commands/poetry +10 -0
  47. package/dist/dotfiles/intercepted-commands/python +104 -0
  48. package/dist/dotfiles/intercepted-commands/python3 +104 -0
  49. package/dist/dotfiles/mcp.json.template +32 -0
  50. package/dist/dotfiles/models.json +27 -0
  51. package/dist/dotfiles/settings.json +25 -0
  52. package/dist/index.js +1344 -0
  53. package/package.json +34 -0
@@ -0,0 +1,730 @@
1
+ import { basename } from "node:path";
2
+
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ UserBashEventResult,
7
+ ToolCallEventResult,
8
+ Theme,
9
+ } from "@mariozechner/pi-coding-agent";
10
+ import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
11
+ import type { TUI, KeybindingsManager } from "@mariozechner/pi-tui";
12
+ import { matchesKey } from "@mariozechner/pi-tui";
13
+
14
+ const APPROVE_OPTION = "Approve";
15
+ const DENY_OPTION = "Deny";
16
+
17
+ const REASON_INTERACTIVE_REQUIRED =
18
+ "PR operation blocked: interactive approval is required.";
19
+ const REASON_PR_CREATE_DENIED = "gh pr create blocked: approval denied.";
20
+ const REASON_PR_MERGE_DENIED = "gh pr merge blocked: approval denied.";
21
+ const REASON_PUSH_DENIED = "git push blocked: approval denied.";
22
+
23
+ const PROTECTED_BRANCHES = new Set([
24
+ "main",
25
+ "master",
26
+ "production",
27
+ "release",
28
+ ]);
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Types
32
+ // ---------------------------------------------------------------------------
33
+
34
+ interface PrCreateMetadata {
35
+ title: string | null;
36
+ base: string | null;
37
+ head: string | null;
38
+ isDraft: boolean;
39
+ reviewers: string[];
40
+ body: string | null;
41
+ }
42
+
43
+ interface PrMergeMetadata {
44
+ prRef: string | null;
45
+ strategy: string | null;
46
+ deleteBranch: boolean;
47
+ autoMerge: boolean;
48
+ }
49
+
50
+ interface PushMetadata {
51
+ remote: string | null;
52
+ branch: string | null;
53
+ isForce: boolean;
54
+ isProtected: boolean;
55
+ }
56
+
57
+ type PrOperation =
58
+ | { kind: "pr-create"; metadata: PrCreateMetadata }
59
+ | { kind: "pr-merge"; metadata: PrMergeMetadata }
60
+ | { kind: "push"; metadata: PushMetadata };
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Shell parsing (mirrors commit-approval)
64
+ // ---------------------------------------------------------------------------
65
+
66
+ function shellSplit(input: string): string[] {
67
+ const command = input.trim();
68
+ const tokens: string[] = [];
69
+ let current = "";
70
+ let quote: '"' | "'" | null = null;
71
+
72
+ const flushCurrent = () => {
73
+ if (current) {
74
+ tokens.push(current);
75
+ current = "";
76
+ }
77
+ };
78
+
79
+ for (let i = 0; i < command.length; i += 1) {
80
+ const ch = command[i] ?? "";
81
+
82
+ if (quote === "'") {
83
+ if (ch === "'") {
84
+ quote = null;
85
+ } else {
86
+ current += ch;
87
+ }
88
+ continue;
89
+ }
90
+
91
+ if (quote === '"') {
92
+ if (ch === '"') {
93
+ quote = null;
94
+ continue;
95
+ }
96
+
97
+ if (ch === "\\") {
98
+ const next = command[i + 1] ?? "";
99
+ if (next === '"' || next === "\\" || next === "$" || next === "`") {
100
+ current += next;
101
+ i += 1;
102
+ } else if (next === "\n") {
103
+ i += 1;
104
+ } else {
105
+ current += "\\";
106
+ }
107
+ continue;
108
+ }
109
+
110
+ current += ch;
111
+ continue;
112
+ }
113
+
114
+ if (ch === '"' || ch === "'") {
115
+ quote = ch;
116
+ continue;
117
+ }
118
+
119
+ if (ch === "\\") {
120
+ const next = command[i + 1];
121
+ if (next !== undefined) {
122
+ current += next;
123
+ i += 1;
124
+ } else {
125
+ current += "\\";
126
+ }
127
+ continue;
128
+ }
129
+
130
+ if (/\s/.test(ch)) {
131
+ flushCurrent();
132
+ continue;
133
+ }
134
+
135
+ current += ch;
136
+ }
137
+
138
+ flushCurrent();
139
+ return tokens;
140
+ }
141
+
142
+ function splitByShellOperators(command: string): string[] {
143
+ // Collapse backslash-newline continuations before splitting,
144
+ // exactly as bash does for line continuations.
145
+ const normalized = command.replace(/\\\n\s*/g, " ");
146
+
147
+ // Quote-aware split: only split on &&, ||, ;, \n when outside quotes.
148
+ const segments: string[] = [];
149
+ let current = "";
150
+ let quote: '"' | "'" | null = null;
151
+
152
+ for (let i = 0; i < normalized.length; i += 1) {
153
+ const ch = normalized[i] ?? "";
154
+
155
+ if (quote) {
156
+ current += ch;
157
+ if (ch === quote) quote = null;
158
+ continue;
159
+ }
160
+
161
+ if (ch === '"' || ch === "'") {
162
+ current += ch;
163
+ quote = ch;
164
+ continue;
165
+ }
166
+
167
+ if (ch === "\n" || ch === ";") {
168
+ if (current.trim()) segments.push(current.trim());
169
+ current = "";
170
+ continue;
171
+ }
172
+
173
+ if (ch === "&" && normalized[i + 1] === "&") {
174
+ if (current.trim()) segments.push(current.trim());
175
+ current = "";
176
+ i += 1;
177
+ continue;
178
+ }
179
+
180
+ if (ch === "|" && normalized[i + 1] === "|") {
181
+ if (current.trim()) segments.push(current.trim());
182
+ current = "";
183
+ i += 1;
184
+ continue;
185
+ }
186
+
187
+ current += ch;
188
+ }
189
+
190
+ if (current.trim()) segments.push(current.trim());
191
+ return segments;
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Parsers
196
+ // ---------------------------------------------------------------------------
197
+
198
+ function findExecutableIndex(tokens: string[], name: string): number {
199
+ for (let i = 0; i < tokens.length; i += 1) {
200
+ const token = tokens[i] ?? "";
201
+ if (/^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token)) continue;
202
+ if (basename(token).toLowerCase() === name) return i;
203
+ return -1;
204
+ }
205
+ return -1;
206
+ }
207
+
208
+ function consumeOptionValue(
209
+ args: string[],
210
+ index: number,
211
+ shortFlag: string,
212
+ longFlag: string,
213
+ ): { value: string | null; skip: number } {
214
+ const token = args[index] ?? "";
215
+
216
+ if (token === shortFlag || token === longFlag) {
217
+ return { value: args[index + 1] ?? null, skip: 2 };
218
+ }
219
+
220
+ if (token.startsWith(`${longFlag}=`)) {
221
+ return { value: token.slice(longFlag.length + 1), skip: 1 };
222
+ }
223
+
224
+ return { value: null, skip: 0 };
225
+ }
226
+
227
+ function parsePrCreate(tokens: string[]): PrCreateMetadata | null {
228
+ const ghIdx = findExecutableIndex(tokens, "gh");
229
+ if (ghIdx < 0) return null;
230
+ if (tokens[ghIdx + 1] !== "pr" || tokens[ghIdx + 2] !== "create") return null;
231
+
232
+ const args = tokens.slice(ghIdx + 3);
233
+ let title: string | null = null;
234
+ let base: string | null = null;
235
+ let head: string | null = null;
236
+ let isDraft = false;
237
+ let body: string | null = null;
238
+ const reviewers: string[] = [];
239
+
240
+ for (let i = 0; i < args.length; i += 1) {
241
+ const token = args[i] ?? "";
242
+
243
+ const titleOpt = consumeOptionValue(args, i, "-t", "--title");
244
+ if (titleOpt.skip) { title = titleOpt.value; i += titleOpt.skip - 1; continue; }
245
+
246
+ const baseOpt = consumeOptionValue(args, i, "-B", "--base");
247
+ if (baseOpt.skip) { base = baseOpt.value; i += baseOpt.skip - 1; continue; }
248
+
249
+ const headOpt = consumeOptionValue(args, i, "-H", "--head");
250
+ if (headOpt.skip) { head = headOpt.value; i += headOpt.skip - 1; continue; }
251
+
252
+ const bodyOpt = consumeOptionValue(args, i, "-b", "--body");
253
+ if (bodyOpt.skip) { body = bodyOpt.value; i += bodyOpt.skip - 1; continue; }
254
+
255
+ const revOpt = consumeOptionValue(args, i, "-r", "--reviewer");
256
+ if (revOpt.skip) { if (revOpt.value) reviewers.push(revOpt.value); i += revOpt.skip - 1; continue; }
257
+
258
+ if (token === "-d" || token === "--draft") { isDraft = true; continue; }
259
+ }
260
+
261
+ return { title, base, head, isDraft, reviewers, body };
262
+ }
263
+
264
+ function parsePrMerge(tokens: string[]): PrMergeMetadata | null {
265
+ const ghIdx = findExecutableIndex(tokens, "gh");
266
+ if (ghIdx < 0) return null;
267
+ if (tokens[ghIdx + 1] !== "pr" || tokens[ghIdx + 2] !== "merge") return null;
268
+
269
+ const args = tokens.slice(ghIdx + 3);
270
+ let prRef: string | null = null;
271
+ let strategy: string | null = null;
272
+ let deleteBranch = false;
273
+ let autoMerge = false;
274
+
275
+ for (let i = 0; i < args.length; i += 1) {
276
+ const token = args[i] ?? "";
277
+
278
+ if (token === "--squash" || token === "-s") { strategy = "squash"; continue; }
279
+ if (token === "--merge") { strategy = "merge"; continue; }
280
+ if (token === "--rebase" || token === "-r") { strategy = "rebase"; continue; }
281
+ if (token === "--delete-branch" || token === "-d") { deleteBranch = true; continue; }
282
+ if (token === "--auto") { autoMerge = true; continue; }
283
+
284
+ if (!token.startsWith("-") && prRef === null) {
285
+ prRef = token;
286
+ }
287
+ }
288
+
289
+ return { prRef, strategy, deleteBranch, autoMerge };
290
+ }
291
+
292
+ function parsePush(tokens: string[]): PushMetadata | null {
293
+ const gitIdx = findExecutableIndex(tokens, "git");
294
+ if (gitIdx < 0) return null;
295
+
296
+ const GIT_GLOBAL_OPTS_WITH_VALUE = new Set([
297
+ "-c", "-C", "--exec-path", "--git-dir", "--work-tree", "--namespace",
298
+ ]);
299
+
300
+ let subIdx = gitIdx + 1;
301
+ while (subIdx < tokens.length) {
302
+ const token = tokens[subIdx] ?? "";
303
+ if (!token.startsWith("-")) break;
304
+ if (token === "--") return null;
305
+ if (GIT_GLOBAL_OPTS_WITH_VALUE.has(token)) { subIdx += 2; continue; }
306
+ if (token.startsWith("-c") && token.length > 2) { subIdx += 1; continue; }
307
+ if (token.startsWith("-C") && token.length > 2) { subIdx += 1; continue; }
308
+ if (token.startsWith("--") && token.includes("=")) { subIdx += 1; continue; }
309
+ subIdx += 1;
310
+ }
311
+
312
+ if (tokens[subIdx] !== "push") return null;
313
+
314
+ const args = tokens.slice(subIdx + 1);
315
+ let isForce = false;
316
+ const positional: string[] = [];
317
+
318
+ for (let i = 0; i < args.length; i += 1) {
319
+ const token = args[i] ?? "";
320
+
321
+ if (token === "-f" || token === "--force" || token === "--force-with-lease") {
322
+ isForce = true;
323
+ continue;
324
+ }
325
+
326
+ if (token === "--repo") { i += 1; continue; }
327
+ if (token === "-u" || token === "--set-upstream") continue;
328
+ if (token.startsWith("-")) continue;
329
+
330
+ positional.push(token);
331
+ }
332
+
333
+ const remote = positional[0] ?? null;
334
+ const rawRef = positional[1] ?? null;
335
+ const branch = rawRef && rawRef.includes(":")
336
+ ? rawRef.split(":")[1] ?? rawRef
337
+ : rawRef;
338
+
339
+ const isProtected = branch !== null && PROTECTED_BRANCHES.has(branch);
340
+
341
+ return { remote, branch, isForce, isProtected };
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Top-level command parser
346
+ // ---------------------------------------------------------------------------
347
+
348
+ function parsePrOperation(command: string): PrOperation | null {
349
+ for (const segment of splitByShellOperators(command)) {
350
+ const tokens = shellSplit(segment);
351
+
352
+ const prCreate = parsePrCreate(tokens);
353
+ if (prCreate) return { kind: "pr-create", metadata: prCreate };
354
+
355
+ const prMerge = parsePrMerge(tokens);
356
+ if (prMerge) return { kind: "pr-merge", metadata: prMerge };
357
+
358
+ const push = parsePush(tokens);
359
+ if (push) return { kind: "push", metadata: push };
360
+ }
361
+
362
+ return null;
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Validation
367
+ // ---------------------------------------------------------------------------
368
+
369
+ interface ValidationIssue {
370
+ level: "error" | "warning";
371
+ message: string;
372
+ }
373
+
374
+ interface ValidationResult {
375
+ issues: ValidationIssue[];
376
+ hasErrors: boolean;
377
+ }
378
+
379
+ function validatePrCreate(meta: PrCreateMetadata): ValidationResult {
380
+ const issues: ValidationIssue[] = [];
381
+
382
+ if (!meta.title || meta.title.trim().length === 0) {
383
+ issues.push({
384
+ level: "error",
385
+ message: "PR title is missing. Provide a descriptive title with --title.",
386
+ });
387
+ } else if (meta.title.trim().length < 10) {
388
+ issues.push({
389
+ level: "warning",
390
+ message: `PR title is very short (${meta.title.trim().length} chars). Be descriptive.`,
391
+ });
392
+ }
393
+
394
+ if (!meta.body || meta.body.trim().length === 0) {
395
+ issues.push({
396
+ level: "error",
397
+ message:
398
+ "PR body is missing. Add --body explaining what changed and why.",
399
+ });
400
+ } else if (meta.body.trim().length < 20) {
401
+ issues.push({
402
+ level: "warning",
403
+ message: "PR body is very short. Include context on what changed and why.",
404
+ });
405
+ }
406
+
407
+ return {
408
+ issues,
409
+ hasErrors: issues.some((i) => i.level === "error"),
410
+ };
411
+ }
412
+
413
+ function validatePrMerge(meta: PrMergeMetadata): ValidationResult {
414
+ const issues: ValidationIssue[] = [];
415
+
416
+ if (!meta.strategy) {
417
+ issues.push({
418
+ level: "warning",
419
+ message: "No merge strategy specified (--squash, --merge, or --rebase).",
420
+ });
421
+ }
422
+
423
+ return {
424
+ issues,
425
+ hasErrors: issues.some((i) => i.level === "error"),
426
+ };
427
+ }
428
+
429
+ function validateOperation(op: PrOperation): ValidationResult {
430
+ switch (op.kind) {
431
+ case "pr-create": return validatePrCreate(op.metadata);
432
+ case "pr-merge": return validatePrMerge(op.metadata);
433
+ case "push": return { issues: [], hasErrors: false };
434
+ }
435
+ }
436
+
437
+ function formatValidationIssues(issues: ValidationIssue[]): string {
438
+ return issues
439
+ .map((i) => ` ${i.level === "error" ? "[x]" : "[!]"} ${i.message}`)
440
+ .join("\n");
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // Approval prompts
445
+ // ---------------------------------------------------------------------------
446
+
447
+ function buildPrCreatePrompt(meta: PrCreateMetadata): string {
448
+ const lines = ["PR Create:"];
449
+ lines.push(` Title: ${meta.title ?? "(will prompt interactively)"}`);
450
+ lines.push(` Base: ${meta.base ?? "(default branch)"}`);
451
+ if (meta.head) lines.push(` Head: ${meta.head}`);
452
+ lines.push(` Draft: ${meta.isDraft ? "yes" : "no"}`);
453
+ if (meta.reviewers.length > 0) {
454
+ lines.push(` Reviewers: ${meta.reviewers.join(", ")}`);
455
+ }
456
+ if (meta.body) {
457
+ const preview = meta.body.length > 80 ? meta.body.slice(0, 80) + "..." : meta.body;
458
+ lines.push(` Body: ${preview}`);
459
+ }
460
+ lines.push("");
461
+ lines.push("Approve this PR creation?");
462
+ return lines.join("\n");
463
+ }
464
+
465
+ function buildPrMergePrompt(meta: PrMergeMetadata): string {
466
+ const lines = ["PR Merge:"];
467
+ lines.push(` PR: ${meta.prRef ?? "(current branch)"}`);
468
+ lines.push(` Strategy: ${meta.strategy ?? "(will prompt interactively)"}`);
469
+ lines.push(` Delete branch: ${meta.deleteBranch ? "yes" : "no"}`);
470
+ if (meta.autoMerge) lines.push(" Auto-merge: yes");
471
+ lines.push("");
472
+ lines.push("Approve this PR merge?");
473
+ return lines.join("\n");
474
+ }
475
+
476
+ function buildPushPrompt(meta: PushMetadata): string {
477
+ const lines = ["Git Push:"];
478
+ lines.push(` Remote: ${meta.remote ?? "(default)"}`);
479
+ lines.push(` Branch: ${meta.branch ?? "(current branch)"}`);
480
+ if (meta.isForce) lines.push(" ** FORCE PUSH **");
481
+ if (meta.isProtected) lines.push(` ** PROTECTED BRANCH: ${meta.branch} **`);
482
+ lines.push("");
483
+ lines.push("Approve this push?");
484
+ return lines.join("\n");
485
+ }
486
+
487
+ function buildApprovalPrompt(
488
+ op: PrOperation,
489
+ issues?: ValidationIssue[],
490
+ ): string {
491
+ let prompt: string;
492
+ switch (op.kind) {
493
+ case "pr-create": prompt = buildPrCreatePrompt(op.metadata); break;
494
+ case "pr-merge": prompt = buildPrMergePrompt(op.metadata); break;
495
+ case "push": prompt = buildPushPrompt(op.metadata); break;
496
+ }
497
+
498
+ if (issues && issues.length > 0) {
499
+ const issueBlock = formatValidationIssues(issues);
500
+ prompt = prompt.replace(
501
+ /\nApprove this /,
502
+ `\nIssues:\n${issueBlock}\n\nApprove this `,
503
+ );
504
+ }
505
+
506
+ return prompt;
507
+ }
508
+
509
+ function getDeniedReason(op: PrOperation): string {
510
+ switch (op.kind) {
511
+ case "pr-create": return REASON_PR_CREATE_DENIED;
512
+ case "pr-merge": return REASON_PR_MERGE_DENIED;
513
+ case "push": return REASON_PUSH_DENIED;
514
+ }
515
+ }
516
+
517
+ // ---------------------------------------------------------------------------
518
+ // Gating logic
519
+ // ---------------------------------------------------------------------------
520
+
521
+ function shouldGate(op: PrOperation): boolean {
522
+ if (op.kind === "pr-create" || op.kind === "pr-merge") return true;
523
+ if (op.kind === "push") return op.metadata.isForce || op.metadata.isProtected;
524
+ return false;
525
+ }
526
+
527
+ function getPreviewLines(op: PrOperation): string[] {
528
+ switch (op.kind) {
529
+ case "pr-create": {
530
+ const m = op.metadata;
531
+ const lines = ["PR Create:"];
532
+ lines.push(` Title: ${m.title ?? "(will prompt interactively)"}`);
533
+ lines.push(` Base: ${m.base ?? "(default branch)"}`);
534
+ if (m.head) lines.push(` Head: ${m.head}`);
535
+ lines.push(` Draft: ${m.isDraft ? "yes" : "no"}`);
536
+ if (m.reviewers.length > 0) lines.push(` Reviewers: ${m.reviewers.join(", ")}`);
537
+ if (m.body) {
538
+ lines.push("");
539
+ lines.push(" Body:");
540
+ for (const line of m.body.split("\n")) {
541
+ lines.push(` ${line}`);
542
+ }
543
+ }
544
+ return lines;
545
+ }
546
+ case "pr-merge": {
547
+ const m = op.metadata;
548
+ const lines = ["PR Merge:"];
549
+ lines.push(` PR: ${m.prRef ?? "(current branch)"}`);
550
+ lines.push(` Strategy: ${m.strategy ?? "(will prompt interactively)"}`);
551
+ lines.push(` Delete branch: ${m.deleteBranch ? "yes" : "no"}`);
552
+ if (m.autoMerge) lines.push(" Auto-merge: yes");
553
+ return lines;
554
+ }
555
+ case "push": {
556
+ const m = op.metadata;
557
+ const lines = ["Git Push:"];
558
+ lines.push(` Remote: ${m.remote ?? "(default)"}`);
559
+ lines.push(` Branch: ${m.branch ?? "(current branch)"}`);
560
+ if (m.isForce) lines.push(" ** FORCE PUSH **");
561
+ if (m.isProtected) lines.push(` ** PROTECTED BRANCH: ${m.branch} **`);
562
+ return lines;
563
+ }
564
+ }
565
+ }
566
+
567
+ function getApprovalQuestion(op: PrOperation): string {
568
+ switch (op.kind) {
569
+ case "pr-create": return "Approve this PR creation?";
570
+ case "pr-merge": return "Approve this PR merge?";
571
+ case "push": return "Approve this push?";
572
+ }
573
+ }
574
+
575
+ async function requestApproval(
576
+ ctx: ExtensionContext,
577
+ op: PrOperation,
578
+ issues?: ValidationIssue[],
579
+ ): Promise<boolean> {
580
+ const previewLines = getPreviewLines(op);
581
+ const question = getApprovalQuestion(op);
582
+ const issueText = issues && issues.length > 0
583
+ ? formatValidationIssues(issues)
584
+ : undefined;
585
+ const options = [APPROVE_OPTION, DENY_OPTION];
586
+
587
+ const result = await ctx.ui.custom<boolean>(
588
+ (tui: TUI, theme: Theme, _kb: KeybindingsManager, done: (result: boolean) => void) => {
589
+ let selected = 0;
590
+
591
+ function render(width: number): string[] {
592
+ const lines: string[] = [];
593
+ const rule = theme.fg("dim", "-".repeat(Math.min(width, 60)));
594
+
595
+ for (const line of previewLines) {
596
+ lines.push(line);
597
+ }
598
+
599
+ if (issueText) {
600
+ lines.push("");
601
+ lines.push(rule);
602
+ lines.push(theme.fg("warning", "Issues:"));
603
+ for (const line of issueText.split("\n")) {
604
+ lines.push(theme.fg("warning", line));
605
+ }
606
+ }
607
+
608
+ lines.push("");
609
+ lines.push(rule);
610
+ lines.push(theme.bold(question));
611
+ lines.push("");
612
+
613
+ for (let i = 0; i < options.length; i++) {
614
+ const label = options[i]!;
615
+ if (i === selected) {
616
+ lines.push(theme.fg("accent", `> ${label}`));
617
+ } else {
618
+ lines.push(theme.fg("dim", ` ${label}`));
619
+ }
620
+ }
621
+
622
+ lines.push("");
623
+ lines.push(theme.fg("dim", "Up/Down select Enter confirm Esc deny"));
624
+
625
+ return lines;
626
+ }
627
+
628
+ function handleInput(data: string): void {
629
+ if (matchesKey(data, "up") || matchesKey(data, "left")) {
630
+ selected = selected > 0 ? selected - 1 : options.length - 1;
631
+ tui.requestRender();
632
+ } else if (matchesKey(data, "down") || matchesKey(data, "right")) {
633
+ selected = (selected + 1) % options.length;
634
+ tui.requestRender();
635
+ } else if (matchesKey(data, "enter")) {
636
+ done(selected === 0);
637
+ } else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
638
+ done(false);
639
+ }
640
+ }
641
+
642
+ return {
643
+ render,
644
+ handleInput,
645
+ invalidate() {},
646
+ };
647
+ },
648
+ );
649
+
650
+ return result === true;
651
+ }
652
+
653
+ function userBashBlocked(message: string): UserBashEventResult {
654
+ return {
655
+ result: {
656
+ output: `${message}\n`,
657
+ exitCode: 1,
658
+ cancelled: false,
659
+ truncated: false,
660
+ },
661
+ };
662
+ }
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // Extension entry point
666
+ // ---------------------------------------------------------------------------
667
+
668
+ export default function prApprovalExtension(pi: ExtensionAPI) {
669
+ pi.on("tool_call", async (event, ctx): Promise<ToolCallEventResult | undefined> => {
670
+ if (!isToolCallEventType("bash", event)) return;
671
+
672
+ const command = String(event.input.command ?? "").trim();
673
+ if (!command) return;
674
+
675
+ const op = parsePrOperation(command);
676
+ if (!op || !shouldGate(op)) return;
677
+
678
+ const validation = validateOperation(op);
679
+
680
+ if (validation.hasErrors) {
681
+ return {
682
+ block: true,
683
+ reason: `${getDeniedReason(op).replace("approval denied", "message does not meet standards")}.\n${formatValidationIssues(validation.issues)}\nFix and retry.`,
684
+ };
685
+ }
686
+
687
+ if (!ctx.hasUI) {
688
+ return {
689
+ block: true,
690
+ reason: REASON_INTERACTIVE_REQUIRED,
691
+ };
692
+ }
693
+
694
+ const approved = await requestApproval(ctx, op, validation.issues);
695
+ if (!approved) {
696
+ return {
697
+ block: true,
698
+ reason: getDeniedReason(op),
699
+ };
700
+ }
701
+
702
+ return;
703
+ });
704
+
705
+ pi.on("user_bash", async (event, ctx) => {
706
+ const command = event.command.trim();
707
+ if (!command) return;
708
+
709
+ const op = parsePrOperation(command);
710
+ if (!op || !shouldGate(op)) return;
711
+
712
+ const validation = validateOperation(op);
713
+
714
+ if (!ctx.hasUI) {
715
+ if (validation.hasErrors) {
716
+ return userBashBlocked(
717
+ `PR operation blocked: does not meet standards.\n${formatValidationIssues(validation.issues)}`,
718
+ );
719
+ }
720
+ return userBashBlocked(REASON_INTERACTIVE_REQUIRED);
721
+ }
722
+
723
+ const approved = await requestApproval(ctx, op, validation.issues);
724
+ if (!approved) {
725
+ return userBashBlocked(getDeniedReason(op));
726
+ }
727
+
728
+ return;
729
+ });
730
+ }