clementine-agent 1.18.63 → 1.18.65
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 +2 -0
- package/dist/agent/agent-definitions.js +3 -2
- package/dist/cli/dashboard.js +382 -69
- package/dist/cli/index.js +219 -1
- package/dist/gateway/cron-diagnostic-turn.d.ts +0 -3
- package/dist/gateway/cron-diagnostic-turn.js +0 -6
- package/dist/gateway/recent-context.js +4 -57
- package/dist/gateway/router.js +0 -29
- package/dist/tools/admin-tools.js +11 -0
- package/electron-builder.yml +2 -0
- package/package.json +3 -2
- package/scripts/build-desktop-mac.sh +132 -0
package/README.md
CHANGED
|
@@ -54,6 +54,7 @@ After setup:
|
|
|
54
54
|
clementine launch # start as background daemon
|
|
55
55
|
clementine status # verify it's running
|
|
56
56
|
clementine dashboard # open the web command center
|
|
57
|
+
clementine desktop install # optional macOS desktop app
|
|
57
58
|
```
|
|
58
59
|
|
|
59
60
|
Already installed? Update in place with `clementine update`.
|
|
@@ -198,6 +199,7 @@ clementine status PID, uptime, active channels
|
|
|
198
199
|
clementine update [--dry-run] Pull latest, rebuild, reinstall (preserves config)
|
|
199
200
|
clementine doctor [--fix] Verify (and optionally repair) config and vault
|
|
200
201
|
clementine dashboard Open the web command center (localhost:3030)
|
|
202
|
+
clementine desktop install Download/open the macOS desktop app installer
|
|
201
203
|
clementine tools List available MCP tools, plugins, and channels
|
|
202
204
|
```
|
|
203
205
|
|
|
@@ -54,10 +54,10 @@ const CRON_FIXER_PROMPT = [
|
|
|
54
54
|
'You are the cron-fix specialist. You diagnose and apply fixes to broken cron jobs.',
|
|
55
55
|
'',
|
|
56
56
|
'Workflow:',
|
|
57
|
-
'1.
|
|
57
|
+
'1. If you already know the job name (parent named it, or notification context names it), call `cron_diagnose` first — it returns the bounded recent-run summary, phase status, and inferred root cause in one shot. If you need a list of currently failing jobs, call `list_broken_jobs` instead.',
|
|
58
58
|
'2. For each job the user/parent asked about, check the proposed fix:',
|
|
59
59
|
' - confidence=high + risk=low + autoApply=true → call `apply_broken_job_fix`.',
|
|
60
|
-
' - Otherwise → describe the diagnosis and ask the parent for explicit approval.',
|
|
60
|
+
' - Otherwise → describe the diagnosis and ask the parent for explicit approval before any manual repair.',
|
|
61
61
|
'3. After applying a fix, the verification system auto-rolls-back if the next 3 runs do not improve. You do NOT need to monitor manually.',
|
|
62
62
|
'',
|
|
63
63
|
'Return: a one-paragraph summary of what you applied (or what is blocking apply), per job.',
|
|
@@ -154,6 +154,7 @@ export function buildAgentMap(opts = {}) {
|
|
|
154
154
|
prompt: CRON_FIXER_PROMPT,
|
|
155
155
|
model: 'sonnet',
|
|
156
156
|
tools: [
|
|
157
|
+
'mcp__clementine-tools__cron_diagnose',
|
|
157
158
|
'mcp__clementine-tools__list_broken_jobs',
|
|
158
159
|
'mcp__clementine-tools__apply_broken_job_fix',
|
|
159
160
|
'mcp__clementine-tools__cron_list',
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -7215,6 +7215,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
7215
7215
|
slug: p.slug,
|
|
7216
7216
|
name: p.name,
|
|
7217
7217
|
description: p.description,
|
|
7218
|
+
avatar: p.avatar ?? null,
|
|
7218
7219
|
}));
|
|
7219
7220
|
const activeSlug = gw.getSessionProfile('dashboard:web') ?? null;
|
|
7220
7221
|
res.json({ profiles, active: activeSlug });
|
|
@@ -12230,22 +12231,137 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
12230
12231
|
.home-chat-panel-header {
|
|
12231
12232
|
display: flex;
|
|
12232
12233
|
align-items: center;
|
|
12233
|
-
gap:
|
|
12234
|
-
padding: 12px
|
|
12234
|
+
gap: 6px;
|
|
12235
|
+
padding: 10px 12px;
|
|
12235
12236
|
border-bottom: 1px solid var(--border);
|
|
12236
12237
|
font-size: var(--text-base);
|
|
12237
12238
|
font-weight: 600;
|
|
12238
12239
|
background: var(--bg-secondary);
|
|
12240
|
+
position: relative; /* anchor for .chat-agent-popover */
|
|
12239
12241
|
}
|
|
12240
12242
|
.home-chat-panel-header .icn { width: 16px; height: 16px; color: var(--clementine); }
|
|
12241
|
-
|
|
12242
|
-
|
|
12243
|
-
|
|
12243
|
+
|
|
12244
|
+
/* Active-agent header button — replaces the bare <select>.
|
|
12245
|
+
Closed: avatar + name + role tagline + caret. Click opens popover. */
|
|
12246
|
+
.chat-agent-btn {
|
|
12247
|
+
display: flex; align-items: center; gap: 10px;
|
|
12248
|
+
flex: 1; min-width: 0;
|
|
12249
|
+
padding: 6px 8px;
|
|
12250
|
+
border-radius: var(--radius-sm);
|
|
12251
|
+
background: transparent;
|
|
12252
|
+
border: 1px solid transparent;
|
|
12253
|
+
color: var(--text-primary);
|
|
12254
|
+
cursor: pointer;
|
|
12255
|
+
text-align: left;
|
|
12256
|
+
font: inherit;
|
|
12257
|
+
transition: background var(--motion-fast), border-color var(--motion-fast);
|
|
12258
|
+
}
|
|
12259
|
+
.chat-agent-btn:hover { background: var(--bg-hover); border-color: var(--border); }
|
|
12260
|
+
.chat-agent-btn[aria-expanded="true"] { background: var(--bg-hover); border-color: var(--border); }
|
|
12261
|
+
.chat-agent-btn-avatar {
|
|
12262
|
+
width: 32px; height: 32px;
|
|
12263
|
+
border-radius: 50%;
|
|
12264
|
+
background: linear-gradient(135deg, var(--clementine), #ff6b00);
|
|
12265
|
+
background-size: cover;
|
|
12266
|
+
background-position: center;
|
|
12267
|
+
display: flex; align-items: center; justify-content: center;
|
|
12268
|
+
font-size: 13px; font-weight: 700; color: #fff;
|
|
12269
|
+
flex-shrink: 0;
|
|
12270
|
+
}
|
|
12271
|
+
.chat-agent-btn-avatar.hired {
|
|
12272
|
+
background: var(--bg-input);
|
|
12273
|
+
color: var(--text-primary);
|
|
12274
|
+
}
|
|
12275
|
+
.chat-agent-btn-avatar.has-image { color: transparent; } /* hide initial when image present */
|
|
12276
|
+
.chat-agent-btn-text {
|
|
12277
|
+
display: flex; flex-direction: column;
|
|
12278
|
+
flex: 1; min-width: 0;
|
|
12279
|
+
gap: 1px;
|
|
12280
|
+
}
|
|
12281
|
+
.chat-agent-btn-name {
|
|
12282
|
+
font-size: var(--text-base);
|
|
12283
|
+
font-weight: 600;
|
|
12284
|
+
line-height: 1.2;
|
|
12285
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
12286
|
+
}
|
|
12287
|
+
.chat-agent-btn-role {
|
|
12288
|
+
font-size: 11px;
|
|
12289
|
+
color: var(--text-muted);
|
|
12290
|
+
line-height: 1.2;
|
|
12291
|
+
font-weight: 400;
|
|
12292
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
12293
|
+
}
|
|
12294
|
+
.chat-agent-btn-caret {
|
|
12295
|
+
width: 14px; height: 14px;
|
|
12296
|
+
color: var(--text-muted);
|
|
12297
|
+
transition: transform var(--motion-fast);
|
|
12298
|
+
flex-shrink: 0;
|
|
12299
|
+
}
|
|
12300
|
+
.chat-agent-btn[aria-expanded="true"] .chat-agent-btn-caret { transform: rotate(180deg); }
|
|
12301
|
+
|
|
12302
|
+
/* Popover — list of agents anchored under the header button */
|
|
12303
|
+
.chat-agent-popover {
|
|
12304
|
+
position: absolute;
|
|
12305
|
+
top: calc(100% + 4px);
|
|
12306
|
+
left: 8px; right: 8px;
|
|
12307
|
+
z-index: 250;
|
|
12308
|
+
background: var(--bg-secondary);
|
|
12244
12309
|
border: 1px solid var(--border);
|
|
12245
|
-
border-radius: var(--radius-
|
|
12310
|
+
border-radius: var(--radius-md);
|
|
12311
|
+
box-shadow: var(--shadow-lg);
|
|
12312
|
+
max-height: 320px; overflow-y: auto;
|
|
12313
|
+
padding: 6px;
|
|
12314
|
+
animation: msgFadeIn 0.18s ease;
|
|
12315
|
+
}
|
|
12316
|
+
.chat-agent-row {
|
|
12317
|
+
display: flex; align-items: center; gap: 10px;
|
|
12318
|
+
padding: 8px 10px;
|
|
12319
|
+
border-radius: var(--radius-sm);
|
|
12320
|
+
cursor: pointer;
|
|
12321
|
+
transition: background var(--motion-fast);
|
|
12322
|
+
position: relative;
|
|
12323
|
+
}
|
|
12324
|
+
.chat-agent-row:hover { background: var(--bg-hover); }
|
|
12325
|
+
.chat-agent-row.active { background: var(--bg-hover); }
|
|
12326
|
+
.chat-agent-row.active::before {
|
|
12327
|
+
content: '';
|
|
12328
|
+
position: absolute; left: 2px; top: 8px; bottom: 8px;
|
|
12329
|
+
width: 3px; background: var(--clementine);
|
|
12330
|
+
border-radius: 2px;
|
|
12331
|
+
}
|
|
12332
|
+
.chat-agent-row-avatar {
|
|
12333
|
+
width: 28px; height: 28px;
|
|
12334
|
+
border-radius: 50%;
|
|
12335
|
+
background: linear-gradient(135deg, var(--clementine), #ff6b00);
|
|
12336
|
+
background-size: cover;
|
|
12337
|
+
background-position: center;
|
|
12338
|
+
display: flex; align-items: center; justify-content: center;
|
|
12339
|
+
font-size: 11px; font-weight: 700; color: #fff;
|
|
12340
|
+
flex-shrink: 0;
|
|
12341
|
+
}
|
|
12342
|
+
.chat-agent-row-avatar.hired {
|
|
12246
12343
|
background: var(--bg-input);
|
|
12247
12344
|
color: var(--text-primary);
|
|
12248
12345
|
}
|
|
12346
|
+
.chat-agent-row-avatar.has-image { color: transparent; }
|
|
12347
|
+
.chat-agent-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
|
12348
|
+
.chat-agent-row-name {
|
|
12349
|
+
font-size: 13px; font-weight: 600;
|
|
12350
|
+
color: var(--text-primary);
|
|
12351
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
12352
|
+
}
|
|
12353
|
+
.chat-agent-row-desc {
|
|
12354
|
+
font-size: 11px; color: var(--text-muted);
|
|
12355
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
12356
|
+
}
|
|
12357
|
+
.chat-agent-row-status {
|
|
12358
|
+
width: 8px; height: 8px;
|
|
12359
|
+
border-radius: 50%;
|
|
12360
|
+
background: var(--text-muted);
|
|
12361
|
+
flex-shrink: 0;
|
|
12362
|
+
}
|
|
12363
|
+
.chat-agent-row-status.online { background: var(--green); }
|
|
12364
|
+
|
|
12249
12365
|
.home-chat-panel-messages {
|
|
12250
12366
|
flex: 1;
|
|
12251
12367
|
overflow-y: auto;
|
|
@@ -12258,9 +12374,9 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
12258
12374
|
padding: 10px 12px;
|
|
12259
12375
|
border-top: 1px solid var(--border);
|
|
12260
12376
|
background: var(--bg-secondary);
|
|
12261
|
-
align-items:
|
|
12377
|
+
align-items: flex-end;
|
|
12262
12378
|
}
|
|
12263
|
-
.home-chat-panel-input-row
|
|
12379
|
+
.home-chat-panel-input-row textarea {
|
|
12264
12380
|
flex: 1;
|
|
12265
12381
|
padding: 8px 12px;
|
|
12266
12382
|
border: 1px solid var(--border);
|
|
@@ -12269,9 +12385,18 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
12269
12385
|
color: var(--text-primary);
|
|
12270
12386
|
font-size: var(--text-base);
|
|
12271
12387
|
font-family: inherit;
|
|
12388
|
+
resize: none;
|
|
12389
|
+
line-height: 1.4;
|
|
12390
|
+
max-height: 110px;
|
|
12391
|
+
overflow-y: auto;
|
|
12272
12392
|
}
|
|
12273
12393
|
.home-chat-panel-input-row .btn-icon { padding: 6px; }
|
|
12274
12394
|
.home-chat-panel-input-row .icn { width: 14px; height: 14px; }
|
|
12395
|
+
/* Send button busy state — toggle send/spinner instead of swapping text */
|
|
12396
|
+
#chat-send-btn .icon-spinner { display: none; }
|
|
12397
|
+
#chat-send-btn.is-busy .icon-send { display: none; }
|
|
12398
|
+
#chat-send-btn.is-busy .icon-spinner { display: inline-block; animation: btnSpin 0.8s linear infinite; }
|
|
12399
|
+
@keyframes btnSpin { to { transform: rotate(360deg); } }
|
|
12275
12400
|
|
|
12276
12401
|
@media (max-width: 600px) {
|
|
12277
12402
|
.home-chat-panel {
|
|
@@ -14649,6 +14774,8 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
14649
14774
|
height: 28px;
|
|
14650
14775
|
border-radius: 50%;
|
|
14651
14776
|
background: linear-gradient(135deg, var(--clementine), #ff6b00);
|
|
14777
|
+
background-size: cover;
|
|
14778
|
+
background-position: center;
|
|
14652
14779
|
display: flex;
|
|
14653
14780
|
align-items: center;
|
|
14654
14781
|
justify-content: center;
|
|
@@ -14658,6 +14785,11 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
14658
14785
|
flex-shrink: 0;
|
|
14659
14786
|
margin-top: 2px;
|
|
14660
14787
|
}
|
|
14788
|
+
.chat-avatar-sm.hired {
|
|
14789
|
+
background: var(--bg-input);
|
|
14790
|
+
color: var(--text-primary);
|
|
14791
|
+
}
|
|
14792
|
+
.chat-avatar-sm.has-image { color: transparent; } /* hide initial when image present */
|
|
14661
14793
|
/* Build page is full-height flex (canvas + chat). Home page handles its own layout. */
|
|
14662
14794
|
#page-build.active {
|
|
14663
14795
|
display: flex !important;
|
|
@@ -18404,28 +18536,26 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
18404
18536
|
</button>
|
|
18405
18537
|
<div id="home-chat-panel" class="home-chat-panel" aria-hidden="true">
|
|
18406
18538
|
<div class="home-chat-panel-header">
|
|
18407
|
-
<
|
|
18408
|
-
|
|
18409
|
-
|
|
18410
|
-
|
|
18411
|
-
|
|
18412
|
-
|
|
18413
|
-
|
|
18414
|
-
|
|
18415
|
-
<
|
|
18416
|
-
|
|
18417
|
-
<div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:center">
|
|
18418
|
-
<button class="btn btn-sm quick-pill" onclick="quickChat("What's on my schedule?")">Schedule?</button>
|
|
18419
|
-
<button class="btn btn-sm quick-pill" onclick="quickChat('Check my email')">Email</button>
|
|
18420
|
-
<button class="btn btn-sm quick-pill" onclick="quickChat('Run morning briefing')">Briefing</button>
|
|
18421
|
-
<button class="btn btn-sm quick-pill" onclick="quickChat('What did you do today?')">Today's work</button>
|
|
18422
|
-
</div>
|
|
18423
|
-
</div>
|
|
18539
|
+
<button id="chat-agent-btn" class="chat-agent-btn" onclick="toggleAgentPicker(event)" aria-haspopup="listbox" aria-expanded="false" title="Switch agent">
|
|
18540
|
+
<span class="chat-agent-btn-avatar" id="chat-agent-btn-avatar">C</span>
|
|
18541
|
+
<span class="chat-agent-btn-text">
|
|
18542
|
+
<span class="chat-agent-btn-name" id="chat-agent-btn-name">Clementine</span>
|
|
18543
|
+
<span class="chat-agent-btn-role" id="chat-agent-btn-role">Personal AI Assistant</span>
|
|
18544
|
+
</span>
|
|
18545
|
+
<span class="chat-agent-btn-caret icon-slot" data-icon="chevronDown"></span>
|
|
18546
|
+
</button>
|
|
18547
|
+
<button class="btn-icon btn-sm" onclick="toggleHomeChat()" title="Close chat" style="margin-left:8px">×</button>
|
|
18548
|
+
<div id="chat-agent-popover" class="chat-agent-popover" role="listbox" hidden></div>
|
|
18424
18549
|
</div>
|
|
18550
|
+
<div id="chat-messages" class="home-chat-panel-messages"></div>
|
|
18425
18551
|
<div class="home-chat-panel-input-row">
|
|
18426
|
-
<
|
|
18427
|
-
|
|
18428
|
-
|
|
18552
|
+
<textarea id="chat-input" rows="1"
|
|
18553
|
+
placeholder="Message Clementine…"
|
|
18554
|
+
onkeydown="onChatKeyDown(event)"
|
|
18555
|
+
oninput="onChatInputChange(this)"></textarea>
|
|
18556
|
+
<button class="btn-primary btn-sm" id="chat-send-btn" onclick="sendChat()" title="Send" disabled>
|
|
18557
|
+
<span class="icon-slot icon-send" data-icon="send"></span>
|
|
18558
|
+
<span class="icon-slot icon-spinner" data-icon="loader" hidden></span>
|
|
18429
18559
|
</button>
|
|
18430
18560
|
</div>
|
|
18431
18561
|
</div>
|
|
@@ -19587,7 +19717,12 @@ async function ensureTeamAgentCache(force) {
|
|
|
19587
19717
|
if (Array.isArray(agents)) {
|
|
19588
19718
|
for (var i = 0; i < agents.length; i++) {
|
|
19589
19719
|
var a = agents[i];
|
|
19590
|
-
if (a && a.slug) next[a.slug] = {
|
|
19720
|
+
if (a && a.slug) next[a.slug] = {
|
|
19721
|
+
slug: a.slug,
|
|
19722
|
+
name: a.name || a.slug,
|
|
19723
|
+
avatar: a.avatar || null,
|
|
19724
|
+
description: a.description || '',
|
|
19725
|
+
};
|
|
19591
19726
|
}
|
|
19592
19727
|
}
|
|
19593
19728
|
__teamAgentsByslug = next;
|
|
@@ -19601,18 +19736,55 @@ async function ensureTeamAgentCache(force) {
|
|
|
19601
19736
|
* getAssistantIdentity() → active chat profile (or Clementine)
|
|
19602
19737
|
* getAssistantIdentity(slugFromSseEvent) → that specific agent
|
|
19603
19738
|
*
|
|
19604
|
-
* Returns { slug, name, initial }. Always safe —
|
|
19605
|
-
* Clementine when nothing matches. Initial is uppercase
|
|
19606
|
-
* suitable for a .chat-avatar-sm bubble.
|
|
19739
|
+
* Returns { slug, name, initial, avatar, description }. Always safe —
|
|
19740
|
+
* falls back to Clementine when nothing matches. Initial is uppercase
|
|
19741
|
+
* first char, suitable for a .chat-avatar-sm bubble. Avatar is a URL
|
|
19742
|
+
* or null; the bubble renderer applies it as background-image when set.
|
|
19607
19743
|
*/
|
|
19608
19744
|
function getAssistantIdentity(explicitSlug) {
|
|
19609
19745
|
var slug = explicitSlug || currentAgentSlug || '';
|
|
19610
19746
|
if (slug && slug !== 'clementine' && __teamAgentsByslug[slug]) {
|
|
19611
19747
|
var a = __teamAgentsByslug[slug];
|
|
19612
|
-
return {
|
|
19748
|
+
return {
|
|
19749
|
+
slug: a.slug,
|
|
19750
|
+
name: a.name,
|
|
19751
|
+
initial: (a.name || 'A').charAt(0).toUpperCase(),
|
|
19752
|
+
avatar: a.avatar || null,
|
|
19753
|
+
description: a.description || '',
|
|
19754
|
+
primary: false,
|
|
19755
|
+
};
|
|
19613
19756
|
}
|
|
19614
19757
|
var clemName = (lastStatusData && lastStatusData.name) ? lastStatusData.name : 'Clementine';
|
|
19615
|
-
return {
|
|
19758
|
+
return {
|
|
19759
|
+
slug: 'clementine',
|
|
19760
|
+
name: clemName,
|
|
19761
|
+
initial: clemName.charAt(0).toUpperCase(),
|
|
19762
|
+
avatar: null,
|
|
19763
|
+
description: 'Personal AI Assistant',
|
|
19764
|
+
primary: true,
|
|
19765
|
+
};
|
|
19766
|
+
}
|
|
19767
|
+
|
|
19768
|
+
/**
|
|
19769
|
+
* Build the empty-state markup for the active (or specified) agent.
|
|
19770
|
+
* Used both at first load and after pickAgent clears the panel — so
|
|
19771
|
+
* a switch always lands on a clean, identity-aware slate instead of
|
|
19772
|
+
* "Session cleared." or a hardcoded "What can I help with?".
|
|
19773
|
+
*/
|
|
19774
|
+
function renderEmptyStateFor(slug) {
|
|
19775
|
+
var id = getAssistantIdentity(slug || null);
|
|
19776
|
+
var greet = id.primary
|
|
19777
|
+
? 'What can I help with?'
|
|
19778
|
+
: 'Chat with ' + id.name + (id.description ? ' — ' + id.description : '');
|
|
19779
|
+
return '<div class="empty-state" style="margin-top:24px">'
|
|
19780
|
+
+ '<p style="margin-bottom:12px;color:var(--text-muted);font-size:var(--text-base)">' + esc(greet) + '</p>'
|
|
19781
|
+
+ '<div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:center">'
|
|
19782
|
+
+ '<button class="btn btn-sm quick-pill" onclick="quickChat(\\'What\\\\\\'s on my schedule?\\')">Schedule?</button>'
|
|
19783
|
+
+ '<button class="btn btn-sm quick-pill" onclick="quickChat(\\'Check my email\\')">Email</button>'
|
|
19784
|
+
+ '<button class="btn btn-sm quick-pill" onclick="quickChat(\\'Run morning briefing\\')">Briefing</button>'
|
|
19785
|
+
+ '<button class="btn btn-sm quick-pill" onclick="quickChat(\\'What did you do today?\\')">Today\\'s work</button>'
|
|
19786
|
+
+ '</div>'
|
|
19787
|
+
+ '</div>';
|
|
19616
19788
|
}
|
|
19617
19789
|
|
|
19618
19790
|
// ── Routing ────────────────────────────────────────────────────
|
|
@@ -24361,10 +24533,33 @@ document.getElementById('log-filter').addEventListener('input', applyLogFilter);
|
|
|
24361
24533
|
|
|
24362
24534
|
// ── Chat ──────────────────────────────────
|
|
24363
24535
|
function quickChat(msg) {
|
|
24364
|
-
document.getElementById('chat-input')
|
|
24536
|
+
var el = document.getElementById('chat-input');
|
|
24537
|
+
el.value = msg;
|
|
24538
|
+
onChatInputChange(el); // resize + enable send button
|
|
24365
24539
|
sendChat();
|
|
24366
24540
|
}
|
|
24367
24541
|
|
|
24542
|
+
// Enter sends; Shift+Enter inserts a newline. Cmd/Ctrl+Enter also sends
|
|
24543
|
+
// for muscle-memory parity with builder chats.
|
|
24544
|
+
function onChatKeyDown(e) {
|
|
24545
|
+
if (e.key === 'Enter') {
|
|
24546
|
+
if (e.shiftKey) return; // newline
|
|
24547
|
+
e.preventDefault();
|
|
24548
|
+
sendChat();
|
|
24549
|
+
}
|
|
24550
|
+
}
|
|
24551
|
+
|
|
24552
|
+
// Autosize the chat textarea up to ~5 rows; toggle send-button enabled
|
|
24553
|
+
// state. Called from oninput on the composer + after quickChat sets a value.
|
|
24554
|
+
function onChatInputChange(el) {
|
|
24555
|
+
if (!el) return;
|
|
24556
|
+
el.style.height = 'auto';
|
|
24557
|
+
var max = 110; // ~5 rows at 22px line-height
|
|
24558
|
+
el.style.height = Math.min(el.scrollHeight, max) + 'px';
|
|
24559
|
+
var btn = document.getElementById('chat-send-btn');
|
|
24560
|
+
if (btn) btn.disabled = !el.value.trim() || btn.classList.contains('is-busy');
|
|
24561
|
+
}
|
|
24562
|
+
|
|
24368
24563
|
function renderMd(text) {
|
|
24369
24564
|
let s = esc(text);
|
|
24370
24565
|
var BT = String.fromCharCode(96);
|
|
@@ -24416,22 +24611,26 @@ async function sendChat() {
|
|
|
24416
24611
|
container.appendChild(typing);
|
|
24417
24612
|
container.scrollTop = container.scrollHeight;
|
|
24418
24613
|
|
|
24419
|
-
//
|
|
24614
|
+
// Swap send button into busy state — keeps the icon, shows a spinner
|
|
24615
|
+
// (no more "Thinking..." text). The .is-busy class is the source of
|
|
24616
|
+
// truth; CSS handles the icon swap.
|
|
24420
24617
|
const sendBtn = document.getElementById('chat-send-btn');
|
|
24421
24618
|
input.disabled = true;
|
|
24422
24619
|
sendBtn.disabled = true;
|
|
24423
|
-
sendBtn.
|
|
24620
|
+
sendBtn.classList.add('is-busy');
|
|
24424
24621
|
|
|
24425
24622
|
try {
|
|
24426
24623
|
// Identity follows the active chat profile so a hired agent's
|
|
24427
|
-
// reply renders with their initial
|
|
24624
|
+
// reply renders with their initial (or avatar URL when set) —
|
|
24625
|
+
// same as Discord/Slack.
|
|
24428
24626
|
await ensureTeamAgentCache();
|
|
24429
24627
|
var asstIdentity = getAssistantIdentity();
|
|
24430
24628
|
var asstRow = document.createElement('div');
|
|
24431
24629
|
asstRow.className = 'chat-assistant-row';
|
|
24432
24630
|
var chatAv = document.createElement('div');
|
|
24433
|
-
chatAv.className = 'chat-avatar-sm';
|
|
24631
|
+
chatAv.className = 'chat-avatar-sm' + (asstIdentity.primary ? '' : ' hired') + (asstIdentity.avatar ? ' has-image' : '');
|
|
24434
24632
|
chatAv.title = asstIdentity.name;
|
|
24633
|
+
if (asstIdentity.avatar) chatAv.style.backgroundImage = "url('" + asstIdentity.avatar + "')";
|
|
24435
24634
|
chatAv.innerHTML = asstIdentity.initial;
|
|
24436
24635
|
asstRow.appendChild(chatAv);
|
|
24437
24636
|
var asstBubble = document.createElement('div');
|
|
@@ -24522,8 +24721,12 @@ async function sendChat() {
|
|
|
24522
24721
|
}
|
|
24523
24722
|
|
|
24524
24723
|
input.disabled = false;
|
|
24525
|
-
sendBtn.
|
|
24526
|
-
|
|
24724
|
+
sendBtn.classList.remove('is-busy');
|
|
24725
|
+
// Re-enable only if there's content to send (or stay disabled — the
|
|
24726
|
+
// user clears the textarea by sending, so it's empty here).
|
|
24727
|
+
sendBtn.disabled = !input.value.trim();
|
|
24728
|
+
// Reset the textarea autosize back to single-row.
|
|
24729
|
+
if (typeof onChatInputChange === 'function') onChatInputChange(input);
|
|
24527
24730
|
input.focus();
|
|
24528
24731
|
container.scrollTop = container.scrollHeight;
|
|
24529
24732
|
}
|
|
@@ -24952,37 +25155,138 @@ async function deleteChunk(id) {
|
|
|
24952
25155
|
} catch (e) { toast('Delete failed: ' + String(e), 'error'); }
|
|
24953
25156
|
}
|
|
24954
25157
|
|
|
24955
|
-
// ── Profile Switching
|
|
24956
|
-
|
|
24957
|
-
|
|
24958
|
-
|
|
24959
|
-
|
|
24960
|
-
|
|
24961
|
-
|
|
24962
|
-
|
|
24963
|
-
|
|
24964
|
-
|
|
24965
|
-
|
|
24966
|
-
|
|
24967
|
-
|
|
24968
|
-
|
|
24969
|
-
|
|
25158
|
+
// ── Profile Switching — chat agent picker (header button + popover) ─────
|
|
25159
|
+
//
|
|
25160
|
+
// Replaces the legacy <select> with a richer header affordance:
|
|
25161
|
+
// • closed: avatar + name + role tagline of the active agent
|
|
25162
|
+
// • open: avatar-row list of Clementine + every hired agent, with
|
|
25163
|
+
// status dots and descriptions
|
|
25164
|
+
//
|
|
25165
|
+
// Source of truth for "active profile" stays the gateway's session
|
|
25166
|
+
// profile map (set via /api/profiles/switch). The picker just makes
|
|
25167
|
+
// it visible and one-click switchable.
|
|
25168
|
+
|
|
25169
|
+
var _agentPickerOpen = false;
|
|
25170
|
+
var _chatAgentRows = [];
|
|
25171
|
+
|
|
25172
|
+
async function refreshChatAgentPicker() {
|
|
25173
|
+
await ensureTeamAgentCache();
|
|
25174
|
+
var rProf;
|
|
25175
|
+
try { rProf = await apiFetch('/api/profiles'); } catch { rProf = { ok: false }; }
|
|
25176
|
+
var d = (rProf && rProf.ok) ? await rProf.json() : { profiles: [], active: null };
|
|
25177
|
+
|
|
25178
|
+
var clemName = (lastStatusData && lastStatusData.name) ? lastStatusData.name : 'Clementine';
|
|
25179
|
+
var clemRole = 'Personal AI Assistant';
|
|
25180
|
+
var clemOnline = !!(lastStatusData && lastStatusData.alive);
|
|
25181
|
+
var rows = [{ slug: '', name: clemName, role: clemRole, avatar: null, online: clemOnline, primary: true }];
|
|
25182
|
+
for (var i = 0; i < (d.profiles || []).length; i++) {
|
|
25183
|
+
var p = d.profiles[i];
|
|
25184
|
+
var cached = __teamAgentsByslug[p.slug] || {};
|
|
25185
|
+
rows.push({
|
|
25186
|
+
slug: p.slug,
|
|
25187
|
+
name: p.name || p.slug,
|
|
25188
|
+
role: p.description || '',
|
|
25189
|
+
avatar: p.avatar || cached.avatar || null,
|
|
25190
|
+
online: cached.botStatus === 'online' || cached.status === 'active',
|
|
25191
|
+
primary: false,
|
|
25192
|
+
});
|
|
25193
|
+
}
|
|
25194
|
+
_chatAgentRows = rows;
|
|
25195
|
+
var activeSlug = d.active || '';
|
|
25196
|
+
currentAgentSlug = activeSlug || null;
|
|
25197
|
+
|
|
25198
|
+
// Header button — always visible identity affordance
|
|
25199
|
+
var btnAvatar = document.getElementById('chat-agent-btn-avatar');
|
|
25200
|
+
var btnName = document.getElementById('chat-agent-btn-name');
|
|
25201
|
+
var btnRole = document.getElementById('chat-agent-btn-role');
|
|
25202
|
+
var active = null;
|
|
25203
|
+
for (var j = 0; j < rows.length; j++) { if (rows[j].slug === activeSlug) { active = rows[j]; break; } }
|
|
25204
|
+
if (!active) active = rows[0];
|
|
25205
|
+
if (btnAvatar) applyAgentAvatar(btnAvatar, active);
|
|
25206
|
+
if (btnName) btnName.textContent = active.name;
|
|
25207
|
+
if (btnRole) btnRole.textContent = active.role || (active.primary ? clemRole : '');
|
|
25208
|
+
|
|
25209
|
+
// Popover rows
|
|
25210
|
+
var pop = document.getElementById('chat-agent-popover');
|
|
25211
|
+
if (pop) {
|
|
25212
|
+
var html = '';
|
|
25213
|
+
for (var k = 0; k < rows.length; k++) {
|
|
25214
|
+
var r = rows[k];
|
|
25215
|
+
var isActive = r.slug === activeSlug;
|
|
25216
|
+
var avatarStyle = r.avatar ? (' style="background-image:url(\\''+ esc(r.avatar) +'\\')"') : '';
|
|
25217
|
+
var avatarClasses = 'chat-agent-row-avatar' + (r.primary ? '' : ' hired') + (r.avatar ? ' has-image' : '');
|
|
25218
|
+
var initial = esc((r.name || '?').charAt(0).toUpperCase());
|
|
25219
|
+
html += '<div class="chat-agent-row' + (isActive ? ' active' : '') + '" role="option" aria-selected="' + (isActive ? 'true' : 'false') + '" onclick="pickAgent(\\''+ esc(r.slug) +'\\')">'
|
|
25220
|
+
+ '<span class="' + avatarClasses + '"' + avatarStyle + '>' + initial + '</span>'
|
|
25221
|
+
+ '<span class="chat-agent-row-text">'
|
|
25222
|
+
+ '<span class="chat-agent-row-name">' + esc(r.name) + '</span>'
|
|
25223
|
+
+ (r.role ? '<span class="chat-agent-row-desc">' + esc(r.role) + '</span>' : '')
|
|
25224
|
+
+ '</span>'
|
|
25225
|
+
+ '<span class="chat-agent-row-status ' + (r.online ? 'online' : 'offline') + '" title="' + (r.online ? 'Online' : 'Offline') + '"></span>'
|
|
25226
|
+
+ '</div>';
|
|
24970
25227
|
}
|
|
24971
|
-
|
|
24972
|
-
|
|
24973
|
-
|
|
25228
|
+
pop.innerHTML = html;
|
|
25229
|
+
}
|
|
25230
|
+
|
|
25231
|
+
// Update input placeholder to match the active agent
|
|
25232
|
+
var input = document.getElementById('chat-input');
|
|
25233
|
+
if (input) input.placeholder = 'Message ' + active.name + '…';
|
|
25234
|
+
}
|
|
25235
|
+
|
|
25236
|
+
function applyAgentAvatar(el, identity) {
|
|
25237
|
+
if (!el) return;
|
|
25238
|
+
// Wipe + recompute classes so we can swap between Clementine/hired
|
|
25239
|
+
// and toggle has-image on each refresh without leftover state.
|
|
25240
|
+
var base = el.classList.contains('chat-agent-row-avatar') ? 'chat-agent-row-avatar' : 'chat-agent-btn-avatar';
|
|
25241
|
+
el.className = base + (identity.primary ? '' : ' hired') + (identity.avatar ? ' has-image' : '');
|
|
25242
|
+
if (identity.avatar) {
|
|
25243
|
+
el.style.backgroundImage = "url('" + identity.avatar + "')";
|
|
25244
|
+
} else {
|
|
25245
|
+
el.style.backgroundImage = '';
|
|
25246
|
+
}
|
|
25247
|
+
el.textContent = (identity.name || '?').charAt(0).toUpperCase();
|
|
25248
|
+
}
|
|
25249
|
+
|
|
25250
|
+
function toggleAgentPicker(e) {
|
|
25251
|
+
if (e && e.stopPropagation) e.stopPropagation();
|
|
25252
|
+
var btn = document.getElementById('chat-agent-btn');
|
|
25253
|
+
var pop = document.getElementById('chat-agent-popover');
|
|
25254
|
+
_agentPickerOpen = !_agentPickerOpen;
|
|
25255
|
+
if (btn) btn.setAttribute('aria-expanded', _agentPickerOpen ? 'true' : 'false');
|
|
25256
|
+
if (pop) pop.hidden = !_agentPickerOpen;
|
|
24974
25257
|
}
|
|
24975
25258
|
|
|
24976
|
-
async function
|
|
25259
|
+
async function pickAgent(slug) {
|
|
25260
|
+
toggleAgentPicker();
|
|
25261
|
+
if ((slug || '') === (currentAgentSlug || '')) return;
|
|
24977
25262
|
try {
|
|
24978
25263
|
await apiJson('POST', '/api/profiles/switch', { slug: slug || null });
|
|
24979
|
-
|
|
25264
|
+
currentAgentSlug = slug || null;
|
|
25265
|
+
// Server-side session was cleared. Mirror that visually with the
|
|
25266
|
+
// new agent's empty state — replaces the bare "Session cleared." line.
|
|
24980
25267
|
var container = document.getElementById('chat-messages');
|
|
24981
|
-
container.innerHTML =
|
|
24982
|
-
|
|
24983
|
-
|
|
25268
|
+
if (container) container.innerHTML = renderEmptyStateFor(currentAgentSlug);
|
|
25269
|
+
await refreshChatAgentPicker();
|
|
25270
|
+
var picked = _chatAgentRows.find ? _chatAgentRows.find(function(r){ return r.slug === (slug || ''); }) : null;
|
|
25271
|
+
toast(slug ? ('Now chatting with ' + (picked ? picked.name : slug)) : 'Switched to Clementine', 'success');
|
|
25272
|
+
} catch (e) {
|
|
25273
|
+
toast('Failed to switch agent: ' + e, 'error');
|
|
25274
|
+
}
|
|
24984
25275
|
}
|
|
24985
25276
|
|
|
25277
|
+
// Click-outside closes the popover
|
|
25278
|
+
document.addEventListener('click', function(e) {
|
|
25279
|
+
if (!_agentPickerOpen) return;
|
|
25280
|
+
var btn = document.getElementById('chat-agent-btn');
|
|
25281
|
+
var pop = document.getElementById('chat-agent-popover');
|
|
25282
|
+
if (!btn || !pop) return;
|
|
25283
|
+
if (btn.contains(e.target) || pop.contains(e.target)) return;
|
|
25284
|
+
toggleAgentPicker();
|
|
25285
|
+
});
|
|
25286
|
+
|
|
25287
|
+
// Back-compat shim — older call sites still reference loadProfiles().
|
|
25288
|
+
function loadProfiles() { return refreshChatAgentPicker(); }
|
|
25289
|
+
|
|
24986
25290
|
// ── Skill Studio — opens builder in skill-focused mode ──────────
|
|
24987
25291
|
|
|
24988
25292
|
function openSkillStudio() {
|
|
@@ -28553,7 +28857,14 @@ function toggleHomeChat(forceOpen) {
|
|
|
28553
28857
|
var input = document.getElementById('chat-input');
|
|
28554
28858
|
if (input) input.focus();
|
|
28555
28859
|
}, 80);
|
|
28556
|
-
|
|
28860
|
+
// Refresh the agent picker (replaces the legacy loadProfiles()).
|
|
28861
|
+
// Also populate the empty state for the active agent if the panel
|
|
28862
|
+
// is still empty — first-open path.
|
|
28863
|
+
if (typeof refreshChatAgentPicker === 'function') refreshChatAgentPicker();
|
|
28864
|
+
var container = document.getElementById('chat-messages');
|
|
28865
|
+
if (container && !container.children.length) {
|
|
28866
|
+
container.innerHTML = renderEmptyStateFor(currentAgentSlug);
|
|
28867
|
+
}
|
|
28557
28868
|
}
|
|
28558
28869
|
}
|
|
28559
28870
|
|
|
@@ -32517,17 +32828,19 @@ try {
|
|
|
32517
32828
|
// Lazy-refresh the cache if the SSE event names a slug we
|
|
32518
32829
|
// haven't seen yet (e.g. an agent hired since page load).
|
|
32519
32830
|
if (deepSlug && !__teamAgentsByslug[deepSlug]) { ensureTeamAgentCache(true); }
|
|
32520
|
-
var deepIdentity =
|
|
32521
|
-
|
|
32522
|
-
|
|
32831
|
+
var deepIdentity = getAssistantIdentity(deepSlug);
|
|
32832
|
+
// Server-side payload may carry a fresher name than our cache.
|
|
32833
|
+
if (deepName) deepIdentity.name = deepName;
|
|
32834
|
+
if (deepName) deepIdentity.initial = deepName.charAt(0).toUpperCase();
|
|
32523
32835
|
if (container && text) {
|
|
32524
32836
|
var emptyState = container.querySelector('.empty-state');
|
|
32525
32837
|
if (emptyState) emptyState.remove();
|
|
32526
32838
|
var row = document.createElement('div');
|
|
32527
32839
|
row.className = 'chat-assistant-row';
|
|
32528
32840
|
var av = document.createElement('div');
|
|
32529
|
-
av.className = 'chat-avatar-sm';
|
|
32841
|
+
av.className = 'chat-avatar-sm' + (deepIdentity.primary ? '' : ' hired') + (deepIdentity.avatar ? ' has-image' : '');
|
|
32530
32842
|
av.title = deepIdentity.name;
|
|
32843
|
+
if (deepIdentity.avatar) av.style.backgroundImage = "url('" + deepIdentity.avatar + "')";
|
|
32531
32844
|
av.innerHTML = deepIdentity.initial;
|
|
32532
32845
|
row.appendChild(av);
|
|
32533
32846
|
var bubble = document.createElement('div');
|
package/dist/cli/index.js
CHANGED
|
@@ -15,9 +15,12 @@ catch {
|
|
|
15
15
|
*/
|
|
16
16
|
import { Command } from 'commander';
|
|
17
17
|
import { spawn, execSync } from 'node:child_process';
|
|
18
|
-
import
|
|
18
|
+
import http from 'node:http';
|
|
19
|
+
import https from 'node:https';
|
|
20
|
+
import { cpSync, createWriteStream, existsSync, openSync, closeSync, readSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync, renameSync, statSync, } from 'node:fs';
|
|
19
21
|
import os from 'node:os';
|
|
20
22
|
import path from 'node:path';
|
|
23
|
+
import { pipeline } from 'node:stream/promises';
|
|
21
24
|
import { fileURLToPath } from 'node:url';
|
|
22
25
|
import { runSetup } from './setup.js';
|
|
23
26
|
import { cmdCronList, cmdCronRun, cmdCronRunDue, cmdCronRuns, cmdCronAdd, cmdCronTest, cmdHeartbeat } from './cron.js';
|
|
@@ -1963,6 +1966,186 @@ function cmdTools() {
|
|
|
1963
1966
|
console.log();
|
|
1964
1967
|
}
|
|
1965
1968
|
}
|
|
1969
|
+
// ── Desktop installer command ───────────────────────────────────────
|
|
1970
|
+
const DESKTOP_RELEASE_REPO = process.env.CLEMENTINE_DESKTOP_REPO || 'Natebreynolds/Clementine-AI-Assistant';
|
|
1971
|
+
function githubRequestHeaders() {
|
|
1972
|
+
return {
|
|
1973
|
+
Accept: 'application/vnd.github+json',
|
|
1974
|
+
'User-Agent': `clementine-cli/${pkgVersion}`,
|
|
1975
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
function requestUrl(url, redirects = 0) {
|
|
1979
|
+
return new Promise((resolve, reject) => {
|
|
1980
|
+
if (redirects > 5) {
|
|
1981
|
+
reject(new Error('Too many redirects while contacting GitHub.'));
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
const parsed = new URL(url);
|
|
1985
|
+
const client = parsed.protocol === 'http:' ? http : https;
|
|
1986
|
+
const req = client.get(parsed, { headers: githubRequestHeaders() }, (res) => {
|
|
1987
|
+
const status = res.statusCode ?? 0;
|
|
1988
|
+
const location = res.headers.location;
|
|
1989
|
+
if (status >= 300 && status < 400 && location) {
|
|
1990
|
+
res.resume();
|
|
1991
|
+
resolve(requestUrl(new URL(location, parsed).toString(), redirects + 1));
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
if (status < 200 || status >= 300) {
|
|
1995
|
+
let body = '';
|
|
1996
|
+
res.setEncoding('utf-8');
|
|
1997
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
1998
|
+
res.on('end', () => {
|
|
1999
|
+
const detail = body.trim() ? `: ${body.trim().slice(0, 240)}` : '';
|
|
2000
|
+
reject(new Error(`GitHub request failed (${status})${detail}`));
|
|
2001
|
+
});
|
|
2002
|
+
res.on('error', reject);
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
resolve(res);
|
|
2006
|
+
});
|
|
2007
|
+
req.setTimeout(30_000, () => {
|
|
2008
|
+
req.destroy(new Error('Timed out while contacting GitHub.'));
|
|
2009
|
+
});
|
|
2010
|
+
req.on('error', reject);
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
async function readResponseText(res) {
|
|
2014
|
+
return await new Promise((resolve, reject) => {
|
|
2015
|
+
let body = '';
|
|
2016
|
+
res.setEncoding('utf-8');
|
|
2017
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
2018
|
+
res.on('end', () => resolve(body));
|
|
2019
|
+
res.on('error', reject);
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
async function fetchJson(url) {
|
|
2023
|
+
const body = await readResponseText(await requestUrl(url));
|
|
2024
|
+
try {
|
|
2025
|
+
return JSON.parse(body);
|
|
2026
|
+
}
|
|
2027
|
+
catch {
|
|
2028
|
+
throw new Error('GitHub returned an invalid JSON response.');
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
function normalizeReleaseVersion(version) {
|
|
2032
|
+
const raw = (version || 'latest').trim();
|
|
2033
|
+
if (!raw || raw === 'latest')
|
|
2034
|
+
return 'latest';
|
|
2035
|
+
return raw.startsWith('v') ? raw : `v${raw}`;
|
|
2036
|
+
}
|
|
2037
|
+
function desktopReleaseApiUrl(version) {
|
|
2038
|
+
const normalized = normalizeReleaseVersion(version);
|
|
2039
|
+
const repo = encodeURIComponent(DESKTOP_RELEASE_REPO).replace('%2F', '/');
|
|
2040
|
+
if (normalized === 'latest') {
|
|
2041
|
+
return `https://api.github.com/repos/${repo}/releases/latest`;
|
|
2042
|
+
}
|
|
2043
|
+
return `https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(normalized)}`;
|
|
2044
|
+
}
|
|
2045
|
+
function macDesktopArch() {
|
|
2046
|
+
if (process.arch === 'arm64')
|
|
2047
|
+
return 'arm64';
|
|
2048
|
+
if (process.arch === 'x64')
|
|
2049
|
+
return 'x64';
|
|
2050
|
+
throw new Error(`Clementine Desktop is only packaged for Apple silicon and Intel Macs. Detected: ${process.arch}`);
|
|
2051
|
+
}
|
|
2052
|
+
function selectDesktopDmgAsset(release) {
|
|
2053
|
+
const assets = release.assets ?? [];
|
|
2054
|
+
const arch = macDesktopArch();
|
|
2055
|
+
const archSpecific = assets.find(asset => asset.name.endsWith(`-mac-${arch}.dmg`));
|
|
2056
|
+
if (archSpecific)
|
|
2057
|
+
return archSpecific;
|
|
2058
|
+
const universal = assets.find(asset => /-mac-universal\.dmg$/i.test(asset.name));
|
|
2059
|
+
if (universal)
|
|
2060
|
+
return universal;
|
|
2061
|
+
const available = assets
|
|
2062
|
+
.filter(asset => /\.dmg$/i.test(asset.name))
|
|
2063
|
+
.map(asset => asset.name)
|
|
2064
|
+
.join(', ');
|
|
2065
|
+
throw new Error(available
|
|
2066
|
+
? `No ${arch} macOS DMG found in this release. Available DMGs: ${available}`
|
|
2067
|
+
: 'No macOS DMG asset found in the latest Clementine release.');
|
|
2068
|
+
}
|
|
2069
|
+
function expandHomePath(rawPath) {
|
|
2070
|
+
if (rawPath === '~')
|
|
2071
|
+
return os.homedir();
|
|
2072
|
+
if (rawPath.startsWith('~/'))
|
|
2073
|
+
return path.join(os.homedir(), rawPath.slice(2));
|
|
2074
|
+
return rawPath;
|
|
2075
|
+
}
|
|
2076
|
+
function desktopDownloadDestination(output, assetName) {
|
|
2077
|
+
if (output) {
|
|
2078
|
+
const expanded = path.resolve(expandHomePath(output));
|
|
2079
|
+
if (existsSync(expanded) && statSync(expanded).isDirectory()) {
|
|
2080
|
+
return path.join(expanded, assetName);
|
|
2081
|
+
}
|
|
2082
|
+
return expanded;
|
|
2083
|
+
}
|
|
2084
|
+
return path.join(os.homedir(), 'Downloads', assetName);
|
|
2085
|
+
}
|
|
2086
|
+
async function downloadFile(url, destination, expectedSize) {
|
|
2087
|
+
mkdirSync(path.dirname(destination), { recursive: true });
|
|
2088
|
+
const tempPath = `${destination}.download`;
|
|
2089
|
+
try {
|
|
2090
|
+
unlinkSync(tempPath);
|
|
2091
|
+
}
|
|
2092
|
+
catch { /* no previous partial download */ }
|
|
2093
|
+
const res = await requestUrl(url);
|
|
2094
|
+
const total = Number(res.headers['content-length'] ?? expectedSize ?? 0);
|
|
2095
|
+
let downloaded = 0;
|
|
2096
|
+
let lastPrintedAt = 0;
|
|
2097
|
+
res.on('data', (chunk) => {
|
|
2098
|
+
downloaded += chunk.length;
|
|
2099
|
+
const now = Date.now();
|
|
2100
|
+
if (now - lastPrintedAt < 250 && downloaded !== total)
|
|
2101
|
+
return;
|
|
2102
|
+
lastPrintedAt = now;
|
|
2103
|
+
const totalLabel = total > 0 ? ` / ${formatBytes(total)}` : '';
|
|
2104
|
+
process.stdout.write(`\r Downloaded ${formatBytes(downloaded)}${totalLabel}`);
|
|
2105
|
+
});
|
|
2106
|
+
try {
|
|
2107
|
+
await pipeline(res, createWriteStream(tempPath, { mode: 0o644 }));
|
|
2108
|
+
process.stdout.write('\n');
|
|
2109
|
+
renameSync(tempPath, destination);
|
|
2110
|
+
}
|
|
2111
|
+
catch (err) {
|
|
2112
|
+
try {
|
|
2113
|
+
unlinkSync(tempPath);
|
|
2114
|
+
}
|
|
2115
|
+
catch { /* best effort */ }
|
|
2116
|
+
throw err;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
function openDesktopInstaller(destination) {
|
|
2120
|
+
const child = spawn('open', [destination], { detached: true, stdio: 'ignore' });
|
|
2121
|
+
child.unref();
|
|
2122
|
+
}
|
|
2123
|
+
async function cmdDesktopDownload(options) {
|
|
2124
|
+
const DIM = '\x1b[0;90m';
|
|
2125
|
+
const BOLD = '\x1b[1m';
|
|
2126
|
+
const GREEN = '\x1b[0;32m';
|
|
2127
|
+
const RESET = '\x1b[0m';
|
|
2128
|
+
if (process.platform !== 'darwin') {
|
|
2129
|
+
throw new Error('Clementine Desktop install is currently macOS only.');
|
|
2130
|
+
}
|
|
2131
|
+
const release = await fetchJson(desktopReleaseApiUrl(options.version));
|
|
2132
|
+
const asset = selectDesktopDmgAsset(release);
|
|
2133
|
+
const destination = desktopDownloadDestination(options.output, asset.name);
|
|
2134
|
+
console.log();
|
|
2135
|
+
console.log(` ${DIM}Clementine Desktop${RESET} ${release.tag_name ? `${DIM}(${release.tag_name})${RESET}` : ''}`);
|
|
2136
|
+
console.log(` Downloading ${BOLD}${asset.name}${RESET}`);
|
|
2137
|
+
console.log(` ${DIM}${destination}${RESET}`);
|
|
2138
|
+
await downloadFile(asset.browser_download_url, destination, asset.size);
|
|
2139
|
+
console.log(` ${GREEN}OK${RESET} Downloaded ${BOLD}${path.basename(destination)}${RESET}`);
|
|
2140
|
+
if (options.open !== false) {
|
|
2141
|
+
openDesktopInstaller(destination);
|
|
2142
|
+
console.log(` ${GREEN}OK${RESET} Opened installer. Drag Clementine to Applications.`);
|
|
2143
|
+
}
|
|
2144
|
+
else {
|
|
2145
|
+
console.log(` ${DIM}Open it later with: open "${destination}"${RESET}`);
|
|
2146
|
+
}
|
|
2147
|
+
console.log();
|
|
2148
|
+
}
|
|
1966
2149
|
// ── Program ──────────────────────────────────────────────────────────
|
|
1967
2150
|
const program = new Command();
|
|
1968
2151
|
let pkgVersion = '0.0.0';
|
|
@@ -2356,6 +2539,41 @@ program
|
|
|
2356
2539
|
process.exit(1);
|
|
2357
2540
|
});
|
|
2358
2541
|
});
|
|
2542
|
+
const desktopCmd = program
|
|
2543
|
+
.command('desktop')
|
|
2544
|
+
.description('Download and open Clementine Desktop for macOS')
|
|
2545
|
+
.option('-o, --output <path>', 'Download path or directory')
|
|
2546
|
+
.option('--version <tag>', 'GitHub release tag/version (default: latest)', 'latest')
|
|
2547
|
+
.option('--no-open', 'Download only; do not open the DMG')
|
|
2548
|
+
.action((options) => {
|
|
2549
|
+
cmdDesktopDownload({ ...options, open: options.open !== false }).catch((err) => {
|
|
2550
|
+
console.error('Desktop install failed:', err instanceof Error ? err.message : err);
|
|
2551
|
+
process.exit(1);
|
|
2552
|
+
});
|
|
2553
|
+
});
|
|
2554
|
+
desktopCmd
|
|
2555
|
+
.command('install')
|
|
2556
|
+
.description('Download and open the latest macOS DMG installer')
|
|
2557
|
+
.option('-o, --output <path>', 'Download path or directory')
|
|
2558
|
+
.option('--version <tag>', 'GitHub release tag/version (default: latest)', 'latest')
|
|
2559
|
+
.option('--no-open', 'Download only; do not open the DMG')
|
|
2560
|
+
.action((options) => {
|
|
2561
|
+
cmdDesktopDownload({ ...options, open: options.open !== false }).catch((err) => {
|
|
2562
|
+
console.error('Desktop install failed:', err instanceof Error ? err.message : err);
|
|
2563
|
+
process.exit(1);
|
|
2564
|
+
});
|
|
2565
|
+
});
|
|
2566
|
+
desktopCmd
|
|
2567
|
+
.command('download')
|
|
2568
|
+
.description('Download the latest macOS DMG without opening it')
|
|
2569
|
+
.option('-o, --output <path>', 'Download path or directory')
|
|
2570
|
+
.option('--version <tag>', 'GitHub release tag/version (default: latest)', 'latest')
|
|
2571
|
+
.action((options) => {
|
|
2572
|
+
cmdDesktopDownload({ ...options, open: false }).catch((err) => {
|
|
2573
|
+
console.error('Desktop download failed:', err instanceof Error ? err.message : err);
|
|
2574
|
+
process.exit(1);
|
|
2575
|
+
});
|
|
2576
|
+
});
|
|
2359
2577
|
program
|
|
2360
2578
|
.command('update')
|
|
2361
2579
|
.description('Pull latest code, rebuild, and reinstall (preserves config). Pass "history" to show recent updates.')
|
|
@@ -20,7 +20,4 @@ export declare function detectCronDiagnosticRequest(text: string, opts?: {
|
|
|
20
20
|
export declare function buildCronDiagnosticResponseForRequest(request: CronDiagnosticRequest, opts?: {
|
|
21
21
|
baseDir: string;
|
|
22
22
|
}): string | null;
|
|
23
|
-
export declare function buildCronDiagnosticResponse(text: string, opts?: {
|
|
24
|
-
baseDir: string;
|
|
25
|
-
}): string | null;
|
|
26
23
|
//# sourceMappingURL=cron-diagnostic-turn.d.ts.map
|
|
@@ -274,10 +274,4 @@ export function buildCronDiagnosticResponseForRequest(request, opts = { baseDir:
|
|
|
274
274
|
}
|
|
275
275
|
return lines.join('\n');
|
|
276
276
|
}
|
|
277
|
-
export function buildCronDiagnosticResponse(text, opts = { baseDir: process.env.CLEMENTINE_HOME || '' }) {
|
|
278
|
-
const request = detectCronDiagnosticRequest(text, { baseDir: opts.baseDir });
|
|
279
|
-
if (!request || !opts.baseDir)
|
|
280
|
-
return null;
|
|
281
|
-
return buildCronDiagnosticResponseForRequest(request, opts);
|
|
282
|
-
}
|
|
283
277
|
//# sourceMappingURL=cron-diagnostic-turn.js.map
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { listBackgroundTasks } from '../agent/background-tasks.js';
|
|
10
10
|
import { buildNotificationContextPrompt, findRecentNotificationContext, looksLikeNotificationFollowup, } from './notification-context.js';
|
|
11
|
-
import {
|
|
11
|
+
import { detectCronDiagnosticRequest, isInternalSyntheticPrompt, } from './cron-diagnostic-turn.js';
|
|
12
12
|
export { isInternalSyntheticPrompt } from './cron-diagnostic-turn.js';
|
|
13
13
|
const RECENT_TASK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
14
14
|
function normalizeForMatch(text) {
|
|
@@ -22,48 +22,6 @@ function normalizeForMatch(text) {
|
|
|
22
22
|
function compactWhitespace(text) {
|
|
23
23
|
return text.replace(/\s+/g, ' ').trim();
|
|
24
24
|
}
|
|
25
|
-
function wantsFix(text) {
|
|
26
|
-
return /\b(fix|repair|solve|handle|diagnose|debug|what broke|what happened|why did|why is|issue|problem|failure|failed|failing)\b/i.test(text);
|
|
27
|
-
}
|
|
28
|
-
function jobMentionedInText(jobName, text) {
|
|
29
|
-
const normalizedText = normalizeForMatch(text);
|
|
30
|
-
const normalizedJob = normalizeForMatch(jobName);
|
|
31
|
-
return !!normalizedJob && normalizedText.includes(normalizedJob);
|
|
32
|
-
}
|
|
33
|
-
function resolveNotificationJob(event, userText, baseDir) {
|
|
34
|
-
const explicit = detectCronDiagnosticRequest(userText, { baseDir });
|
|
35
|
-
if (explicit?.jobName)
|
|
36
|
-
return explicit.jobName;
|
|
37
|
-
const jobs = event.jobNames ?? [];
|
|
38
|
-
if (jobs.length === 1)
|
|
39
|
-
return jobs[0] ?? null;
|
|
40
|
-
const mentioned = jobs.find((job) => jobMentionedInText(job, userText));
|
|
41
|
-
return mentioned ?? null;
|
|
42
|
-
}
|
|
43
|
-
function summarizeCronNotification(event, userText, opts) {
|
|
44
|
-
const jobs = event.jobNames ?? [];
|
|
45
|
-
if (jobs.length === 0)
|
|
46
|
-
return null;
|
|
47
|
-
const targetJob = resolveNotificationJob(event, userText, opts.baseDir);
|
|
48
|
-
if (targetJob) {
|
|
49
|
-
return buildCronDiagnosticResponseForRequest({ jobName: targetJob, wantsFix: wantsFix(userText) }, { baseDir: opts.baseDir });
|
|
50
|
-
}
|
|
51
|
-
const lines = [
|
|
52
|
-
`I am resolving this to the recent cron failure alert: ${jobs.join(', ')}.`,
|
|
53
|
-
'More than one job was in that alert, so I am not going to guess or start background work.',
|
|
54
|
-
'',
|
|
55
|
-
];
|
|
56
|
-
for (const job of jobs.slice(0, 5)) {
|
|
57
|
-
const diagnostic = buildCronDiagnosticResponseForRequest({ jobName: job, wantsFix: false }, { baseDir: opts.baseDir });
|
|
58
|
-
const preview = diagnostic
|
|
59
|
-
? diagnostic.split('\n').slice(1, 4).join(' ')
|
|
60
|
-
: 'No local diagnostic summary available.';
|
|
61
|
-
lines.push(`- ${job}: ${compactWhitespace(preview).slice(0, 260)}`);
|
|
62
|
-
}
|
|
63
|
-
lines.push('');
|
|
64
|
-
lines.push(`Reply \`fix ${jobs[0]}\` or name the job you want me to repair first.`);
|
|
65
|
-
return lines.join('\n');
|
|
66
|
-
}
|
|
67
25
|
function taskMatchesSession(task, sessionKey) {
|
|
68
26
|
return task.sessionKey === sessionKey;
|
|
69
27
|
}
|
|
@@ -151,22 +109,11 @@ export function resolveRecentOperationalContext(sessionKey, text, opts) {
|
|
|
151
109
|
now: opts.now,
|
|
152
110
|
});
|
|
153
111
|
if (notification) {
|
|
154
|
-
if (notification.type === 'cron_failure') {
|
|
155
|
-
const responseText = summarizeCronNotification(notification, text, opts);
|
|
156
|
-
if (responseText) {
|
|
157
|
-
return {
|
|
158
|
-
source: 'notification',
|
|
159
|
-
reason: 'vague-followup-to-cron-failure-notification',
|
|
160
|
-
responseText,
|
|
161
|
-
suppressDeepMode: true,
|
|
162
|
-
eventId: notification.id,
|
|
163
|
-
jobNames: notification.jobNames,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
112
|
return {
|
|
168
113
|
source: 'notification',
|
|
169
|
-
reason:
|
|
114
|
+
reason: notification.type === 'cron_failure'
|
|
115
|
+
? 'vague-followup-to-cron-failure-notification'
|
|
116
|
+
: 'vague-followup-to-proactive-notification',
|
|
170
117
|
promptText: buildNotificationContextPrompt(notification, text),
|
|
171
118
|
suppressDeepMode: true,
|
|
172
119
|
eventId: notification.id,
|
package/dist/gateway/router.js
CHANGED
|
@@ -20,7 +20,6 @@ import { listBackgroundTasks, loadBackgroundTask, markFailed } from '../agent/ba
|
|
|
20
20
|
import { applyAssistantExperienceUpdate, detectApprovalReply, detectLocalTurn } from '../agent/local-turn.js';
|
|
21
21
|
import { buildApprovalFollowupPrompt, detectActionExpectation } from '../agent/action-enforcer.js';
|
|
22
22
|
import { updateClementineJson } from '../config/clementine-json.js';
|
|
23
|
-
import { buildCronDiagnosticResponse } from './cron-diagnostic-turn.js';
|
|
24
23
|
import { classifyIntent } from '../agent/intent-classifier.js';
|
|
25
24
|
import { decideTurn } from '../agent/turn-policy.js';
|
|
26
25
|
import { recordProactiveNotificationEvent, } from './notification-context.js';
|
|
@@ -1441,34 +1440,6 @@ export class Gateway {
|
|
|
1441
1440
|
text = recentContext.promptText;
|
|
1442
1441
|
}
|
|
1443
1442
|
}
|
|
1444
|
-
// Cron "what broke / fix this job" asks should not spin up a broad SDK
|
|
1445
|
-
// session. They are bounded local diagnostics over run summaries and scalar
|
|
1446
|
-
// config only, and they intentionally do not execute the cron job.
|
|
1447
|
-
if (this.isTrustedPersonalSession(sessionKey) && !isInternalSyntheticPrompt(text)) {
|
|
1448
|
-
const cronDiagnostic = buildCronDiagnosticResponse(text, { baseDir: BASE_DIR });
|
|
1449
|
-
if (cronDiagnostic) {
|
|
1450
|
-
const current = this.sessions.get(sessionKey);
|
|
1451
|
-
if (current?.abortController && !current.abortController.signal.aborted) {
|
|
1452
|
-
current.abortController.abort('replaced-by-cron-diagnostic');
|
|
1453
|
-
logger.info({ sessionKey }, 'Interrupted active chat for local cron diagnostic');
|
|
1454
|
-
}
|
|
1455
|
-
this.assistant.injectContext(sessionKey, originalText, cronDiagnostic);
|
|
1456
|
-
if (onText) {
|
|
1457
|
-
try {
|
|
1458
|
-
await onText(cronDiagnostic);
|
|
1459
|
-
}
|
|
1460
|
-
catch { /* channel streaming is best-effort */ }
|
|
1461
|
-
}
|
|
1462
|
-
logger.info({
|
|
1463
|
-
sessionKey,
|
|
1464
|
-
totalMs: Date.now() - tInnerStart,
|
|
1465
|
-
chatMs: Date.now() - localTurnStarted,
|
|
1466
|
-
localCronDiagnostic: true,
|
|
1467
|
-
responseLen: cronDiagnostic.length,
|
|
1468
|
-
}, 'chat:latency');
|
|
1469
|
-
return cronDiagnostic;
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
1443
|
// Show "queued" status if either lane or session lock is contended,
|
|
1473
1444
|
// so the user doesn't stare at "thinking..." for up to 60s while a
|
|
1474
1445
|
// previous message is still processing.
|
|
@@ -1779,5 +1779,16 @@ export function registerAdminTools(server) {
|
|
|
1779
1779
|
`The fix-verification tracker will roll it back automatically if the next runs don't improve. ` +
|
|
1780
1780
|
`Root cause: ${d.rootCause?.slice(0, 200) ?? ""}.`);
|
|
1781
1781
|
});
|
|
1782
|
+
server.tool('cron_diagnose', 'Return a bounded deterministic diagnosis for one cron job — recent run summary, unleashed phase status, last clean success, current scalar config, and the inferred root cause. Reads only from local run history and cron config; does NOT execute the job. Use this when the user asks what is broken with a specific job, before deciding whether to call apply_broken_job_fix or to propose a manual repair.', {
|
|
1783
|
+
jobName: z.string().describe('The job name as shown in CRON.md or list_broken_jobs output (e.g. "audit-inbox-check" or "ross-the-sdr:reply-detection").'),
|
|
1784
|
+
}, async ({ jobName }) => {
|
|
1785
|
+
const { buildCronDiagnosticResponseForRequest } = await import('../gateway/cron-diagnostic-turn.js');
|
|
1786
|
+
const response = buildCronDiagnosticResponseForRequest({ jobName, wantsFix: true }, { baseDir: BASE_DIR });
|
|
1787
|
+
if (!response) {
|
|
1788
|
+
return textResult(`No diagnostic data for \`${jobName}\` — either the job is not configured in CRON.md or there is no run history yet. ` +
|
|
1789
|
+
`Check the job name with cron_list or list_broken_jobs.`);
|
|
1790
|
+
}
|
|
1791
|
+
return textResult(response);
|
|
1792
|
+
});
|
|
1782
1793
|
}
|
|
1783
1794
|
//# sourceMappingURL=admin-tools.js.map
|
package/electron-builder.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clementine-agent",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.65",
|
|
4
4
|
"description": "Clementine — Personal AI Assistant (TypeScript)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"desktop:debug": "npm run build && CLEMENTINE_DESKTOP_DEBUG=1 electron dist/desktop/main.js",
|
|
19
19
|
"desktop:prepare": "npm run build && npm rebuild better-sqlite3",
|
|
20
20
|
"desktop:pack": "npm run desktop:prepare && electron-builder --mac --dir",
|
|
21
|
-
"desktop:dist": "
|
|
21
|
+
"desktop:dist": "scripts/build-desktop-mac.sh",
|
|
22
|
+
"desktop:dist:unnotarized": "CLEMENTINE_ALLOW_UNNOTARIZED=1 scripts/build-desktop-mac.sh",
|
|
22
23
|
"mcp": "tsx src/tools/mcp-server.ts",
|
|
23
24
|
"cli": "tsx src/cli/index.ts",
|
|
24
25
|
"typecheck": "tsc --noEmit",
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
+
cd "$ROOT_DIR"
|
|
6
|
+
|
|
7
|
+
load_env_file() {
|
|
8
|
+
local file="$1"
|
|
9
|
+
if [[ -f "$file" ]]; then
|
|
10
|
+
set -a
|
|
11
|
+
# shellcheck disable=SC1090
|
|
12
|
+
source "$file"
|
|
13
|
+
set +a
|
|
14
|
+
fi
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
load_env_file "$ROOT_DIR/.env.signing"
|
|
18
|
+
load_env_file "$ROOT_DIR/.env.release"
|
|
19
|
+
load_env_file "$HOME/.clementine/signing.env"
|
|
20
|
+
|
|
21
|
+
if [[ -n "${APPLE_ID_PASSWORD:-}" && -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]]; then
|
|
22
|
+
export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_ID_PASSWORD"
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
if [[ -n "${CLEMENTINE_NOTARY_PROFILE:-}" && -z "${APPLE_KEYCHAIN_PROFILE:-}" ]]; then
|
|
26
|
+
export APPLE_KEYCHAIN_PROFILE="$CLEMENTINE_NOTARY_PROFILE"
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
has_password_creds=false
|
|
30
|
+
if [[ -n "${APPLE_ID:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" && -n "${APPLE_TEAM_ID:-}" ]]; then
|
|
31
|
+
has_password_creds=true
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
has_api_key_creds=false
|
|
35
|
+
if [[ -n "${APPLE_API_KEY:-}" && -n "${APPLE_API_KEY_ID:-}" && -n "${APPLE_API_ISSUER:-}" ]]; then
|
|
36
|
+
has_api_key_creds=true
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
has_keychain_profile=false
|
|
40
|
+
if [[ -n "${APPLE_KEYCHAIN_PROFILE:-}" ]]; then
|
|
41
|
+
has_keychain_profile=true
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
notarytool_args=()
|
|
45
|
+
if [[ "$has_password_creds" == true ]]; then
|
|
46
|
+
notarytool_args=(--apple-id "$APPLE_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$APPLE_TEAM_ID")
|
|
47
|
+
elif [[ "$has_api_key_creds" == true ]]; then
|
|
48
|
+
notarytool_args=(--key "$APPLE_API_KEY" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER")
|
|
49
|
+
elif [[ "$has_keychain_profile" == true ]]; then
|
|
50
|
+
notarytool_args=(--keychain-profile "$APPLE_KEYCHAIN_PROFILE")
|
|
51
|
+
if [[ -n "${APPLE_KEYCHAIN:-}" ]]; then
|
|
52
|
+
notarytool_args=(--keychain "$APPLE_KEYCHAIN" "${notarytool_args[@]}")
|
|
53
|
+
fi
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
if [[ "$has_password_creds" != true && "$has_api_key_creds" != true && "$has_keychain_profile" != true ]]; then
|
|
57
|
+
cat >&2 <<'EOF'
|
|
58
|
+
No Apple notarization credentials were detected, so the release DMG would be signed but not notarized.
|
|
59
|
+
|
|
60
|
+
Set one of these before running npm run desktop:dist:
|
|
61
|
+
- APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID
|
|
62
|
+
- APPLE_API_KEY, APPLE_API_KEY_ID, APPLE_API_ISSUER
|
|
63
|
+
- APPLE_KEYCHAIN_PROFILE
|
|
64
|
+
|
|
65
|
+
This script also accepts:
|
|
66
|
+
- APPLE_ID_PASSWORD as an alias for APPLE_APP_SPECIFIC_PASSWORD
|
|
67
|
+
- CLEMENTINE_NOTARY_PROFILE as an alias for APPLE_KEYCHAIN_PROFILE
|
|
68
|
+
|
|
69
|
+
One-time local keychain setup:
|
|
70
|
+
xcrun notarytool store-credentials clementine --apple-id <apple-id> --team-id 4AR3Y8XD72 --sync
|
|
71
|
+
CLEMENTINE_NOTARY_PROFILE=clementine npm run desktop:dist
|
|
72
|
+
|
|
73
|
+
For a local signed-but-unnotarized test build:
|
|
74
|
+
npm run desktop:dist:unnotarized
|
|
75
|
+
EOF
|
|
76
|
+
if [[ "${CLEMENTINE_ALLOW_UNNOTARIZED:-}" != "1" ]]; then
|
|
77
|
+
exit 1
|
|
78
|
+
fi
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
npm run desktop:prepare
|
|
82
|
+
"$ROOT_DIR/node_modules/.bin/electron-builder" --mac
|
|
83
|
+
|
|
84
|
+
if [[ ${#notarytool_args[@]} -gt 0 ]]; then
|
|
85
|
+
package_version="$(node -p "require('./package.json').version")"
|
|
86
|
+
shopt -s nullglob
|
|
87
|
+
dmg_files=("$ROOT_DIR"/release/Clementine-"$package_version"-mac-*.dmg)
|
|
88
|
+
shopt -u nullglob
|
|
89
|
+
|
|
90
|
+
for dmg_file in "${dmg_files[@]}"; do
|
|
91
|
+
echo "Notarizing DMG wrapper: $(basename "$dmg_file")"
|
|
92
|
+
xcrun notarytool submit "$dmg_file" --wait "${notarytool_args[@]}"
|
|
93
|
+
xcrun stapler staple "$dmg_file"
|
|
94
|
+
xcrun stapler validate "$dmg_file"
|
|
95
|
+
|
|
96
|
+
case "$(uname -m)" in
|
|
97
|
+
arm64) app_builder="$ROOT_DIR/node_modules/app-builder-bin/mac/app-builder_arm64" ;;
|
|
98
|
+
x86_64) app_builder="$ROOT_DIR/node_modules/app-builder-bin/mac/app-builder_amd64" ;;
|
|
99
|
+
*) app_builder="" ;;
|
|
100
|
+
esac
|
|
101
|
+
if [[ -n "$app_builder" && -x "$app_builder" ]]; then
|
|
102
|
+
"$app_builder" blockmap --input "$dmg_file" --output "$dmg_file.blockmap"
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
if [[ -f "$ROOT_DIR/release/latest-mac.yml" ]]; then
|
|
106
|
+
node --input-type=module - "$dmg_file" "$ROOT_DIR/release/latest-mac.yml" <<'NODE'
|
|
107
|
+
import { createHash } from 'node:crypto';
|
|
108
|
+
import { readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
109
|
+
import { basename } from 'node:path';
|
|
110
|
+
|
|
111
|
+
const [dmgPath, latestPath] = process.argv.slice(2);
|
|
112
|
+
const url = basename(dmgPath);
|
|
113
|
+
const sha512 = createHash('sha512').update(readFileSync(dmgPath)).digest('base64');
|
|
114
|
+
const size = statSync(dmgPath).size;
|
|
115
|
+
const lines = readFileSync(latestPath, 'utf8').split(/\r?\n/);
|
|
116
|
+
|
|
117
|
+
for (let index = 0; index < lines.length; index++) {
|
|
118
|
+
if (lines[index].trim() === `- url: ${url}`) {
|
|
119
|
+
for (let cursor = index + 1; cursor < lines.length; cursor++) {
|
|
120
|
+
if (/^\S/.test(lines[cursor]) || /^\s*-\s+url:/.test(lines[cursor])) break;
|
|
121
|
+
if (lines[cursor].trim().startsWith('sha512:')) lines[cursor] = ` sha512: ${sha512}`;
|
|
122
|
+
if (lines[cursor].trim().startsWith('size:')) lines[cursor] = ` size: ${size}`;
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
writeFileSync(latestPath, `${lines.join('\n').replace(/\n*$/, '')}\n`);
|
|
129
|
+
NODE
|
|
130
|
+
fi
|
|
131
|
+
done
|
|
132
|
+
fi
|