@muggleai/works 2.0.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.
@@ -0,0 +1,862 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Postinstall script for @muggleai/works.
4
+ * Downloads the Electron app binary for local testing.
5
+ * Output is written to both console and ~/.muggle-ai/postinstall.log
6
+ */
7
+
8
+ import { createHash } from "crypto";
9
+ import { exec } from "child_process";
10
+ import {
11
+ readFileSync,
12
+ appendFileSync,
13
+ createReadStream,
14
+ createWriteStream,
15
+ existsSync,
16
+ mkdirSync,
17
+ readdirSync,
18
+ rmSync,
19
+ writeFileSync,
20
+ } from "fs";
21
+ import { homedir, platform } from "os";
22
+ import { join } from "path";
23
+ import { pipeline } from "stream/promises";
24
+ import { createRequire } from "module";
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const VERSION_DIRECTORY_NAME_PATTERN = /^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/;
28
+ const CURSOR_SERVER_NAME = "muggle";
29
+ const INSTALL_METADATA_FILE_NAME = ".install-metadata.json";
30
+ const LOG_FILE_NAME = "postinstall.log";
31
+ const VERSION_OVERRIDE_FILE_NAME = "electron-app-version-override.json";
32
+ const SKILLS_DIR_NAME = "skills-dist";
33
+ const SKILLS_TARGET_DIR = join(homedir(), ".claude", "skills", "muggle");
34
+ const COMMANDS_TARGET_DIR = join(homedir(), ".claude", "commands");
35
+ const SKILLS_CHECKSUMS_FILE = "skills-checksums.json";
36
+ const COMMAND_FILES = ["muggle-do.md"];
37
+
38
+ /**
39
+ * Get the path to the postinstall log file.
40
+ * @returns {string} Path to ~/.muggle-ai/postinstall.log
41
+ */
42
+ function getLogFilePath() {
43
+ return join(homedir(), ".muggle-ai", LOG_FILE_NAME);
44
+ }
45
+
46
+ /**
47
+ * Initialize the log file with a separator and timestamp.
48
+ */
49
+ function initLogFile() {
50
+ const logPath = getLogFilePath();
51
+ const logDir = join(homedir(), ".muggle-ai");
52
+ mkdirSync(logDir, { recursive: true });
53
+
54
+ const separator = "\n" + "=".repeat(60) + "\n";
55
+ const header = `Postinstall started at ${new Date().toISOString()}\n`;
56
+ appendFileSync(logPath, separator + header, "utf-8");
57
+ }
58
+
59
+ /**
60
+ * Log a message to both console and the log file.
61
+ * @param {...unknown} args - Arguments to log
62
+ */
63
+ function log(...args) {
64
+ const message = args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ");
65
+ console.log(...args);
66
+ try {
67
+ appendFileSync(getLogFilePath(), message + "\n", "utf-8");
68
+ } catch {
69
+ // Ignore log file write errors
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Log an error to both console and the log file.
75
+ * @param {...unknown} args - Arguments to log
76
+ */
77
+ function logError(...args) {
78
+ const message = args
79
+ .map((arg) => {
80
+ if (arg instanceof Error) {
81
+ return arg.stack || arg.message;
82
+ }
83
+ return typeof arg === "string" ? arg : JSON.stringify(arg);
84
+ })
85
+ .join(" ");
86
+ console.error(...args);
87
+ try {
88
+ appendFileSync(getLogFilePath(), "[ERROR] " + message + "\n", "utf-8");
89
+ } catch {
90
+ // Ignore log file write errors
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Remove the electron-app version override file if it exists.
96
+ * Each new install should use the bundled version from package.json.
97
+ * Users can still override manually after install, but it resets on next install.
98
+ */
99
+ function removeVersionOverrideFile() {
100
+ const overridePath = join(homedir(), ".muggle-ai", VERSION_OVERRIDE_FILE_NAME);
101
+
102
+ if (existsSync(overridePath)) {
103
+ try {
104
+ rmSync(overridePath, { force: true });
105
+ log(`Removed version override file: ${overridePath}`);
106
+ } catch (error) {
107
+ logError(`Failed to remove version override file: ${error.message}`);
108
+ }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Get the Cursor MCP config path.
114
+ * @returns {string} Path to ~/.cursor/mcp.json
115
+ */
116
+ function getCursorMcpConfigPath() {
117
+ return join(homedir(), ".cursor", "mcp.json");
118
+ }
119
+
120
+ /**
121
+ * Build the default Cursor server configuration for this package.
122
+ * @returns {{command: string, args: string[]}} Server configuration
123
+ */
124
+ function buildCursorServerConfig() {
125
+ const localCliPath = join(process.cwd(), "bin", "muggle.js");
126
+ if (!existsSync(localCliPath)) {
127
+ throw new Error(`CLI entrypoint not found at expected path: ${localCliPath}`);
128
+ }
129
+ return {
130
+ command: "node",
131
+ args: [localCliPath, "serve"],
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Read and parse Cursor mcp.json.
137
+ * @param {string} configPath - Path to mcp.json
138
+ * @returns {Record<string, unknown>} Parsed config object
139
+ */
140
+ function readCursorConfig(configPath) {
141
+ if (!existsSync(configPath)) {
142
+ return {};
143
+ }
144
+
145
+ const rawConfig = readFileSync(configPath, "utf-8");
146
+ const parsedConfig = JSON.parse(rawConfig);
147
+
148
+ if (typeof parsedConfig !== "object" || parsedConfig === null || Array.isArray(parsedConfig)) {
149
+ throw new Error(`Invalid JSON structure in ${configPath}: expected an object at root`);
150
+ }
151
+
152
+ return parsedConfig;
153
+ }
154
+
155
+ /**
156
+ * Update ~/.cursor/mcp.json with the muggle server entry.
157
+ * Existing server configurations are preserved.
158
+ */
159
+ function updateCursorMcpConfig() {
160
+ const configPath = getCursorMcpConfigPath();
161
+ const cursorDir = join(homedir(), ".cursor");
162
+
163
+ try {
164
+ const parsedConfig = readCursorConfig(configPath);
165
+ const currentMcpServers = parsedConfig.mcpServers;
166
+ let normalizedMcpServers = {};
167
+
168
+ if (currentMcpServers !== undefined) {
169
+ if (
170
+ typeof currentMcpServers !== "object" ||
171
+ currentMcpServers === null ||
172
+ Array.isArray(currentMcpServers)
173
+ ) {
174
+ throw new Error(`Invalid mcpServers in ${configPath}: expected an object`);
175
+ }
176
+ normalizedMcpServers = currentMcpServers;
177
+ }
178
+
179
+ normalizedMcpServers[CURSOR_SERVER_NAME] = buildCursorServerConfig();
180
+ parsedConfig.mcpServers = normalizedMcpServers;
181
+
182
+ mkdirSync(cursorDir, { recursive: true });
183
+ const prettyJson = `${JSON.stringify(parsedConfig, null, 2)}\n`;
184
+ writeFileSync(configPath, prettyJson, "utf-8");
185
+ log(`Updated Cursor MCP config: ${configPath}`);
186
+ } catch (error) {
187
+ logError("\n========================================");
188
+ logError("ERROR: Failed to update Cursor MCP config");
189
+ logError("========================================\n");
190
+ logError("Path:", configPath);
191
+ logError("\nFull error details:");
192
+ logError(error instanceof Error ? error.stack || error : error);
193
+ logError("");
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Get the Muggle AI data directory.
199
+ * @returns {string} Path to ~/.muggle-ai
200
+ */
201
+ function getDataDir() {
202
+ return join(homedir(), ".muggle-ai");
203
+ }
204
+
205
+ /**
206
+ * Get the Electron app directory.
207
+ * @returns {string} Path to ~/.muggle-ai/electron-app
208
+ */
209
+ function getElectronAppDir() {
210
+ return join(getDataDir(), "electron-app");
211
+ }
212
+
213
+ /**
214
+ * Get platform key for checksum lookup.
215
+ * @returns {string} Platform key (e.g., "darwin-arm64", "win32-x64")
216
+ */
217
+ function getPlatformKey() {
218
+ const os = platform();
219
+ const arch = process.arch;
220
+
221
+ switch (os) {
222
+ case "darwin":
223
+ return arch === "arm64" ? "darwin-arm64" : "darwin-x64";
224
+ case "win32":
225
+ return "win32-x64";
226
+ case "linux":
227
+ return "linux-x64";
228
+ default:
229
+ throw new Error(`Unsupported platform: ${os}`);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Check whether a directory name looks like a version folder.
235
+ * @param {string} directoryName - Directory name to validate
236
+ * @returns {boolean} True when the directory name is a version
237
+ */
238
+ function isVersionDirectoryName(directoryName) {
239
+ return VERSION_DIRECTORY_NAME_PATTERN.test(directoryName);
240
+ }
241
+
242
+ /**
243
+ * Calculate SHA256 checksum of a file.
244
+ * @param {string} filePath - Path to the file
245
+ * @returns {Promise<string>} SHA256 checksum as hex string
246
+ */
247
+ async function calculateFileChecksum(filePath) {
248
+ return new Promise((resolve, reject) => {
249
+ const hash = createHash("sha256");
250
+ const stream = createReadStream(filePath);
251
+
252
+ stream.on("data", (data) => {
253
+ hash.update(data);
254
+ });
255
+
256
+ stream.on("end", () => {
257
+ resolve(hash.digest("hex"));
258
+ });
259
+
260
+ stream.on("error", (error) => {
261
+ reject(error);
262
+ });
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Verify file checksum against expected value.
268
+ * @param {string} filePath - Path to the file
269
+ * @param {string} expectedChecksum - Expected SHA256 checksum
270
+ * @returns {Promise<{valid: boolean, actual: string}>} Verification result
271
+ */
272
+ async function verifyFileChecksum(filePath, expectedChecksum) {
273
+ if (!expectedChecksum || expectedChecksum.trim() === "") {
274
+ return { valid: true, actual: "", skipped: true };
275
+ }
276
+
277
+ const actualChecksum = await calculateFileChecksum(filePath);
278
+ const normalizedExpected = expectedChecksum.toLowerCase().trim();
279
+ const normalizedActual = actualChecksum.toLowerCase();
280
+
281
+ return {
282
+ valid: normalizedExpected === normalizedActual,
283
+ expected: normalizedExpected,
284
+ actual: normalizedActual,
285
+ skipped: false,
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Remove Electron app version directories that do not match the current version.
291
+ * @param {object} params - Cleanup parameters
292
+ * @param {string} params.appDir - Base Electron app directory
293
+ * @param {string} params.currentVersion - Version that should be kept
294
+ */
295
+ function cleanupNonCurrentVersions({ appDir, currentVersion }) {
296
+ if (!existsSync(appDir)) {
297
+ return;
298
+ }
299
+
300
+ const appEntries = readdirSync(appDir, { withFileTypes: true });
301
+
302
+ for (const appEntry of appEntries) {
303
+ if (!appEntry.isDirectory()) {
304
+ continue;
305
+ }
306
+
307
+ if (!isVersionDirectoryName(appEntry.name)) {
308
+ continue;
309
+ }
310
+
311
+ if (appEntry.name === currentVersion) {
312
+ continue;
313
+ }
314
+
315
+ const staleVersionDir = join(appDir, appEntry.name);
316
+ try {
317
+ log(`Removing stale Electron app version: ${appEntry.name}`);
318
+ rmSync(staleVersionDir, { recursive: true, force: true });
319
+ } catch (error) {
320
+ logError("\n========================================");
321
+ logError("ERROR: Failed to remove stale Electron app version");
322
+ logError("========================================\n");
323
+ logError("Version:", appEntry.name);
324
+ logError("Path:", staleVersionDir);
325
+ logError("\nFull error details:");
326
+ logError(error instanceof Error ? error.stack || error : error);
327
+ logError("");
328
+ }
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Get platform-specific binary name.
334
+ * @returns {string} Binary filename
335
+ */
336
+ function getBinaryName() {
337
+ const os = platform();
338
+ const arch = process.arch;
339
+
340
+ switch (os) {
341
+ case "darwin":
342
+ // Support both Apple Silicon (arm64) and Intel (x64) Macs
343
+ return arch === "arm64" ? "MuggleAI-darwin-arm64.zip" : "MuggleAI-darwin-x64.zip";
344
+ case "win32":
345
+ return "MuggleAI-win32-x64.zip";
346
+ case "linux":
347
+ return "MuggleAI-linux-x64.zip";
348
+ default:
349
+ throw new Error(`Unsupported platform: ${os}`);
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Get the expected extracted executable path for the current platform.
355
+ * @param {string} versionDir - Version directory path
356
+ * @returns {string} Expected executable path
357
+ */
358
+ function getExpectedExecutablePath(versionDir) {
359
+ const os = platform();
360
+
361
+ switch (os) {
362
+ case "darwin":
363
+ return join(versionDir, "MuggleAI.app", "Contents", "MacOS", "MuggleAI");
364
+ case "win32":
365
+ return join(versionDir, "MuggleAI.exe");
366
+ case "linux":
367
+ return join(versionDir, "MuggleAI");
368
+ default:
369
+ throw new Error(`Unsupported platform: ${os}`);
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Get the metadata file path for an installed version.
375
+ * @param {string} versionDir - Version directory path
376
+ * @returns {string} Metadata file path
377
+ */
378
+ function getInstallMetadataPath(versionDir) {
379
+ return join(versionDir, INSTALL_METADATA_FILE_NAME);
380
+ }
381
+
382
+ /**
383
+ * Read install metadata from disk.
384
+ * @param {string} metadataPath - Metadata file path
385
+ * @returns {Record<string, unknown> | null} Parsed metadata, or null if missing/invalid
386
+ */
387
+ function readInstallMetadata(metadataPath) {
388
+ if (!existsSync(metadataPath)) {
389
+ return null;
390
+ }
391
+
392
+ try {
393
+ const metadataContent = readFileSync(metadataPath, "utf-8");
394
+ const parsedMetadata = JSON.parse(metadataContent);
395
+ if (typeof parsedMetadata !== "object" || parsedMetadata === null || Array.isArray(parsedMetadata)) {
396
+ return null;
397
+ }
398
+ return parsedMetadata;
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Persist install metadata to disk.
406
+ * @param {object} params - Metadata fields
407
+ * @param {string} params.metadataPath - Metadata file path
408
+ * @param {string} params.version - Installed version
409
+ * @param {string} params.binaryName - Archive filename
410
+ * @param {string} params.platformKey - Platform key
411
+ * @param {string} params.executableChecksum - Checksum of extracted executable
412
+ * @param {string} params.expectedArchiveChecksum - Configured archive checksum for platform
413
+ */
414
+ function writeInstallMetadata({
415
+ metadataPath,
416
+ version,
417
+ binaryName,
418
+ platformKey,
419
+ executableChecksum,
420
+ expectedArchiveChecksum,
421
+ }) {
422
+ const metadata = {
423
+ version: version,
424
+ binaryName: binaryName,
425
+ platformKey: platformKey,
426
+ executableChecksum: executableChecksum,
427
+ expectedArchiveChecksum: expectedArchiveChecksum,
428
+ updatedAt: new Date().toISOString(),
429
+ };
430
+
431
+ writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf-8");
432
+ }
433
+
434
+ /**
435
+ * Verify existing installed executable and metadata.
436
+ * @param {object} params - Verification params
437
+ * @param {string} params.versionDir - Installed version directory
438
+ * @param {string} params.executablePath - Expected executable path
439
+ * @param {string} params.version - Version string
440
+ * @param {string} params.binaryName - Archive filename
441
+ * @param {string} params.platformKey - Platform key
442
+ * @param {string} params.expectedArchiveChecksum - Configured checksum for downloaded archive
443
+ * @returns {Promise<{valid: boolean, reason: string}>} Verification result
444
+ */
445
+ async function verifyExistingInstall({
446
+ versionDir,
447
+ executablePath,
448
+ version,
449
+ binaryName,
450
+ platformKey,
451
+ expectedArchiveChecksum,
452
+ }) {
453
+ const metadataPath = getInstallMetadataPath(versionDir);
454
+ const metadata = readInstallMetadata(metadataPath);
455
+
456
+ if (!metadata) {
457
+ return { valid: false, reason: "install metadata is missing or invalid" };
458
+ }
459
+
460
+ if (metadata.version !== version) {
461
+ return { valid: false, reason: "installed metadata version does not match configured version" };
462
+ }
463
+
464
+ if (metadata.binaryName !== binaryName) {
465
+ return { valid: false, reason: "installed metadata binary name does not match current platform archive" };
466
+ }
467
+
468
+ if (metadata.platformKey !== platformKey) {
469
+ return { valid: false, reason: "installed metadata platform key does not match current platform" };
470
+ }
471
+
472
+ if ((metadata.expectedArchiveChecksum || "") !== expectedArchiveChecksum) {
473
+ return { valid: false, reason: "configured archive checksum changed since previous install" };
474
+ }
475
+
476
+ if (typeof metadata.executableChecksum !== "string" || metadata.executableChecksum === "") {
477
+ return { valid: false, reason: "installed metadata executable checksum is missing" };
478
+ }
479
+
480
+ const currentExecutableChecksum = await calculateFileChecksum(executablePath);
481
+ if (currentExecutableChecksum !== metadata.executableChecksum) {
482
+ return { valid: false, reason: "installed executable checksum does not match recorded checksum" };
483
+ }
484
+
485
+ return { valid: true, reason: "installed executable checksum is valid" };
486
+ }
487
+
488
+ /**
489
+ * Download and extract the Electron app.
490
+ */
491
+ async function downloadElectronApp() {
492
+ try {
493
+ // Read config from package.json
494
+ const packageJson = require("../package.json");
495
+ const config = packageJson.muggleConfig || {};
496
+ const version = config.electronAppVersion || "1.0.0";
497
+ const baseUrl = config.downloadBaseUrl || "https://github.com/multiplex-ai/muggle-ai-works/releases/download";
498
+
499
+ const binaryName = getBinaryName();
500
+ const checksums = config.checksums || {};
501
+ const platformKey = getPlatformKey();
502
+ const expectedChecksum = checksums[platformKey] || "";
503
+ const downloadUrl = `${baseUrl}/v${version}/${binaryName}`;
504
+
505
+ const appDir = getElectronAppDir();
506
+ const versionDir = join(appDir, version);
507
+ const metadataPath = getInstallMetadataPath(versionDir);
508
+
509
+ // Check if already downloaded and extracted correctly
510
+ const expectedExecutablePath = getExpectedExecutablePath(versionDir);
511
+ if (existsSync(versionDir)) {
512
+ if (existsSync(expectedExecutablePath)) {
513
+ const existingInstallVerification = await verifyExistingInstall({
514
+ versionDir: versionDir,
515
+ executablePath: expectedExecutablePath,
516
+ version: version,
517
+ binaryName: binaryName,
518
+ platformKey: platformKey,
519
+ expectedArchiveChecksum: expectedChecksum,
520
+ });
521
+
522
+ if (existingInstallVerification.valid) {
523
+ cleanupNonCurrentVersions({ appDir: appDir, currentVersion: version });
524
+ log(`Electron app v${version} already installed at ${versionDir}`);
525
+ return;
526
+ }
527
+
528
+ log(
529
+ `Installed Electron app v${version} failed verification (${existingInstallVerification.reason}). Re-downloading...`,
530
+ );
531
+ rmSync(versionDir, { recursive: true, force: true });
532
+ } else {
533
+ log(`Electron app v${version} directory exists but executable is missing. Re-downloading...`);
534
+ rmSync(versionDir, { recursive: true, force: true });
535
+ }
536
+ }
537
+
538
+ log(`Downloading Muggle Test Electron app v${version}...`);
539
+ log(`URL: ${downloadUrl}`);
540
+
541
+ // Create directories
542
+ mkdirSync(versionDir, { recursive: true });
543
+
544
+ // Download using fetch
545
+ log("Fetching...");
546
+ const response = await fetch(downloadUrl);
547
+ if (!response.ok) {
548
+ const errorBody = await response.text().catch(() => "");
549
+ throw new Error(
550
+ `Download failed: ${response.status} ${response.statusText}\n` +
551
+ `URL: ${downloadUrl}\n` +
552
+ `Response body: ${errorBody.substring(0, 500)}`,
553
+ );
554
+ }
555
+ log(
556
+ `Response OK (${response.status}), downloading ${response.headers.get("content-length") || "unknown"} bytes...`,
557
+ );
558
+
559
+ const tempFile = join(versionDir, binaryName);
560
+ const fileStream = createWriteStream(tempFile);
561
+ await pipeline(response.body, fileStream);
562
+
563
+ log("Download complete, verifying checksum...");
564
+
565
+ // Verify checksum
566
+ const checksumResult = await verifyFileChecksum(tempFile, expectedChecksum);
567
+
568
+ if (!checksumResult.valid && !checksumResult.skipped) {
569
+ rmSync(versionDir, { recursive: true, force: true });
570
+ throw new Error(
571
+ `Checksum verification failed!\n` +
572
+ `Expected: ${checksumResult.expected}\n` +
573
+ `Actual: ${checksumResult.actual}\n` +
574
+ `The downloaded file may be corrupted or tampered with.`,
575
+ );
576
+ }
577
+
578
+ if (checksumResult.skipped) {
579
+ log("Warning: No checksum configured, skipping verification.");
580
+ } else {
581
+ log("Checksum verified successfully.");
582
+ }
583
+
584
+ log("Extracting...");
585
+
586
+ // Extract based on file type
587
+ if (binaryName.endsWith(".zip")) {
588
+ await extractZip(tempFile, versionDir);
589
+ } else if (binaryName.endsWith(".tar.gz")) {
590
+ await extractTarGz(tempFile, versionDir);
591
+ }
592
+
593
+ // Clean up temp file
594
+ rmSync(tempFile, { force: true });
595
+
596
+ if (!existsSync(expectedExecutablePath)) {
597
+ throw new Error(
598
+ `Extraction completed but executable was not found.\n` +
599
+ `Expected path: ${expectedExecutablePath}\n` +
600
+ `Version directory: ${versionDir}`,
601
+ );
602
+ }
603
+
604
+ const executableChecksum = await calculateFileChecksum(expectedExecutablePath);
605
+ writeInstallMetadata({
606
+ metadataPath: metadataPath,
607
+ version: version,
608
+ binaryName: binaryName,
609
+ platformKey: platformKey,
610
+ executableChecksum: executableChecksum,
611
+ expectedArchiveChecksum: expectedChecksum,
612
+ });
613
+
614
+ cleanupNonCurrentVersions({ appDir: appDir, currentVersion: version });
615
+
616
+ log(`Electron app installed to ${versionDir}`);
617
+ } catch (error) {
618
+ logError("\n========================================");
619
+ logError("ERROR: Failed to download Electron app");
620
+ logError("========================================\n");
621
+ logError("Error message:", error.message);
622
+ logError("\nFull error details:");
623
+ logError(error.stack || error);
624
+ logError("\nDebug info:");
625
+ logError(" - Platform:", platform());
626
+ logError(" - Architecture:", process.arch);
627
+ logError(" - Node version:", process.version);
628
+ try {
629
+ const packageJson = require("../package.json");
630
+ const config = packageJson.muggleConfig || {};
631
+ logError(" - Electron app version:", config.electronAppVersion || "unknown");
632
+ logError(" - Download URL:", `${config.downloadBaseUrl}/v${config.electronAppVersion}/${getBinaryName()}`);
633
+ } catch {
634
+ logError(" - Could not read package.json config");
635
+ }
636
+ console.error("\nYou can manually download it later using: muggle setup");
637
+ console.error("Or set ELECTRON_APP_PATH to point to an existing installation.\n");
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Extract a zip file.
643
+ * @param {string} zipPath - Path to zip file
644
+ * @param {string} destDir - Destination directory
645
+ */
646
+ async function extractZip(zipPath, destDir) {
647
+ return new Promise((resolve, reject) => {
648
+ const cmd =
649
+ platform() === "win32"
650
+ ? `powershell -command "Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force"`
651
+ : `unzip -o "${zipPath}" -d "${destDir}"`;
652
+
653
+ exec(cmd, (error, stdout, stderr) => {
654
+ if (error) {
655
+ logError("Extraction command failed:", cmd);
656
+ logError("stdout:", stdout);
657
+ logError("stderr:", stderr);
658
+ reject(new Error(`Extraction failed: ${error.message}\nstderr: ${stderr}`));
659
+ } else {
660
+ resolve();
661
+ }
662
+ });
663
+ });
664
+ }
665
+
666
+ /**
667
+ * Extract a tar.gz file.
668
+ * @param {string} tarPath - Path to tar.gz file
669
+ * @param {string} destDir - Destination directory
670
+ */
671
+ async function extractTarGz(tarPath, destDir) {
672
+ return new Promise((resolve, reject) => {
673
+ exec(`tar -xzf "${tarPath}" -C "${destDir}"`, (error) => {
674
+ if (error) {
675
+ reject(error);
676
+ } else {
677
+ resolve();
678
+ }
679
+ });
680
+ });
681
+ }
682
+
683
+ /**
684
+ * Read the skills checksums file.
685
+ * @returns {Record<string, unknown> | null} Parsed checksums, or null if missing/invalid
686
+ */
687
+ function readSkillsChecksums() {
688
+ const checksumPath = join(homedir(), ".muggle-ai", SKILLS_CHECKSUMS_FILE);
689
+ if (!existsSync(checksumPath)) {
690
+ return null;
691
+ }
692
+ try {
693
+ const content = readFileSync(checksumPath, "utf-8");
694
+ const parsed = JSON.parse(content);
695
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
696
+ return null;
697
+ }
698
+ return parsed;
699
+ } catch {
700
+ return null;
701
+ }
702
+ }
703
+
704
+ /**
705
+ * Write the skills checksums file.
706
+ * @param {Record<string, string>} fileChecksums - Map of filename to SHA-256 hex
707
+ */
708
+ function writeSkillsChecksums(fileChecksums) {
709
+ const packageJson = require("../package.json");
710
+ const checksumPath = join(homedir(), ".muggle-ai", SKILLS_CHECKSUMS_FILE);
711
+ const data = {
712
+ schemaVersion: 1,
713
+ packageVersion: packageJson.version,
714
+ files: fileChecksums,
715
+ };
716
+ mkdirSync(join(homedir(), ".muggle-ai"), { recursive: true });
717
+ writeFileSync(checksumPath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
718
+ }
719
+
720
+ /**
721
+ * Prompt user for A/B choice with timeout.
722
+ * @param {string} filename - The skill file being updated
723
+ * @returns {Promise<"A"|"B">} User's choice, defaults to B on timeout/no-TTY
724
+ */
725
+ async function promptUserChoice(filename) {
726
+ if (!process.stdin.isTTY) {
727
+ return "B";
728
+ }
729
+
730
+ const { createInterface } = await import("readline");
731
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
732
+
733
+ return new Promise((resolve) => {
734
+ const timeout = setTimeout(() => {
735
+ rl.close();
736
+ log(`Timeout waiting for input on ${filename}, defaulting to backup + overwrite`);
737
+ resolve("B");
738
+ }, 30000);
739
+
740
+ rl.question(
741
+ `\nSkill file "${filename}" has been modified.\n` +
742
+ ` (A) Overwrite with new version\n` +
743
+ ` (B) Backup current version, then overwrite\n` +
744
+ `Choice [B]: `,
745
+ (answer) => {
746
+ clearTimeout(timeout);
747
+ rl.close();
748
+ const choice = (answer || "").trim().toUpperCase();
749
+ resolve(choice === "A" ? "A" : "B");
750
+ }
751
+ );
752
+ });
753
+ }
754
+
755
+ /**
756
+ * Backup a skill file to ~/.muggle-ai/skills-backup/{timestamp}/
757
+ * @param {string} filename - Skill filename
758
+ * @param {string} sourcePath - Current file path to backup
759
+ */
760
+ function backupSkillFile(filename, sourcePath) {
761
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
762
+ const backupDir = join(homedir(), ".muggle-ai", "skills-backup", timestamp);
763
+ mkdirSync(backupDir, { recursive: true });
764
+ const backupPath = join(backupDir, filename);
765
+ const content = readFileSync(sourcePath, "utf-8");
766
+ writeFileSync(backupPath, content, "utf-8");
767
+ log(`Backed up ${filename} to ${backupPath}`);
768
+ }
769
+
770
+ /**
771
+ * Install skill files to ~/.claude/skills/muggle/
772
+ */
773
+ async function installSkills() {
774
+ try {
775
+ const packageDir = join(process.cwd());
776
+ const skillsSourceDir = join(packageDir, SKILLS_DIR_NAME);
777
+
778
+ if (!existsSync(skillsSourceDir)) {
779
+ log("No skills-dist directory found, skipping skill installation.");
780
+ return;
781
+ }
782
+
783
+ const skillFiles = readdirSync(skillsSourceDir).filter(f => f.endsWith(".md"));
784
+ if (skillFiles.length === 0) {
785
+ log("No skill files found in skills-dist/, skipping.");
786
+ return;
787
+ }
788
+
789
+ mkdirSync(SKILLS_TARGET_DIR, { recursive: true });
790
+ mkdirSync(COMMANDS_TARGET_DIR, { recursive: true });
791
+
792
+ const existingChecksums = readSkillsChecksums();
793
+ const storedFiles = (existingChecksums && existingChecksums.files) || {};
794
+ const newChecksums = {};
795
+
796
+ for (const filename of skillFiles) {
797
+ const sourcePath = join(skillsSourceDir, filename);
798
+ // Command files (e.g., muggle-do.md) go to ~/.claude/commands/ for /slash-command access
799
+ // Skill files go to ~/.claude/skills/muggle/ for contextual triggering
800
+ const isCommand = COMMAND_FILES.includes(filename);
801
+ const targetPath = isCommand
802
+ ? join(COMMANDS_TARGET_DIR, filename)
803
+ : join(SKILLS_TARGET_DIR, filename);
804
+ const targetLabel = isCommand ? "command" : "skill";
805
+ const sourceChecksum = await calculateFileChecksum(sourcePath);
806
+
807
+ if (!existsSync(targetPath)) {
808
+ // File doesn't exist — copy it
809
+ const content = readFileSync(sourcePath, "utf-8");
810
+ writeFileSync(targetPath, content, "utf-8");
811
+ log(`Installed ${targetLabel}: ${filename}`);
812
+ } else {
813
+ const targetChecksum = await calculateFileChecksum(targetPath);
814
+ const storedChecksum = storedFiles[filename] || "";
815
+
816
+ if (targetChecksum === storedChecksum || storedChecksum === "") {
817
+ // Not modified by user — overwrite silently
818
+ const content = readFileSync(sourcePath, "utf-8");
819
+ writeFileSync(targetPath, content, "utf-8");
820
+ log(`Updated ${targetLabel}: ${filename}`);
821
+ } else {
822
+ // User modified the file — prompt
823
+ const choice = await promptUserChoice(filename);
824
+ if (choice === "B") {
825
+ backupSkillFile(filename, targetPath);
826
+ }
827
+ const content = readFileSync(sourcePath, "utf-8");
828
+ writeFileSync(targetPath, content, "utf-8");
829
+ log(`${choice === "B" ? "Backed up and overwrote" : "Overwrote"} ${targetLabel}: ${filename}`);
830
+ }
831
+ }
832
+
833
+ newChecksums[filename] = sourceChecksum;
834
+ }
835
+
836
+ // Clean up command files from the skills directory (migration from earlier versions)
837
+ for (const cmdFile of COMMAND_FILES) {
838
+ const stalePath = join(SKILLS_TARGET_DIR, cmdFile);
839
+ if (existsSync(stalePath)) {
840
+ rmSync(stalePath, { force: true });
841
+ log(`Cleaned up stale skill copy: ${stalePath}`);
842
+ }
843
+ }
844
+
845
+ writeSkillsChecksums(newChecksums);
846
+ log(`Installed ${skillFiles.length} file(s): commands to ${COMMANDS_TARGET_DIR}, skills to ${SKILLS_TARGET_DIR}`);
847
+ } catch (error) {
848
+ logError("\n========================================");
849
+ logError("ERROR: Failed to install skills");
850
+ logError("========================================\n");
851
+ logError("Error:", error instanceof Error ? error.stack || error.message : error);
852
+ logError("\nSkill installation is optional. MCP tools still work without skills.");
853
+ logError("");
854
+ }
855
+ }
856
+
857
+ // Run postinstall
858
+ initLogFile();
859
+ removeVersionOverrideFile();
860
+ updateCursorMcpConfig();
861
+ installSkills().catch(logError);
862
+ downloadElectronApp().catch(logError);