pi-repoprompt-cli 0.1.0 → 0.2.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.
@@ -4,6 +4,179 @@ import { Text } from "@mariozechner/pi-tui";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import * as Diff from "diff";
6
6
 
7
+ let parseBash: ((input: string) => any) | null = null;
8
+ let justBashLoadPromise: Promise<void> | null = null;
9
+ let justBashLoadDone = false;
10
+
11
+ async function ensureJustBashLoaded(): Promise<void> {
12
+ if (justBashLoadDone) return;
13
+
14
+ if (!justBashLoadPromise) {
15
+ justBashLoadPromise = import("just-bash")
16
+ .then((mod: any) => {
17
+ parseBash = typeof mod?.parse === "function" ? mod.parse : null;
18
+ })
19
+ .catch(() => {
20
+ parseBash = null;
21
+ })
22
+ .finally(() => {
23
+ justBashLoadDone = true;
24
+ });
25
+ }
26
+
27
+ await justBashLoadPromise;
28
+ }
29
+
30
+ let warnedAstUnavailable = false;
31
+ function maybeWarnAstUnavailable(ctx: any): void {
32
+ if (warnedAstUnavailable) return;
33
+ if (parseBash) return;
34
+ if (!ctx?.hasUI) return;
35
+
36
+ warnedAstUnavailable = true;
37
+ ctx.ui.notify(
38
+ "repoprompt-cli: just-bash >= 2 is not available; falling back to best-effort command parsing",
39
+ "warning",
40
+ );
41
+ }
42
+
43
+ type BashInvocation = {
44
+ statementIndex: number;
45
+ pipelineIndex: number;
46
+ pipelineLength: number;
47
+ commandNameRaw: string;
48
+ commandName: string;
49
+ args: string[];
50
+ };
51
+
52
+ function commandBaseName(value: string): string {
53
+ const normalized = value.replace(/\\+/g, "/");
54
+ const idx = normalized.lastIndexOf("/");
55
+ const base = idx >= 0 ? normalized.slice(idx + 1) : normalized;
56
+ return base.toLowerCase();
57
+ }
58
+
59
+ function partToText(part: any): string {
60
+ if (!part || typeof part !== "object") return "";
61
+
62
+ switch (part.type) {
63
+ case "Literal":
64
+ case "SingleQuoted":
65
+ case "Escaped":
66
+ return typeof part.value === "string" ? part.value : "";
67
+ case "DoubleQuoted":
68
+ return Array.isArray(part.parts) ? part.parts.map(partToText).join("") : "";
69
+ case "Glob":
70
+ return typeof part.pattern === "string" ? part.pattern : "";
71
+ case "TildeExpansion":
72
+ return typeof part.user === "string" && part.user.length > 0 ? `~${part.user}` : "~";
73
+ case "ParameterExpansion":
74
+ return typeof part.parameter === "string" && part.parameter.length > 0
75
+ ? "${" + part.parameter + "}"
76
+ : "${}";
77
+ case "CommandSubstitution":
78
+ return "$(...)";
79
+ case "ProcessSubstitution":
80
+ return part.direction === "output" ? ">(...)" : "<(...)";
81
+ case "ArithmeticExpansion":
82
+ return "$((...))";
83
+ default:
84
+ return "";
85
+ }
86
+ }
87
+
88
+ function wordToText(word: any): string {
89
+ if (!word || typeof word !== "object" || !Array.isArray(word.parts)) return "";
90
+ return word.parts.map(partToText).join("");
91
+ }
92
+
93
+ function analyzeTopLevelBashScript(command: string): { parseError?: string; topLevelInvocations: BashInvocation[] } {
94
+ try {
95
+ if (!parseBash) {
96
+ return { parseError: "just-bash parse unavailable", topLevelInvocations: [] };
97
+ }
98
+
99
+ const ast: any = parseBash(command);
100
+ const topLevelInvocations: BashInvocation[] = [];
101
+
102
+ if (!ast || typeof ast !== "object" || !Array.isArray(ast.statements)) {
103
+ return { topLevelInvocations };
104
+ }
105
+
106
+ ast.statements.forEach((statement: any, statementIndex: number) => {
107
+ if (!statement || typeof statement !== "object" || !Array.isArray(statement.pipelines)) return;
108
+
109
+ statement.pipelines.forEach((pipeline: any, pipelineIndex: number) => {
110
+ if (!pipeline || typeof pipeline !== "object" || !Array.isArray(pipeline.commands)) return;
111
+
112
+ const pipelineLength = pipeline.commands.length;
113
+ pipeline.commands.forEach((commandNode: any) => {
114
+ if (!commandNode || commandNode.type !== "SimpleCommand") return;
115
+
116
+ const commandNameRaw = wordToText(commandNode.name).trim();
117
+ if (!commandNameRaw) return;
118
+
119
+ const args = Array.isArray(commandNode.args)
120
+ ? commandNode.args.map((arg: any) => wordToText(arg)).filter(Boolean)
121
+ : [];
122
+
123
+ topLevelInvocations.push({
124
+ statementIndex,
125
+ pipelineIndex,
126
+ pipelineLength,
127
+ commandNameRaw,
128
+ commandName: commandBaseName(commandNameRaw),
129
+ args,
130
+ });
131
+ });
132
+ });
133
+ });
134
+
135
+ return { topLevelInvocations };
136
+ } catch (error: any) {
137
+ return {
138
+ parseError: error?.message ?? String(error),
139
+ topLevelInvocations: [],
140
+ };
141
+ }
142
+ }
143
+
144
+ function hasSemicolonOutsideQuotes(script: string): boolean {
145
+ let inSingleQuote = false;
146
+ let inDoubleQuote = false;
147
+ let escaped = false;
148
+
149
+ for (let i = 0; i < script.length; i += 1) {
150
+ const ch = script[i];
151
+
152
+ if (escaped) {
153
+ escaped = false;
154
+ continue;
155
+ }
156
+
157
+ if (ch === "\\") {
158
+ escaped = true;
159
+ continue;
160
+ }
161
+
162
+ if (!inDoubleQuote && ch === "'") {
163
+ inSingleQuote = !inSingleQuote;
164
+ continue;
165
+ }
166
+
167
+ if (!inSingleQuote && ch === '"') {
168
+ inDoubleQuote = !inDoubleQuote;
169
+ continue;
170
+ }
171
+
172
+ if (!inSingleQuote && !inDoubleQuote && ch === ";") {
173
+ return true;
174
+ }
175
+ }
176
+
177
+ return false;
178
+ }
179
+
7
180
  /**
8
181
  * RepoPrompt CLI ↔ Pi integration extension
9
182
  *
@@ -19,6 +192,7 @@ import * as Diff from "diff";
19
192
  * UX goals:
20
193
  * - Persist binding across session reloads via `pi.appendEntry()` (does not enter LLM context)
21
194
  * - Provide actionable error messages when blocked
195
+ * - For best command parsing (AST-based), install `just-bash` >= 2; otherwise it falls back to a legacy splitter
22
196
  * - Syntax-highlight fenced code blocks in output (read, structure, etc.)
23
197
  * - Word-level diff highlighting for edit output
24
198
  */
@@ -66,8 +240,13 @@ function truncateText(text: string, maxChars: number): { text: string; truncated
66
240
  };
67
241
  }
68
242
 
69
- function parseCommandChain(cmd: string): { commands: string[]; hasSemicolonOutsideQuotes: boolean } {
70
- // Lightweight parser to split on `&&` / `;` without breaking quoted JSON or quoted strings
243
+ type ParsedCommandChain = {
244
+ commands: string[];
245
+ invocations: BashInvocation[];
246
+ hasSemicolonOutsideQuotes: boolean;
247
+ };
248
+
249
+ function parseCommandChainLegacy(cmd: string): { commands: string[]; hasSemicolonOutsideQuotes: boolean } {
71
250
  const commands: string[] = [];
72
251
  let current = "";
73
252
  let inSingleQuote = false;
@@ -81,7 +260,7 @@ function parseCommandChain(cmd: string): { commands: string[]; hasSemicolonOutsi
81
260
  current = "";
82
261
  };
83
262
 
84
- for (let i = 0; i < cmd.length; i++) {
263
+ for (let i = 0; i < cmd.length; i += 1) {
85
264
  const ch = cmd[i];
86
265
 
87
266
  if (escaped) {
@@ -102,7 +281,7 @@ function parseCommandChain(cmd: string): { commands: string[]; hasSemicolonOutsi
102
281
  continue;
103
282
  }
104
283
 
105
- if (!inSingleQuote && ch === "\"") {
284
+ if (!inSingleQuote && ch === '"') {
106
285
  inDoubleQuote = !inDoubleQuote;
107
286
  current += ch;
108
287
  continue;
@@ -129,21 +308,74 @@ function parseCommandChain(cmd: string): { commands: string[]; hasSemicolonOutsi
129
308
  return { commands, hasSemicolonOutsideQuotes };
130
309
  }
131
310
 
311
+ function renderInvocation(invocation: BashInvocation): string {
312
+ return [invocation.commandNameRaw, ...invocation.args].filter(Boolean).join(" ").trim();
313
+ }
314
+
315
+ function parseCommandChain(cmd: string): ParsedCommandChain {
316
+ const semicolonOutsideQuotes = hasSemicolonOutsideQuotes(cmd);
317
+ const analysis = analyzeTopLevelBashScript(cmd);
318
+
319
+ if (!analysis.parseError && analysis.topLevelInvocations.length > 0) {
320
+ const commands = analysis.topLevelInvocations
321
+ .map(renderInvocation)
322
+ .filter((command) => command.length > 0);
323
+
324
+ return {
325
+ commands,
326
+ invocations: analysis.topLevelInvocations,
327
+ hasSemicolonOutsideQuotes: semicolonOutsideQuotes,
328
+ };
329
+ }
330
+
331
+ const legacy = parseCommandChainLegacy(cmd);
332
+ return {
333
+ commands: legacy.commands,
334
+ invocations: [],
335
+ hasSemicolonOutsideQuotes: legacy.hasSemicolonOutsideQuotes || semicolonOutsideQuotes,
336
+ };
337
+ }
338
+
132
339
  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) {
340
+ const parsed = parseCommandChain(cmd);
341
+
342
+ if (parsed.invocations.length > 0) {
343
+ for (const invocation of parsed.invocations) {
344
+ const commandName = invocation.commandName;
345
+ const args = invocation.args.map((arg) => arg.toLowerCase());
346
+
347
+ if (commandName === "file" && args[0] === "delete") return true;
348
+ if (commandName === "workspace" && args[0] === "delete") return true;
349
+
350
+ if (commandName === "call") {
351
+ const normalized = args.join(" ");
352
+ if (
353
+ /\baction\s*=\s*delete\b/.test(normalized)
354
+ || /"action"\s*:\s*"delete"/.test(normalized)
355
+ || /'action'\s*:\s*'delete'/.test(normalized)
356
+ ) {
357
+ return true;
358
+ }
359
+ }
360
+ }
361
+ return false;
362
+ }
363
+
364
+ // Fallback when parsing fails
365
+ for (const command of parsed.commands) {
135
366
  const normalized = command.trim().toLowerCase();
136
367
  if (normalized === "file delete" || normalized.startsWith("file delete ")) return true;
137
368
  if (normalized === "workspace delete" || normalized.startsWith("workspace delete ")) return true;
138
369
 
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
- }
370
+ if (
371
+ normalized.startsWith("call ")
372
+ && (
373
+ /\baction\s*=\s*delete\b/.test(normalized)
374
+ || /"action"\s*:\s*"delete"/.test(normalized)
375
+ || /'action'\s*:\s*'delete'/.test(normalized)
376
+ )
377
+ ) {
378
+ return true;
147
379
  }
148
380
  }
149
381
 
@@ -151,8 +383,26 @@ function looksLikeDeleteCommand(cmd: string): boolean {
151
383
  }
152
384
 
153
385
  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) {
386
+ const parsed = parseCommandChain(cmd);
387
+
388
+ if (parsed.invocations.length > 0) {
389
+ for (const invocation of parsed.invocations) {
390
+ if (invocation.commandName !== "workspace") continue;
391
+
392
+ const args = invocation.args.map((arg) => arg.toLowerCase());
393
+ const action = args[0] ?? "";
394
+ const hasNewWindow = args.includes("--new-window");
395
+ const hasSwitchFlag = args.includes("--switch");
396
+
397
+ if (action === "switch" && !hasNewWindow) return true;
398
+ if (action === "create" && hasSwitchFlag && !hasNewWindow) return true;
399
+ }
400
+
401
+ return false;
402
+ }
403
+
404
+ // Fallback when parsing fails
405
+ for (const command of parsed.commands) {
156
406
  const normalized = command.toLowerCase();
157
407
 
158
408
  if (normalized.startsWith("workspace switch ") && !normalized.includes("--new-window")) return true;
@@ -166,15 +416,22 @@ function looksLikeWorkspaceSwitchInPlace(cmd: string): boolean {
166
416
  }
167
417
 
168
418
  function looksLikeEditCommand(cmd: string): boolean {
169
- for (const command of parseCommandChain(cmd).commands) {
170
- const normalized = command.trim().toLowerCase();
419
+ const parsed = parseCommandChain(cmd);
171
420
 
172
- if (normalized === 'edit' || normalized.startsWith('edit ')) return true;
421
+ if (parsed.invocations.length > 0) {
422
+ return parsed.invocations.some((invocation) => {
423
+ if (invocation.commandName === "edit") return true;
424
+ if (invocation.commandName !== "call") return false;
173
425
 
174
- if (normalized.startsWith('call ') && normalized.includes('apply_edits')) return true;
426
+ return invocation.args.some((arg) => arg.toLowerCase().includes("apply_edits"));
427
+ });
175
428
  }
176
429
 
177
- return false;
430
+ return parsed.commands.some((command) => {
431
+ const normalized = command.trim().toLowerCase();
432
+ if (normalized === "edit" || normalized.startsWith("edit ")) return true;
433
+ return normalized.startsWith("call ") && normalized.includes("apply_edits");
434
+ });
178
435
  }
179
436
 
180
437
  function parseLeadingInt(text: string): number | undefined {
@@ -219,7 +476,31 @@ function looksLikeNoopEditOutput(output: string): boolean {
219
476
  }
220
477
 
221
478
  function isSafeSingleCommandToRunUnbound(cmd: string): boolean {
222
- // Allow only "bootstrap" commands before binding so agents don't operate on the wrong window/workspace
479
+ const parsed = parseCommandChain(cmd);
480
+
481
+ if (parsed.invocations.length > 0) {
482
+ if (parsed.invocations.length !== 1) return false;
483
+ const invocation = parsed.invocations[0];
484
+ const commandName = invocation.commandName;
485
+ const args = invocation.args.map((arg) => arg.toLowerCase());
486
+
487
+ if (commandName === "windows") return true;
488
+ if (commandName === "help") return true;
489
+ if (commandName === "refresh" && args.length === 0) return true;
490
+ if (commandName === "tabs" && args.length === 0) return true;
491
+
492
+ if (commandName === "workspace") {
493
+ const action = args[0] ?? "";
494
+ if (action === "list") return true;
495
+ if (action === "tabs") return true;
496
+ if (action === "switch" && args.includes("--new-window")) return true;
497
+ if (action === "create" && args.includes("--new-window")) return true;
498
+ }
499
+
500
+ return false;
501
+ }
502
+
503
+ // Fallback when parsing fails
223
504
  const normalized = cmd.trim().toLowerCase();
224
505
 
225
506
  if (normalized === "windows" || normalized.startsWith("windows ")) return true;
@@ -240,8 +521,15 @@ function isSafeToRunUnbound(cmd: string): boolean {
240
521
  // Allow `&&` chains, but only if *every* sub-command is safe before binding
241
522
  const parsed = parseCommandChain(cmd);
242
523
  if (parsed.hasSemicolonOutsideQuotes) return false;
243
- if (parsed.commands.length === 0) return false;
244
524
 
525
+ if (parsed.invocations.length > 0) {
526
+ return parsed.invocations.every((invocation) => {
527
+ const commandText = renderInvocation(invocation);
528
+ return isSafeSingleCommandToRunUnbound(commandText);
529
+ });
530
+ }
531
+
532
+ if (parsed.commands.length === 0) return false;
245
533
  return parsed.commands.every((command) => isSafeSingleCommandToRunUnbound(command));
246
534
  }
247
535
 
@@ -536,11 +824,15 @@ export default function (pi: ExtensionAPI) {
536
824
  };
537
825
 
538
826
  const reconstructBinding = (ctx: ExtensionContext) => {
539
- // Prefer persisted binding (appendEntry), then fall back to prior rp_bind tool results
827
+ // Prefer persisted binding (appendEntry) from the *current branch*, then fall back to prior rp_bind tool results
828
+ // Branch semantics: if the current branch has no binding state, stay unbound
829
+ boundWindowId = undefined;
830
+ boundTab = undefined;
831
+
540
832
  let reconstructedWindowId: number | undefined;
541
833
  let reconstructedTab: string | undefined;
542
834
 
543
- for (const entry of ctx.sessionManager.getEntries()) {
835
+ for (const entry of ctx.sessionManager.getBranch()) {
544
836
  if (entry.type !== "custom" || entry.customType !== BINDING_CUSTOM_TYPE) continue;
545
837
 
546
838
  const data = entry.data as { windowId?: unknown; tab?: unknown } | undefined;
@@ -571,6 +863,8 @@ export default function (pi: ExtensionAPI) {
571
863
 
572
864
  pi.on("session_start", async (_event, ctx) => reconstructBinding(ctx));
573
865
  pi.on("session_switch", async (_event, ctx) => reconstructBinding(ctx));
866
+ // session_fork is the current event name; keep session_branch for backwards compatibility
867
+ pi.on("session_fork", async (_event, ctx) => reconstructBinding(ctx));
574
868
  pi.on("session_branch", async (_event, ctx) => reconstructBinding(ctx));
575
869
  pi.on("session_tree", async (_event, ctx) => reconstructBinding(ctx));
576
870
 
@@ -594,7 +888,9 @@ export default function (pi: ExtensionAPI) {
594
888
  description: "Bind rp_exec to a specific RepoPrompt window and compose tab",
595
889
  parameters: BindParams,
596
890
 
597
- async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
891
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
892
+ await ensureJustBashLoaded();
893
+ maybeWarnAstUnavailable(ctx);
598
894
  persistBinding(params.windowId, params.tab);
599
895
 
600
896
  return {
@@ -610,8 +906,11 @@ export default function (pi: ExtensionAPI) {
610
906
  description: "Run rp-cli in the bound RepoPrompt window/tab, with quiet defaults and output truncation",
611
907
  parameters: ExecParams,
612
908
 
613
- async execute(_toolCallId, params, onUpdate, _ctx, signal) {
909
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
614
910
  // Routing: prefer call-time overrides, otherwise fall back to the last persisted binding
911
+ await ensureJustBashLoaded();
912
+ maybeWarnAstUnavailable(ctx);
913
+
615
914
  const windowId = params.windowId ?? boundWindowId;
616
915
  const tab = params.tab ?? boundTab;
617
916
  const rawJson = params.rawJson ?? false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-repoprompt-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Integrates RepoPrompt with Pi via RepoPrompt's `rp-cli` executable",
5
5
  "keywords": ["pi-package", "pi", "pi-coding-agent", "repoprompt"],
6
6
  "license": "MIT",
@@ -17,10 +17,11 @@
17
17
  "extensions": ["extensions/repoprompt-cli.ts"]
18
18
  },
19
19
  "dependencies": {
20
- "diff": "^7.0.0"
20
+ "diff": "^7.0.0",
21
+ "just-bash": "^2.10.0"
21
22
  },
22
23
  "peerDependencies": {
23
- "@mariozechner/pi-coding-agent": "*",
24
+ "@mariozechner/pi-coding-agent": ">=0.51.0",
24
25
  "@mariozechner/pi-tui": "*",
25
26
  "@sinclair/typebox": "*"
26
27
  },