claude-home 1.5.9 → 1.5.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/public/index.html +361 -12
  3. package/server.js +215 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-home",
3
- "version": "1.5.9",
3
+ "version": "1.5.26",
4
4
  "description": "Web dashboard for Claude Code — browse sessions, manage skills, hooks, commands, and agents",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -1548,6 +1548,17 @@
1548
1548
  Plans
1549
1549
  <span class="nav-count" x-show="plans.length>0" x-text="plans.length"></span>
1550
1550
  </div>
1551
+ <div class="nav-item" :class="{ active: view === 'notes' }" @click="view='notes';selectedSession=null;loadPersonalNotes()">
1552
+ <span class="nav-icon">
1553
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke-width="1.5" stroke="currentColor">
1554
+ <path d="M3 2h8l3 3v9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/>
1555
+ <polyline points="11 2 11 5 14 5"/>
1556
+ <line x1="5" y1="7" x2="11" y2="7"/><line x1="5" y1="10" x2="9" y2="10"/>
1557
+ </svg>
1558
+ </span>
1559
+ Notes
1560
+ <span class="nav-count" x-show="personalNotes.length>0" x-text="personalNotes.length"></span>
1561
+ </div>
1551
1562
  </div>
1552
1563
  <div class="nav-divider"></div>
1553
1564
  <div class="nav-section">
@@ -2252,25 +2263,32 @@
2252
2263
  </div>
2253
2264
  </div>
2254
2265
  <div style="display:flex;gap:6px;align-items:center;flex-shrink:0">
2255
- <button class="btn btn-sm" :class="cleanMode ? 'btn-primary' : ''" style="background:var(--canvas-2);color:var(--ink)" :style="cleanMode ? 'background:var(--blue);color:#fff' : ''" @click="cleanMode=!cleanMode" title="Toggle focus view">Focus</button>
2266
+ <button class="btn btn-sm" :style="cleanMode ? 'background:var(--blue);color:#fff' : 'background:var(--canvas-2);color:var(--ink)'" @click="cleanMode=!cleanMode" title="Toggle focus view">Focus</button>
2267
+ <div style="flex:1"></div>
2268
+ <span x-show="exportMsg" x-text="exportMsg" style="font-size:12px;color:var(--green)" x-transition></span>
2269
+ <span x-show="deletingSession" style="font-size:12px;color:var(--ink-3)">Deleting…</span>
2270
+ <button class="btn btn-primary btn-sm" id="resume-detail" :disabled="!sessionDetail?.resumable" :title="!sessionDetail?.resumable ? 'Session file deleted — cannot resume' : 'Copy command to resume this session'" @click="sessionDetail?.resumable && resumeSession(selectedSession.sessionId, 'resume-detail', selectedSession.projectPath)">
2271
+ Resume →
2272
+ </button>
2273
+ <!-- ⋮ dropdown -->
2256
2274
  <div style="position:relative">
2257
- <button class="btn btn-sm" style="background:var(--canvas-2);color:var(--ink);display:flex;align-items:center;gap:4px" @click="exportDropOpen=!exportDropOpen" @click.outside="exportDropOpen=false">
2258
- Export <svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3.5L5 6.5L8 3.5"/></svg>
2275
+ <button class="btn btn-sm" style="background:var(--canvas-2);color:var(--ink);border:1px solid var(--rule);padding:4px 10px;display:flex;align-items:center" @click="exportDropOpen=!exportDropOpen" @click.outside="exportDropOpen=false" title="More options">
2276
+ <svg width="3" height="15" viewBox="0 0 3 15" fill="currentColor"><circle cx="1.5" cy="1.5" r="1.5"/><circle cx="1.5" cy="7.5" r="1.5"/><circle cx="1.5" cy="13.5" r="1.5"/></svg>
2259
2277
  </button>
2260
- <div x-show="exportDropOpen" x-transition style="position:absolute;top:calc(100% + 4px);right:0;background:var(--surface);border:1px solid var(--rule);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,.15);z-index:50;min-width:160px;overflow:hidden">
2278
+ <div x-show="exportDropOpen" x-transition style="position:absolute;top:calc(100% + 4px);right:0;background:var(--canvas,#fff);border:1px solid var(--rule);border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:50;min-width:180px;overflow:hidden">
2261
2279
  <button class="export-drop-item" @click="exportSession(true); exportDropOpen=false">Download .md</button>
2262
- <button class="export-drop-item" @click="copySessionMd(); exportDropOpen=false">Copy to clipboard</button>
2280
+ <button class="export-drop-item" @click="copySessionMd(); exportDropOpen=false">Copy as Markdown</button>
2281
+ <button class="export-drop-item" @click="shareSession(); exportDropOpen=false" :disabled="!!shareMsg"><span x-text="shareMsg || 'Publish to Gist'"></span></button>
2282
+ <button class="export-drop-item" @click="navigator.clipboard.writeText(location.origin+location.pathname+'#/session/'+selectedSession.projectDir+'/'+selectedSession.sessionId);exportDropOpen=false">Copy local link</button>
2283
+ <button class="export-drop-item" @click="saveNoteFromSession();exportDropOpen=false">Save as note</button>
2284
+ <div style="height:1px;background:var(--rule);margin:4px 0"></div>
2285
+ <button class="export-drop-item" style="color:var(--red)" @click="deleteSession();exportDropOpen=false" x-show="!deletingSession">Delete session</button>
2263
2286
  </div>
2264
2287
  </div>
2265
- <button class="btn btn-sm" style="background:var(--red-dim,#3a1a1a);color:var(--red);border:1px solid var(--red)" @click="deleteSession()" x-show="!deletingSession">Delete</button>
2266
- <span x-show="deletingSession" style="font-size:12px;color:var(--ink-3)">Deleting…</span>
2267
- <button class="btn btn-primary btn-sm" id="resume-detail" :disabled="!sessionDetail?.resumable" :title="!sessionDetail?.resumable ? 'Session file deleted — cannot resume' : 'Copy command to resume this session'" @click="sessionDetail?.resumable && resumeSession(selectedSession.sessionId, 'resume-detail', selectedSession.projectPath)">
2268
- Resume →
2269
- </button>
2270
- <span x-show="exportMsg" x-text="exportMsg" style="font-size:12px;color:var(--green)" x-transition></span>
2271
2288
  </div>
2272
2289
  </div>
2273
2290
 
2291
+
2274
2292
  <div class="chat-messages">
2275
2293
  <template x-if="loadingDetail">
2276
2294
  <div class="loading"><div class="spinner"></div> Loading conversation…</div>
@@ -2528,8 +2546,10 @@
2528
2546
  <template x-if="selectedPlan">
2529
2547
  <div style="display:flex;align-items:center;gap:8px;margin-left:auto">
2530
2548
  <span class="save-msg" x-show="planExportMsg" x-text="planExportMsg"></span>
2549
+ <span class="save-msg" x-show="planShareMsg" x-text="planShareMsg"></span>
2531
2550
  <button class="btn btn-sm btn-outline" @click="copyPlanMd()">Copy MD</button>
2532
2551
  <button class="btn btn-sm btn-outline" @click="downloadPlan()">Download</button>
2552
+ <button class="btn btn-sm" style="background:var(--canvas-2);color:var(--ink)" title="Publish to GitHub Gist and copy public URL" @click="sharePlan(selectedPlan.filename)" :disabled="!!planShareMsg">Share</button>
2533
2553
  <button class="btn btn-sm" style="background:var(--red-dim,#3a1a1a);color:var(--red);border:1px solid var(--red)" @click="deletePlan()">Delete</button>
2534
2554
  </div>
2535
2555
  </template>
@@ -2575,6 +2595,103 @@
2575
2595
  </div>
2576
2596
  </template>
2577
2597
 
2598
+ <!-- Notes view -->
2599
+ <template x-if="view === 'notes' && !selectedSession">
2600
+ <div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">
2601
+ <div class="topbar">
2602
+ <span class="topbar-title">Notes</span>
2603
+ <span style="font-size:11px;color:var(--ink-3);padding:2px 8px;background:var(--canvas-2);border:1px solid var(--rule);border-radius:4px" title="These are your personal notes — Claude doesn't read or use them as context. They're only for you.">Your notepad · not Claude's memory</span>
2604
+ <input class="search-input" type="text" placeholder="Search notes…" x-model="noteSearch" style="font-size:12px;width:200px;padding:4px 10px" />
2605
+ <span style="font-size:11px;color:var(--ink-3)" x-show="personalNotes.length>0" x-text="personalNotes.length + ' notes'"></span>
2606
+ <div style="display:flex;align-items:center;gap:8px;margin-left:auto">
2607
+ <template x-if="selectedNote && noteEditing">
2608
+ <div style="display:flex;align-items:center;gap:8px">
2609
+ <span class="save-msg" x-show="noteMsg==='Saved'" x-text="noteMsg"></span>
2610
+ <span class="err-msg" x-show="noteMsg && noteMsg!=='Saved'" x-text="noteMsg"></span>
2611
+ <button class="btn btn-sm btn-outline" @click="noteEditing=false">Cancel</button>
2612
+ <button class="btn btn-primary btn-sm" @click="savePersonalNote()" :disabled="noteSaving" x-text="noteSaving?'Saving…':'Save'"></button>
2613
+ </div>
2614
+ </template>
2615
+ <template x-if="selectedNote && !noteEditing">
2616
+ <div style="display:flex;align-items:center;gap:8px">
2617
+ <button class="btn btn-sm btn-outline" @click="noteEditing=true;noteTitleDraft=selectedNote.title;noteBodyDraft=selectedNote.content">Edit</button>
2618
+ <button class="btn btn-sm" style="background:var(--red-dim,#3a1a1a);color:var(--red);border:1px solid var(--red)" @click="deletePersonalNote()">Delete</button>
2619
+ </div>
2620
+ </template>
2621
+ <button class="btn btn-primary btn-sm" @click="noteCreating=true;noteNewTitle='';noteNewBody=''">+ New note</button>
2622
+ </div>
2623
+ </div>
2624
+
2625
+ <!-- New note form -->
2626
+ <template x-if="noteCreating">
2627
+ <div style="padding:16px 20px;border-bottom:1px solid var(--rule);background:var(--canvas-2)">
2628
+ <input class="settings-input" type="text" placeholder="Note title…" x-model="noteNewTitle" style="width:100%;margin-bottom:8px;font-size:13px" @keydown.enter="createPersonalNote()" />
2629
+ <textarea class="settings-input" placeholder="Content (optional)…" x-model="noteNewBody" rows="3" style="width:100%;resize:vertical;font-size:12px;font-family:inherit"></textarea>
2630
+ <div style="display:flex;gap:8px;margin-top:8px;justify-content:flex-end">
2631
+ <button class="btn btn-sm btn-outline" @click="noteCreating=false">Cancel</button>
2632
+ <button class="btn btn-primary btn-sm" @click="createPersonalNote()" :disabled="noteSaving||!noteNewTitle.trim()">Create</button>
2633
+ </div>
2634
+ </div>
2635
+ </template>
2636
+
2637
+ <div style="flex:1;overflow:hidden;">
2638
+ <template x-if="personalNotesLoading"><div class="loading"><div class="spinner"></div> Loading…</div></template>
2639
+ <template x-if="!personalNotesLoading && noteClaudeInstalled === false">
2640
+ <div style="margin:12px 20px;padding:10px 14px;background:var(--canvas-2);border:1px solid var(--rule);border-radius:8px;display:flex;align-items:center;justify-content:space-between;gap:12px">
2641
+ <div>
2642
+ <div style="font-size:12px;font-weight:600;margin-bottom:2px">Claude doesn't know about Notes yet</div>
2643
+ <div style="font-size:11px;color:var(--ink-3)">Add one instruction to your CLAUDE.md so Claude can save notes when you ask.</div>
2644
+ </div>
2645
+ <div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
2646
+ <span x-show="noteSetupMsg" x-text="noteSetupMsg" style="font-size:11px;color:var(--green)"></span>
2647
+ <button class="btn btn-primary btn-sm" @click="setupClaudeNotes()" :disabled="!!noteSetupMsg">Set up</button>
2648
+ </div>
2649
+ </div>
2650
+ </template>
2651
+ <template x-if="!personalNotesLoading && personalNotes.length === 0">
2652
+ <div class="empty"><div class="empty-mark">✎</div>No notes yet. Create one or ask Claude to save one for you.</div>
2653
+ </template>
2654
+ <template x-if="!personalNotesLoading && personalNotes.length > 0">
2655
+ <div class="memory-layout">
2656
+ <div class="memory-sidebar" :style="'width:'+sidebarW+'px'">
2657
+ <template x-for="n in personalNotes.filter(n => !noteSearch || n.title.toLowerCase().includes(noteSearch.toLowerCase()) || n.content.toLowerCase().includes(noteSearch.toLowerCase()))" :key="n.filename">
2658
+ <div class="plan-row" :class="{active: selectedNote?.filename === n.filename}" @click="selectedNote=n;noteEditing=false;window.location.hash='#/note/'+n.filename">
2659
+ <div class="plan-row-title" x-text="n.title"></div>
2660
+ <div class="plan-row-meta">
2661
+ <span x-text="formatDate(n.modified)"></span>
2662
+ <template x-if="n.session"><span style="color:var(--blue);font-size:10px">session</span></template>
2663
+ </div>
2664
+ </div>
2665
+ </template>
2666
+ </div>
2667
+ <div class="resize-handle" @mousedown.prevent="startResize($event)"></div>
2668
+ <div class="memory-detail">
2669
+ <template x-if="!selectedNote"><div class="memory-empty"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg>Select a note</div></template>
2670
+ <template x-if="selectedNote && !noteEditing">
2671
+ <div>
2672
+ <div class="memory-detail-title" x-text="selectedNote.title"></div>
2673
+ <div class="memory-detail-meta">
2674
+ <span style="font-size:11px;color:var(--ink-3)" x-text="formatDate(selectedNote.modified)"></span>
2675
+ <template x-if="selectedNote.session">
2676
+ <span style="font-size:11px;color:var(--blue);cursor:pointer" @click="openSessionById(selectedNote.session)" x-text="'→ Session ' + selectedNote.session.slice(0,8) + '…'"></span>
2677
+ </template>
2678
+ </div>
2679
+ <div class="memory-content md-content" x-html="selectedNote.content ? renderMd(selectedNote.content) : '<span style=\'color:var(--ink-3)\'>Empty note. Click Edit to add content.</span>'"></div>
2680
+ </div>
2681
+ </template>
2682
+ <template x-if="selectedNote && noteEditing">
2683
+ <div style="display:flex;flex-direction:column;height:100%;gap:10px">
2684
+ <input class="settings-input" type="text" x-model="noteTitleDraft" style="font-size:14px;font-weight:600;width:100%" />
2685
+ <textarea class="settings-input" x-model="noteBodyDraft" style="flex:1;resize:none;font-family:monospace;font-size:12px;min-height:300px;width:100%"></textarea>
2686
+ </div>
2687
+ </template>
2688
+ </div>
2689
+ </div>
2690
+ </template>
2691
+ </div>
2692
+ </div>
2693
+ </template>
2694
+
2578
2695
  <!-- Memory view -->
2579
2696
  <template x-if="view === 'memory' && !selectedSession">
2580
2697
  <div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">
@@ -3380,6 +3497,58 @@
3380
3497
  </div>
3381
3498
  <template x-if="config.settings?.statusLine?.command"><div class="config-row"><div class="config-key">Status line</div><div class="config-val" x-text="config.settings.statusLine.command"></div></div></template>
3382
3499
  </div>
3500
+
3501
+ <div class="config-section">
3502
+ <div class="config-section-title" style="display:flex;align-items:center;justify-content:space-between">
3503
+ Sharing
3504
+ <div style="display:flex;align-items:center;gap:8px">
3505
+ <span class="save-msg" x-show="githubTokenMsg==='Saved'" x-text="githubTokenMsg"></span>
3506
+ <span class="err-msg" x-show="githubTokenMsg && githubTokenMsg!=='Saved'" x-text="githubTokenMsg"></span>
3507
+ <button class="btn btn-primary btn-sm" @click="saveGithubToken()" :disabled="githubTokenSaving || !githubTokenDraft">
3508
+ <span x-text="githubTokenSaving?'Saving…':'Save'"></span>
3509
+ </button>
3510
+ </div>
3511
+ </div>
3512
+ <div class="config-row">
3513
+ <div class="config-key">GitHub token</div>
3514
+ <div class="config-val" style="flex:1">
3515
+ <input type="password" class="settings-input" x-model="githubTokenDraft" placeholder="ghp_…" style="width:100%;font-family:monospace" />
3516
+ <div style="font-size:11px;color:var(--ink-3);margin-top:4px">
3517
+ <template x-if="githubTokenSet">
3518
+ <span>Token saved (<span x-text="githubTokenMasked"></span>). Enter a new value to replace it.</span>
3519
+ </template>
3520
+ <template x-if="!githubTokenSet">
3521
+ <span>Required to publish sessions and plans as public GitHub Gists. Generate a classic token at github.com/settings/tokens with the <strong>gist</strong> scope checked.</span>
3522
+ </template>
3523
+ </div>
3524
+ </div>
3525
+ </div>
3526
+ </div>
3527
+
3528
+ <div class="config-section">
3529
+ <div class="config-section-title">Integrations</div>
3530
+ <div class="config-row">
3531
+ <div class="config-key">Notes</div>
3532
+ <div class="config-val" style="flex:1">
3533
+ <div style="display:flex;align-items:center;gap:10px">
3534
+ <template x-if="noteClaudeInstalled">
3535
+ <span style="font-size:12px;color:var(--green);display:flex;align-items:center;gap:4px">
3536
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="2"><polyline points="2 7 5 10 11 3"/></svg>
3537
+ Enabled
3538
+ </span>
3539
+ </template>
3540
+ <template x-if="!noteClaudeInstalled">
3541
+ <div style="display:flex;align-items:center;gap:8px">
3542
+ <span x-show="noteSetupMsg" x-text="noteSetupMsg" style="font-size:12px;color:var(--green)"></span>
3543
+ <button class="btn btn-primary btn-sm" @click="setupClaudeNotes()" :disabled="!!noteSetupMsg">Set up</button>
3544
+ </div>
3545
+ </template>
3546
+ </div>
3547
+ <div style="font-size:11px;color:var(--ink-3);margin-top:4px">Adds an instruction to your CLAUDE.md so Claude can save notes when you ask. Notes are your personal notepad — Claude won't read them as context.</div>
3548
+ </div>
3549
+ </div>
3550
+ </div>
3551
+
3383
3552
  </div>
3384
3553
  </div>
3385
3554
  </template>
@@ -4165,8 +4334,24 @@
4165
4334
  exportDropOpen: false,
4166
4335
  exportMsg: '',
4167
4336
  cleanMode: true,
4337
+
4168
4338
  planExportMsg: '',
4169
4339
  planExportOpen: false,
4340
+ planShareMsg: '',
4341
+ personalNotes: [],
4342
+ personalNotesLoading: false,
4343
+ selectedNote: null,
4344
+ noteEditing: false,
4345
+ noteTitleDraft: '',
4346
+ noteBodyDraft: '',
4347
+ noteMsg: '',
4348
+ noteSaving: false,
4349
+ noteSearch: '',
4350
+ noteCreating: false,
4351
+ noteNewTitle: '',
4352
+ noteNewBody: '',
4353
+ noteClaudeInstalled: null,
4354
+ noteSetupMsg: '',
4170
4355
  sidebarW: parseInt(localStorage.getItem('cm:sidebarW') || '260'),
4171
4356
  historyEntries: [],
4172
4357
  historyLoading: false,
@@ -4210,6 +4395,13 @@
4210
4395
  settingsDraft: { model: '', language: '', voiceEnabled: false, outputStyle: '', effortLevel: '', defaultMode: '' },
4211
4396
  settingsSaving: false,
4212
4397
  settingsMsg: '',
4398
+ githubTokenDraft: '',
4399
+ githubTokenSet: false,
4400
+ githubTokenMasked: '',
4401
+ githubTokenSaving: false,
4402
+ githubTokenMsg: '',
4403
+ shareMsg: '',
4404
+ shareUrl: '',
4213
4405
  permsDraft: { allow: [], deny: [], ask: [] },
4214
4406
  permsNewRule: { allow: '', deny: '', ask: '' },
4215
4407
  ruleBuilder: { type: 'deny', tool: 'Bash', specifier: '', pathType: './' },
@@ -4325,16 +4517,36 @@
4325
4517
  this.loadCosts();
4326
4518
  this.loadStatus();
4327
4519
  this.loadInsights();
4520
+ // Load counts for nav badges without blocking
4521
+ this.loadPlans();
4522
+ this.loadAgents();
4523
+ this.loadTools();
4524
+ this.loadMemory();
4525
+ this.loadPersonalNotes();
4526
+ // Parse URL hash for direct links
4527
+ const hash = window.location.hash;
4528
+ const mSession = hash.match(/^#\/session\/([^/]+)\/([^/]+)$/);
4529
+ const mNote = hash.match(/^#\/note\/(.+)$/);
4530
+ if (mSession) { this.view = 'sessions'; await this.openSessionById(mSession[2], mSession[1]); }
4531
+ if (mNote) {
4532
+ await this.loadPersonalNotes();
4533
+ const note = this.personalNotes.find(n => n.filename === mNote[1]);
4534
+ if (note) { this.view = 'notes'; this.selectedNote = note; }
4535
+ }
4536
+ // Clear hash when closing
4537
+ this.$watch('selectedSession', v => { if (!v) { if (window.location.hash.startsWith('#/session/')) window.location.hash = ''; } });
4538
+ this.$watch('selectedNote', v => { if (!v) { if (window.location.hash.startsWith('#/note/')) window.location.hash = ''; } });
4328
4539
  },
4329
4540
 
4330
4541
  initView(v) {
4331
4542
  if (v === 'dashboard') { this.loadStats(); this.loadInsights(); }
4332
4543
  if (v === 'projects') { this.loadProjects(); }
4333
4544
  if (v === 'memory') { this.loadMemory(); }
4545
+ if (v === 'notes') { this.loadPersonalNotes(); }
4334
4546
  if (v === 'plans') { this.loadPlans(); }
4335
4547
  if (v === 'commands' || v === 'skills') { this.loadTools(); }
4336
4548
  if (v === 'agents') { this.loadAgents(); }
4337
- if (v === 'config' || v === 'instructions' || v === 'permissions' || v === 'hooks') { this.loadConfig(); }
4549
+ if (v === 'config' || v === 'instructions' || v === 'permissions' || v === 'hooks') { this.loadConfig(); this.checkNoteClaudeStatus(); }
4338
4550
  },
4339
4551
 
4340
4552
  async loadProjects() {
@@ -4425,6 +4637,8 @@
4425
4637
  this.selectedSession = session;
4426
4638
  this.loadingDetail = true;
4427
4639
  this.sessionDetail = null;
4640
+
4641
+ window.location.hash = `#/session/${session.projectDir}/${session.sessionId}`;
4428
4642
  const data = await fetch(`/api/sessions/${session.projectDir}/${session.sessionId}`).then(r => r.json());
4429
4643
  this.sessionDetail = data;
4430
4644
  this.loadingDetail = false;
@@ -4439,6 +4653,7 @@
4439
4653
  await this.openSession(s);
4440
4654
  },
4441
4655
 
4656
+
4442
4657
  async resumeSession(sessionId, btnId, projectPath) {
4443
4658
  const cmd = projectPath
4444
4659
  ? `cd "${projectPath}" && claude -r ${sessionId}`
@@ -5147,6 +5362,136 @@
5147
5362
  } catch (e) { alert('Error: ' + e.message); }
5148
5363
  },
5149
5364
 
5365
+ async saveGithubToken() {
5366
+ this.githubTokenSaving = true; this.githubTokenMsg = '';
5367
+ try {
5368
+ const r = await fetch('/api/app-settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ githubToken: this.githubTokenDraft }) });
5369
+ if (!r.ok) throw new Error((await r.json()).error);
5370
+ this.githubTokenSet = !!this.githubTokenDraft;
5371
+ this.githubTokenMasked = this.githubTokenDraft ? this.githubTokenDraft.slice(0,4) + '…' + this.githubTokenDraft.slice(-4) : '';
5372
+ this.githubTokenDraft = '';
5373
+ this.githubTokenMsg = 'Saved';
5374
+ setTimeout(() => this.githubTokenMsg = '', 2000);
5375
+ } catch (e) { this.githubTokenMsg = 'Error: ' + e.message; }
5376
+ this.githubTokenSaving = false;
5377
+ },
5378
+
5379
+ async shareSession() {
5380
+ this.shareMsg = 'Sharing…'; this.shareUrl = '';
5381
+ try {
5382
+ const r = await fetch(`/api/share/session/${this.selectedSession.projectDir}/${this.selectedSession.sessionId}`, { method: 'POST' });
5383
+ const data = await r.json();
5384
+ if (!r.ok) throw new Error(data.error);
5385
+ this.shareUrl = data.url;
5386
+ await navigator.clipboard.writeText(data.url);
5387
+ this.shareMsg = 'Link copied!';
5388
+ } catch (e) { this.shareMsg = e.message; }
5389
+ setTimeout(() => { this.shareMsg = ''; this.shareUrl = ''; }, 5000);
5390
+ },
5391
+
5392
+ async sharePlan(filename) {
5393
+ this.planShareMsg = 'Sharing…';
5394
+ try {
5395
+ const r = await fetch(`/api/share/plan/${filename}`, { method: 'POST' });
5396
+ const data = await r.json();
5397
+ if (!r.ok) throw new Error(data.error);
5398
+ await navigator.clipboard.writeText(data.url);
5399
+ this.planShareMsg = 'Link copied!';
5400
+ } catch (e) { this.planShareMsg = e.message; }
5401
+ setTimeout(() => this.planShareMsg = '', 5000);
5402
+ },
5403
+
5404
+ async loadPersonalNotes() {
5405
+ if (this.personalNotesLoading) return;
5406
+ this.personalNotesLoading = true;
5407
+ const [notes, status] = await Promise.all([
5408
+ fetch('/api/notes').then(r => r.json()).catch(() => []),
5409
+ fetch('/api/notes/claude-md-status').then(r => r.json()).catch(() => ({})),
5410
+ ]);
5411
+ this.personalNotes = notes;
5412
+ this.noteClaudeInstalled = status.installed ?? null;
5413
+ this.personalNotesLoading = false;
5414
+ },
5415
+
5416
+ async checkNoteClaudeStatus() {
5417
+ const s = await fetch('/api/notes/claude-md-status').then(r => r.json()).catch(() => ({}));
5418
+ this.noteClaudeInstalled = s.installed ?? null;
5419
+ },
5420
+
5421
+ async setupClaudeNotes() {
5422
+ this.noteSetupMsg = 'Installing…';
5423
+ try {
5424
+ const r = await fetch('/api/notes/setup-claude', { method: 'POST' });
5425
+ const d = await r.json();
5426
+ this.noteClaudeInstalled = true;
5427
+ this.noteSetupMsg = d.alreadyInstalled ? 'Already installed' : 'Installed!';
5428
+ } catch (e) { this.noteSetupMsg = 'Error: ' + e.message; }
5429
+ setTimeout(() => this.noteSetupMsg = '', 3000);
5430
+ },
5431
+
5432
+ async createPersonalNote() {
5433
+ if (!this.noteNewTitle.trim()) return;
5434
+ this.noteSaving = true;
5435
+ try {
5436
+ const note = await fetch('/api/notes', {
5437
+ method: 'POST',
5438
+ headers: { 'Content-Type': 'application/json' },
5439
+ body: JSON.stringify({ title: this.noteNewTitle, content: this.noteNewBody }),
5440
+ }).then(r => r.json());
5441
+ this.personalNotes.unshift(note);
5442
+ this.selectedNote = note;
5443
+ this.noteCreating = false;
5444
+ this.noteNewTitle = '';
5445
+ this.noteNewBody = '';
5446
+ this.noteEditing = false;
5447
+ } catch (e) { this.noteMsg = 'Error: ' + e.message; }
5448
+ this.noteSaving = false;
5449
+ },
5450
+
5451
+ async savePersonalNote() {
5452
+ if (!this.selectedNote) return;
5453
+ this.noteSaving = true; this.noteMsg = '';
5454
+ try {
5455
+ const updated = await fetch(`/api/notes/${this.selectedNote.filename}`, {
5456
+ method: 'PUT',
5457
+ headers: { 'Content-Type': 'application/json' },
5458
+ body: JSON.stringify({ title: this.noteTitleDraft, content: this.noteBodyDraft }),
5459
+ }).then(r => r.json());
5460
+ const idx = this.personalNotes.findIndex(n => n.filename === this.selectedNote.filename);
5461
+ if (idx !== -1) this.personalNotes[idx] = updated;
5462
+ this.selectedNote = updated;
5463
+ this.noteEditing = false;
5464
+ this.noteMsg = 'Saved';
5465
+ setTimeout(() => this.noteMsg = '', 2000);
5466
+ } catch (e) { this.noteMsg = 'Error: ' + e.message; }
5467
+ this.noteSaving = false;
5468
+ },
5469
+
5470
+ async deletePersonalNote() {
5471
+ if (!this.selectedNote || !confirm(`Delete "${this.selectedNote.title}"?`)) return;
5472
+ await fetch(`/api/notes/${this.selectedNote.filename}`, { method: 'DELETE' });
5473
+ this.personalNotes = this.personalNotes.filter(n => n.filename !== this.selectedNote.filename);
5474
+ this.selectedNote = null;
5475
+ this.noteEditing = false;
5476
+ },
5477
+
5478
+ async saveNoteFromSession() {
5479
+ const title = this.selectedSession?.firstPrompt?.slice(0, 60) || 'Session note';
5480
+ const sessionId = this.selectedSession?.sessionId || '';
5481
+ const note = await fetch('/api/notes', {
5482
+ method: 'POST',
5483
+ headers: { 'Content-Type': 'application/json' },
5484
+ body: JSON.stringify({ title, content: '', session: sessionId }),
5485
+ }).then(r => r.json());
5486
+ this.personalNotes.unshift(note);
5487
+ this.selectedNote = note;
5488
+ this.noteEditing = true;
5489
+ this.noteTitleDraft = note.title;
5490
+ this.noteBodyDraft = note.content;
5491
+ this.view = 'notes';
5492
+ this.selectedSession = null;
5493
+ },
5494
+
5150
5495
  async saveSettings() {
5151
5496
  this.settingsSaving = true; this.settingsMsg = '';
5152
5497
  try {
@@ -5576,6 +5921,10 @@
5576
5921
  const proj = this.projects?.find(p => p.dirName === this.configProject);
5577
5922
  const pp = proj?.projectPath ? '?projectPath=' + encodeURIComponent(proj.projectPath) : '';
5578
5923
  this.config = await fetch('/api/config' + pp).then(r => r.json());
5924
+ const as = await fetch('/api/app-settings').then(r => r.json()).catch(() => ({}));
5925
+ this.githubTokenSet = as.githubTokenSet || false;
5926
+ this.githubTokenMasked = as.githubTokenMasked || '';
5927
+ this.githubTokenDraft = '';
5579
5928
  this.settingsDraft = {
5580
5929
  model: this.config.settings?.model || '',
5581
5930
  language: this.config.settings?.language || '',
package/server.js CHANGED
@@ -1767,6 +1767,221 @@ app.get('/api/sessions/:project/:sessionId/export', async (req, res) => {
1767
1767
  } catch (e) { res.status(500).json({ error: e.message }); }
1768
1768
  });
1769
1769
 
1770
+ // ─── App settings (claude-home specific, not Claude's settings.json) ─────────
1771
+ const APP_SETTINGS_FILE = path.join(DATA_DIR, 'app-settings.json');
1772
+
1773
+ function readAppSettings() {
1774
+ try { return JSON.parse(fs.readFileSync(APP_SETTINGS_FILE, 'utf8')); } catch { return {}; }
1775
+ }
1776
+
1777
+ app.get('/api/app-settings', (req, res) => {
1778
+ const s = readAppSettings();
1779
+ // Mask token for display
1780
+ const masked = s.githubToken ? s.githubToken.slice(0, 4) + '…' + s.githubToken.slice(-4) : '';
1781
+ res.json({ githubTokenSet: !!s.githubToken, githubTokenMasked: masked });
1782
+ });
1783
+
1784
+ app.put('/api/app-settings', (req, res) => {
1785
+ try {
1786
+ const current = readAppSettings();
1787
+ const { githubToken } = req.body;
1788
+ if (githubToken !== undefined) current.githubToken = githubToken;
1789
+ ensureDataDir();
1790
+ fs.writeFileSync(APP_SETTINGS_FILE, JSON.stringify(current, null, 2), 'utf8');
1791
+ res.json({ ok: true });
1792
+ } catch (e) { res.status(500).json({ error: e.message }); }
1793
+ });
1794
+
1795
+ // ─── Gist sharing ─────────────────────────────────────────────────────────────
1796
+ function postGist(token, description, filename, content) {
1797
+ return new Promise((resolve, reject) => {
1798
+ const body = JSON.stringify({ description, public: true, files: { [filename]: { content } } });
1799
+ const opts = {
1800
+ hostname: 'api.github.com',
1801
+ path: '/gists',
1802
+ method: 'POST',
1803
+ headers: {
1804
+ 'Authorization': `Bearer ${token}`,
1805
+ 'Content-Type': 'application/json',
1806
+ 'User-Agent': 'claude-home',
1807
+ 'Accept': 'application/vnd.github+json',
1808
+ 'Content-Length': Buffer.byteLength(body),
1809
+ },
1810
+ };
1811
+ const req = https.request(opts, res => {
1812
+ let data = '';
1813
+ res.on('data', c => data += c);
1814
+ res.on('end', () => {
1815
+ try {
1816
+ const json = JSON.parse(data);
1817
+ if (json.html_url) resolve(json.html_url);
1818
+ else {
1819
+ const msg = json.message || 'GitHub API error';
1820
+ const hint = (msg === 'Not Found' || msg === 'Bad credentials')
1821
+ ? `${msg} — check your token has the "gist" scope`
1822
+ : msg;
1823
+ reject(new Error(hint));
1824
+ }
1825
+ } catch { reject(new Error('Invalid response from GitHub')); }
1826
+ });
1827
+ });
1828
+ req.on('error', reject);
1829
+ req.write(body);
1830
+ req.end();
1831
+ });
1832
+ }
1833
+
1834
+ app.post('/api/share/session/:project/:sessionId', async (req, res) => {
1835
+ const { githubToken } = readAppSettings();
1836
+ if (!githubToken) return res.status(400).json({ error: 'GitHub token not configured. Add it in Settings → Sharing.' });
1837
+ const { project, sessionId } = req.params;
1838
+ const filePath = path.join(PROJECTS_DIR, project, `${sessionId}.jsonl`);
1839
+ if (!filePath.startsWith(PROJECTS_DIR)) return res.status(400).json({ error: 'invalid path' });
1840
+ try {
1841
+ const messages = await parseJsonl(filePath);
1842
+ const lines = [`# Session ${sessionId}`, `**Project:** ${project}`, ''];
1843
+ for (const m of messages) {
1844
+ if (m.type === 'user') {
1845
+ const text = typeof m.message?.content === 'string' ? m.message.content
1846
+ : (m.message?.content || []).filter(b => b.type === 'text').map(b => b.text).join('\n');
1847
+ if (text.trim()) lines.push(`## Human\n\n${text.trim()}`, '');
1848
+ } else if (m.type === 'assistant') {
1849
+ const text = typeof m.message?.content === 'string' ? m.message.content
1850
+ : (m.message?.content || []).filter(b => b.type === 'text').map(b => b.text).join('\n');
1851
+ if (text.trim()) lines.push(`## Claude\n\n${text.trim()}`, '');
1852
+ }
1853
+ }
1854
+ const url = await postGist(githubToken, `Claude session — ${project}`, `${sessionId}.md`, lines.join('\n'));
1855
+ res.json({ url });
1856
+ } catch (e) { res.status(500).json({ error: e.message }); }
1857
+ });
1858
+
1859
+ app.post('/api/share/plan/:filename', async (req, res) => {
1860
+ const { githubToken } = readAppSettings();
1861
+ if (!githubToken) return res.status(400).json({ error: 'GitHub token not configured. Add it in Settings → Sharing.' });
1862
+ const filename = path.basename(req.params.filename);
1863
+ if (!filename.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
1864
+ const filePath = path.join(os.homedir(), '.claude', 'plans', filename);
1865
+ try {
1866
+ const content = fs.readFileSync(filePath, 'utf8');
1867
+ const titleMatch = content.match(/^#\s+(.+)$/m);
1868
+ const description = titleMatch ? titleMatch[1].trim() : filename;
1869
+ const url = await postGist(githubToken, `Claude plan — ${description}`, filename, content);
1870
+ res.json({ url });
1871
+ } catch (e) { res.status(500).json({ error: e.message }); }
1872
+ });
1873
+
1874
+ // ─── Personal Notes ───────────────────────────────────────────────────────────
1875
+ const NOTES_DIR = path.join(DATA_DIR, 'notes');
1876
+ function ensureNotesDir() { if (!fs.existsSync(NOTES_DIR)) fs.mkdirSync(NOTES_DIR, { recursive: true }); }
1877
+
1878
+ function parseNoteFile(filename) {
1879
+ const filePath = path.join(NOTES_DIR, filename);
1880
+ const stat = fs.statSync(filePath);
1881
+ const raw = fs.readFileSync(filePath, 'utf8');
1882
+ const { meta, content: body } = parseFrontmatter(raw);
1883
+ return {
1884
+ filename,
1885
+ title: meta.title || filename.replace('.md', ''),
1886
+ date: meta.date || stat.mtimeMs,
1887
+ session: meta.session || '',
1888
+ content: body.trim(),
1889
+ modified: new Date(stat.mtimeMs).toISOString(),
1890
+ };
1891
+ }
1892
+
1893
+ const NOTES_CLAUDE_MD_SNIPPET = `
1894
+ ## Personal Notes
1895
+
1896
+ When the user asks you to "save a note", "add to notes", "guarda esto como nota", or similar:
1897
+ 1. Create a markdown file in \`~/.claude/claude-home/notes/\` with this exact format:
1898
+ - Filename: \`YYYY-MM-DD-short-slug.md\` (e.g. \`2026-03-31-bug-fix-auth.md\`)
1899
+ - Content:
1900
+ \`\`\`
1901
+ ---
1902
+ title: <descriptive title>
1903
+ date: <current ISO date>
1904
+ session: <current session ID if known>
1905
+ ---
1906
+
1907
+ <the content the user wants to save>
1908
+ \`\`\`
1909
+ 2. Use the Bash tool to write the file.
1910
+ 3. Confirm with: "Saved to Notes: http://localhost:3141/#/note/<filename>"
1911
+
1912
+ The notes directory may not exist yet — create it if needed with \`mkdir -p\`.
1913
+ `;
1914
+
1915
+ app.get('/api/notes/claude-md-status', (req, res) => {
1916
+ const claudeMdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
1917
+ try {
1918
+ const content = fs.existsSync(claudeMdPath) ? fs.readFileSync(claudeMdPath, 'utf8') : '';
1919
+ res.json({ installed: content.includes('Personal Notes') });
1920
+ } catch (e) { res.status(500).json({ error: e.message }); }
1921
+ });
1922
+
1923
+ app.post('/api/notes/setup-claude', (req, res) => {
1924
+ const claudeMdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
1925
+ try {
1926
+ const current = fs.existsSync(claudeMdPath) ? fs.readFileSync(claudeMdPath, 'utf8') : '';
1927
+ if (current.includes('Personal Notes')) return res.json({ ok: true, alreadyInstalled: true });
1928
+ fs.writeFileSync(claudeMdPath, current + '\n' + NOTES_CLAUDE_MD_SNIPPET, 'utf8');
1929
+ res.json({ ok: true });
1930
+ } catch (e) { res.status(500).json({ error: e.message }); }
1931
+ });
1932
+
1933
+ app.get('/api/notes', (req, res) => {
1934
+ ensureNotesDir();
1935
+ try {
1936
+ const files = fs.readdirSync(NOTES_DIR).filter(f => f.endsWith('.md'));
1937
+ const notes = files.map(f => parseNoteFile(f)).sort((a, b) => b.modified.localeCompare(a.modified));
1938
+ res.json(notes);
1939
+ } catch (e) { res.status(500).json({ error: e.message }); }
1940
+ });
1941
+
1942
+ app.post('/api/notes', (req, res) => {
1943
+ ensureNotesDir();
1944
+ const { title, content, session } = req.body;
1945
+ if (!title) return res.status(400).json({ error: 'title required' });
1946
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'note';
1947
+ const date = new Date().toISOString();
1948
+ const datePrefix = date.slice(0, 10);
1949
+ let filename = `${datePrefix}-${slug}.md`;
1950
+ // avoid collisions
1951
+ let i = 1;
1952
+ while (fs.existsSync(path.join(NOTES_DIR, filename))) { filename = `${datePrefix}-${slug}-${i++}.md`; }
1953
+ const sessionLine = session ? `\nsession: ${session}` : '';
1954
+ const raw = `---\ntitle: ${title}\ndate: ${date}${sessionLine}\n---\n\n${content || ''}`;
1955
+ fs.writeFileSync(path.join(NOTES_DIR, filename), raw, 'utf8');
1956
+ res.json(parseNoteFile(filename));
1957
+ });
1958
+
1959
+ app.put('/api/notes/:filename', (req, res) => {
1960
+ const filename = path.basename(req.params.filename);
1961
+ if (!filename.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
1962
+ const filePath = path.join(NOTES_DIR, filename);
1963
+ if (!filePath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid path' });
1964
+ const { title, content } = req.body;
1965
+ try {
1966
+ const existing = parseNoteFile(filename);
1967
+ const sessionLine = existing.session ? `\nsession: ${existing.session}` : '';
1968
+ const raw = `---\ntitle: ${title || existing.title}\ndate: ${existing.date}${sessionLine}\n---\n\n${content ?? existing.content}`;
1969
+ fs.writeFileSync(filePath, raw, 'utf8');
1970
+ res.json(parseNoteFile(filename));
1971
+ } catch (e) { res.status(500).json({ error: e.message }); }
1972
+ });
1973
+
1974
+ app.delete('/api/notes/:filename', (req, res) => {
1975
+ const filename = path.basename(req.params.filename);
1976
+ if (!filename.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
1977
+ const filePath = path.join(NOTES_DIR, filename);
1978
+ if (!filePath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid path' });
1979
+ try {
1980
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
1981
+ res.json({ ok: true });
1982
+ } catch (e) { res.status(500).json({ error: e.message }); }
1983
+ });
1984
+
1770
1985
  // ─── Start ────────────────────────────────────────────────────────────────────
1771
1986
 
1772
1987
  function startServer(port) {