inboxd 1.0.10 → 1.0.12
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/.claude/skills/inbox-assistant/SKILL.md +99 -0
- package/CLAUDE.md +15 -0
- package/package.json +1 -1
- package/src/cli.js +364 -3
- package/src/gmail-monitor.js +364 -0
- package/src/sent-log.js +87 -0
- package/src/skill-installer.js +7 -2
- package/tests/gmail-monitor-patterns.test.js +232 -0
- package/tests/link-extraction.test.js +249 -0
- package/tests/older-than.test.js +127 -0
- package/tests/sent-log.test.js +142 -0
package/src/gmail-monitor.js
CHANGED
|
@@ -286,6 +286,359 @@ function groupEmailsBySender(emails) {
|
|
|
286
286
|
return { groups: groupArray, totalCount: emails.length };
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Decodes base64url encoded content
|
|
291
|
+
* @param {string} str - Base64url encoded string
|
|
292
|
+
* @returns {string} Decoded UTF-8 string
|
|
293
|
+
*/
|
|
294
|
+
function decodeBase64Url(str) {
|
|
295
|
+
if (!str) return '';
|
|
296
|
+
return Buffer.from(str, 'base64url').toString('utf8');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Extracts body content from a Gmail message payload
|
|
301
|
+
* Handles multipart messages recursively
|
|
302
|
+
* @param {Object} payload - Gmail message payload
|
|
303
|
+
* @param {Object} options - { preferHtml: boolean } - prefer HTML for link extraction
|
|
304
|
+
* @returns {{type: string, content: string}} Body content with mime type
|
|
305
|
+
*/
|
|
306
|
+
function extractBody(payload, options = {}) {
|
|
307
|
+
const { preferHtml = false } = options;
|
|
308
|
+
|
|
309
|
+
// Simple case: body data directly in payload
|
|
310
|
+
if (payload.body && payload.body.data) {
|
|
311
|
+
return {
|
|
312
|
+
type: payload.mimeType,
|
|
313
|
+
content: decodeBase64Url(payload.body.data)
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!payload.parts) {
|
|
318
|
+
return { type: 'text/plain', content: '' };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Determine preference order based on options
|
|
322
|
+
const mimeOrder = preferHtml
|
|
323
|
+
? ['text/html', 'text/plain']
|
|
324
|
+
: ['text/plain', 'text/html'];
|
|
325
|
+
|
|
326
|
+
for (const mimeType of mimeOrder) {
|
|
327
|
+
const part = payload.parts.find(p => p.mimeType === mimeType);
|
|
328
|
+
if (part && part.body && part.body.data) {
|
|
329
|
+
return {
|
|
330
|
+
type: mimeType,
|
|
331
|
+
content: decodeBase64Url(part.body.data)
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Recursive check for nested multipart (e.g., multipart/mixed containing multipart/alternative)
|
|
337
|
+
for (const part of payload.parts) {
|
|
338
|
+
if (part.parts) {
|
|
339
|
+
const found = extractBody(part, options);
|
|
340
|
+
if (found.content) {
|
|
341
|
+
return found;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { type: 'text/plain', content: '' };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Validates URL scheme - filters out non-http(s) schemes
|
|
351
|
+
* @param {string} url - URL to validate
|
|
352
|
+
* @returns {boolean} True if URL should be included
|
|
353
|
+
*/
|
|
354
|
+
function isValidUrl(url) {
|
|
355
|
+
if (!url) return false;
|
|
356
|
+
const lowerUrl = url.toLowerCase().trim();
|
|
357
|
+
// Only allow http and https
|
|
358
|
+
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Extracts links from email body content
|
|
363
|
+
* @param {string} body - Email body content
|
|
364
|
+
* @param {string} mimeType - 'text/html' or 'text/plain'
|
|
365
|
+
* @returns {Array<{url: string, text: string|null}>} Extracted links
|
|
366
|
+
*/
|
|
367
|
+
function extractLinks(body, mimeType) {
|
|
368
|
+
if (!body) return [];
|
|
369
|
+
|
|
370
|
+
const links = [];
|
|
371
|
+
const seenUrls = new Set();
|
|
372
|
+
|
|
373
|
+
// For HTML, extract from anchor tags first (captures link text)
|
|
374
|
+
if (mimeType === 'text/html') {
|
|
375
|
+
// Match <a href="URL">Text</a> - handles attributes in any order
|
|
376
|
+
const hrefRegex = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>([^<]*)<\/a>/gi;
|
|
377
|
+
let match;
|
|
378
|
+
while ((match = hrefRegex.exec(body)) !== null) {
|
|
379
|
+
const url = decodeHtmlEntities(match[1].trim());
|
|
380
|
+
const text = match[2].trim() || null;
|
|
381
|
+
if (!seenUrls.has(url) && isValidUrl(url)) {
|
|
382
|
+
seenUrls.add(url);
|
|
383
|
+
links.push({ url, text });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Also extract plain URLs (works for both HTML and plain text)
|
|
389
|
+
// This catches URLs not in anchor tags
|
|
390
|
+
const urlRegex = /https?:\/\/[^\s<>"']+/gi;
|
|
391
|
+
let urlMatch;
|
|
392
|
+
while ((urlMatch = urlRegex.exec(body)) !== null) {
|
|
393
|
+
// Clean trailing punctuation that's likely not part of the URL
|
|
394
|
+
let url = urlMatch[0].replace(/[.,;:!?)>\]]+$/, '');
|
|
395
|
+
// Also handle HTML entity at end
|
|
396
|
+
url = url.replace(/&[a-z]+;?$/i, '');
|
|
397
|
+
// Decode HTML entities for consistency (important for HTML content)
|
|
398
|
+
url = decodeHtmlEntities(url);
|
|
399
|
+
if (!seenUrls.has(url) && isValidUrl(url)) {
|
|
400
|
+
seenUrls.add(url);
|
|
401
|
+
links.push({ url, text: null });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return links;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Decodes common HTML entities in URLs
|
|
410
|
+
* @param {string} str - String potentially containing HTML entities
|
|
411
|
+
* @returns {string} Decoded string
|
|
412
|
+
*/
|
|
413
|
+
function decodeHtmlEntities(str) {
|
|
414
|
+
if (!str) return '';
|
|
415
|
+
return str
|
|
416
|
+
.replace(/&/g, '&')
|
|
417
|
+
.replace(/</g, '<')
|
|
418
|
+
.replace(/>/g, '>')
|
|
419
|
+
.replace(/"/g, '"')
|
|
420
|
+
.replace(/'/g, "'");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Gets full email content by ID
|
|
425
|
+
* @param {string} account - Account name
|
|
426
|
+
* @param {string} messageId - Message ID
|
|
427
|
+
* @param {Object} options - { preferHtml: boolean } - prefer HTML for link extraction
|
|
428
|
+
* @returns {Object|null} Email object with body or null if not found
|
|
429
|
+
*/
|
|
430
|
+
async function getEmailContent(account, messageId, options = {}) {
|
|
431
|
+
try {
|
|
432
|
+
const gmail = await getGmailClient(account);
|
|
433
|
+
const detail = await withRetry(() => gmail.users.messages.get({
|
|
434
|
+
userId: 'me',
|
|
435
|
+
id: messageId,
|
|
436
|
+
format: 'full',
|
|
437
|
+
}));
|
|
438
|
+
|
|
439
|
+
const headers = detail.data.payload.headers;
|
|
440
|
+
const getHeader = (name) => {
|
|
441
|
+
const header = headers.find((h) => h.name.toLowerCase() === name.toLowerCase());
|
|
442
|
+
return header ? header.value : '';
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const bodyData = extractBody(detail.data.payload, options);
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
id: messageId,
|
|
449
|
+
threadId: detail.data.threadId,
|
|
450
|
+
labelIds: detail.data.labelIds || [],
|
|
451
|
+
account,
|
|
452
|
+
from: getHeader('From'),
|
|
453
|
+
to: getHeader('To'),
|
|
454
|
+
subject: getHeader('Subject'),
|
|
455
|
+
date: getHeader('Date'),
|
|
456
|
+
snippet: detail.data.snippet,
|
|
457
|
+
body: bodyData.content,
|
|
458
|
+
mimeType: bodyData.type
|
|
459
|
+
};
|
|
460
|
+
} catch (error) {
|
|
461
|
+
console.error(`Error fetching email content ${messageId}:`, error.message);
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Searches for emails using Gmail query syntax
|
|
468
|
+
* @param {string} account - Account name
|
|
469
|
+
* @param {string} query - Gmail search query (e.g. "is:unread from:google")
|
|
470
|
+
* @param {number} maxResults - Max results to return
|
|
471
|
+
* @returns {Array} List of email metadata objects
|
|
472
|
+
*/
|
|
473
|
+
async function searchEmails(account, query, maxResults = 20) {
|
|
474
|
+
try {
|
|
475
|
+
const gmail = await getGmailClient(account);
|
|
476
|
+
const res = await withRetry(() => gmail.users.messages.list({
|
|
477
|
+
userId: 'me',
|
|
478
|
+
q: query,
|
|
479
|
+
maxResults,
|
|
480
|
+
}));
|
|
481
|
+
|
|
482
|
+
const messages = res.data.messages;
|
|
483
|
+
if (!messages || messages.length === 0) {
|
|
484
|
+
return [];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const emailPromises = messages.map(async (msg) => {
|
|
488
|
+
try {
|
|
489
|
+
const detail = await withRetry(() => gmail.users.messages.get({
|
|
490
|
+
userId: 'me',
|
|
491
|
+
id: msg.id,
|
|
492
|
+
format: 'metadata',
|
|
493
|
+
metadataHeaders: ['From', 'Subject', 'Date'],
|
|
494
|
+
}));
|
|
495
|
+
|
|
496
|
+
const headers = detail.data.payload.headers;
|
|
497
|
+
const getHeader = (name) => {
|
|
498
|
+
const header = headers.find((h) => h.name.toLowerCase() === name.toLowerCase());
|
|
499
|
+
return header ? header.value : '';
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
id: msg.id,
|
|
504
|
+
threadId: detail.data.threadId,
|
|
505
|
+
labelIds: detail.data.labelIds || [],
|
|
506
|
+
account,
|
|
507
|
+
from: getHeader('From'),
|
|
508
|
+
subject: getHeader('Subject'),
|
|
509
|
+
snippet: detail.data.snippet,
|
|
510
|
+
date: getHeader('Date'),
|
|
511
|
+
};
|
|
512
|
+
} catch (_err) {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const results = await Promise.all(emailPromises);
|
|
518
|
+
return results.filter((email) => email !== null);
|
|
519
|
+
} catch (error) {
|
|
520
|
+
console.error(`Error searching emails for ${account}:`, error.message);
|
|
521
|
+
return [];
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Composes a raw RFC 2822 email message
|
|
527
|
+
* @param {Object} options - { to, subject, body, inReplyTo?, references? }
|
|
528
|
+
* @returns {string} Base64url encoded message
|
|
529
|
+
*/
|
|
530
|
+
function composeMessage({ to, subject, body, inReplyTo, references }) {
|
|
531
|
+
const messageParts = [
|
|
532
|
+
`To: ${to}`,
|
|
533
|
+
`Subject: ${subject}`,
|
|
534
|
+
];
|
|
535
|
+
|
|
536
|
+
if (inReplyTo) {
|
|
537
|
+
messageParts.push(`In-Reply-To: ${inReplyTo}`);
|
|
538
|
+
}
|
|
539
|
+
if (references) {
|
|
540
|
+
messageParts.push(`References: ${references}`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
messageParts.push(
|
|
544
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
545
|
+
'MIME-Version: 1.0',
|
|
546
|
+
'',
|
|
547
|
+
body
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
return Buffer.from(messageParts.join('\n')).toString('base64url');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Sends an email
|
|
555
|
+
* @param {string} account - Account name
|
|
556
|
+
* @param {Object} options - { to, subject, body }
|
|
557
|
+
* @returns {Object} Result object with success, id, threadId, or error
|
|
558
|
+
*/
|
|
559
|
+
async function sendEmail(account, { to, subject, body }) {
|
|
560
|
+
try {
|
|
561
|
+
const gmail = await getGmailClient(account);
|
|
562
|
+
const encodedMessage = composeMessage({ to, subject, body });
|
|
563
|
+
|
|
564
|
+
const res = await withRetry(() => gmail.users.messages.send({
|
|
565
|
+
userId: 'me',
|
|
566
|
+
requestBody: {
|
|
567
|
+
raw: encodedMessage
|
|
568
|
+
}
|
|
569
|
+
}));
|
|
570
|
+
|
|
571
|
+
return { success: true, id: res.data.id, threadId: res.data.threadId };
|
|
572
|
+
} catch (error) {
|
|
573
|
+
return { success: false, error: error.message };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Reply to an email
|
|
579
|
+
* @param {string} account - Account name
|
|
580
|
+
* @param {string} messageId - ID of the message to reply to
|
|
581
|
+
* @param {string} body - Reply content
|
|
582
|
+
* @returns {Object} Result object with success, id, threadId, or error
|
|
583
|
+
*/
|
|
584
|
+
async function replyToEmail(account, messageId, body) {
|
|
585
|
+
try {
|
|
586
|
+
const gmail = await getGmailClient(account);
|
|
587
|
+
|
|
588
|
+
// Get original message headers
|
|
589
|
+
const original = await withRetry(() => gmail.users.messages.get({
|
|
590
|
+
userId: 'me',
|
|
591
|
+
id: messageId,
|
|
592
|
+
format: 'metadata',
|
|
593
|
+
metadataHeaders: ['Subject', 'Message-ID', 'References', 'Reply-To', 'From']
|
|
594
|
+
}));
|
|
595
|
+
|
|
596
|
+
const headers = original.data.payload.headers;
|
|
597
|
+
const getHeader = (name) => {
|
|
598
|
+
const header = headers.find(h => h.name.toLowerCase() === name.toLowerCase());
|
|
599
|
+
return header ? header.value : '';
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
const originalSubject = getHeader('Subject');
|
|
603
|
+
const originalMessageId = getHeader('Message-ID');
|
|
604
|
+
const originalReferences = getHeader('References');
|
|
605
|
+
// Prefer Reply-To header, fallback to From
|
|
606
|
+
const replyTo = getHeader('Reply-To');
|
|
607
|
+
const originalFrom = getHeader('From');
|
|
608
|
+
const to = replyTo || originalFrom;
|
|
609
|
+
|
|
610
|
+
// Add Re: prefix if not present
|
|
611
|
+
const subject = originalSubject.toLowerCase().startsWith('re:')
|
|
612
|
+
? originalSubject
|
|
613
|
+
: `Re: ${originalSubject}`;
|
|
614
|
+
|
|
615
|
+
// Build references chain
|
|
616
|
+
const references = originalReferences
|
|
617
|
+
? `${originalReferences} ${originalMessageId}`
|
|
618
|
+
: originalMessageId;
|
|
619
|
+
|
|
620
|
+
const encodedMessage = composeMessage({
|
|
621
|
+
to,
|
|
622
|
+
subject,
|
|
623
|
+
body,
|
|
624
|
+
inReplyTo: originalMessageId,
|
|
625
|
+
references
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
const res = await withRetry(() => gmail.users.messages.send({
|
|
629
|
+
userId: 'me',
|
|
630
|
+
requestBody: {
|
|
631
|
+
raw: encodedMessage,
|
|
632
|
+
threadId: original.data.threadId
|
|
633
|
+
}
|
|
634
|
+
}));
|
|
635
|
+
|
|
636
|
+
return { success: true, id: res.data.id, threadId: res.data.threadId };
|
|
637
|
+
} catch (error) {
|
|
638
|
+
return { success: false, error: error.message };
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
289
642
|
module.exports = {
|
|
290
643
|
getUnreadEmails,
|
|
291
644
|
getEmailCount,
|
|
@@ -296,4 +649,15 @@ module.exports = {
|
|
|
296
649
|
archiveEmails,
|
|
297
650
|
extractSenderDomain,
|
|
298
651
|
groupEmailsBySender,
|
|
652
|
+
getEmailContent,
|
|
653
|
+
searchEmails,
|
|
654
|
+
sendEmail,
|
|
655
|
+
replyToEmail,
|
|
656
|
+
extractLinks,
|
|
657
|
+
// Exposed for testing
|
|
658
|
+
extractBody,
|
|
659
|
+
decodeBase64Url,
|
|
660
|
+
composeMessage,
|
|
661
|
+
isValidUrl,
|
|
662
|
+
decodeHtmlEntities,
|
|
299
663
|
};
|
package/src/sent-log.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { TOKEN_DIR } = require('./gmail-auth');
|
|
4
|
+
const { atomicWriteJsonSync } = require('./utils');
|
|
5
|
+
|
|
6
|
+
const LOG_DIR = TOKEN_DIR;
|
|
7
|
+
const LOG_FILE = path.join(LOG_DIR, 'sent-log.json');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Ensures the log directory exists
|
|
11
|
+
*/
|
|
12
|
+
function ensureLogDir() {
|
|
13
|
+
if (!fs.existsSync(LOG_DIR)) {
|
|
14
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Reads the current sent log
|
|
20
|
+
* @returns {Array} Array of sent email entries
|
|
21
|
+
*/
|
|
22
|
+
function readSentLog() {
|
|
23
|
+
ensureLogDir();
|
|
24
|
+
if (!fs.existsSync(LOG_FILE)) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const content = fs.readFileSync(LOG_FILE, 'utf8');
|
|
29
|
+
return JSON.parse(content);
|
|
30
|
+
} catch (_err) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Logs a sent email to the sent log
|
|
37
|
+
* @param {Object} entry - { account, to, subject, body, id, threadId, replyToId? }
|
|
38
|
+
*/
|
|
39
|
+
function logSentEmail(entry) {
|
|
40
|
+
ensureLogDir();
|
|
41
|
+
const log = readSentLog();
|
|
42
|
+
const timestamp = new Date().toISOString();
|
|
43
|
+
|
|
44
|
+
log.push({
|
|
45
|
+
sentAt: timestamp,
|
|
46
|
+
account: entry.account,
|
|
47
|
+
id: entry.id,
|
|
48
|
+
threadId: entry.threadId,
|
|
49
|
+
to: entry.to,
|
|
50
|
+
subject: entry.subject,
|
|
51
|
+
bodyPreview: entry.body.substring(0, 200),
|
|
52
|
+
replyToId: entry.replyToId || null,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
atomicWriteJsonSync(LOG_FILE, log);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Gets recent sent emails from the log
|
|
60
|
+
* @param {number} days - Number of days to look back (default: 30)
|
|
61
|
+
* @returns {Array} Array of sent email entries within the time range
|
|
62
|
+
*/
|
|
63
|
+
function getRecentSent(days = 30) {
|
|
64
|
+
const log = readSentLog();
|
|
65
|
+
const cutoff = new Date();
|
|
66
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
67
|
+
|
|
68
|
+
return log.filter((entry) => {
|
|
69
|
+
const sentAt = new Date(entry.sentAt);
|
|
70
|
+
return sentAt >= cutoff;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Gets the path to the log file (for display purposes)
|
|
76
|
+
* @returns {string} The log file path
|
|
77
|
+
*/
|
|
78
|
+
function getSentLogPath() {
|
|
79
|
+
return LOG_FILE;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
logSentEmail,
|
|
84
|
+
getRecentSent,
|
|
85
|
+
getSentLogPath,
|
|
86
|
+
readSentLog,
|
|
87
|
+
};
|
package/src/skill-installer.js
CHANGED
|
@@ -203,11 +203,14 @@ function installSkill(options = {}) {
|
|
|
203
203
|
const skillsDir = path.dirname(SKILL_DEST_DIR);
|
|
204
204
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
205
205
|
|
|
206
|
-
// Backup if replacing existing
|
|
206
|
+
// Backup if replacing existing AND user modified it
|
|
207
|
+
// (Don't backup if it matches source - nothing worth preserving)
|
|
207
208
|
let backedUp = false;
|
|
208
209
|
let backupPath = null;
|
|
209
210
|
let backupResult = null;
|
|
210
|
-
if (status.installed) {
|
|
211
|
+
if (status.installed && updateInfo.hashMismatch) {
|
|
212
|
+
// Only backup if the installed version differs from our source
|
|
213
|
+
// This prevents overwriting a previous backup with an unmodified version
|
|
211
214
|
backupResult = createBackup(SKILL_DEST_DIR);
|
|
212
215
|
|
|
213
216
|
if (!backupResult.success) {
|
|
@@ -219,7 +222,9 @@ function installSkill(options = {}) {
|
|
|
219
222
|
path: SKILL_DEST_DIR
|
|
220
223
|
};
|
|
221
224
|
}
|
|
225
|
+
}
|
|
222
226
|
|
|
227
|
+
if (status.installed) {
|
|
223
228
|
// Remove existing for clean update
|
|
224
229
|
fs.rmSync(SKILL_DEST_DIR, { recursive: true, force: true });
|
|
225
230
|
}
|