pi-var 1.0.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.
Files changed (64) hide show
  1. package/.github/FUNDING.yml +4 -0
  2. package/.prettierrc +7 -0
  3. package/README.md +240 -0
  4. package/commitlint.config.cjs +3 -0
  5. package/extensions/pi-var/__tests__/integration/smoke.test.ts +308 -0
  6. package/extensions/pi-var/__tests__/unit/command.test.ts +322 -0
  7. package/extensions/pi-var/__tests__/unit/cow-detection.test.ts +340 -0
  8. package/extensions/pi-var/__tests__/unit/cow-git-merge.test.ts +404 -0
  9. package/extensions/pi-var/__tests__/unit/edr.test.ts +119 -0
  10. package/extensions/pi-var/__tests__/unit/environment.test.ts +369 -0
  11. package/extensions/pi-var/__tests__/unit/file-redirect.test.ts +295 -0
  12. package/extensions/pi-var/__tests__/unit/store.test.ts +489 -0
  13. package/extensions/pi-var/__tests__/unit/tools.test.ts +133 -0
  14. package/extensions/pi-var/__tests__/unit/variations.test.ts +352 -0
  15. package/extensions/pi-var/index.ts +111 -0
  16. package/extensions/pi-var/src/config.ts +30 -0
  17. package/extensions/pi-var/src/cow/clone.ts +46 -0
  18. package/extensions/pi-var/src/cow/detection.ts +215 -0
  19. package/extensions/pi-var/src/cow/index.ts +6 -0
  20. package/extensions/pi-var/src/edr/constants.ts +199 -0
  21. package/extensions/pi-var/src/edr/crowdstrike.ts +62 -0
  22. package/extensions/pi-var/src/edr/detection.ts +97 -0
  23. package/extensions/pi-var/src/edr/index.ts +6 -0
  24. package/extensions/pi-var/src/environment/copy-env.ts +79 -0
  25. package/extensions/pi-var/src/environment/detect.ts +91 -0
  26. package/extensions/pi-var/src/environment/index.ts +54 -0
  27. package/extensions/pi-var/src/environment/marker.ts +42 -0
  28. package/extensions/pi-var/src/environment/symlink.ts +82 -0
  29. package/extensions/pi-var/src/gitignore/manager.ts +77 -0
  30. package/extensions/pi-var/src/state/index.ts +7 -0
  31. package/extensions/pi-var/src/state/keys.ts +14 -0
  32. package/extensions/pi-var/src/state/store.ts +110 -0
  33. package/extensions/pi-var/src/state/types.ts +70 -0
  34. package/extensions/pi-var/src/tools/command/clean.ts +87 -0
  35. package/extensions/pi-var/src/tools/command/index.ts +95 -0
  36. package/extensions/pi-var/src/tools/command/status.ts +58 -0
  37. package/extensions/pi-var/src/tools/command/stop.ts +37 -0
  38. package/extensions/pi-var/src/tools/file-redirect.ts +126 -0
  39. package/extensions/pi-var/src/tools/redirect/bash.ts +75 -0
  40. package/extensions/pi-var/src/tools/redirect/edit.ts +60 -0
  41. package/extensions/pi-var/src/tools/redirect/index.ts +64 -0
  42. package/extensions/pi-var/src/tools/redirect/read.ts +61 -0
  43. package/extensions/pi-var/src/tools/redirect/write.ts +60 -0
  44. package/extensions/pi-var/src/tools/variation/create.ts +108 -0
  45. package/extensions/pi-var/src/tools/variation/merge.ts +80 -0
  46. package/extensions/pi-var/src/types/index.ts +139 -0
  47. package/extensions/pi-var/src/utils/names.ts +120 -0
  48. package/extensions/pi-var/src/variation/create-copy.ts +35 -0
  49. package/extensions/pi-var/src/variation/create-cow.ts +81 -0
  50. package/extensions/pi-var/src/variation/create-worktree.ts +31 -0
  51. package/extensions/pi-var/src/variation/create.ts +127 -0
  52. package/extensions/pi-var/src/variation/index.ts +9 -0
  53. package/extensions/pi-var/src/variation/merge/copy.ts +151 -0
  54. package/extensions/pi-var/src/variation/merge/git.ts +176 -0
  55. package/extensions/pi-var/src/variation/merge/index.ts +36 -0
  56. package/extensions/pi-var/src/variation/merge/rsync.ts +74 -0
  57. package/extensions/pi-var/src/variation/merge/strategy.ts +62 -0
  58. package/extensions/pi-var/src/variation/paths.ts +26 -0
  59. package/extensions/pi-var/src/variation/remove.ts +79 -0
  60. package/npm-shrinkwrap.json +6908 -0
  61. package/package.json +63 -0
  62. package/skills/variations/SKILL.md +182 -0
  63. package/tsconfig.json +25 -0
  64. package/vitest.config.ts +19 -0
@@ -0,0 +1,4 @@
1
+ github: monotykamary
2
+ ko_fi: monotykamary
3
+ buy_me_a_coffee: monotykamary
4
+ polar: monotykamary
package/.prettierrc ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "semi": true,
3
+ "trailingComma": "es5",
4
+ "singleQuote": true,
5
+ "printWidth": 100,
6
+ "tabWidth": 2
7
+ }
package/README.md ADDED
@@ -0,0 +1,240 @@
1
+ <div align="center">
2
+
3
+ # 🌿 pi-var
4
+
5
+ **AI-driven copy-on-write variations for [pi](https://github.com/earendil-works/pi-coding-agent)**
6
+
7
+ _Work on multiple features simultaneously with isolated workspaces — automatically._
8
+
9
+ [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
10
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ---
17
+
18
+ ## Philosophy: The AI Does It
19
+
20
+ Traditional variation management requires you to:
21
+
22
+ - Name the variation
23
+ - Remember to switch contexts
24
+ - Manually merge and clean up
25
+
26
+ **pi-var** removes all of that. The AI detects when isolated work is needed and handles everything:
27
+
28
+ - Creates variations with semantic names
29
+ - Redirects all file operations automatically
30
+ - Suggests merge when work is complete
31
+
32
+ You just say what you want. The AI manages the workspace.
33
+
34
+ ---
35
+
36
+ ## How It Works
37
+
38
+ ```
39
+ User: "Work on the new dashboard feature while I fix this bug in main"
40
+ │
41
+ ▼
42
+ ┌─────────────┐
43
+ │ pi-var │ ← AI decides isolation is needed
44
+ └──────┬──────┘
45
+ │
46
+ ┌─────┴─────┐
47
+ ▼ ▼
48
+ ┌───────┐ ┌─────────┐
49
+ │ main │ │ 🌿 dash │ ← CoW clone (instant, zero copy)
50
+ │ (src) │ │ (src') │
51
+ └───┬───┘ └────┬────┘
52
+ │ │
53
+ │ bug fix │ dashboard feature
54
+ │ │
55
+ ▼ ▼
56
+ merge ◄──────┘
57
+ ```
58
+
59
+ **Auto-detected method** (fastest available):
60
+
61
+ 1. **CoW** (APFS clonefile / Linux reflink) — fastest, instant
62
+ 2. **Git worktree** — for git projects on non-CoW filesystems
63
+ 3. **Full copy** — universal fallback
64
+
65
+ ---
66
+
67
+ ## Installation
68
+
69
+ ```bash
70
+ pi install https://github.com/monotykamary/pi-var
71
+ ```
72
+
73
+ Or project-local:
74
+
75
+ ```bash
76
+ pi install -l https://github.com/monotykamary/pi-var
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Usage
82
+
83
+ ### Natural Language
84
+
85
+ Just describe what you need:
86
+
87
+ > "Work on the new dashboard feature while I fix this bug in main"
88
+
89
+ The AI automatically:
90
+
91
+ 1. Creates a variation for the dashboard work
92
+ 2. Redirects all operations to that variation
93
+ 3. When you mention the bug fix, creates another variation
94
+ 4. Switches between them transparently
95
+ 5. Merges completed work back
96
+
97
+ ### Dev Server Isolation
98
+
99
+ > "Run this experiment on a different port so my main dev server keeps running"
100
+
101
+ The AI:
102
+
103
+ 1. Creates a variation
104
+ 2. Allocates a unique port via `npx portless`
105
+ 3. Runs the dev server
106
+ 4. All without touching your main server
107
+
108
+ ---
109
+
110
+ ## For Users: Manual Commands
111
+
112
+ If you need to intervene or check status:
113
+
114
+ | Command | Purpose |
115
+ | ---------------------- | ----------------------------------- |
116
+ | `/var` | Show status and active variation |
117
+ | `/var list` | List all variations |
118
+ | `/var clean <name>` | Delete a variation |
119
+ | `/var clean --stale 7` | Delete variations older than 7 days |
120
+ | `/var stop` | Return to source directory |
121
+
122
+ ---
123
+
124
+ ## For the AI: Tools Reference
125
+
126
+ ### create_variation
127
+
128
+ Create an isolated workspace automatically.
129
+
130
+ ```typescript
131
+ {
132
+ purpose: string; // "fix auth redirect bug"
133
+ type?: 'cow' | 'worktree' | 'copy'; // Auto-detected if omitted
134
+ createBranch?: boolean; // Create git branch for worktrees
135
+ }
136
+ ```
137
+
138
+ **Auto-generated name:** Creates semantic names from `purpose` (e.g., `fix-auth-redirect-bug`).
139
+
140
+ **Port isolation:** For dev servers, use `npx portless` via bash:
141
+
142
+ ```bash
143
+ export PORT=$(npx portless --json | jq -r '.port')
144
+ npm run dev
145
+ ```
146
+
147
+ ### merge_variation
148
+
149
+ Merge current variation back to source.
150
+
151
+ ```typescript
152
+ {
153
+ dryRun?: boolean; // Preview changes
154
+ }
155
+ ```
156
+
157
+ **Variations persist after merge.** They are never automatically deleted to protect against data loss. Use `/var clean` to remove old variations.
158
+
159
+ ---
160
+
161
+ ## Environment Synchronization
162
+
163
+ **Copied files:** `.env`, `.envrc`, `.npmrc`, `.tool-versions`, `.node-version`
164
+
165
+ **Symlinked directories:** `node_modules`, `.next`, `.nuxt`, `target/`, `.venv/`
166
+
167
+ This saves gigabytes of disk space while ensuring each variation has isolated environment configuration.
168
+
169
+ ---
170
+
171
+ ## Transparent Redirection
172
+
173
+ When a variation is active:
174
+
175
+ - `read` → reads from variation directory
176
+ - `edit` → modifies files in variation
177
+ - `write` → creates files in variation
178
+ - `bash` → executes in variation directory
179
+ - External paths (outside project) → accessed directly
180
+
181
+ The footer shows: `🌿 variation-name`
182
+
183
+ ---
184
+
185
+ ## Comparison
186
+
187
+ ### vs Git Worktrees Alone
188
+
189
+ | Feature | Git Worktrees | pi-var |
190
+ | ---------------- | ------------------- | -------------------------- |
191
+ | Setup | Manual branching | Automatic |
192
+ | File redirection | Manual `cd` | Transparent |
193
+ | Environment sync | Manual copy/symlink | Automatic |
194
+ | Port conflicts | Manual management | AI handles via portless |
195
+ | Best method | Git only | CoW > worktree > copy |
196
+ | Cleanup | Manual git commands | AI-managed or `/var clean` |
197
+
198
+ ### vs Docker/Dev Containers
199
+
200
+ - **pi-var:** Native filesystem performance, instant startup, same environment
201
+ - **Docker:** True OS isolation, image builds, slower startup
202
+
203
+ Use pi-var for parallel development work. Use Docker for different OS/toolchain environments.
204
+
205
+ ---
206
+
207
+ ## Configuration
208
+
209
+ Create `.varconfig.yaml` in project root for custom sync rules:
210
+
211
+ ```yaml
212
+ # Files to copy
213
+ copy:
214
+ - .env.local
215
+ - secrets.json
216
+
217
+ # Directories to symlink
218
+ symlink:
219
+ - node_modules
220
+ - .turbo
221
+ ```
222
+
223
+ ---
224
+
225
+ ## Development
226
+
227
+ ```bash
228
+ # Install dependencies
229
+ npm install
230
+
231
+ # Run tests
232
+ npm test
233
+
234
+ # Type check
235
+ npm run typecheck
236
+ ```
237
+
238
+ ## License
239
+
240
+ MIT
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ extends: ['@commitlint/config-conventional'],
3
+ };
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Integration/smoke tests for pi-var extension
3
+ *
4
+ * These tests verify the end-to-end functionality of the /var command
5
+ * in a real (but isolated) environment.
6
+ */
7
+
8
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
9
+ import { mkdir, rm, writeFile, readFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { tmpdir } from 'node:os';
12
+ import { execSync } from 'node:child_process';
13
+
14
+ describe('pi-var smoke tests', () => {
15
+ const testDir = join(tmpdir(), `pi-var-test-${Date.now()}`);
16
+ let originalCwd: string;
17
+
18
+ beforeAll(async () => {
19
+ originalCwd = process.cwd();
20
+
21
+ // Create test project structure
22
+ await mkdir(testDir, { recursive: true });
23
+ await mkdir(join(testDir, 'src'), { recursive: true });
24
+
25
+ // Create a simple package.json
26
+ await writeFile(
27
+ join(testDir, 'package.json'),
28
+ JSON.stringify({
29
+ name: 'test-project',
30
+ version: '1.0.0',
31
+ })
32
+ );
33
+
34
+ // Create a source file
35
+ await writeFile(join(testDir, 'src', 'index.js'), 'console.log("Hello from main");');
36
+
37
+ // Initialize git repo for worktree tests
38
+ try {
39
+ execSync('git init', { cwd: testDir, stdio: 'ignore' });
40
+ execSync('git config user.email "test@test.com"', { cwd: testDir, stdio: 'ignore' });
41
+ execSync('git config user.name "Test User"', { cwd: testDir, stdio: 'ignore' });
42
+ execSync('git add .', { cwd: testDir, stdio: 'ignore' });
43
+ execSync('git commit -m "Initial commit"', { cwd: testDir, stdio: 'ignore' });
44
+ } catch {
45
+ // Git may not be available, skip git-related tests
46
+ }
47
+
48
+ process.chdir(testDir);
49
+ });
50
+
51
+ afterAll(async () => {
52
+ process.chdir(originalCwd);
53
+ await rm(testDir, { recursive: true, force: true });
54
+ });
55
+
56
+ describe('Extension loading', () => {
57
+ it('should have required files', async () => {
58
+ // Verify extension structure exists - use a path that works from test directory
59
+ const fs = await import('node:fs');
60
+ const { dirname } = await import('node:path');
61
+ const { fileURLToPath } = await import('node:url');
62
+
63
+ // Get the extension root from the current test file location
64
+ const testDir = dirname(fileURLToPath(import.meta.url));
65
+ const extensionPath = dirname(dirname(testDir)); // Up two levels from __tests__/integration
66
+
67
+ expect(fs.existsSync(join(extensionPath, 'index.ts'))).toBe(true);
68
+ expect(fs.existsSync(join(extensionPath, 'src', 'types', 'index.ts'))).toBe(true);
69
+ expect(fs.existsSync(join(extensionPath, 'src', 'tools', 'command', 'index.ts'))).toBe(true);
70
+ expect(fs.existsSync(join(extensionPath, 'src', 'state', 'store.ts'))).toBe(true);
71
+ });
72
+ });
73
+
74
+ describe('Type definitions', () => {
75
+ it('should be importable', async () => {
76
+ const types = await import('../../src/types/index.js');
77
+ // TypeScript types don't exist at runtime, just verify module loads
78
+ expect(types).toBeDefined();
79
+ });
80
+ });
81
+
82
+ describe('State management', () => {
83
+ it('should create runtime store', async () => {
84
+ const { createRuntimeStore, getSessionKey } = await import('../../src/state/index.js');
85
+
86
+ const store = createRuntimeStore();
87
+ expect(store).toHaveProperty('ensure');
88
+ expect(store).toHaveProperty('get');
89
+ expect(store).toHaveProperty('delete');
90
+
91
+ const mockContext = {
92
+ sessionManager: {
93
+ getSessionId: () => 'test-session-123',
94
+ },
95
+ };
96
+
97
+ const key = getSessionKey(mockContext as any);
98
+ expect(key).toBe('test-session-123');
99
+ });
100
+
101
+ it('should manage runtime lifecycle', async () => {
102
+ const { createRuntimeStore } = await import('../../src/state/store.js');
103
+
104
+ const store = createRuntimeStore();
105
+
106
+ // Create runtime
107
+ const runtime = store.ensure('session-1');
108
+ expect(runtime.state.sessionId).toBe('session-1');
109
+ expect(runtime.state.activeVariationId).toBeNull();
110
+ expect(runtime.state.variations).toEqual([]);
111
+
112
+ // Modify and verify persistence
113
+ runtime.state.activeVariationId = 'test-variation';
114
+
115
+ const retrieved = store.get('session-1');
116
+ expect(retrieved?.state.activeVariationId).toBe('test-variation');
117
+
118
+ // Delete
119
+ const deleted = store.delete('session-1');
120
+ expect(deleted).toBe(true);
121
+ expect(store.get('session-1')).toBeUndefined();
122
+ });
123
+ });
124
+
125
+ describe('Name generation', () => {
126
+ it('should generate unique IDs', async () => {
127
+ const { generateVariationId } = await import('../../src/utils/names.js');
128
+
129
+ const id1 = generateVariationId();
130
+ const id2 = generateVariationId();
131
+
132
+ expect(id1).toBeDefined();
133
+ expect(id2).toBeDefined();
134
+ expect(id1).not.toBe(id2);
135
+ expect(id1.length).toBeGreaterThan(10);
136
+ });
137
+
138
+ it('should generate readable names', async () => {
139
+ const { generateVariationName } = await import('../../src/utils/names.js');
140
+
141
+ const name = generateVariationName();
142
+
143
+ expect(name).toBeDefined();
144
+ expect(name).toMatch(/^[a-z]+-[a-z]+$/);
145
+ expect(name.length).toBeGreaterThan(3);
146
+ });
147
+
148
+ it('should generate different names on multiple calls', async () => {
149
+ const { generateVariationName } = await import('../../src/utils/names.js');
150
+
151
+ const names = new Set();
152
+ for (let i = 0; i < 10; i++) {
153
+ names.add(generateVariationName());
154
+ }
155
+
156
+ // With 30 adjectives and 38 nouns, we should get variety
157
+ expect(names.size).toBeGreaterThan(5);
158
+ });
159
+ });
160
+
161
+ describe('Command registration', () => {
162
+ it('should export registerVarCommand', async () => {
163
+ const command = await import('../../src/tools/command/index.js');
164
+
165
+ expect(command).toHaveProperty('registerVarCommand');
166
+ expect(typeof command.registerVarCommand).toBe('function');
167
+ });
168
+
169
+ it('should accept ExtensionAPI and dependencies', async () => {
170
+ const { registerVarCommand } = await import('../../src/tools/command/index.js');
171
+
172
+ const mockPi = {
173
+ registerCommand: () => {},
174
+ on: () => {},
175
+ appendEntry: () => {},
176
+ };
177
+
178
+ const mockRuntime = {
179
+ state: { activeVariationId: null, variations: [], sessionId: 'test' },
180
+
181
+ lastPersisted: Date.now(),
182
+ };
183
+
184
+ const mockRuntimeStore = {
185
+ ensure: () => mockRuntime,
186
+ get: () => mockRuntime,
187
+ delete: () => true,
188
+ persistState: () => {},
189
+ restoreState: () => {},
190
+ };
191
+
192
+ // Should not throw
193
+ expect(() => {
194
+ registerVarCommand(mockPi as any, {
195
+ getRuntime: () => mockRuntime,
196
+ pi: mockPi as any,
197
+ runtimeStore: mockRuntimeStore as any,
198
+ });
199
+ }).not.toThrow();
200
+ });
201
+ });
202
+
203
+ describe('Extension integration', () => {
204
+ it('should have complete extension structure', async () => {
205
+ const extension = await import('../../index.js');
206
+
207
+ expect(extension).toHaveProperty('default');
208
+ expect(typeof extension.default).toBe('function');
209
+ });
210
+
211
+ it('should initialize with ExtensionAPI', async () => {
212
+ const extension = await import('../../index.js');
213
+
214
+ const calls: string[] = [];
215
+ const mockPi = {
216
+ registerCommand: (name: string) => calls.push(`registerCommand:${name}`),
217
+ registerTool: (tool: any) => calls.push(`registerTool:${tool.name || 'unnamed'}`),
218
+ on: (event: string) => calls.push(`on:${event}`),
219
+ exec: () => Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
220
+ };
221
+
222
+ // Should not throw when initializing
223
+ expect(() => {
224
+ extension.default(mockPi as any);
225
+ }).not.toThrow();
226
+
227
+ // Should have registered the /var command
228
+ expect(calls.some((c) => c.includes('var'))).toBe(true);
229
+ });
230
+ });
231
+
232
+ describe('File operations integration', () => {
233
+ it('should create and read files in test directory', async () => {
234
+ const testFile = join(testDir, 'test-file.txt');
235
+ const content = 'Hello from test';
236
+
237
+ await writeFile(testFile, content);
238
+ const read = await readFile(testFile, 'utf-8');
239
+
240
+ expect(read).toBe(content);
241
+ });
242
+
243
+ it('should handle directory creation', async () => {
244
+ const nestedDir = join(testDir, 'nested', 'deep', 'dir');
245
+
246
+ await mkdir(nestedDir, { recursive: true });
247
+
248
+ const fs = await import('node:fs');
249
+ expect(fs.existsSync(nestedDir)).toBe(true);
250
+ });
251
+ });
252
+
253
+ describe('Portless integration (mocked)', () => {
254
+ it('should handle missing portless gracefully', async () => {
255
+ const { registerVarCommand } = await import('../../src/tools/command/index.js');
256
+
257
+ const mockRuntime = {
258
+ state: { activeVariationId: null, variations: [], sessionId: 'test' },
259
+
260
+ lastPersisted: Date.now(),
261
+ };
262
+
263
+ let handler: Function | undefined;
264
+ const mockPi = {
265
+ registerCommand: (name: string, config: any) => {
266
+ handler = config.handler;
267
+ },
268
+ on: () => {},
269
+ appendEntry: () => {},
270
+ };
271
+
272
+ const notifications: Array<{ msg: string; type: string }> = [];
273
+ const mockCtx = {
274
+ cwd: testDir,
275
+ ui: {
276
+ notify: (msg: string, type: string) => notifications.push({ msg, type }),
277
+ setStatus: () => {},
278
+ confirm: () => Promise.resolve(true),
279
+ select: () => Promise.resolve(null),
280
+ },
281
+ sessionManager: { getSessionId: () => 'test', getSessionFile: () => '' },
282
+ };
283
+
284
+ registerVarCommand(mockPi as any, {
285
+ getRuntime: () => mockRuntime,
286
+ pi: mockPi as any,
287
+ runtimeStore: {
288
+ ensure: () => mockRuntime,
289
+ get: () => mockRuntime,
290
+ delete: () => true,
291
+ persistState: () => {},
292
+ restoreState: () => {},
293
+ } as any,
294
+ });
295
+
296
+ // Attempt to create variation with --isolated (portless won't be available)
297
+ // Should handle gracefully and not crash
298
+ try {
299
+ await handler?.('new test-isolated --isolated', mockCtx as any);
300
+ } catch {
301
+ // Expected to potentially fail without real portless
302
+ }
303
+
304
+ // Extension should still be functional
305
+ expect(mockRuntime.state.variations.length).toBeGreaterThanOrEqual(0);
306
+ });
307
+ });
308
+ });