@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.
- package/README.md +326 -0
- package/bin/muggle.js +2 -0
- package/dist/chunk-O6JAG3WQ.js +6950 -0
- package/dist/cli.js +8 -0
- package/dist/index.js +1 -0
- package/package.json +94 -0
- package/scripts/postinstall.mjs +862 -0
- package/skills-dist/muggle-do.md +589 -0
- package/skills-dist/publish-test-to-cloud.md +43 -0
- package/skills-dist/test-feature-local.md +344 -0
|
@@ -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);
|