pi-repoprompt-cli 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Warren Winter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # RepoPrompt CLI bridge for Pi (`pi-repoprompt-cli`)
2
+
3
+ Integrates RepoPrompt with Pi via RepoPrompt's `rp-cli` executable.
4
+
5
+ Provides two tools:
6
+ - `rp_bind` — bind a RepoPrompt window + compose tab (routing)
7
+ - `rp_exec` — run `rp-cli -e <cmd>` against that binding (quiet defaults + output truncation)
8
+
9
+ Also provides a convenience command:
10
+ - `/rpbind <window_id> <tab>`
11
+
12
+ ## Install
13
+
14
+ From npm:
15
+
16
+ ```bash
17
+ pi install npm:pi-repoprompt-cli
18
+ ```
19
+
20
+ From the dot314 git bundle (filtered install):
21
+
22
+ Add to `~/.pi/agent/settings.json` (or replace an existing unfiltered `git:github.com/w-winter/dot314` entry):
23
+
24
+ ```json
25
+ {
26
+ "packages": [
27
+ {
28
+ "source": "git:github.com/w-winter/dot314",
29
+ "extensions": ["extensions/repoprompt-cli.ts"],
30
+ "skills": [],
31
+ "themes": [],
32
+ "prompts": []
33
+ }
34
+ ]
35
+ }
36
+ ```
37
+
38
+ ## Requirements
39
+
40
+ - `rp-cli` must be installed and available on `PATH`
41
+
42
+ ## Quick start
43
+
44
+ 1) Find your RepoPrompt window + tab (from a terminal):
45
+
46
+ ```bash
47
+ rp-cli -e windows
48
+ rp-cli -e "workspace tabs"
49
+ ```
50
+
51
+ 2) Bind inside Pi:
52
+
53
+ ```text
54
+ /rpbind 3 Compose
55
+ ```
56
+
57
+ 3) Instruct the agent to use RepoPrompt via the `rp_exec` tool, for example:
58
+
59
+ ```text
60
+ Use rp_exec with cmd: "get_file_tree type=files max_depth=4".
61
+ ```
62
+
63
+ ## Safety behavior (by default)
64
+
65
+ - Blocks delete-like commands unless `allowDelete: true`
66
+ - Blocks in-place workspace switching unless `allowWorkspaceSwitchInPlace: true`
67
+ - Blocks non-trivial commands when unbound (to avoid operating on the wrong window/tab)
68
+ - Treats "0 edits applied" as an error by default (`failOnNoopEdits: true`)
@@ -0,0 +1,834 @@
1
+ import type { ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
2
+ import { highlightCode, Theme } from "@mariozechner/pi-coding-agent";
3
+ import { Text } from "@mariozechner/pi-tui";
4
+ import { Type } from "@sinclair/typebox";
5
+ import * as Diff from "diff";
6
+
7
+ /**
8
+ * RepoPrompt CLI ↔ Pi integration extension
9
+ *
10
+ * Registers two Pi tools:
11
+ * - `rp_bind`: binds a RepoPrompt window + compose tab (routing)
12
+ * - `rp_exec`: runs `rp-cli -e <cmd>` against that binding (quiet defaults, output truncation)
13
+ *
14
+ * Safety goals:
15
+ * - Prevent "unbound" rp_exec calls from operating on an unintended window/workspace
16
+ * - Prevent in-place workspace switches by default (they can clobber selection/prompt/context)
17
+ * - Block delete-like commands unless explicitly allowed
18
+ *
19
+ * UX goals:
20
+ * - Persist binding across session reloads via `pi.appendEntry()` (does not enter LLM context)
21
+ * - Provide actionable error messages when blocked
22
+ * - Syntax-highlight fenced code blocks in output (read, structure, etc.)
23
+ * - Word-level diff highlighting for edit output
24
+ */
25
+
26
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
27
+ const DEFAULT_MAX_OUTPUT_CHARS = 12000;
28
+ const BINDING_CUSTOM_TYPE = "repoprompt-binding";
29
+
30
+ const BindParams = Type.Object({
31
+ windowId: Type.Number({ description: "RepoPrompt window id (from `rp-cli -e windows`)" }),
32
+ tab: Type.String({ description: "RepoPrompt compose tab name or UUID" }),
33
+ });
34
+
35
+ const ExecParams = Type.Object({
36
+ cmd: Type.String({ description: "rp-cli exec string (e.g. `tree`, `select set src/ && context`)" }),
37
+ rawJson: Type.Optional(Type.Boolean({ description: "Pass --raw-json to rp-cli" })),
38
+ quiet: Type.Optional(Type.Boolean({ description: "Pass -q/--quiet to rp-cli (default: true)" })),
39
+ failFast: Type.Optional(Type.Boolean({ description: "Pass --fail-fast to rp-cli (default: true)" })),
40
+ timeoutMs: Type.Optional(Type.Number({ description: "Timeout in ms (default: 15 minutes)" })),
41
+ maxOutputChars: Type.Optional(Type.Number({ description: "Truncate output to this many chars (default: 12000)" })),
42
+ windowId: Type.Optional(Type.Number({ description: "Override bound window id for this call" })),
43
+ tab: Type.Optional(Type.String({ description: "Override bound tab for this call" })),
44
+ allowDelete: Type.Optional(
45
+ Type.Boolean({ description: "Allow delete commands like `file delete ...` or `workspace delete ...` (default: false)" }),
46
+ ),
47
+ allowWorkspaceSwitchInPlace: Type.Optional(
48
+ Type.Boolean({
49
+ description:
50
+ "Allow in-place workspace changes (e.g. `workspace switch <name>` or `workspace create ... --switch`) without --new-window (default: false). In-place switching can disrupt other sessions",
51
+ }),
52
+ ),
53
+ failOnNoopEdits: Type.Optional(
54
+ Type.Boolean({
55
+ description: "Treat edit commands that apply 0 changes (or produce empty output) as errors (default: true)",
56
+ }),
57
+ ),
58
+ });
59
+
60
+ function truncateText(text: string, maxChars: number): { text: string; truncated: boolean } {
61
+ if (maxChars <= 0) return { text: "", truncated: text.length > 0 };
62
+ if (text.length <= maxChars) return { text, truncated: false };
63
+ return {
64
+ text: `${text.slice(0, maxChars)}\n… [truncated; redirect output to a file if needed]`,
65
+ truncated: true,
66
+ };
67
+ }
68
+
69
+ function parseCommandChain(cmd: string): { commands: string[]; hasSemicolonOutsideQuotes: boolean } {
70
+ // Lightweight parser to split on `&&` / `;` without breaking quoted JSON or quoted strings
71
+ const commands: string[] = [];
72
+ let current = "";
73
+ let inSingleQuote = false;
74
+ let inDoubleQuote = false;
75
+ let escaped = false;
76
+ let hasSemicolonOutsideQuotes = false;
77
+
78
+ const pushCurrent = () => {
79
+ const trimmed = current.trim();
80
+ if (trimmed.length > 0) commands.push(trimmed);
81
+ current = "";
82
+ };
83
+
84
+ for (let i = 0; i < cmd.length; i++) {
85
+ const ch = cmd[i];
86
+
87
+ if (escaped) {
88
+ current += ch;
89
+ escaped = false;
90
+ continue;
91
+ }
92
+
93
+ if (ch === "\\") {
94
+ current += ch;
95
+ escaped = true;
96
+ continue;
97
+ }
98
+
99
+ if (!inDoubleQuote && ch === "'") {
100
+ inSingleQuote = !inSingleQuote;
101
+ current += ch;
102
+ continue;
103
+ }
104
+
105
+ if (!inSingleQuote && ch === "\"") {
106
+ inDoubleQuote = !inDoubleQuote;
107
+ current += ch;
108
+ continue;
109
+ }
110
+
111
+ if (!inSingleQuote && !inDoubleQuote) {
112
+ if (ch === "&" && cmd[i + 1] === "&") {
113
+ pushCurrent();
114
+ i += 1;
115
+ continue;
116
+ }
117
+
118
+ if (ch === ";") {
119
+ hasSemicolonOutsideQuotes = true;
120
+ pushCurrent();
121
+ continue;
122
+ }
123
+ }
124
+
125
+ current += ch;
126
+ }
127
+
128
+ pushCurrent();
129
+ return { commands, hasSemicolonOutsideQuotes };
130
+ }
131
+
132
+ function looksLikeDeleteCommand(cmd: string): boolean {
133
+ // Conservative detection: block obvious deletes and common `call ... {"action":"delete"}` patterns
134
+ for (const command of parseCommandChain(cmd).commands) {
135
+ const normalized = command.trim().toLowerCase();
136
+ if (normalized === "file delete" || normalized.startsWith("file delete ")) return true;
137
+ if (normalized === "workspace delete" || normalized.startsWith("workspace delete ")) return true;
138
+
139
+ if (normalized.startsWith("call ")) {
140
+ if (
141
+ /\baction\s*=\s*delete\b/.test(normalized) ||
142
+ /"action"\s*:\s*"delete"/.test(normalized) ||
143
+ /'action'\s*:\s*'delete'/.test(normalized)
144
+ ) {
145
+ return true;
146
+ }
147
+ }
148
+ }
149
+
150
+ return false;
151
+ }
152
+
153
+ function looksLikeWorkspaceSwitchInPlace(cmd: string): boolean {
154
+ // Prevent clobbering shared state: require `--new-window` for workspace switching/creation by default
155
+ for (const command of parseCommandChain(cmd).commands) {
156
+ const normalized = command.toLowerCase();
157
+
158
+ if (normalized.startsWith("workspace switch ") && !normalized.includes("--new-window")) return true;
159
+
160
+ const isCreate = normalized.startsWith("workspace create ");
161
+ const requestsSwitch = /\B--switch\b/.test(normalized);
162
+ if (isCreate && requestsSwitch && !normalized.includes("--new-window")) return true;
163
+ }
164
+
165
+ return false;
166
+ }
167
+
168
+ function looksLikeEditCommand(cmd: string): boolean {
169
+ for (const command of parseCommandChain(cmd).commands) {
170
+ const normalized = command.trim().toLowerCase();
171
+
172
+ if (normalized === 'edit' || normalized.startsWith('edit ')) return true;
173
+
174
+ if (normalized.startsWith('call ') && normalized.includes('apply_edits')) return true;
175
+ }
176
+
177
+ return false;
178
+ }
179
+
180
+ function parseLeadingInt(text: string): number | undefined {
181
+ const trimmed = text.trimStart();
182
+ let digits = '';
183
+
184
+ for (const ch of trimmed) {
185
+ if (ch >= '0' && ch <= '9') {
186
+ digits += ch;
187
+ } else {
188
+ break;
189
+ }
190
+ }
191
+
192
+ return digits.length > 0 ? Number.parseInt(digits, 10) : undefined;
193
+ }
194
+
195
+ function looksLikeNoopEditOutput(output: string): boolean {
196
+ const trimmed = output.trim();
197
+ if (trimmed.length === 0) return true;
198
+
199
+ const lower = trimmed.toLowerCase();
200
+
201
+ if (lower.includes('search block not found')) return true;
202
+
203
+ const appliedIndex = lower.indexOf('applied');
204
+ if (appliedIndex !== -1) {
205
+ const afterLabel = trimmed.slice(appliedIndex + 'applied'.length);
206
+ const colonIndex = afterLabel.indexOf(':');
207
+
208
+ if (colonIndex !== -1 && colonIndex < 10) {
209
+ const appliedCount = parseLeadingInt(afterLabel.slice(colonIndex + 1));
210
+ if (appliedCount !== undefined) return appliedCount === 0;
211
+ }
212
+ }
213
+
214
+ // Fallback heuristics when the output format doesn't include an explicit applied count
215
+ if (lower.includes('lines changed: 0')) return true;
216
+ if (lower.includes('lines_changed') && lower.includes(': 0')) return true;
217
+
218
+ return false;
219
+ }
220
+
221
+ function isSafeSingleCommandToRunUnbound(cmd: string): boolean {
222
+ // Allow only "bootstrap" commands before binding so agents don't operate on the wrong window/workspace
223
+ const normalized = cmd.trim().toLowerCase();
224
+
225
+ if (normalized === "windows" || normalized.startsWith("windows ")) return true;
226
+ if (normalized === "help" || normalized.startsWith("help ")) return true;
227
+ if (normalized === "refresh") return true;
228
+
229
+ if (normalized === "workspace list") return true;
230
+ if (normalized === "workspace tabs") return true;
231
+ if (normalized === "tabs") return true;
232
+
233
+ if (normalized.startsWith("workspace switch ") && normalized.includes("--new-window")) return true;
234
+ if (normalized.startsWith("workspace create ") && normalized.includes("--new-window")) return true;
235
+
236
+ return false;
237
+ }
238
+
239
+ function isSafeToRunUnbound(cmd: string): boolean {
240
+ // Allow `&&` chains, but only if *every* sub-command is safe before binding
241
+ const parsed = parseCommandChain(cmd);
242
+ if (parsed.hasSemicolonOutsideQuotes) return false;
243
+ if (parsed.commands.length === 0) return false;
244
+
245
+ return parsed.commands.every((command) => isSafeSingleCommandToRunUnbound(command));
246
+ }
247
+
248
+ function parseRpbindArgs(args: unknown): { windowId: number; tab: string } | { error: string } {
249
+ const parts = Array.isArray(args) ? args : [];
250
+ if (parts.length < 2) return { error: "Usage: /rpbind <window_id> <tab_name_or_uuid>" };
251
+
252
+ const rawWindowId = String(parts[0]).trim();
253
+ const windowId = Number.parseInt(rawWindowId, 10);
254
+ if (!Number.isFinite(windowId)) return { error: `Invalid window_id: ${rawWindowId}` };
255
+
256
+ const tab = parts.slice(1).join(" ").trim();
257
+ if (!tab) return { error: "Tab cannot be empty" };
258
+
259
+ return { windowId, tab };
260
+ }
261
+
262
+ // ─────────────────────────────────────────────────────────────────────────────
263
+ // Rendering utilities for rp_exec output
264
+ // ─────────────────────────────────────────────────────────────────────────────
265
+
266
+ interface FencedBlock {
267
+ lang: string | undefined;
268
+ code: string;
269
+ startIndex: number;
270
+ endIndex: number;
271
+ }
272
+
273
+ /**
274
+ * Parse fenced code blocks from text. Handles:
275
+ * - Multiple blocks
276
+ * - Various language identifiers (typescript, diff, shell, etc.)
277
+ * - Empty/missing language
278
+ * - Unclosed fences (treated as extending to end of text)
279
+ */
280
+ function parseFencedBlocks(text: string): FencedBlock[] {
281
+ const blocks: FencedBlock[] = [];
282
+ const lines = text.split("\n");
283
+ let i = 0;
284
+
285
+ while (i < lines.length) {
286
+ const line = lines[i];
287
+ const fenceMatch = line.match(/^\s*```(\S*)\s*$/);
288
+
289
+ if (fenceMatch) {
290
+ const lang = fenceMatch[1] || undefined;
291
+ const startLine = i;
292
+ const codeLines: string[] = [];
293
+ i++;
294
+
295
+ // Find closing fence (```)
296
+ while (i < lines.length) {
297
+ const closingMatch = lines[i].match(/^\s*```\s*$/);
298
+ if (closingMatch) {
299
+ i++;
300
+ break;
301
+ }
302
+ codeLines.push(lines[i]);
303
+ i++;
304
+ }
305
+
306
+ // Calculate character indices
307
+ const startIndex = lines.slice(0, startLine).join("\n").length + (startLine > 0 ? 1 : 0);
308
+ const endIndex = lines.slice(0, i).join("\n").length;
309
+
310
+ blocks.push({
311
+ lang,
312
+ code: codeLines.join("\n"),
313
+ startIndex,
314
+ endIndex,
315
+ });
316
+ } else {
317
+ i++;
318
+ }
319
+ }
320
+
321
+ return blocks;
322
+ }
323
+
324
+ /**
325
+ * Compute word-level diff with inverse highlighting on changed parts
326
+ */
327
+ function renderIntraLineDiff(
328
+ oldContent: string,
329
+ newContent: string,
330
+ theme: Theme
331
+ ): { removedLine: string; addedLine: string } {
332
+ const wordDiff = Diff.diffWords(oldContent, newContent);
333
+
334
+ let removedLine = "";
335
+ let addedLine = "";
336
+ let isFirstRemoved = true;
337
+ let isFirstAdded = true;
338
+
339
+ for (const part of wordDiff) {
340
+ if (part.removed) {
341
+ let value = part.value;
342
+ if (isFirstRemoved) {
343
+ const leadingWs = value.match(/^(\s*)/)?.[1] || "";
344
+ value = value.slice(leadingWs.length);
345
+ removedLine += leadingWs;
346
+ isFirstRemoved = false;
347
+ }
348
+ if (value) {
349
+ removedLine += theme.inverse(value);
350
+ }
351
+ } else if (part.added) {
352
+ let value = part.value;
353
+ if (isFirstAdded) {
354
+ const leadingWs = value.match(/^(\s*)/)?.[1] || "";
355
+ value = value.slice(leadingWs.length);
356
+ addedLine += leadingWs;
357
+ isFirstAdded = false;
358
+ }
359
+ if (value) {
360
+ addedLine += theme.inverse(value);
361
+ }
362
+ } else {
363
+ removedLine += part.value;
364
+ addedLine += part.value;
365
+ }
366
+ }
367
+
368
+ return { removedLine, addedLine };
369
+ }
370
+
371
+ /**
372
+ * Render diff lines with syntax highlighting (red/green, word-level inverse)
373
+ */
374
+ function renderDiffBlock(code: string, theme: Theme): string {
375
+ const lines = code.split("\n");
376
+ const result: string[] = [];
377
+
378
+ let i = 0;
379
+ while (i < lines.length) {
380
+ const line = lines[i];
381
+ const trimmed = line.trimStart();
382
+ const indent = line.slice(0, line.length - trimmed.length);
383
+
384
+ // File headers: --- a/file or +++ b/file
385
+ if (trimmed.match(/^---\s+\S/) || trimmed.match(/^\+\+\+\s+\S/)) {
386
+ result.push(indent + theme.fg("accent", trimmed));
387
+ i++;
388
+ }
389
+ // Hunk headers: @@ -1,5 +1,6 @@
390
+ else if (trimmed.match(/^@@\s+-\d+/)) {
391
+ result.push(indent + theme.fg("muted", trimmed));
392
+ i++;
393
+ }
394
+ // Removed lines (not file headers)
395
+ else if (trimmed.startsWith("-") && !trimmed.match(/^---\s/)) {
396
+ // Collect consecutive removed lines
397
+ const removedLines: Array<{ indent: string; content: string }> = [];
398
+ while (i < lines.length) {
399
+ const l = lines[i];
400
+ const t = l.trimStart();
401
+ const ind = l.slice(0, l.length - t.length);
402
+ if (t.startsWith("-") && !t.match(/^---\s/)) {
403
+ removedLines.push({ indent: ind, content: t.slice(1) });
404
+ i++;
405
+ } else {
406
+ break;
407
+ }
408
+ }
409
+
410
+ // Collect consecutive added lines
411
+ const addedLines: Array<{ indent: string; content: string }> = [];
412
+ while (i < lines.length) {
413
+ const l = lines[i];
414
+ const t = l.trimStart();
415
+ const ind = l.slice(0, l.length - t.length);
416
+ if (t.startsWith("+") && !t.match(/^\+\+\+\s/)) {
417
+ addedLines.push({ indent: ind, content: t.slice(1) });
418
+ i++;
419
+ } else {
420
+ break;
421
+ }
422
+ }
423
+
424
+ // Word-level highlighting for 1:1 line changes
425
+ if (removedLines.length === 1 && addedLines.length === 1) {
426
+ const { removedLine, addedLine } = renderIntraLineDiff(
427
+ removedLines[0].content,
428
+ addedLines[0].content,
429
+ theme
430
+ );
431
+ result.push(removedLines[0].indent + theme.fg("toolDiffRemoved", "-" + removedLine));
432
+ result.push(addedLines[0].indent + theme.fg("toolDiffAdded", "+" + addedLine));
433
+ } else {
434
+ for (const r of removedLines) {
435
+ result.push(r.indent + theme.fg("toolDiffRemoved", "-" + r.content));
436
+ }
437
+ for (const a of addedLines) {
438
+ result.push(a.indent + theme.fg("toolDiffAdded", "+" + a.content));
439
+ }
440
+ }
441
+ }
442
+ // Added lines (not file headers)
443
+ else if (trimmed.startsWith("+") && !trimmed.match(/^\+\+\+\s/)) {
444
+ result.push(indent + theme.fg("toolDiffAdded", trimmed));
445
+ i++;
446
+ }
447
+ // Context lines (start with space in unified diff)
448
+ else if (line.startsWith(" ")) {
449
+ result.push(theme.fg("toolDiffContext", line));
450
+ i++;
451
+ }
452
+ // Empty or other lines
453
+ else {
454
+ result.push(indent + theme.fg("dim", trimmed));
455
+ i++;
456
+ }
457
+ }
458
+
459
+ return result.join("\n");
460
+ }
461
+
462
+ /**
463
+ * Render rp_exec output with syntax highlighting for fenced code blocks.
464
+ * - ```diff blocks get word-level diff highlighting
465
+ * - Other fenced blocks get syntax highlighting via Pi's highlightCode
466
+ * - Non-fenced content is rendered dim (no markdown parsing)
467
+ */
468
+ function renderRpExecOutput(text: string, theme: Theme): string {
469
+ const blocks = parseFencedBlocks(text);
470
+
471
+ if (blocks.length === 0) {
472
+ // No code fences - render everything dim
473
+ return text.split("\n").map(line => theme.fg("dim", line)).join("\n");
474
+ }
475
+
476
+ const result: string[] = [];
477
+ let lastEnd = 0;
478
+
479
+ for (const block of blocks) {
480
+ // Render text before this block (dim)
481
+ if (block.startIndex > lastEnd) {
482
+ const before = text.slice(lastEnd, block.startIndex);
483
+ result.push(before.split("\n").map(line => theme.fg("dim", line)).join("\n"));
484
+ }
485
+
486
+ // Render the fenced block
487
+ if (block.lang?.toLowerCase() === "diff") {
488
+ // Diff block: use word-level diff highlighting
489
+ result.push(theme.fg("muted", "```diff"));
490
+ result.push(renderDiffBlock(block.code, theme));
491
+ result.push(theme.fg("muted", "```"));
492
+ } else if (block.lang) {
493
+ // Other language: use Pi's syntax highlighting
494
+ result.push(theme.fg("muted", "```" + block.lang));
495
+ const highlighted = highlightCode(block.code, block.lang);
496
+ result.push(highlighted.join("\n"));
497
+ result.push(theme.fg("muted", "```"));
498
+ } else {
499
+ // No language specified: render as dim
500
+ result.push(theme.fg("muted", "```"));
501
+ result.push(theme.fg("dim", block.code));
502
+ result.push(theme.fg("muted", "```"));
503
+ }
504
+
505
+ lastEnd = block.endIndex;
506
+ }
507
+
508
+ // Render text after last block (dim)
509
+ if (lastEnd < text.length) {
510
+ const after = text.slice(lastEnd);
511
+ result.push(after.split("\n").map(line => theme.fg("dim", line)).join("\n"));
512
+ }
513
+
514
+ return result.join("\n");
515
+ }
516
+
517
+ // Collapsed output settings
518
+ const COLLAPSED_MAX_LINES = 15;
519
+ const COLLAPSED_MAX_CHARS = 2000;
520
+
521
+ export default function (pi: ExtensionAPI) {
522
+ let boundWindowId: number | undefined;
523
+ let boundTab: string | undefined;
524
+
525
+ const setBinding = (windowId: number, tab: string) => {
526
+ boundWindowId = windowId;
527
+ boundTab = tab;
528
+ };
529
+
530
+ const persistBinding = (windowId: number, tab: string) => {
531
+ // Persist binding across session reloads without injecting extra text into the model context
532
+ if (boundWindowId === windowId && boundTab === tab) return;
533
+
534
+ setBinding(windowId, tab);
535
+ pi.appendEntry(BINDING_CUSTOM_TYPE, { windowId, tab });
536
+ };
537
+
538
+ const reconstructBinding = (ctx: ExtensionContext) => {
539
+ // Prefer persisted binding (appendEntry), then fall back to prior rp_bind tool results
540
+ let reconstructedWindowId: number | undefined;
541
+ let reconstructedTab: string | undefined;
542
+
543
+ for (const entry of ctx.sessionManager.getEntries()) {
544
+ if (entry.type !== "custom" || entry.customType !== BINDING_CUSTOM_TYPE) continue;
545
+
546
+ const data = entry.data as { windowId?: unknown; tab?: unknown } | undefined;
547
+ const windowId = typeof data?.windowId === "number" ? data.windowId : undefined;
548
+ const tab = typeof data?.tab === "string" ? data.tab : undefined;
549
+ if (windowId !== undefined && tab) {
550
+ reconstructedWindowId = windowId;
551
+ reconstructedTab = tab;
552
+ }
553
+ }
554
+
555
+ if (reconstructedWindowId !== undefined && reconstructedTab !== undefined) {
556
+ setBinding(reconstructedWindowId, reconstructedTab);
557
+ return;
558
+ }
559
+
560
+ for (const entry of ctx.sessionManager.getBranch()) {
561
+ if (entry.type !== "message") continue;
562
+ const msg = entry.message;
563
+ if (msg.role !== "toolResult" || msg.toolName !== "rp_bind") continue;
564
+
565
+ const details = msg.details as { windowId?: number; tab?: string } | undefined;
566
+ if (details?.windowId !== undefined && details?.tab) {
567
+ persistBinding(details.windowId, details.tab);
568
+ }
569
+ }
570
+ };
571
+
572
+ pi.on("session_start", async (_event, ctx) => reconstructBinding(ctx));
573
+ pi.on("session_switch", async (_event, ctx) => reconstructBinding(ctx));
574
+ pi.on("session_branch", async (_event, ctx) => reconstructBinding(ctx));
575
+ pi.on("session_tree", async (_event, ctx) => reconstructBinding(ctx));
576
+
577
+ pi.registerCommand("rpbind", {
578
+ description: "Bind rp_exec to RepoPrompt: /rpbind <window_id> <tab>",
579
+ handler: async (args, ctx) => {
580
+ const parsed = parseRpbindArgs(args);
581
+ if ("error" in parsed) {
582
+ ctx.ui.notify(parsed.error, "error");
583
+ return;
584
+ }
585
+
586
+ persistBinding(parsed.windowId, parsed.tab);
587
+ ctx.ui.notify(`Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"`, "success");
588
+ },
589
+ });
590
+
591
+ pi.registerTool({
592
+ name: "rp_bind",
593
+ label: "RepoPrompt Bind",
594
+ description: "Bind rp_exec to a specific RepoPrompt window and compose tab",
595
+ parameters: BindParams,
596
+
597
+ async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
598
+ persistBinding(params.windowId, params.tab);
599
+
600
+ return {
601
+ content: [{ type: "text", text: `Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"` }],
602
+ details: { windowId: boundWindowId, tab: boundTab },
603
+ };
604
+ },
605
+ });
606
+
607
+ pi.registerTool({
608
+ name: "rp_exec",
609
+ label: "RepoPrompt Exec",
610
+ description: "Run rp-cli in the bound RepoPrompt window/tab, with quiet defaults and output truncation",
611
+ parameters: ExecParams,
612
+
613
+ async execute(_toolCallId, params, onUpdate, _ctx, signal) {
614
+ // Routing: prefer call-time overrides, otherwise fall back to the last persisted binding
615
+ const windowId = params.windowId ?? boundWindowId;
616
+ const tab = params.tab ?? boundTab;
617
+ const rawJson = params.rawJson ?? false;
618
+ const quiet = params.quiet ?? true;
619
+ const failFast = params.failFast ?? true;
620
+ const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS;
621
+ const maxOutputChars = params.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
622
+ const allowDelete = params.allowDelete ?? false;
623
+ const allowWorkspaceSwitchInPlace = params.allowWorkspaceSwitchInPlace ?? false;
624
+ const failOnNoopEdits = params.failOnNoopEdits ?? true;
625
+
626
+ if (!allowDelete && looksLikeDeleteCommand(params.cmd)) {
627
+ return {
628
+ content: [
629
+ {
630
+ type: "text",
631
+ text: "Blocked potential delete command. If deletion is explicitly requested, rerun with allowDelete=true",
632
+ },
633
+ ],
634
+ details: { blocked: true, reason: "delete", cmd: params.cmd, windowId, tab },
635
+ };
636
+ }
637
+
638
+ if (!allowWorkspaceSwitchInPlace && looksLikeWorkspaceSwitchInPlace(params.cmd)) {
639
+ return {
640
+ content: [
641
+ {
642
+ type: "text",
643
+ text:
644
+ "Blocked in-place workspace change (it can clobber selection/prompt/context and disrupt other sessions). " +
645
+ "Add `--new-window`, or rerun with allowWorkspaceSwitchInPlace=true if explicitly safe",
646
+ },
647
+ ],
648
+ details: { blocked: true, reason: "workspace_switch_in_place", cmd: params.cmd, windowId, tab },
649
+ };
650
+ }
651
+
652
+ const isBound = windowId !== undefined && tab !== undefined;
653
+ if (!isBound && !isSafeToRunUnbound(params.cmd)) {
654
+ return {
655
+ content: [
656
+ {
657
+ type: "text",
658
+ text:
659
+ "Blocked rp_exec because it is not bound to a window+tab. " +
660
+ "Do not fall back to native Pi tools—bind first. " +
661
+ "Run `windows` and `workspace tabs`, then bind with rp_bind(windowId, tab). " +
662
+ "If RepoPrompt is in single-window mode, windowId is usually 1",
663
+ },
664
+ ],
665
+ details: { blocked: true, reason: "unbound", cmd: params.cmd, windowId, tab },
666
+ };
667
+ }
668
+
669
+ const rpArgs: string[] = [];
670
+ if (windowId !== undefined) rpArgs.push("-w", String(windowId));
671
+ if (tab !== undefined) rpArgs.push("-t", tab);
672
+ if (quiet) rpArgs.push("-q");
673
+ if (rawJson) rpArgs.push("--raw-json");
674
+ if (failFast) rpArgs.push("--fail-fast");
675
+ rpArgs.push("-e", params.cmd);
676
+
677
+ if (windowId === undefined || tab === undefined) {
678
+ onUpdate({
679
+ status:
680
+ "Running rp-cli without a bound window/tab (non-deterministic). Bind first with rp_bind(windowId, tab)",
681
+ });
682
+ } else {
683
+ onUpdate({ status: `Running rp-cli in window ${windowId}, tab "${tab}"…` });
684
+ }
685
+
686
+ let stdout = "";
687
+ let stderr = "";
688
+ let exitCode = -1;
689
+ let execError: string | undefined;
690
+
691
+ try {
692
+ const result = await pi.exec("rp-cli", rpArgs, { signal, timeout: timeoutMs });
693
+ stdout = result.stdout ?? "";
694
+ stderr = result.stderr ?? "";
695
+ exitCode = result.code ?? 0;
696
+ } catch (error) {
697
+ execError = error instanceof Error ? error.message : String(error);
698
+ }
699
+
700
+ const combinedOutput = [stdout, stderr].filter(Boolean).join("\n").trim();
701
+
702
+ const rawOutput = execError ? `rp-cli execution failed: ${execError}` : combinedOutput;
703
+
704
+ const editNoop =
705
+ !execError &&
706
+ exitCode === 0 &&
707
+ looksLikeEditCommand(params.cmd) &&
708
+ looksLikeNoopEditOutput(rawOutput);
709
+
710
+ const shouldFailNoopEdit = editNoop && failOnNoopEdits;
711
+
712
+ let outputForUser = rawOutput;
713
+ if (editNoop) {
714
+ const rpCliOutput = rawOutput.length > 0 ? `\n--- rp-cli output ---\n${rawOutput}` : "";
715
+
716
+ if (shouldFailNoopEdit) {
717
+ outputForUser =
718
+ "RepoPrompt edit made no changes (0 edits applied). This usually means the search string was not found.\n" +
719
+ "If this was expected, rerun with failOnNoopEdits=false. Otherwise, verify the search text or rerun with rawJson=true / quiet=false.\n" +
720
+ "Tip: for tricky edits with multiline content, use rp-cli directly: rp-cli -c apply_edits -j '{...}'" +
721
+ rpCliOutput;
722
+ } else {
723
+ outputForUser =
724
+ "RepoPrompt edit made no changes (0 edits applied).\n" +
725
+ "RepoPrompt may report this as an error (e.g. 'search block not found'), but failOnNoopEdits=false is treating it as non-fatal.\n" +
726
+ "Tip: for tricky edits with multiline content, use rp-cli directly: rp-cli -c apply_edits -j '{...}'" +
727
+ rpCliOutput;
728
+ }
729
+ }
730
+
731
+ const outputWithBindingWarning =
732
+ windowId === undefined || tab === undefined
733
+ ? `WARNING: rp_exec is not bound to a RepoPrompt window/tab. Bind with rp_bind(windowId, tab).\n\n${outputForUser}`
734
+ : outputForUser;
735
+
736
+ const { text: truncatedOutput, truncated } = truncateText(outputWithBindingWarning.trim(), maxOutputChars);
737
+ const finalText = truncatedOutput.length > 0 ? truncatedOutput : "(no output)";
738
+
739
+ return {
740
+ isError: shouldFailNoopEdit,
741
+ content: [{ type: "text", text: finalText }],
742
+ details: {
743
+ cmd: params.cmd,
744
+ windowId,
745
+ tab,
746
+ rawJson,
747
+ quiet,
748
+ failOnNoopEdits,
749
+ failFast,
750
+ timeoutMs,
751
+ maxOutputChars,
752
+ exitCode,
753
+ truncated,
754
+ stderrIncluded: stderr.trim().length > 0,
755
+ execError,
756
+ editNoop,
757
+ shouldFailNoopEdit,
758
+ },
759
+ };
760
+ },
761
+
762
+ renderCall(args: Record<string, unknown>, theme: Theme) {
763
+ const cmd = (args.cmd as string) || "...";
764
+ const windowId = args.windowId ?? boundWindowId;
765
+ const tab = args.tab ?? boundTab;
766
+
767
+ let text = theme.fg("toolTitle", theme.bold("rp_exec"));
768
+ text += " " + theme.fg("accent", cmd);
769
+
770
+ if (windowId !== undefined && tab !== undefined) {
771
+ text += theme.fg("muted", ` (window ${windowId}, tab "${tab}")`);
772
+ } else {
773
+ text += theme.fg("warning", " (unbound)");
774
+ }
775
+
776
+ return new Text(text, 0, 0);
777
+ },
778
+
779
+ renderResult(
780
+ result: { content: Array<{ type: string; text?: string }>; details?: Record<string, unknown>; isError?: boolean },
781
+ options: ToolRenderResultOptions,
782
+ theme: Theme
783
+ ) {
784
+ const details = result.details || {};
785
+ const exitCode = details.exitCode as number | undefined;
786
+ const truncated = details.truncated as boolean | undefined;
787
+ const blocked = details.blocked as boolean | undefined;
788
+
789
+ // Get text content
790
+ const textContent = result.content
791
+ .filter((c) => c.type === "text")
792
+ .map((c) => c.text || "")
793
+ .join("\n");
794
+
795
+ // Handle partial/streaming state
796
+ if (options.isPartial) {
797
+ return new Text(theme.fg("warning", "Running…"), 0, 0);
798
+ }
799
+
800
+ // Handle blocked commands
801
+ if (blocked) {
802
+ return new Text(theme.fg("error", "✗ " + textContent), 0, 0);
803
+ }
804
+
805
+ // Handle errors
806
+ if (result.isError || (exitCode !== undefined && exitCode !== 0)) {
807
+ const exitInfo = exitCode !== undefined ? ` (exit ${exitCode})` : "";
808
+ return new Text(theme.fg("error", `✗${exitInfo}\n${textContent}`), 0, 0);
809
+ }
810
+
811
+ // Success case
812
+ const truncatedNote = truncated ? theme.fg("warning", " (truncated)") : "";
813
+ const successPrefix = theme.fg("success", "✓");
814
+
815
+ // Collapsed view: show line count
816
+ if (!options.expanded) {
817
+ const lines = textContent.split("\n");
818
+ if (lines.length > COLLAPSED_MAX_LINES || textContent.length > COLLAPSED_MAX_CHARS) {
819
+ const preview = renderRpExecOutput(
820
+ lines.slice(0, COLLAPSED_MAX_LINES).join("\n"),
821
+ theme
822
+ );
823
+ const remaining = lines.length - COLLAPSED_MAX_LINES;
824
+ const moreText = remaining > 0 ? theme.fg("muted", `\n… (${remaining} more lines)`) : "";
825
+ return new Text(`${successPrefix}${truncatedNote}\n${preview}${moreText}`, 0, 0);
826
+ }
827
+ }
828
+
829
+ // Expanded view or short output: render with syntax highlighting
830
+ const highlighted = renderRpExecOutput(textContent, theme);
831
+ return new Text(`${successPrefix}${truncatedNote}\n${highlighted}`, 0, 0);
832
+ },
833
+ });
834
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-repoprompt-cli",
3
+ "version": "0.1.0",
4
+ "description": "Integrates RepoPrompt with Pi via RepoPrompt's `rp-cli` executable",
5
+ "keywords": ["pi-package", "pi", "pi-coding-agent", "repoprompt"],
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/w-winter/dot314.git",
10
+ "directory": "packages/pi-repoprompt-cli"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/w-winter/dot314/issues"
14
+ },
15
+ "homepage": "https://github.com/w-winter/dot314#readme",
16
+ "pi": {
17
+ "extensions": ["extensions/repoprompt-cli.ts"]
18
+ },
19
+ "dependencies": {
20
+ "diff": "^7.0.0"
21
+ },
22
+ "peerDependencies": {
23
+ "@mariozechner/pi-coding-agent": "*",
24
+ "@mariozechner/pi-tui": "*",
25
+ "@sinclair/typebox": "*"
26
+ },
27
+ "scripts": {
28
+ "prepack": "node ../../scripts/pi-package-prepack.mjs"
29
+ },
30
+ "files": ["extensions/**", "README.md", "LICENSE", "package.json"],
31
+ "dot314Prepack": {
32
+ "copy": [
33
+ { "from": "../../extensions/repoprompt-cli.ts", "to": "extensions/repoprompt-cli.ts" },
34
+ { "from": "../../LICENSE", "to": "LICENSE" }
35
+ ]
36
+ }
37
+ }