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,91 @@
1
+ /**
2
+ * Variation context detection from current working directory
3
+ */
4
+
5
+ import path from 'path';
6
+ import { promises as fs } from 'fs';
7
+ import type { VariationContext } from '../types/index';
8
+
9
+ /**
10
+ * Detect variation context from current working directory
11
+ * Returns information about whether we're in a variation and which one
12
+ */
13
+ export function detectVariationContext(cwd: string): VariationContext {
14
+ // Normalize path
15
+ const normalizedCwd = path.resolve(cwd);
16
+
17
+ // Check if we're inside a .pi/variations directory structure
18
+ const variationsPattern = /[\/\\]\.pi[\/\\]variations[\/\\][^\/\\]+[\/\\]([^\/\\]+)[\/\\]?/;
19
+ const match = normalizedCwd.match(variationsPattern);
20
+
21
+ if (match) {
22
+ const variationName = match[1];
23
+ const variationPath = normalizedCwd.substring(
24
+ 0,
25
+ normalizedCwd.indexOf(match[0]) + match[0].length
26
+ );
27
+
28
+ // Find source path (go up to project root)
29
+ const sourcePath = path.dirname(path.dirname(path.dirname(path.dirname(variationPath))));
30
+
31
+ // Generate a consistent variation ID based on path
32
+ const variationId = `var-${Buffer.from(variationPath)
33
+ .toString('base64')
34
+ .replace(/[^a-zA-Z0-9]/g, '')
35
+ .slice(0, 16)}`;
36
+
37
+ return {
38
+ inVariation: true,
39
+ variationId,
40
+ variationName,
41
+ variationPath,
42
+ sourcePath,
43
+ };
44
+ }
45
+
46
+ // Not in a variation - return context with cwd as source
47
+ return {
48
+ inVariation: false,
49
+ variationId: null,
50
+ variationName: null,
51
+ variationPath: null,
52
+ sourcePath: normalizedCwd,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Detect variation context async - checks for marker file
58
+ * Use this when you need to detect via marker file
59
+ */
60
+ export async function detectVariationContextAsync(cwd: string): Promise<VariationContext> {
61
+ // First try the sync version
62
+ const syncContext = detectVariationContext(cwd);
63
+ if (syncContext.inVariation) {
64
+ return syncContext;
65
+ }
66
+
67
+ // Check for .pi-variation marker file
68
+ const normalizedCwd = path.resolve(cwd);
69
+ const markerPath = path.join(normalizedCwd, '.pi-variation');
70
+
71
+ try {
72
+ const stat = await fs.stat(markerPath);
73
+ if (stat.isFile()) {
74
+ const content = await fs.readFile(markerPath, 'utf-8');
75
+ const info = JSON.parse(content);
76
+ if (info.sourcePath && info.variationName) {
77
+ return {
78
+ inVariation: true,
79
+ variationId: info.id || 'var-unknown',
80
+ variationName: info.variationName,
81
+ variationPath: normalizedCwd,
82
+ sourcePath: info.sourcePath,
83
+ };
84
+ }
85
+ }
86
+ } catch {
87
+ // No marker file or error reading it, ignore
88
+ }
89
+
90
+ return syncContext;
91
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Environment setup and variation context detection module
3
+ */
4
+
5
+ import type { VarConfig } from '../types/index';
6
+ import { copyEnvFiles } from './copy-env';
7
+ import { symlinkHeavyDirs } from './symlink';
8
+ import { detectVariationContext, detectVariationContextAsync } from './detect';
9
+ import { createVariationMarker, removeVariationMarker } from './marker';
10
+
11
+ // Default configuration for environment sync
12
+ export const DEFAULT_CONFIG = {
13
+ copy: ['.env', '.env.*', '.envrc', '.npmrc', '.tool-versions'],
14
+ symlink: ['node_modules', '.next', '.nuxt', 'target', '.venv'],
15
+ postCreate: [],
16
+ };
17
+
18
+ /**
19
+ * Setup the variation environment by copying env files and symlinking heavy directories
20
+ */
21
+ export async function setupVariationEnvironment(
22
+ source: string,
23
+ variation: string,
24
+ config: Partial<VarConfig> = {}
25
+ ): Promise<void> {
26
+ const mergedConfig = { ...DEFAULT_CONFIG, ...config };
27
+
28
+ // Copy environment files
29
+ await copyEnvFiles(source, variation, mergedConfig.copy);
30
+
31
+ // Symlink heavy directories
32
+ await symlinkHeavyDirs(source, variation, mergedConfig.symlink);
33
+
34
+ // Run post-create commands
35
+ for (const cmd of mergedConfig.postCreate) {
36
+ try {
37
+ const { exec } = await import('child_process');
38
+ const { promisify } = await import('util');
39
+ const execAsync = promisify(exec);
40
+ await execAsync(cmd, { cwd: variation });
41
+ } catch (err) {
42
+ console.warn(`Post-create command failed: ${cmd}`, err);
43
+ }
44
+ }
45
+ }
46
+
47
+ export {
48
+ detectVariationContext,
49
+ detectVariationContextAsync,
50
+ createVariationMarker,
51
+ removeVariationMarker,
52
+ copyEnvFiles,
53
+ symlinkHeavyDirs,
54
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Variation marker file management
3
+ */
4
+
5
+ import { promises as fs } from 'fs';
6
+ import * as path from 'path';
7
+
8
+ /**
9
+ * Create a marker file in the variation for context detection
10
+ */
11
+ export async function createVariationMarker(
12
+ variationPath: string,
13
+ info: {
14
+ id: string;
15
+ name: string;
16
+ sourcePath: string;
17
+ type: string;
18
+ }
19
+ ): Promise<void> {
20
+ const markerPath = path.join(variationPath, '.pi-variation');
21
+ const content = {
22
+ id: info.id,
23
+ variationName: info.name,
24
+ sourcePath: info.sourcePath,
25
+ type: info.type,
26
+ createdAt: new Date().toISOString(),
27
+ };
28
+
29
+ await fs.writeFile(markerPath, JSON.stringify(content, null, 2));
30
+ }
31
+
32
+ /**
33
+ * Remove the variation marker file
34
+ */
35
+ export async function removeVariationMarker(variationPath: string): Promise<void> {
36
+ const markerPath = path.join(variationPath, '.pi-variation');
37
+ try {
38
+ await fs.unlink(markerPath);
39
+ } catch {
40
+ // Ignore if doesn't exist
41
+ }
42
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Symlink management for heavy directories
3
+ */
4
+
5
+ import { promises as fs } from 'fs';
6
+ import * as path from 'path';
7
+
8
+ /**
9
+ * Create symlinks for heavy directories (node_modules, .next, etc.)
10
+ */
11
+ export async function symlinkHeavyDirs(
12
+ source: string,
13
+ variation: string,
14
+ dirNames: string[]
15
+ ): Promise<void> {
16
+ for (const dirName of dirNames) {
17
+ const srcPath = path.join(source, dirName);
18
+ const destPath = path.join(variation, dirName);
19
+
20
+ try {
21
+ // Check if source directory exists
22
+ const stat = await fs.stat(srcPath);
23
+ if (!stat.isDirectory()) {
24
+ continue;
25
+ }
26
+
27
+ // Ensure the variation parent directory exists
28
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
29
+
30
+ // Remove destination if it exists (but is not a symlink)
31
+ try {
32
+ const destStat = await fs.lstat(destPath);
33
+ if (destStat.isSymbolicLink()) {
34
+ // Already a symlink, might be from previous setup
35
+ await fs.unlink(destPath);
36
+ } else if (destStat.isDirectory()) {
37
+ // It's a directory - remove it to replace with symlink
38
+ await fs.rm(destPath, { recursive: true });
39
+ } else {
40
+ // It's a file
41
+ await fs.unlink(destPath);
42
+ }
43
+ } catch (err) {
44
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
45
+ throw err;
46
+ }
47
+ // Destination doesn't exist, proceed
48
+ }
49
+
50
+ // Create the symlink
51
+ // Use relative path for portability
52
+ const relativeSrc = path.relative(path.dirname(destPath), srcPath);
53
+ await fs.symlink(relativeSrc, destPath, 'junction');
54
+ } catch (err) {
55
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
56
+ // Source directory doesn't exist, skip
57
+ continue;
58
+ }
59
+ // Log but don't throw - symlinks are optional optimizations
60
+ console.warn(`Failed to symlink ${dirName}:`, (err as Error).message);
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Remove symlinks from a variation (before merge or delete)
67
+ * @internal Not currently used but kept for future cleanup operations
68
+ */
69
+ async function removeSymlinks(variation: string): Promise<void> {
70
+ try {
71
+ const entries = await fs.readdir(variation, { withFileTypes: true });
72
+
73
+ for (const entry of entries) {
74
+ if (entry.isSymbolicLink()) {
75
+ const linkPath = path.join(variation, entry.name);
76
+ await fs.unlink(linkPath);
77
+ }
78
+ }
79
+ } catch (err) {
80
+ // Ignore errors during cleanup
81
+ }
82
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Global gitignore management for .pi/variations
3
+ */
4
+
5
+ import { promises as fs, constants as fsConstants } from 'fs';
6
+ import * as fsSync from 'fs';
7
+ import * as os from 'os';
8
+ import * as path from 'path';
9
+ import { execSync } from 'child_process';
10
+
11
+ /** Get the path to the global gitignore file */
12
+ function getGlobalGitignorePath(): string | null {
13
+ try {
14
+ // Check if core.excludesfile is set
15
+ const result = execSync('git config --global core.excludesfile', {
16
+ encoding: 'utf-8',
17
+ stdio: ['pipe', 'pipe', 'ignore'],
18
+ });
19
+ const configured = result.trim();
20
+ if (configured) return configured;
21
+ } catch {
22
+ // Not configured, fall through to default
23
+ }
24
+
25
+ // Default locations by platform
26
+ // Check env vars first (allows testing with fake home), then os.homedir()
27
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
28
+ const candidates = [
29
+ path.join(home, '.gitignore'),
30
+ path.join(home, '.gitignore_global'),
31
+ path.join(home, '.config', 'git', 'ignore'),
32
+ ];
33
+
34
+ for (const candidate of candidates) {
35
+ if (fsSync.existsSync(candidate)) {
36
+ return candidate;
37
+ }
38
+ }
39
+
40
+ // Default to ~/.gitignore if nothing exists
41
+ return path.join(home, '.gitignore');
42
+ }
43
+
44
+ /** Ensure .pi/variations/ is in the global gitignore */
45
+ export function ensureGlobalGitignore(): void {
46
+ try {
47
+ const gitignorePath = getGlobalGitignorePath();
48
+ if (!gitignorePath) return;
49
+
50
+ const pattern = '.pi/variations/';
51
+ let content = '';
52
+
53
+ if (fsSync.existsSync(gitignorePath)) {
54
+ content = fsSync.readFileSync(gitignorePath, 'utf-8');
55
+ // Already present?
56
+ if (content.split(/\r?\n/).some((line) => line.trim() === pattern)) {
57
+ return;
58
+ }
59
+ }
60
+
61
+ // Ensure parent directory exists
62
+ const parentDir = path.dirname(gitignorePath);
63
+ if (!fsSync.existsSync(parentDir)) {
64
+ fsSync.mkdirSync(parentDir, { recursive: true });
65
+ }
66
+
67
+ // Append with a comment
68
+ const entry =
69
+ content.endsWith('\n') || content === ''
70
+ ? `# pi-var variations\n${pattern}\n`
71
+ : `\n# pi-var variations\n${pattern}\n`;
72
+
73
+ fsSync.appendFileSync(gitignorePath, entry, 'utf-8');
74
+ } catch {
75
+ // Silently fail — this is a convenience, not a requirement
76
+ }
77
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * State management module
3
+ */
4
+
5
+ export type { ExtensionContext, SessionManager } from './types';
6
+ export { getSessionKey } from './keys';
7
+ export { createRuntimeStore } from './store.js';
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Session key extraction
3
+ */
4
+
5
+ import type { ExtensionContext } from './types';
6
+
7
+ /**
8
+ * Extract session ID from extension context
9
+ * @param ctx - Extension context containing session manager
10
+ * @returns Session key string
11
+ */
12
+ export function getSessionKey(ctx: ExtensionContext): string {
13
+ return ctx.sessionManager.getSessionId?.() || 'default';
14
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * State persistence and restoration
3
+ */
4
+
5
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
6
+ import { VAR_STATE_ENTRY_TYPE, PersistedVarState, SessionManager, RuntimeStore } from './types.js';
7
+ import type { VarRuntime, VarState } from '../types/index.js';
8
+
9
+ /**
10
+ * Create default initial state for a new session
11
+ * @param sessionId - The session identifier
12
+ * @returns Initial VarState object
13
+ */
14
+ function createInitialState(sessionId: string): VarState {
15
+ return {
16
+ activeVariationId: null,
17
+ variations: [],
18
+ sessionId,
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Create a new VarRuntime instance with initial state
24
+ * @param sessionId - The session identifier
25
+ * @returns New VarRuntime instance
26
+ */
27
+ function createRuntime(sessionId: string): VarRuntime {
28
+ return {
29
+ state: createInitialState(sessionId),
30
+ lastPersisted: Date.now(),
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Create Map-based runtime store for managing per-session VarRuntime instances
36
+ * @returns RuntimeStore instance with ensure, get, delete, and persistState methods
37
+ */
38
+ export function createRuntimeStore(): RuntimeStore {
39
+ const store = new Map<string, VarRuntime>();
40
+
41
+ return {
42
+ ensure(sessionKey: string): VarRuntime {
43
+ let runtime = store.get(sessionKey);
44
+ if (!runtime) {
45
+ runtime = createRuntime(sessionKey);
46
+ store.set(sessionKey, runtime);
47
+ }
48
+ return runtime;
49
+ },
50
+
51
+ get(sessionKey: string): VarRuntime | undefined {
52
+ return store.get(sessionKey);
53
+ },
54
+
55
+ delete(sessionKey: string): boolean {
56
+ return store.delete(sessionKey);
57
+ },
58
+
59
+ persistState(sessionKey: string, pi: ExtensionAPI): void {
60
+ const runtime = store.get(sessionKey);
61
+ if (!runtime) return;
62
+
63
+ const persistedState: PersistedVarState = {
64
+ variations: runtime.state.variations,
65
+ activeVariationId: runtime.state.activeVariationId,
66
+ };
67
+
68
+ pi.appendEntry(VAR_STATE_ENTRY_TYPE, persistedState);
69
+ runtime.lastPersisted = Date.now();
70
+ },
71
+
72
+ restoreState(sessionKey: string, sessionManager: SessionManager): void {
73
+ const runtime = store.get(sessionKey);
74
+ if (!runtime) return;
75
+
76
+ // Get entries - prefer getEntries() as it's always available during session_start
77
+ // getBranch() may fail early in session lifecycle before tree is initialized
78
+ let entries: SessionEntry[] = [];
79
+ try {
80
+ if (sessionManager.getEntries) {
81
+ entries = sessionManager.getEntries();
82
+ } else if (sessionManager.getBranch) {
83
+ entries = sessionManager.getBranch();
84
+ }
85
+ } catch {
86
+ // Session manager may not be fully initialized yet
87
+ return;
88
+ }
89
+
90
+ if (!entries || entries.length === 0) return;
91
+
92
+ // Find the last state entry (entries are in order, so we take the last one)
93
+ const stateEntries = entries.filter(
94
+ (e: SessionEntry) => e.type === 'custom' && e.customType === VAR_STATE_ENTRY_TYPE
95
+ );
96
+
97
+ if (stateEntries.length === 0) return;
98
+
99
+ const lastEntry = stateEntries[stateEntries.length - 1];
100
+ const persistedState = lastEntry.data as PersistedVarState;
101
+
102
+ if (persistedState) {
103
+ runtime.state.variations = persistedState.variations || [];
104
+ runtime.state.activeVariationId = persistedState.activeVariationId ?? null;
105
+ }
106
+ },
107
+ };
108
+ }
109
+
110
+ import type { SessionEntry } from './types.js';
@@ -0,0 +1,70 @@
1
+ /**
2
+ * State types and interfaces
3
+ */
4
+
5
+ import type { VarRuntime, VarState, Variation } from '../types/index';
6
+
7
+ /** Custom entry type for session persistence */
8
+ export const VAR_STATE_ENTRY_TYPE = 'pi-var:state';
9
+
10
+ /** Serialized state for persistence */
11
+ export interface PersistedVarState {
12
+ variations: Variation[];
13
+ activeVariationId: string | null;
14
+ }
15
+
16
+ /** Session entry interface for state restoration */
17
+ export interface SessionEntry {
18
+ type: string;
19
+ customType?: string;
20
+ data?: unknown;
21
+ }
22
+
23
+ /** Session manager interface for accessing entries */
24
+ export interface SessionManager {
25
+ getEntries?(): SessionEntry[];
26
+ getBranch?(): SessionEntry[];
27
+ getSessionId?(): string;
28
+ }
29
+
30
+ /**
31
+ * Extension context interface for session key extraction
32
+ */
33
+ export interface ExtensionContext {
34
+ sessionManager: SessionManager;
35
+ }
36
+
37
+ /**
38
+ * Runtime store interface for managing per-session VarRuntime instances
39
+ * @internal
40
+ */
41
+ export interface RuntimeStore {
42
+ /**
43
+ * Ensure a VarRuntime exists for the given session key,
44
+ * creating one if it doesn't exist
45
+ */
46
+ ensure(sessionKey: string): VarRuntime;
47
+
48
+ /**
49
+ * Get an existing VarRuntime for the session key,
50
+ * returning undefined if not found
51
+ */
52
+ get(sessionKey: string): VarRuntime | undefined;
53
+
54
+ /**
55
+ * Delete a VarRuntime for the given session key
56
+ */
57
+ delete(sessionKey: string): boolean;
58
+
59
+ /**
60
+ * Persist current state to the session using pi.appendEntry()
61
+ */
62
+ persistState(sessionKey: string, pi: ExtensionAPI): void;
63
+
64
+ /**
65
+ * Restore state from session entries
66
+ */
67
+ restoreState(sessionKey: string, sessionManager: SessionManager): void;
68
+ }
69
+
70
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
@@ -0,0 +1,87 @@
1
+ /**
2
+ * /var command clean handlers
3
+ */
4
+
5
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
6
+ import type { VarRuntime } from '../../types/index';
7
+ import { removeVariation } from '../../variation/remove';
8
+ import type { RuntimeStore } from '../../state/types';
9
+
10
+ function getSessionKey(ctx: ExtensionContext): string {
11
+ return ctx.sessionManager.getSessionId?.() || 'default';
12
+ }
13
+
14
+ export async function handleCleanStale(
15
+ runtime: VarRuntime,
16
+ ctx: ExtensionContext,
17
+ staleDays: number,
18
+ runtimeStore: RuntimeStore,
19
+ pi: ExtensionAPI
20
+ ): Promise<void> {
21
+ const cutoff = Date.now() - staleDays * 24 * 60 * 60 * 1000;
22
+
23
+ const staleVariations = runtime.state.variations.filter((v) => {
24
+ const lastAccessed = new Date(v.lastAccessed).getTime();
25
+ return lastAccessed < cutoff;
26
+ });
27
+
28
+ if (staleVariations.length === 0) {
29
+ ctx.ui.notify(`No variations older than ${staleDays} days`, 'info');
30
+ return;
31
+ }
32
+
33
+ const confirmed = await ctx.ui.confirm(
34
+ 'Clean stale variations?',
35
+ `Delete ${staleVariations.length} variation(s) older than ${staleDays} days?`
36
+ );
37
+
38
+ if (!confirmed) return;
39
+
40
+ for (const v of staleVariations) {
41
+ await removeVariation(v);
42
+ runtime.state.variations = runtime.state.variations.filter((existing) => existing.id !== v.id);
43
+ }
44
+
45
+ // Persist state after cleaning
46
+ runtimeStore.persistState(getSessionKey(ctx), pi);
47
+
48
+ ctx.ui.notify(`Cleaned ${staleVariations.length} stale variation(s)`, 'info');
49
+ }
50
+
51
+ export async function handleCleanSpecific(
52
+ runtime: VarRuntime,
53
+ ctx: ExtensionContext,
54
+ name: string,
55
+ runtimeStore: RuntimeStore,
56
+ pi: ExtensionAPI
57
+ ): Promise<void> {
58
+ const variation = runtime.state.variations.find((v) => v.name === name);
59
+ if (!variation) {
60
+ ctx.ui.notify(`Variation "${name}" not found`, 'error');
61
+ return;
62
+ }
63
+
64
+ const confirmed = await ctx.ui.confirm(
65
+ 'Delete variation?',
66
+ `Delete "${name}"? This cannot be undone.`
67
+ );
68
+
69
+ if (!confirmed) {
70
+ ctx.ui.notify('Clean cancelled', 'info');
71
+ return;
72
+ }
73
+
74
+ await removeVariation(variation);
75
+ runtime.state.variations = runtime.state.variations.filter((v) => v.id !== variation.id);
76
+
77
+ // If this was active, deactivate
78
+ if (runtime.state.activeVariationId === variation.id) {
79
+ runtime.state.activeVariationId = null;
80
+ ctx.ui.setStatus('pi-var', '');
81
+ }
82
+
83
+ // Persist state after cleaning
84
+ runtimeStore.persistState(getSessionKey(ctx), pi);
85
+
86
+ ctx.ui.notify(`Deleted variation "${name}"`, 'info');
87
+ }