@dreki-gg/pi-plan-mode 0.4.0 → 0.5.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`8e9aa09`](https://github.com/dreki-gg/pi-extensions/commit/8e9aa0963fe81286e9c5972f6a9d666645807f1a) Thanks [@jalbarrang](https://github.com/jalbarrang)! - fix(plan-mode): allow safe bash commands that were incorrectly blocked in plan mode
8
+
9
+ Three fixes to `isSafeCommand`:
10
+
11
+ - Allow `mkdir -p .plans/` since the planner needs to create plan directories
12
+ - Fix redirect pattern to not false-positive on stderr redirects like `2>/dev/null`
13
+ - Split piped commands and validate each segment independently, so `curl ... | grep ... | head` works correctly
14
+
15
+ ## 0.5.0
16
+
17
+ ### Minor Changes
18
+
19
+ - [`32797ff`](https://github.com/dreki-gg/pi-extensions/commit/32797ff18d968e22c6c44e95c46e3393d8928cef) Thanks [@jalbarrang](https://github.com/jalbarrang)! - feat(plan-mode): add Windows compatibility — replace Unix shell commands with cross-platform Bun/Node APIs
20
+
21
+ Plan-mode no longer shells out to `cat`, `bash`, or `mkdir` via `pi.exec()`. File I/O now uses `Bun.file()` / `Bun.write()` and `node:fs/promises` `mkdir`, making the extension fully cross-platform. Destructive and safe command pattern lists now include Windows equivalents (`del`, `rd`, `copy`, `move`, `powershell`, `dir`, `where`, `tasklist`, etc.).
22
+
23
+ Also fixes Windows compatibility in three other packages:
24
+
25
+ - **browser-tools**: `spawn` now uses `shell: true` on Windows so `.cmd` wrappers resolve correctly; `shellEscape` uses double-quote style on Windows; install guidance is platform-aware (Homebrew shown only on macOS).
26
+ - **subagent**: `spawn` uses `shell: true` on Windows when the command is bare `pi`, allowing `pi.cmd` resolution.
27
+ - **lsp**: `globalConfigPath()` now uses `os.homedir()` on Windows instead of the unreliable `process.env.HOME`.
28
+
3
29
  ## 0.4.0
4
30
 
5
31
  ### Minor Changes
@@ -22,6 +22,7 @@ import type { AgentMessage } from '@earendil-works/pi-agent-core';
22
22
  import type { AssistantMessage, TextContent } from '@earendil-works/pi-ai';
23
23
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
24
24
  import { Key } from '@earendil-works/pi-tui';
25
+ import { mkdir } from 'node:fs/promises';
25
26
  import {
26
27
  extractTodoItems,
27
28
  isSafeCommand,
@@ -111,7 +112,7 @@ export default function planMode(pi: ExtensionAPI): void {
111
112
  status: 'in-progress' | 'done',
112
113
  title?: string,
113
114
  ): Promise<void> {
114
- const manifest = await readPlansJson((cmd, args) => pi.exec(cmd, args));
115
+ const manifest = await readPlansJson();
115
116
  const existing = manifest[planName];
116
117
  const now = new Date().toISOString();
117
118
 
@@ -122,10 +123,9 @@ export default function planMode(pi: ExtensionAPI): void {
122
123
  completed: status === 'done' ? now : null,
123
124
  };
124
125
 
125
- await pi.exec('mkdir', ['-p', '.plans']);
126
+ await mkdir('.plans', { recursive: true });
126
127
  const content = serializePlansJson(manifest);
127
- // Write via a temp approach — use bash echo to avoid needing the write tool
128
- await pi.exec('bash', ['-c', `cat > .plans/plans.json << 'PLANS_EOF'\n${content}PLANS_EOF`]);
128
+ await Bun.write('.plans/plans.json', content);
129
129
  }
130
130
 
131
131
  // ── UI updates ────────────────────────────────────────────────────────────
@@ -419,10 +419,8 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
419
419
  let title = 'Untitled plan';
420
420
  if (path.endsWith('PLAN.md')) {
421
421
  try {
422
- const result = await pi.exec('cat', [path]);
423
- if (result.code === 0) {
424
- title = extractPlanTitle(result.stdout);
425
- }
422
+ const content = await Bun.file(path).text();
423
+ title = extractPlanTitle(content);
426
424
  } catch {
427
425
  // Fall through
428
426
  }
@@ -432,11 +430,9 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
432
430
  } else if (match && planDir && path.endsWith('PLAN.md')) {
433
431
  // planDir already set but PLAN.md just written — update title
434
432
  try {
435
- const result = await pi.exec('cat', [path]);
436
- if (result.code === 0) {
437
- const title = extractPlanTitle(result.stdout);
438
- await updatePlansManifest(match[1], 'in-progress', title);
439
- }
433
+ const content = await Bun.file(path).text();
434
+ const title = extractPlanTitle(content);
435
+ await updatePlansManifest(match[1], 'in-progress', title);
440
436
  } catch {
441
437
  // Fall through
442
438
  }
@@ -500,10 +496,7 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
500
496
  // Read the plan to extract todos
501
497
  let planContent = '';
502
498
  try {
503
- const result = await pi.exec('cat', [planMdPath]);
504
- if (result.code === 0) {
505
- planContent = result.stdout;
506
- }
499
+ planContent = await Bun.file(planMdPath).text();
507
500
  } catch {
508
501
  // Fall through — will use empty plan content
509
502
  }
@@ -516,10 +509,7 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
516
509
  // Read the start prompt for clean handoff
517
510
  let startPrompt = '';
518
511
  try {
519
- const result = await pi.exec('cat', [startPromptPath]);
520
- if (result.code === 0) {
521
- startPrompt = result.stdout.trim();
522
- }
512
+ startPrompt = (await Bun.file(startPromptPath).text()).trim();
523
513
  } catch {
524
514
  // Fall through
525
515
  }
@@ -25,12 +25,15 @@ export type PlansManifest = Record<string, PlanEntry>;
25
25
 
26
26
  const PLANS_JSON = '.plans/plans.json';
27
27
 
28
- /** Read plans.json via pi.exec, returning current manifest (empty object if missing). */
29
- export async function readPlansJson(exec: (cmd: string, args: string[]) => Promise<{ code: number; stdout: string }>): Promise<PlansManifest> {
28
+ /** Read plans.json, returning current manifest (empty object if missing). */
29
+ export async function readPlansJson(): Promise<PlansManifest> {
30
30
  try {
31
- const result = await exec('cat', [PLANS_JSON]);
32
- if (result.code === 0 && result.stdout.trim()) {
33
- return JSON.parse(result.stdout) as PlansManifest;
31
+ const file = Bun.file(PLANS_JSON);
32
+ if (await file.exists()) {
33
+ const text = await file.text();
34
+ if (text.trim()) {
35
+ return JSON.parse(text) as PlansManifest;
36
+ }
34
37
  }
35
38
  } catch {
36
39
  // File doesn't exist or isn't valid JSON
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { isSafeCommand } from './utils.js';
3
+
4
+ describe('isSafeCommand', () => {
5
+ // ── Commands that SHOULD be allowed ──────────────────────────────────────
6
+ describe('allowed commands', () => {
7
+ test('mkdir -p .plans/<name>', () => {
8
+ expect(isSafeCommand('mkdir -p .plans/monorepo-src-migration')).toBe(true);
9
+ });
10
+
11
+ test('mkdir .plans/<name>', () => {
12
+ expect(isSafeCommand('mkdir .plans/my-plan')).toBe(true);
13
+ });
14
+
15
+ test('curl with 2>/dev/null pipe chain', () => {
16
+ expect(
17
+ isSafeCommand(
18
+ `curl -sL "https://effect.website/docs/platform/introduction/" 2>/dev/null | grep -oP '(?<=href=")[^"]*' | grep -iE "(http|server|rpc)" | head -20`,
19
+ ),
20
+ ).toBe(true);
21
+ });
22
+
23
+ test('curl with 2>/dev/null simple', () => {
24
+ expect(
25
+ isSafeCommand(
26
+ `curl -s "https://raw.githubusercontent.com/Effect-TS/effect/main/packages/platform/README.md" 2>/dev/null | head -100`,
27
+ ),
28
+ ).toBe(true);
29
+ });
30
+
31
+ test('simple ls', () => {
32
+ expect(isSafeCommand('ls -la')).toBe(true);
33
+ });
34
+
35
+ test('git status', () => {
36
+ expect(isSafeCommand('git status')).toBe(true);
37
+ });
38
+
39
+ test('git log', () => {
40
+ expect(isSafeCommand('git log --oneline -10')).toBe(true);
41
+ });
42
+
43
+ test('find piped to grep', () => {
44
+ expect(isSafeCommand('find . -name "*.ts" | grep -v node_modules')).toBe(true);
45
+ });
46
+
47
+ test('cat piped to head', () => {
48
+ expect(isSafeCommand('cat README.md | head -50')).toBe(true);
49
+ });
50
+
51
+ test('rg (ripgrep)', () => {
52
+ expect(isSafeCommand('rg "pattern" src/')).toBe(true);
53
+ });
54
+
55
+ test('grep with context', () => {
56
+ expect(isSafeCommand('grep -rn "export" src/ | head -20')).toBe(true);
57
+ });
58
+
59
+ test('npm list', () => {
60
+ expect(isSafeCommand('npm list --depth=0')).toBe(true);
61
+ });
62
+
63
+ test('curl piped to jq', () => {
64
+ expect(isSafeCommand('curl -s https://api.example.com | jq .name')).toBe(true);
65
+ });
66
+ });
67
+
68
+ // ── Commands that SHOULD be blocked ──────────────────────────────────────
69
+ describe('blocked commands', () => {
70
+ test('rm -rf', () => {
71
+ expect(isSafeCommand('rm -rf node_modules')).toBe(false);
72
+ });
73
+
74
+ test('mkdir outside .plans/', () => {
75
+ expect(isSafeCommand('mkdir -p src/new-dir')).toBe(false);
76
+ });
77
+
78
+ test('stdout redirect to file', () => {
79
+ expect(isSafeCommand('echo "hello" > file.txt')).toBe(false);
80
+ });
81
+
82
+ test('append redirect', () => {
83
+ expect(isSafeCommand('echo "hello" >> file.txt')).toBe(false);
84
+ });
85
+
86
+ test('git commit', () => {
87
+ expect(isSafeCommand('git commit -m "test"')).toBe(false);
88
+ });
89
+
90
+ test('npm install', () => {
91
+ expect(isSafeCommand('npm install lodash')).toBe(false);
92
+ });
93
+
94
+ test('mv file', () => {
95
+ expect(isSafeCommand('mv old.ts new.ts')).toBe(false);
96
+ });
97
+
98
+ test('sudo anything', () => {
99
+ expect(isSafeCommand('sudo ls')).toBe(false);
100
+ });
101
+
102
+ test('cp file', () => {
103
+ expect(isSafeCommand('cp a.ts b.ts')).toBe(false);
104
+ });
105
+
106
+ test('touch file', () => {
107
+ expect(isSafeCommand('touch newfile.ts')).toBe(false);
108
+ });
109
+ });
110
+ });
@@ -24,7 +24,8 @@ const DESTRUCTIVE_PATTERNS = [
24
24
  /\btruncate\b/i,
25
25
  /\bdd\b/i,
26
26
  /\bshred\b/i,
27
- /(^|[^<])>(?!>)/,
27
+ // stdout redirect — but NOT stderr redirects like 2>/dev/null or 2>&1
28
+ /(?<!\d)>(?!>|&)/,
28
29
  />>/,
29
30
  /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
30
31
  /\byarn\s+(add|remove|install|publish)/i,
@@ -43,6 +44,17 @@ const DESTRUCTIVE_PATTERNS = [
43
44
  /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
44
45
  /\bservice\s+\S+\s+(start|stop|restart)/i,
45
46
  /\b(vim?|nano|emacs|code|subl)\b/i,
47
+ // Windows equivalents
48
+ /\bdel\b/i,
49
+ /\brd\b/i,
50
+ /\bcopy\b/i,
51
+ /\bmove\b/i,
52
+ /\bren\b/i,
53
+ /\brename\b/i,
54
+ /\bicacls\b/i,
55
+ /\battrib\b/i,
56
+ /\bpowershell\b/i,
57
+ /\bpwsh\b/i,
46
58
  ];
47
59
 
48
60
  // ── Safe read-only bash patterns (allowed in plan mode) ─────────────────────
@@ -98,12 +110,78 @@ const SAFE_PATTERNS = [
98
110
  /^\s*fd\b/,
99
111
  /^\s*bat\b/,
100
112
  /^\s*eza\b/,
113
+ // Windows equivalents
114
+ /^\s*dir\b/,
115
+ /^\s*where\b/,
116
+ /^\s*set\b/,
117
+ /^\s*systeminfo\b/,
118
+ /^\s*tasklist\b/,
101
119
  ];
102
120
 
121
+ /**
122
+ * Check if a command is safe for plan mode.
123
+ *
124
+ * For simple commands, checks that the command starts with a safe pattern and
125
+ * doesn't contain destructive patterns. For piped commands, each segment is
126
+ * checked individually. Special exceptions allow `mkdir -p` for `.plans/` paths.
127
+ */
103
128
  export function isSafeCommand(command: string): boolean {
104
- const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
105
- const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
106
- return !isDestructive && isSafe;
129
+ // Special case: allow mkdir for .plans/ directory (planner needs this)
130
+ if (/^\s*mkdir\s+(-p\s+)?\.plans(\/|\\|\s|$)/.test(command)) {
131
+ return true;
132
+ }
133
+
134
+ // Split piped commands and check each segment independently
135
+ const segments = splitPipeSegments(command);
136
+ return segments.every((seg) => {
137
+ const trimmed = seg.trim();
138
+ const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(trimmed));
139
+ const isSafe = SAFE_PATTERNS.some((p) => p.test(trimmed));
140
+ return !isDestructive && isSafe;
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Split a command on unquoted pipe (`|`) characters.
146
+ * Respects single/double quotes and escaped characters.
147
+ */
148
+ function splitPipeSegments(command: string): string[] {
149
+ const segments: string[] = [];
150
+ let current = '';
151
+ let inSingle = false;
152
+ let inDouble = false;
153
+ let escaped = false;
154
+
155
+ for (const ch of command) {
156
+ if (escaped) {
157
+ current += ch;
158
+ escaped = false;
159
+ continue;
160
+ }
161
+ if (ch === '\\') {
162
+ current += ch;
163
+ escaped = true;
164
+ continue;
165
+ }
166
+ if (ch === "'" && !inDouble) {
167
+ inSingle = !inSingle;
168
+ current += ch;
169
+ continue;
170
+ }
171
+ if (ch === '"' && !inSingle) {
172
+ inDouble = !inDouble;
173
+ current += ch;
174
+ continue;
175
+ }
176
+ if (ch === '|' && !inSingle && !inDouble) {
177
+ segments.push(current);
178
+ current = '';
179
+ continue;
180
+ }
181
+ current += ch;
182
+ }
183
+ if (current) segments.push(current);
184
+ return segments;
107
185
  }
108
186
 
109
187
  // ── Plan extraction ─────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -36,6 +36,7 @@
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "24",
39
+ "bun-types": "latest",
39
40
  "oxfmt": "^0.43.0",
40
41
  "oxlint": "^1.58.0",
41
42
  "typescript": "^6.0.0"