@utopia-ai/cli 0.1.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.
Files changed (49) hide show
  1. package/.claude/settings.json +1 -0
  2. package/.claude/settings.local.json +38 -0
  3. package/bin/utopia.js +20 -0
  4. package/package.json +46 -0
  5. package/python/README.md +34 -0
  6. package/python/instrumenter/instrument.py +1148 -0
  7. package/python/pyproject.toml +32 -0
  8. package/python/setup.py +27 -0
  9. package/python/utopia_runtime/__init__.py +30 -0
  10. package/python/utopia_runtime/__pycache__/__init__.cpython-313.pyc +0 -0
  11. package/python/utopia_runtime/__pycache__/client.cpython-313.pyc +0 -0
  12. package/python/utopia_runtime/__pycache__/probe.cpython-313.pyc +0 -0
  13. package/python/utopia_runtime/client.py +31 -0
  14. package/python/utopia_runtime/probe.py +446 -0
  15. package/python/utopia_runtime.egg-info/PKG-INFO +59 -0
  16. package/python/utopia_runtime.egg-info/SOURCES.txt +10 -0
  17. package/python/utopia_runtime.egg-info/dependency_links.txt +1 -0
  18. package/python/utopia_runtime.egg-info/top_level.txt +1 -0
  19. package/scripts/publish-npm.sh +14 -0
  20. package/scripts/publish-pypi.sh +17 -0
  21. package/src/cli/commands/codex.ts +193 -0
  22. package/src/cli/commands/context.ts +188 -0
  23. package/src/cli/commands/destruct.ts +237 -0
  24. package/src/cli/commands/easter-eggs.ts +203 -0
  25. package/src/cli/commands/init.ts +505 -0
  26. package/src/cli/commands/instrument.ts +962 -0
  27. package/src/cli/commands/mcp.ts +16 -0
  28. package/src/cli/commands/serve.ts +194 -0
  29. package/src/cli/commands/status.ts +304 -0
  30. package/src/cli/commands/validate.ts +328 -0
  31. package/src/cli/index.ts +37 -0
  32. package/src/cli/utils/config.ts +54 -0
  33. package/src/graph/index.ts +687 -0
  34. package/src/instrumenter/javascript.ts +1798 -0
  35. package/src/mcp/index.ts +886 -0
  36. package/src/runtime/js/index.ts +518 -0
  37. package/src/runtime/js/package-lock.json +30 -0
  38. package/src/runtime/js/package.json +30 -0
  39. package/src/runtime/js/tsconfig.json +16 -0
  40. package/src/server/db/index.ts +26 -0
  41. package/src/server/db/schema.ts +45 -0
  42. package/src/server/index.ts +79 -0
  43. package/src/server/middleware/auth.ts +74 -0
  44. package/src/server/routes/admin.ts +36 -0
  45. package/src/server/routes/graph.ts +358 -0
  46. package/src/server/routes/probes.ts +286 -0
  47. package/src/types.ts +147 -0
  48. package/src/utopia-mode/index.ts +206 -0
  49. package/tsconfig.json +19 -0
@@ -0,0 +1,193 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { spawn } from 'node:child_process';
4
+ import { loadConfig, configExists } from '../utils/config.js';
5
+
6
+ interface ContextResponse {
7
+ count: number;
8
+ keywords?: string[];
9
+ probes: Array<{
10
+ id: string;
11
+ projectId: string;
12
+ probeType: string;
13
+ timestamp: string;
14
+ file: string;
15
+ line: number;
16
+ functionName: string;
17
+ data: Record<string, unknown>;
18
+ metadata: Record<string, unknown>;
19
+ }>;
20
+ }
21
+
22
+ function formatProbeForContext(probe: ContextResponse['probes'][number]): string {
23
+ const lines: string[] = [];
24
+ lines.push(`[${probe.probeType.toUpperCase()}] ${probe.functionName || '(anonymous)'} in ${probe.file}:${probe.line}`);
25
+ lines.push(` Timestamp: ${probe.timestamp}`);
26
+
27
+ const data = probe.data;
28
+ switch (probe.probeType) {
29
+ case 'error':
30
+ if (data.errorType) lines.push(` Error: ${data.errorType}: ${data.message}`);
31
+ if (data.stack) {
32
+ const stackLines = String(data.stack).split('\n').slice(0, 5);
33
+ lines.push(` Stack:\n ${stackLines.join('\n ')}`);
34
+ }
35
+ if (data.codeLine) lines.push(` Code: ${data.codeLine}`);
36
+ break;
37
+
38
+ case 'database':
39
+ if (data.operation) lines.push(` Operation: ${data.operation}`);
40
+ if (data.query) lines.push(` Query: ${String(data.query).slice(0, 200)}`);
41
+ if (data.duration !== undefined) lines.push(` Duration: ${data.duration}ms`);
42
+ if (data.rowCount !== undefined) lines.push(` Rows: ${data.rowCount}`);
43
+ break;
44
+
45
+ case 'api':
46
+ if (data.method && data.url) lines.push(` ${data.method} ${data.url}`);
47
+ if (data.statusCode !== undefined) lines.push(` Status: ${data.statusCode}`);
48
+ if (data.duration !== undefined) lines.push(` Duration: ${data.duration}ms`);
49
+ if (data.error) lines.push(` Error: ${data.error}`);
50
+ break;
51
+
52
+ case 'infra':
53
+ if (data.provider) lines.push(` Provider: ${data.provider}`);
54
+ if (data.region) lines.push(` Region: ${data.region}`);
55
+ if (data.serviceType) lines.push(` Service: ${data.serviceType}`);
56
+ if (data.memoryUsage !== undefined) lines.push(` Memory: ${data.memoryUsage}MB`);
57
+ break;
58
+
59
+ case 'function':
60
+ if (data.duration !== undefined) lines.push(` Duration: ${data.duration}ms`);
61
+ if (data.llmContext) lines.push(` LLM Context: ${String(data.llmContext).slice(0, 300)}`);
62
+ if (data.args) lines.push(` Args: ${JSON.stringify(data.args).slice(0, 200)}`);
63
+ break;
64
+
65
+ default: {
66
+ const dataStr = JSON.stringify(data);
67
+ if (dataStr.length < 300) {
68
+ lines.push(` Data: ${dataStr}`);
69
+ }
70
+ break;
71
+ }
72
+ }
73
+
74
+ return lines.join('\n');
75
+ }
76
+
77
+ async function fetchProductionContext(
78
+ endpoint: string,
79
+ prompt: string,
80
+ ): Promise<string | null> {
81
+ try {
82
+ const url = new URL('/api/v1/probes/context', endpoint);
83
+ url.searchParams.set('prompt', prompt);
84
+ url.searchParams.set('limit', '10');
85
+
86
+ const response = await fetch(url.toString(), {
87
+ method: 'GET',
88
+ headers: {
89
+ 'Content-Type': 'application/json',
90
+ },
91
+ });
92
+
93
+ if (!response.ok) {
94
+ return null;
95
+ }
96
+
97
+ const data = (await response.json()) as ContextResponse;
98
+
99
+ if (data.count === 0) {
100
+ return null;
101
+ }
102
+
103
+ const sections: string[] = [];
104
+
105
+ // Group probes by type
106
+ const byType: Record<string, ContextResponse['probes']> = {};
107
+ for (const probe of data.probes) {
108
+ if (!byType[probe.probeType]) byType[probe.probeType] = [];
109
+ byType[probe.probeType].push(probe);
110
+ }
111
+
112
+ for (const [type, probes] of Object.entries(byType)) {
113
+ sections.push(`--- ${type.toUpperCase()} PROBES (${probes.length}) ---`);
114
+ for (const probe of probes) {
115
+ sections.push(formatProbeForContext(probe));
116
+ }
117
+ sections.push('');
118
+ }
119
+
120
+ return sections.join('\n');
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ export const codexCommand = new Command('codex')
127
+ .description('Run OpenAI Codex CLI with production context from Utopia')
128
+ .argument('<prompt...>', 'The prompt to pass to Codex')
129
+ .action(async (promptParts: string[]) => {
130
+ const cwd = process.cwd();
131
+ const prompt = promptParts.join(' ');
132
+
133
+ if (!configExists(cwd)) {
134
+ console.log(chalk.red('\n Error: No .utopia/config.json found.'));
135
+ console.log(chalk.dim(' Run "utopia init" first to set up your project.\n'));
136
+ process.exit(1);
137
+ }
138
+
139
+ const config = await loadConfig(cwd);
140
+
141
+ console.log(chalk.bold.cyan('\n Fetching production context from Utopia...\n'));
142
+
143
+ const context = await fetchProductionContext(
144
+ config.dataEndpoint,
145
+ prompt,
146
+ );
147
+
148
+ let enrichedPrompt: string;
149
+
150
+ if (context) {
151
+ console.log(chalk.green(' Production context retrieved successfully.'));
152
+ console.log(chalk.dim(' Enriching prompt with production data...\n'));
153
+
154
+ enrichedPrompt = [
155
+ '[PRODUCTION CONTEXT from Utopia]',
156
+ context,
157
+ '[END PRODUCTION CONTEXT]',
158
+ '',
159
+ prompt,
160
+ ].join('\n');
161
+ } else {
162
+ console.log(chalk.yellow(' No production context available (service may not be running).'));
163
+ console.log(chalk.dim(' Passing prompt to Codex without enrichment.\n'));
164
+ enrichedPrompt = prompt;
165
+ }
166
+
167
+ console.log(chalk.dim(' Launching Codex...\n'));
168
+
169
+ const child = spawn('codex', [enrichedPrompt], {
170
+ stdio: 'inherit',
171
+ shell: true,
172
+ cwd,
173
+ env: {
174
+ ...process.env,
175
+ UTOPIA_PROJECT_ID: config.projectId,
176
+ UTOPIA_ENDPOINT: config.dataEndpoint,
177
+ },
178
+ });
179
+
180
+ child.on('close', (code) => {
181
+ process.exit(code ?? 0);
182
+ });
183
+
184
+ child.on('error', (err) => {
185
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
186
+ console.log(chalk.red('\n Error: "codex" command not found.'));
187
+ console.log(chalk.dim(' Install OpenAI Codex CLI: npm install -g @openai/codex\n'));
188
+ } else {
189
+ console.log(chalk.red(`\n Error: Failed to launch Codex: ${err.message}\n`));
190
+ }
191
+ process.exit(1);
192
+ });
193
+ });
@@ -0,0 +1,188 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { loadConfig, configExists } from '../utils/config.js';
4
+
5
+ interface ProbeResult {
6
+ id: string;
7
+ projectId: string;
8
+ probeType: string;
9
+ timestamp: string;
10
+ file: string;
11
+ line: number;
12
+ functionName: string;
13
+ data: Record<string, unknown>;
14
+ metadata: Record<string, unknown>;
15
+ }
16
+
17
+ interface ContextResponse {
18
+ count: number;
19
+ keywords?: string[];
20
+ probes: ProbeResult[];
21
+ }
22
+
23
+ function formatProbe(probe: ProbeResult): string {
24
+ const lines: string[] = [];
25
+ const typeColor = getTypeColor(probe.probeType);
26
+
27
+ lines.push(` ${typeColor(probe.probeType.toUpperCase())} ${chalk.white(probe.functionName || '(anonymous)')} ${chalk.dim(`in ${probe.file}:${probe.line}`)}`);
28
+ lines.push(chalk.dim(` ${probe.timestamp}`));
29
+
30
+ // Format data based on probe type
31
+ const data = probe.data;
32
+ switch (probe.probeType) {
33
+ case 'error':
34
+ if (data.errorType) lines.push(chalk.red(` ${data.errorType}: ${data.message}`));
35
+ if (data.stack) {
36
+ const stackLines = String(data.stack).split('\n').slice(0, 3);
37
+ for (const sl of stackLines) {
38
+ lines.push(chalk.dim(` ${sl.trim()}`));
39
+ }
40
+ }
41
+ break;
42
+
43
+ case 'database':
44
+ if (data.operation) lines.push(` Operation: ${chalk.cyan(String(data.operation))}`);
45
+ if (data.query) lines.push(chalk.dim(` Query: ${String(data.query).slice(0, 120)}`));
46
+ if (data.duration !== undefined) lines.push(chalk.dim(` Duration: ${data.duration}ms`));
47
+ break;
48
+
49
+ case 'api':
50
+ if (data.method && data.url) {
51
+ lines.push(` ${chalk.cyan(String(data.method))} ${String(data.url)}`);
52
+ }
53
+ if (data.statusCode !== undefined) {
54
+ const statusNum = Number(data.statusCode);
55
+ const statusStr = String(data.statusCode);
56
+ const statusColor = statusNum >= 400 ? chalk.red : statusNum >= 300 ? chalk.yellow : chalk.green;
57
+ lines.push(` Status: ${statusColor(statusStr)}`);
58
+ }
59
+ if (data.duration !== undefined) lines.push(chalk.dim(` Duration: ${data.duration}ms`));
60
+ break;
61
+
62
+ case 'infra':
63
+ if (data.provider) lines.push(` Provider: ${chalk.cyan(String(data.provider))}`);
64
+ if (data.region) lines.push(` Region: ${chalk.cyan(String(data.region))}`);
65
+ if (data.memoryUsage !== undefined) lines.push(chalk.dim(` Memory: ${data.memoryUsage}MB`));
66
+ break;
67
+
68
+ case 'function':
69
+ if (data.duration !== undefined) lines.push(chalk.dim(` Duration: ${data.duration}ms`));
70
+ if (data.llmContext) lines.push(chalk.magenta(` Context: ${String(data.llmContext).slice(0, 200)}`));
71
+ break;
72
+
73
+ default: {
74
+ const dataStr = JSON.stringify(data, null, 2);
75
+ if (dataStr.length < 200) {
76
+ lines.push(chalk.dim(` ${dataStr}`));
77
+ }
78
+ break;
79
+ }
80
+ }
81
+
82
+ return lines.join('\n');
83
+ }
84
+
85
+ function getTypeColor(type: string): (s: string) => string {
86
+ switch (type) {
87
+ case 'error': return chalk.bgRed.white;
88
+ case 'database': return chalk.bgBlue.white;
89
+ case 'api': return chalk.bgGreen.white;
90
+ case 'infra': return chalk.bgYellow.black;
91
+ case 'function': return chalk.bgMagenta.white;
92
+ default: return chalk.bgGray.white;
93
+ }
94
+ }
95
+
96
+ export const contextCommand = new Command('context')
97
+ .description('Query production context from the Utopia data service')
98
+ .argument('<prompt>', 'The context query')
99
+ .option('--file <file>', 'Focus on a specific file')
100
+ .option('--type <type>', 'Filter by probe type (error, database, api, infra, function)')
101
+ .option('--limit <n>', 'Maximum number of results', '20')
102
+ .action(async (prompt: string, options) => {
103
+ const cwd = process.cwd();
104
+
105
+ if (!configExists(cwd)) {
106
+ console.log(chalk.red('\n Error: No .utopia/config.json found.'));
107
+ console.log(chalk.dim(' Run "utopia init" first to set up your project.\n'));
108
+ process.exit(1);
109
+ }
110
+
111
+ const config = await loadConfig(cwd);
112
+ const endpoint = config.dataEndpoint;
113
+
114
+ console.log(chalk.bold.cyan('\n Querying production context...\n'));
115
+ console.log(chalk.dim(` Query: "${prompt}"`));
116
+
117
+ // Build query URL
118
+ const url = new URL('/api/v1/probes/context', endpoint);
119
+ url.searchParams.set('prompt', prompt);
120
+ url.searchParams.set('limit', options.limit as string);
121
+
122
+ if (options.file) {
123
+ // If filtering by file, also do a direct probes query
124
+ url.searchParams.set('file', options.file as string);
125
+ }
126
+
127
+ try {
128
+ const response = await fetch(url.toString(), {
129
+ method: 'GET',
130
+ headers: {
131
+ 'Content-Type': 'application/json',
132
+ },
133
+ });
134
+
135
+ if (!response.ok) {
136
+ const errorBody = await response.text();
137
+ console.log(chalk.red(`\n Error: Data service returned ${response.status}`));
138
+ console.log(chalk.dim(` ${errorBody}\n`));
139
+ process.exit(1);
140
+ }
141
+
142
+ const data = (await response.json()) as ContextResponse;
143
+
144
+ if (data.keywords && data.keywords.length > 0) {
145
+ console.log(chalk.dim(` Keywords: ${data.keywords.join(', ')}`));
146
+ }
147
+ console.log(chalk.dim(` Results: ${data.count}\n`));
148
+
149
+ if (data.count === 0) {
150
+ console.log(chalk.yellow(' No matching probes found.'));
151
+ console.log(chalk.dim(' Try a broader query or check that probes are being collected.\n'));
152
+ return;
153
+ }
154
+
155
+ // Filter by type if specified
156
+ let probes = data.probes;
157
+ if (options.type) {
158
+ probes = probes.filter((p) => p.probeType === options.type);
159
+ if (probes.length === 0) {
160
+ console.log(chalk.yellow(` No probes of type "${options.type}" found in results.\n`));
161
+ return;
162
+ }
163
+ }
164
+
165
+ // Filter by file if specified (server may not support file param in context endpoint)
166
+ if (options.file) {
167
+ const fileFilter = options.file as string;
168
+ probes = probes.filter((p) => p.file.includes(fileFilter));
169
+ }
170
+
171
+ for (const probe of probes) {
172
+ console.log(formatProbe(probe));
173
+ console.log('');
174
+ }
175
+
176
+ console.log(chalk.dim(` Showing ${probes.length} of ${data.count} results.\n`));
177
+ } catch (err) {
178
+ const errorMessage = (err as Error).message;
179
+ if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('fetch failed')) {
180
+ console.log(chalk.red('\n Error: Could not connect to the Utopia data service.'));
181
+ console.log(chalk.dim(` Endpoint: ${endpoint}`));
182
+ console.log(chalk.dim(' Make sure the service is running: utopia serve\n'));
183
+ } else {
184
+ console.log(chalk.red(`\n Error: ${errorMessage}\n`));
185
+ }
186
+ process.exit(1);
187
+ }
188
+ });
@@ -0,0 +1,237 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { resolve } from 'node:path';
4
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, rmSync } from 'node:fs';
5
+ import { configExists } from '../utils/config.js';
6
+
7
+ const SNAPSHOT_DIR = '.utopia/snapshots';
8
+
9
+ /**
10
+ * Collect all snapshot files with their relative paths.
11
+ */
12
+ function collectSnapshots(snapshotBase: string): { rel: string; snapPath: string }[] {
13
+ const results: { rel: string; snapPath: string }[] = [];
14
+
15
+ function walk(dir: string): void {
16
+ let entries: string[];
17
+ try { entries = readdirSync(dir); } catch { return; }
18
+ for (const entry of entries) {
19
+ const full = resolve(dir, entry);
20
+ try {
21
+ const st = statSync(full);
22
+ if (st.isDirectory()) {
23
+ walk(full);
24
+ } else if (st.isFile()) {
25
+ const rel = full.substring(snapshotBase.length + 1);
26
+ results.push({ rel, snapPath: full });
27
+ }
28
+ } catch { /* skip */ }
29
+ }
30
+ }
31
+
32
+ walk(snapshotBase);
33
+ return results;
34
+ }
35
+
36
+ /**
37
+ * Apply the user's post-instrument changes on top of the snapshot.
38
+ *
39
+ * Strategy:
40
+ * 1. Take the snapshot (original pre-probe file)
41
+ * 2. Take the current file (has probes + maybe user changes)
42
+ * 3. Remove probe blocks from the current file
43
+ * 4. Diff the stripped-current vs snapshot — the diff is the user's changes
44
+ * 5. Return the snapshot if no user changes, or the stripped-current if there are user changes
45
+ *
46
+ * "Probe block" = a `// utopia:probe` (or `# utopia:probe`) comment followed by
47
+ * its try/catch (or try/except) block, plus any `__utopia_start` timing line above,
48
+ * plus the utopia import if no other probes remain.
49
+ */
50
+ function stripProbesFromContent(content: string, isPython: boolean): string {
51
+ const marker = isPython ? '# utopia:probe' : '// utopia:probe';
52
+ const lines = content.split('\n');
53
+ const linesToRemove = new Set<number>();
54
+
55
+ for (let i = 0; i < lines.length; i++) {
56
+ if (lines[i].trim() !== marker) continue;
57
+ linesToRemove.add(i);
58
+
59
+ // Check for __utopia_start timing line above
60
+ let above = i - 1;
61
+ while (above >= 0 && lines[above].trim() === '') above--;
62
+ if (above >= 0 && (lines[above].trim().startsWith('const __utopia_start') || lines[above].trim().startsWith('_utopia_start'))) {
63
+ linesToRemove.add(above);
64
+ }
65
+
66
+ // Remove the try block that follows
67
+ let j = i + 1;
68
+ while (j < lines.length && lines[j].trim() === '') j++;
69
+
70
+ if (isPython) {
71
+ // Python: try: ... except ...: pass
72
+ if (j < lines.length && lines[j].trim().startsWith('try:')) {
73
+ const tryIndent = lines[j].length - lines[j].trimStart().length;
74
+ linesToRemove.add(j);
75
+ let k = j + 1;
76
+ while (k < lines.length) {
77
+ const line = lines[k];
78
+ if (line.trim() === '') { linesToRemove.add(k); k++; continue; }
79
+ const indent = line.length - line.trimStart().length;
80
+ if (indent <= tryIndent && line.trim() !== '') {
81
+ if (line.trim().startsWith('except')) {
82
+ linesToRemove.add(k);
83
+ k++;
84
+ while (k < lines.length) {
85
+ const eline = lines[k];
86
+ if (eline.trim() === '') { linesToRemove.add(k); k++; continue; }
87
+ if ((eline.length - eline.trimStart().length) <= tryIndent) break;
88
+ linesToRemove.add(k); k++;
89
+ }
90
+ break;
91
+ }
92
+ break;
93
+ }
94
+ linesToRemove.add(k); k++;
95
+ }
96
+ }
97
+ } else {
98
+ // JS/TS: try { ... } catch { ... }
99
+ if (j < lines.length && lines[j].trim().startsWith('try {')) {
100
+ let braceDepth = 0;
101
+ let inCatch = false;
102
+ for (let k = j; k < lines.length; k++) {
103
+ for (const ch of lines[k]) {
104
+ if (ch === '{') braceDepth++;
105
+ if (ch === '}') braceDepth--;
106
+ }
107
+ linesToRemove.add(k);
108
+ if (lines[k].includes('catch')) inCatch = true;
109
+ if (braceDepth === 0 && (inCatch || k > j)) break;
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ let result = lines.filter((_line, idx) => !linesToRemove.has(idx)).join('\n');
116
+
117
+ // Remove utopia import if no probes remain
118
+ if (isPython) {
119
+ if (!result.includes('utopia_runtime.report')) {
120
+ result = result.replace(/^import utopia_runtime\s*\n?/gm, '');
121
+ }
122
+ } else {
123
+ if (!result.includes('__utopia.report') && !result.includes('__utopia.init')) {
124
+ result = result.replace(/import\s*\{[^}]*__utopia[^}]*\}\s*from\s*['"]utopia-runtime['"];?\s*\n?/g, '');
125
+ }
126
+ }
127
+
128
+ // Remove leftover timing vars
129
+ result = result.replace(/^\s*const\s+__utopia_start\s*=\s*Date\.now\(\);?\s*\n/gm, '');
130
+ result = result.replace(/^\s*_utopia_start\s*=\s*time\.time\(\)\s*\n/gm, '');
131
+
132
+ // Clean up consecutive blank lines
133
+ result = result.replace(/\n{3,}/g, '\n\n');
134
+ result = result.replace(/[ \t]+$/gm, '');
135
+
136
+ return result;
137
+ }
138
+
139
+ export const destructCommand = new Command('destruct')
140
+ .description('Remove all Utopia probes from the codebase')
141
+ .option('--dry-run', 'Show what would be restored without changing files', false)
142
+ .action(async (options) => {
143
+ const cwd = process.cwd();
144
+
145
+ if (!configExists(cwd)) {
146
+ console.log(chalk.red('\n Error: No .utopia/config.json found.\n'));
147
+ process.exit(1);
148
+ }
149
+
150
+ const snapshotBase = resolve(cwd, SNAPSHOT_DIR);
151
+
152
+ if (!existsSync(snapshotBase)) {
153
+ console.log(chalk.yellow('\n No snapshots found. Nothing to restore.'));
154
+ console.log(chalk.dim(' Snapshots are created when you run "utopia instrument" or "utopia reinstrument".\n'));
155
+ return;
156
+ }
157
+
158
+ const snapshots = collectSnapshots(snapshotBase);
159
+
160
+ if (snapshots.length === 0) {
161
+ console.log(chalk.yellow('\n No snapshots found. Nothing to restore.\n'));
162
+ return;
163
+ }
164
+
165
+ console.log(chalk.bold.cyan('\n Utopia Destruct\n'));
166
+
167
+ if (options.dryRun) {
168
+ console.log(chalk.yellow(' Dry run — no files will be modified.\n'));
169
+ }
170
+
171
+ console.log(chalk.dim(` Found ${snapshots.length} file snapshot(s)\n`));
172
+
173
+ let restored = 0;
174
+ let merged = 0;
175
+ let skipped = 0;
176
+
177
+ for (const { rel, snapPath } of snapshots) {
178
+ const targetPath = resolve(cwd, rel);
179
+
180
+ if (!existsSync(targetPath)) { skipped++; continue; }
181
+
182
+ const snapshot = readFileSync(snapPath, 'utf-8');
183
+ const current = readFileSync(targetPath, 'utf-8');
184
+
185
+ if (current === snapshot) { skipped++; continue; }
186
+
187
+ // Check if the file has user changes beyond probes
188
+ const isPython = rel.endsWith('.py');
189
+ const strippedCurrent = stripProbesFromContent(current, isPython);
190
+
191
+ // Normalize whitespace for comparison — Claude Code may reformat slightly
192
+ const normalize = (s: string) => s.replace(/\s+/g, ' ').trim();
193
+
194
+ // If stripped current matches snapshot (ignoring whitespace), no user changes — restore exactly
195
+ if (normalize(strippedCurrent) === normalize(snapshot)) {
196
+ if (options.dryRun) {
197
+ console.log(chalk.dim(` [dry-run] Restore: ${rel}`));
198
+ } else {
199
+ writeFileSync(targetPath, snapshot);
200
+ console.log(chalk.green(` Restored: ${rel}`));
201
+ }
202
+ restored++;
203
+ } else {
204
+ // File has user changes + probes — strip probes, keep user changes
205
+ if (options.dryRun) {
206
+ console.log(chalk.dim(` [dry-run] Strip probes (user changes preserved): ${rel}`));
207
+ } else {
208
+ writeFileSync(targetPath, strippedCurrent);
209
+ console.log(chalk.yellow(` Stripped probes (user changes preserved): ${rel}`));
210
+ }
211
+ merged++;
212
+ }
213
+ }
214
+
215
+ console.log('');
216
+ if (options.dryRun) {
217
+ console.log(chalk.yellow(` Would restore ${restored}, strip ${merged}, skip ${skipped} file(s).\n`));
218
+ } else {
219
+ try {
220
+ rmSync(snapshotBase, { recursive: true, force: true });
221
+ } catch { /* ignore */ }
222
+
223
+ // Clean up copied Python runtime if it exists
224
+ const pythonRuntimeDir = resolve(cwd, 'utopia_runtime');
225
+ if (existsSync(pythonRuntimeDir)) {
226
+ try {
227
+ rmSync(pythonRuntimeDir, { recursive: true, force: true });
228
+ console.log(chalk.dim(' Removed utopia_runtime/ directory.'));
229
+ } catch { /* ignore */ }
230
+ }
231
+
232
+ if (restored > 0) console.log(chalk.bold.green(` ${restored} file(s) restored to exact pre-instrument state.`));
233
+ if (merged > 0) console.log(chalk.yellow(` ${merged} file(s) had user changes — probes stripped, your changes preserved.`));
234
+ if (skipped > 0) console.log(chalk.dim(` ${skipped} file(s) unchanged.`));
235
+ console.log('');
236
+ }
237
+ });