pi-rtk-optimizer 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,594 +1,43 @@
1
- import { shouldBypassRewriteForCommand, shouldBypassWholeCommandRewrite } from "./rewrite-bypass.js";
2
- import { RTK_REWRITE_RULES, type RtkRewriteCategory, type RtkRewriteRule } from "./rewrite-rules.js";
3
- import type { RtkIntegrationConfig } from "./types.js";
4
-
5
- const ENV_PREFIX_PATTERN = /^((?:[A-Za-z_][A-Za-z0-9_]*=[^\s]*\s+)*)/;
6
- const SHELL_ENV_ASSIGNMENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
7
-
8
- type CommandToken =
9
- | {
10
- type: "segment";
11
- value: string;
12
- }
13
- | {
14
- type: "separator";
15
- value: string;
16
- };
17
-
18
- type SedNextTokenMode = "none" | "defaultScript" | "expressionScript" | "fileArgument" | "inPlaceArgument";
19
-
20
- interface SegmentParseState {
21
- commandName?: string;
22
- sedNextTokenMode: SedNextTokenMode;
23
- sedScriptSeen: boolean;
24
- }
25
-
26
- export interface RewriteDecision {
27
- changed: boolean;
28
- originalCommand: string;
29
- rewrittenCommand: string;
30
- rule?: RtkRewriteRule;
31
- reason:
32
- | "ok"
33
- | "empty"
34
- | "already_rtk"
35
- | "heredoc"
36
- | "disabled_category"
37
- | "no_match";
38
- }
39
-
40
- interface SegmentRewriteResult {
41
- value: string;
42
- changed: boolean;
43
- rule?: RtkRewriteRule;
44
- skippedByDisabledCategory: boolean;
45
- considered: boolean;
46
- alreadyRtk: boolean;
47
- }
48
-
49
- interface SingleSegmentRewriteResult {
50
- changed: boolean;
51
- rule?: RtkRewriteRule;
52
- rewrittenBody?: string;
53
- skippedByDisabledCategory: boolean;
54
- alreadyRtk: boolean;
55
- }
56
-
57
- function categoryEnabled(config: RtkIntegrationConfig, category: RtkRewriteCategory): boolean {
58
- switch (category) {
59
- case "gitGithub":
60
- return config.rewriteGitGithub;
61
- case "filesystem":
62
- return config.rewriteFilesystem;
63
- case "rust":
64
- return config.rewriteRust;
65
- case "javascript":
66
- return config.rewriteJavaScript;
67
- case "python":
68
- return config.rewritePython;
69
- case "go":
70
- return config.rewriteGo;
71
- case "containers":
72
- return config.rewriteContainers;
73
- case "network":
74
- return config.rewriteNetwork;
75
- case "packageManagers":
76
- return config.rewritePackageManagers;
77
- default:
78
- return true;
79
- }
80
- }
81
-
82
- function createSegmentParseState(): SegmentParseState {
83
- return {
84
- sedNextTokenMode: "none",
85
- sedScriptSeen: false,
86
- };
87
- }
88
-
89
- function normalizeShellWord(word: string): string {
90
- const unwrapped = word.replace(/^(?:["'`])|(?:["'`])$/g, "");
91
- const lastPathSeparator = Math.max(unwrapped.lastIndexOf("/"), unwrapped.lastIndexOf("\\"));
92
- const basename = lastPathSeparator >= 0 ? unwrapped.slice(lastPathSeparator + 1) : unwrapped;
93
- return basename.toLowerCase();
94
- }
95
-
96
- function shouldProtectSedWord(state: SegmentParseState): boolean {
97
- return (
98
- state.commandName === "sed" &&
99
- !state.sedScriptSeen &&
100
- (state.sedNextTokenMode === "defaultScript" ||
101
- state.sedNextTokenMode === "expressionScript" ||
102
- state.sedNextTokenMode === "inPlaceArgument")
103
- );
104
- }
105
-
106
- function isQuotedEmptyToken(word: string): boolean {
107
- return word === "''" || word === '""';
108
- }
109
-
110
- function looksLikeSedBackupExtension(word: string): boolean {
111
- const normalized = word.replace(/^(?:["'`])|(?:["'`])$/g, "");
112
- return normalized.startsWith(".") || normalized === "*";
113
- }
114
-
115
- function updateSegmentParseState(state: SegmentParseState, word: string): SegmentParseState {
116
- if (!word) {
117
- return state;
118
- }
119
-
120
- if (!state.commandName) {
121
- if (SHELL_ENV_ASSIGNMENT_PATTERN.test(word)) {
122
- return state;
123
- }
124
-
125
- const commandName = normalizeShellWord(word);
126
- return {
127
- commandName,
128
- sedNextTokenMode: "none",
129
- sedScriptSeen: false,
130
- };
131
- }
132
-
133
- if (state.commandName !== "sed" || state.sedScriptSeen) {
134
- return state;
135
- }
136
-
137
- if (state.sedNextTokenMode === "expressionScript") {
138
- return {
139
- ...state,
140
- sedNextTokenMode: "none",
141
- sedScriptSeen: true,
142
- };
143
- }
144
-
145
- if (state.sedNextTokenMode === "fileArgument") {
146
- return {
147
- ...state,
148
- sedNextTokenMode: "none",
149
- };
150
- }
151
-
152
- if (state.sedNextTokenMode === "inPlaceArgument") {
153
- if (isQuotedEmptyToken(word) || looksLikeSedBackupExtension(word)) {
154
- return {
155
- ...state,
156
- sedNextTokenMode: "defaultScript",
157
- };
158
- }
159
-
160
- return {
161
- ...state,
162
- sedNextTokenMode: "none",
163
- sedScriptSeen: true,
164
- };
165
- }
166
-
167
- if (state.sedNextTokenMode === "defaultScript") {
168
- return {
169
- ...state,
170
- sedNextTokenMode: "none",
171
- sedScriptSeen: true,
172
- };
173
- }
174
-
175
- if (word === "--") {
176
- return {
177
- ...state,
178
- sedNextTokenMode: "defaultScript",
179
- };
180
- }
181
-
182
- if (word === "-e" || word === "--expression") {
183
- return {
184
- ...state,
185
- sedNextTokenMode: "expressionScript",
186
- };
187
- }
188
-
189
- if (word.startsWith("--expression=")) {
190
- return {
191
- ...state,
192
- sedScriptSeen: true,
193
- };
194
- }
195
-
196
- if (/^-[A-Za-z]*e[A-Za-z]*$/.test(word)) {
197
- return {
198
- ...state,
199
- sedNextTokenMode: "expressionScript",
200
- };
201
- }
202
-
203
- if (word === "-f" || word === "--file") {
204
- return {
205
- ...state,
206
- sedNextTokenMode: "fileArgument",
207
- };
208
- }
209
-
210
- if (word.startsWith("--file=") || /^-f.+$/.test(word)) {
211
- return state;
212
- }
213
-
214
- if (word === "-i" || word === "--in-place") {
215
- return {
216
- ...state,
217
- sedNextTokenMode: "inPlaceArgument",
218
- };
219
- }
220
-
221
- if (word.startsWith("--in-place=") || /^-i.+$/.test(word)) {
222
- return {
223
- ...state,
224
- sedNextTokenMode: "defaultScript",
225
- };
226
- }
227
-
228
- if (word.startsWith("-")) {
229
- return state;
230
- }
231
-
232
- return {
233
- ...state,
234
- sedScriptSeen: true,
235
- };
236
- }
237
-
238
- function tokenizeCommand(command: string): CommandToken[] {
239
- if (!command) {
240
- return [];
241
- }
242
-
243
- const tokens: CommandToken[] = [];
244
- let segmentStart = 0;
245
- let segmentState = createSegmentParseState();
246
- let currentWordStart: number | null = null;
247
- let currentWordProtected = false;
248
- let quote: "'" | '"' | "`" | null = null;
249
- let escaped = false;
250
-
251
- const finalizeWord = (endIndexExclusive: number): void => {
252
- if (currentWordStart === null) {
253
- return;
254
- }
255
-
256
- const word = command.slice(currentWordStart, endIndexExclusive);
257
- segmentState = updateSegmentParseState(segmentState, word);
258
- currentWordStart = null;
259
- currentWordProtected = false;
260
- };
261
-
262
- const pushSeparator = (index: number, length: number): void => {
263
- finalizeWord(index);
264
- const segment = command.slice(segmentStart, index);
265
- if (segment.length > 0) {
266
- tokens.push({ type: "segment", value: segment });
267
- }
268
- tokens.push({ type: "separator", value: command.slice(index, index + length) });
269
- segmentStart = index + length;
270
- segmentState = createSegmentParseState();
271
- };
272
-
273
- const beginWord = (index: number): void => {
274
- if (currentWordStart !== null) {
275
- return;
276
- }
277
-
278
- currentWordStart = index;
279
- currentWordProtected = shouldProtectSedWord(segmentState);
280
- };
281
-
282
- for (let index = 0; index < command.length; index += 1) {
283
- const char = command[index];
284
- const nextChar = command[index + 1] ?? "";
285
- const prevChar = index > 0 ? command[index - 1] ?? "" : "";
286
-
287
- if (escaped) {
288
- escaped = false;
289
- continue;
290
- }
291
-
292
- if (quote !== null) {
293
- if (char === "\\" && quote !== "'") {
294
- escaped = true;
295
- continue;
296
- }
297
- if (char === quote) {
298
- quote = null;
299
- }
300
- continue;
301
- }
302
-
303
- if (char === "\\") {
304
- beginWord(index);
305
- escaped = true;
306
- continue;
307
- }
308
-
309
- if (/\s/.test(char)) {
310
- finalizeWord(index);
311
- continue;
312
- }
313
-
314
- if (!currentWordProtected) {
315
- if (char === "&" && nextChar === "&") {
316
- pushSeparator(index, 2);
317
- index += 1;
318
- continue;
319
- }
320
-
321
- if (char === "|" && nextChar === "|") {
322
- pushSeparator(index, 2);
323
- index += 1;
324
- continue;
325
- }
326
-
327
- if (char === "|" && nextChar === "&") {
328
- pushSeparator(index, 2);
329
- index += 1;
330
- continue;
331
- }
332
-
333
- if (char === "|" && prevChar !== ">") {
334
- pushSeparator(index, 1);
335
- continue;
336
- }
337
-
338
- if (char === "&" && nextChar !== ">" && prevChar !== ">" && prevChar !== "<") {
339
- pushSeparator(index, 1);
340
- continue;
341
- }
342
-
343
- if (char === ";") {
344
- pushSeparator(index, 1);
345
- continue;
346
- }
347
- }
348
-
349
- if (char === "'" || char === '"' || char === "`") {
350
- beginWord(index);
351
- quote = char;
352
- continue;
353
- }
354
-
355
- beginWord(index);
356
- }
357
-
358
- finalizeWord(command.length);
359
-
360
- const tail = command.slice(segmentStart);
361
- if (tail.length > 0 || tokens.length === 0) {
362
- tokens.push({ type: "segment", value: tail });
363
- }
364
-
365
- return tokens;
366
- }
367
-
368
- export function isAlreadyRtkCommand(command: string): boolean {
369
- const trimmed = command.trimStart();
370
- return /^rtk\s+/.test(trimmed) || /(?:^|\s)[^\s]*\/rtk\s+/.test(trimmed);
371
- }
372
-
373
- function applyPlatformProxyCommandFixups(command: string): string {
374
- if (process.platform !== "win32") {
375
- return command;
376
- }
377
-
378
- const windowsProxyExecutables: Array<[string, string]> = [
379
- ["npm", "npm.cmd"],
380
- ["npx", "npx.cmd"],
381
- ["pnpm", "pnpm.cmd"],
382
- ["yarn", "yarn.cmd"],
383
- ];
384
-
385
- let next = command;
386
- for (const [base, windowsExecutable] of windowsProxyExecutables) {
387
- next = next.replace(
388
- new RegExp(`^(rtk\\s+proxy\\s+)${base}(\\b)`, "i"),
389
- `$1${windowsExecutable}$2`,
390
- );
391
- }
392
-
393
- return next;
394
- }
395
-
396
- function rewriteSingleSegmentCommand(
397
- segmentCommand: string,
398
- config: RtkIntegrationConfig,
399
- ): SingleSegmentRewriteResult {
400
- const envMatch = segmentCommand.match(ENV_PREFIX_PATTERN);
401
- const envPrefix = envMatch?.[1] ?? "";
402
- const commandBody = segmentCommand.slice(envPrefix.length);
403
-
404
- if (isAlreadyRtkCommand(segmentCommand) || isAlreadyRtkCommand(commandBody)) {
405
- return {
406
- changed: false,
407
- alreadyRtk: true,
408
- skippedByDisabledCategory: false,
409
- };
410
- }
411
-
412
- let skippedByDisabledCategory = false;
413
-
414
- for (const rule of RTK_REWRITE_RULES) {
415
- if (!categoryEnabled(config, rule.category)) {
416
- skippedByDisabledCategory = true;
417
- continue;
418
- }
419
-
420
- rule.matcher.lastIndex = 0;
421
- if (!rule.matcher.test(commandBody)) {
422
- continue;
423
- }
424
-
425
- rule.matcher.lastIndex = 0;
426
- if (shouldBypassRewriteForCommand(commandBody, rule)) {
427
- continue;
428
- }
429
-
430
- const rewrittenBody = commandBody.replace(rule.matcher, rule.replacement);
431
- const finalizedRewrittenBody = applyPlatformProxyCommandFixups(rewrittenBody);
432
- if (finalizedRewrittenBody === commandBody) {
433
- continue;
434
- }
435
-
436
- return {
437
- changed: true,
438
- rule,
439
- rewrittenBody: finalizedRewrittenBody,
440
- alreadyRtk: false,
441
- skippedByDisabledCategory,
442
- };
443
- }
444
-
445
- return {
446
- changed: false,
447
- alreadyRtk: false,
448
- skippedByDisabledCategory,
449
- };
450
- }
451
-
452
- function rewriteSegment(segment: string, config: RtkIntegrationConfig): SegmentRewriteResult {
453
- const leadingWhitespace = segment.match(/^\s*/)?.[0] ?? "";
454
- const trailingWhitespace = segment.match(/\s*$/)?.[0] ?? "";
455
- const core = segment.trim();
456
-
457
- if (!core) {
458
- return {
459
- value: segment,
460
- changed: false,
461
- skippedByDisabledCategory: false,
462
- considered: false,
463
- alreadyRtk: false,
464
- };
465
- }
466
-
467
- const rewrite = rewriteSingleSegmentCommand(core, config);
468
- if (!rewrite.changed || !rewrite.rule) {
469
- return {
470
- value: segment,
471
- changed: false,
472
- rule: undefined,
473
- skippedByDisabledCategory: rewrite.skippedByDisabledCategory,
474
- considered: true,
475
- alreadyRtk: rewrite.alreadyRtk,
476
- };
477
- }
478
-
479
- const envMatch = core.match(ENV_PREFIX_PATTERN);
480
- const envPrefix = envMatch?.[1] ?? "";
481
- const commandBody = core.slice(envPrefix.length);
482
- rewrite.rule.matcher.lastIndex = 0;
483
- const rewrittenBody = rewrite.rewrittenBody ?? commandBody.replace(rewrite.rule.matcher, rewrite.rule.replacement);
484
-
485
- return {
486
- value: `${leadingWhitespace}${envPrefix}${rewrittenBody}${trailingWhitespace}`,
487
- changed: true,
488
- rule: rewrite.rule,
489
- skippedByDisabledCategory: rewrite.skippedByDisabledCategory,
490
- considered: true,
491
- alreadyRtk: false,
492
- };
493
- }
494
-
495
- export function computeRewriteDecision(command: string, config: RtkIntegrationConfig): RewriteDecision {
496
- const original = command;
497
- const trimmed = command.trim();
498
- if (!trimmed) {
499
- return {
500
- changed: false,
501
- originalCommand: original,
502
- rewrittenCommand: original,
503
- reason: "empty",
504
- };
505
- }
506
-
507
- if (trimmed.includes("<<")) {
508
- return {
509
- changed: false,
510
- originalCommand: original,
511
- rewrittenCommand: original,
512
- reason: "heredoc",
513
- };
514
- }
515
-
516
- if (!isAlreadyRtkCommand(command) && shouldBypassWholeCommandRewrite(command)) {
517
- return {
518
- changed: false,
519
- originalCommand: original,
520
- rewrittenCommand: original,
521
- reason: "no_match",
522
- };
523
- }
524
-
525
- const tokens = tokenizeCommand(command);
526
- if (tokens.length === 0) {
527
- return {
528
- changed: false,
529
- originalCommand: original,
530
- rewrittenCommand: original,
531
- reason: "no_match",
532
- };
533
- }
534
-
535
- let changed = false;
536
- let skippedByDisabledCategory = false;
537
- let firstRule: RtkRewriteRule | undefined;
538
- let consideredSegments = 0;
539
- let alreadyRtkSegments = 0;
540
-
541
- const rewrittenTokens = tokens.map((token) => {
542
- if (token.type === "separator") {
543
- return token;
544
- }
545
-
546
- const result = rewriteSegment(token.value, config);
547
- if (result.considered) {
548
- consideredSegments += 1;
549
- if (result.alreadyRtk) {
550
- alreadyRtkSegments += 1;
551
- }
552
- }
553
- if (result.skippedByDisabledCategory) {
554
- skippedByDisabledCategory = true;
555
- }
556
- if (result.changed) {
557
- changed = true;
558
- if (!firstRule) {
559
- firstRule = result.rule;
560
- }
561
- }
562
-
563
- return {
564
- type: "segment" as const,
565
- value: result.value,
566
- };
567
- });
568
-
569
- if (changed) {
570
- return {
571
- changed: true,
572
- originalCommand: original,
573
- rewrittenCommand: rewrittenTokens.map((token) => token.value).join(""),
574
- rule: firstRule,
575
- reason: "ok",
576
- };
577
- }
578
-
579
- if (consideredSegments > 0 && consideredSegments === alreadyRtkSegments) {
580
- return {
581
- changed: false,
582
- originalCommand: original,
583
- rewrittenCommand: original,
584
- reason: "already_rtk",
585
- };
586
- }
587
-
588
- return {
589
- changed: false,
590
- originalCommand: original,
591
- rewrittenCommand: original,
592
- reason: skippedByDisabledCategory ? "disabled_category" : "no_match",
593
- };
594
- }
1
+ import { resolveRtkRewrite } from "./rtk-rewrite-provider.js";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import type { RtkIntegrationConfig } from "./types.js";
4
+
5
+ export interface RewriteDecision {
6
+ changed: boolean;
7
+ originalCommand: string;
8
+ rewrittenCommand: string;
9
+ reason: "ok" | "empty" | "already_rtk" | "no_match";
10
+ }
11
+
12
+ export async function computeRewriteDecision(
13
+ command: string,
14
+ _config: RtkIntegrationConfig,
15
+ pi: ExtensionAPI,
16
+ ): Promise<RewriteDecision> {
17
+ if (!command || !command.trim()) {
18
+ return { changed: false, originalCommand: command, rewrittenCommand: command, reason: "empty" };
19
+ }
20
+
21
+ const trimmedStart = command.trimStart();
22
+ if (trimmedStart === "rtk" || trimmedStart.startsWith("rtk ")) {
23
+ return { changed: false, originalCommand: command, rewrittenCommand: command, reason: "already_rtk" };
24
+ }
25
+
26
+ const result = await resolveRtkRewrite(pi, command);
27
+
28
+ if (result.changed && result.rewrittenCommand) {
29
+ return {
30
+ changed: true,
31
+ originalCommand: command,
32
+ rewrittenCommand: result.rewrittenCommand,
33
+ reason: "ok",
34
+ };
35
+ }
36
+
37
+ return {
38
+ changed: false,
39
+ originalCommand: command,
40
+ rewrittenCommand: command,
41
+ reason: "no_match",
42
+ };
43
+ }
@@ -4,6 +4,7 @@ import { mock } from "bun:test";
4
4
  import { cloneDefaultConfig, runTest } from "./test-helpers.ts";
5
5
 
6
6
  mock.module("@mariozechner/pi-coding-agent", () => ({
7
+ getAgentDir: () => "/tmp/.pi/agent",
7
8
  getSettingsListTheme: () => ({}),
8
9
  }));
9
10
 
@@ -136,6 +137,8 @@ await runAsyncTest("config modal command handlers route RTK subcommands to contr
136
137
 
137
138
  await definition?.handler("show", infoCtx.ctx);
138
139
  assert.ok(lastNotification(infoCtx.notifications).message.includes("mode=rewrite"));
140
+ assert.ok(lastNotification(infoCtx.notifications).message.includes("rewriteSource=rtk"));
141
+ assert.equal(lastNotification(infoCtx.notifications).message.includes("categories="), false);
139
142
 
140
143
  await definition?.handler("path", infoCtx.ctx);
141
144
  assert.equal(lastNotification(infoCtx.notifications).message, "rtk config: C:/tmp/pi-rtk-optimizer/config.json");