total-recall-brain 3.26.0 → 3.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/bin/total-recall.mjs +31 -1
  2. package/docs/ARCHITECTURE.md +76 -9
  3. package/frontend/dist/assets/index-U7o-qU99.js +5742 -0
  4. package/frontend/dist/index.html +1 -1
  5. package/frontend/src/App.tsx +39 -1
  6. package/frontend/src/api/index.ts +1 -0
  7. package/frontend/src/api/plugins.ts +167 -0
  8. package/frontend/src/pages/ChatPage.tsx +44 -17
  9. package/frontend/src/pages/IntegrationsPage.tsx +7 -0
  10. package/frontend/src/pages/PluginsPage.spec.tsx +17 -0
  11. package/frontend/src/pages/PluginsPage.tsx +1331 -0
  12. package/package.json +1 -1
  13. package/scaffold/.agent/plugins/.gitkeep +0 -0
  14. package/scaffold/.agent/plugins/code-quality/README.md +16 -0
  15. package/scaffold/.agent/plugins/code-quality/cli.mjs +15 -0
  16. package/scaffold/.agent/plugins/code-quality/plugin.json +34 -0
  17. package/scaffold/.agent/plugins/git-sentinel/README.md +16 -0
  18. package/scaffold/.agent/plugins/git-sentinel/cli.mjs +86 -0
  19. package/scaffold/.agent/plugins/git-sentinel/plugin.json +34 -0
  20. package/scaffold/.agent/plugins/meta-harness/README.md +16 -0
  21. package/scaffold/.agent/plugins/meta-harness/cli.mjs +34 -0
  22. package/scaffold/.agent/plugins/meta-harness/plugin.json +34 -0
  23. package/scaffold/.agent/plugins/system-monitor/README.md +16 -0
  24. package/scaffold/.agent/plugins/system-monitor/cli.mjs +79 -0
  25. package/scaffold/.agent/plugins/system-monitor/generator.mjs +20 -0
  26. package/scaffold/.agent/plugins/system-monitor/plugin.json +38 -0
  27. package/scaffold/.agent/skills/meta-harness/SKILL.md +113 -0
  28. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +23 -0
  29. package/src/cli/agent.mjs +173 -0
  30. package/src/cli/connect.mjs +89 -4
  31. package/src/cli/deploy-ui.mjs +6 -0
  32. package/src/cli/harness.mjs +135 -0
  33. package/src/cli/ingest-openwiki.mjs +71 -2
  34. package/src/cli/init.mjs +1 -0
  35. package/src/cli/lint.mjs +47 -3
  36. package/src/cli/mesh.mjs +286 -0
  37. package/src/cli/plugin/create.mjs +186 -0
  38. package/src/cli/plugin/index.mjs +139 -0
  39. package/src/cli/plugin/install.mjs +141 -0
  40. package/src/cli/plugin/list.mjs +83 -0
  41. package/src/cli/plugin/remove.mjs +51 -0
  42. package/src/cli/plugin/search.mjs +37 -0
  43. package/src/cli/skill-projection.mjs +14 -0
  44. package/src/cli/wizard.html +9 -0
  45. package/src/core/agent-manager.mjs +290 -0
  46. package/src/core/cache-prune.mjs +341 -0
  47. package/src/core/context-cache.mjs +130 -0
  48. package/src/core/daemon-loop.mjs +47 -1
  49. package/src/core/evolving-context.mjs +171 -0
  50. package/src/core/mesh-activity.mjs +140 -0
  51. package/src/core/mesh.mjs +174 -1
  52. package/src/core/meta-harness.mjs +251 -0
  53. package/src/core/plugin-context.mjs +206 -0
  54. package/src/core/plugin-loader.mjs +210 -0
  55. package/src/core/runtime.mjs +65 -13
  56. package/src/core/surface.mjs +32 -10
  57. package/src/server/api.mjs +18 -2
  58. package/src/server/index.mjs +8 -2
  59. package/src/server/rest.mjs +4 -0
  60. package/src/server/routes/integrations.mjs +5 -1
  61. package/src/server/routes/mesh-presence.mjs +143 -0
  62. package/src/server/routes/models.mjs +42 -30
  63. package/src/server/routes/plugins.mjs +517 -0
  64. package/src/server/tools.mjs +266 -0
  65. package/frontend/dist/assets/index-Bl3I11B0.js +0 -5742
@@ -17,7 +17,7 @@
17
17
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18
18
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
19
19
  <title>Total Recall — Portable Memory</title>
20
- <script type="module" crossorigin src="/assets/index-Bl3I11B0.js"></script>
20
+ <script type="module" crossorigin src="/assets/index-U7o-qU99.js"></script>
21
21
  <link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
22
22
  </head>
23
23
  <body>
@@ -1,7 +1,7 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import { BrowserRouter, Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom'
3
3
  import './App.css'
4
- import { getApiBase, setApiBase, checkSession, logout, registerUnauthedCallback, fetchHealth } from './api'
4
+ import { getApiBase, setApiBase, checkSession, logout, registerUnauthedCallback, fetchHealth, fetchPlugins, type PluginInfo } from './api'
5
5
  import type { HealthData } from './types'
6
6
  import LoginPage from './pages/LoginPage'
7
7
  import ChatPage from './pages/ChatPage'
@@ -19,6 +19,7 @@ import RulesPage from './pages/RulesPage'
19
19
  import HelpPage from './pages/HelpPage'
20
20
  import GraphPage from './pages/GraphPage'
21
21
  import OpenWikiPage from './pages/OpenWikiPage'
22
+ import PluginsPage from './pages/PluginsPage'
22
23
  import OnboardingPage from './pages/OnboardingPage'
23
24
  import { NotificationsPage } from './pages/NotificationsPage'
24
25
  import { isOnboardingComplete } from './utils/onboarding'
@@ -86,6 +87,12 @@ interface SidebarProps {
86
87
  const getNavLinkClass = ({ isActive }: { isActive: boolean }) => `nav-link ${isActive ? 'active' : ''}`;
87
88
 
88
89
  function Sidebar({ onLogout, health, activeBrainId, onBrainChange }: SidebarProps) {
90
+ const [plugins, setPlugins] = useState<PluginInfo[]>([])
91
+
92
+ useEffect(() => {
93
+ fetchPlugins().then(setPlugins).catch(() => {})
94
+ }, [])
95
+
89
96
  return (
90
97
  <aside className="sidebar">
91
98
  <div className="sidebar-brand">
@@ -158,6 +165,36 @@ function Sidebar({ onLogout, health, activeBrainId, onBrainChange }: SidebarProp
158
165
  </svg>
159
166
  OpenWiki
160
167
  </NavLink>
168
+ <NavLink to="/plugins" className={getNavLinkClass} id="nav-plugins">
169
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
170
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
171
+ <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
172
+ <line x1="12" y1="22.08" x2="12" y2="12" />
173
+ </svg>
174
+ Plugins
175
+ </NavLink>
176
+
177
+ {plugins.length > 0 && (
178
+ <>
179
+ <div className="nav-section-label">Plugins</div>
180
+ {plugins.map((p) => (
181
+ <NavLink
182
+ key={p.id}
183
+ to={`/plugins?id=${encodeURIComponent(p.id)}`}
184
+ className={getNavLinkClass}
185
+ id={`nav-plugin-${p.id}`}
186
+ title={p.description}
187
+ >
188
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
189
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
190
+ <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
191
+ <line x1="12" y1="22.08" x2="12" y2="12" />
192
+ </svg>
193
+ {p.name}
194
+ </NavLink>
195
+ ))}
196
+ </>
197
+ )}
161
198
 
162
199
  <div className="nav-section-label">Account</div>
163
200
  <NavLink to="/network" className={getNavLinkClass} id="nav-network">
@@ -396,6 +433,7 @@ function MainContent({ activeBrainId, onBrainChange }: { activeBrainId: string;
396
433
  <Route path="/tasks" element={<TasksPage />} />
397
434
  <Route path="/skills" element={<SkillsPage activeBrainId={activeBrainId} />} />
398
435
  <Route path="/openwiki" element={<OpenWikiPage activeBrainId={activeBrainId} />} />
436
+ <Route path="/plugins" element={<PluginsPage activeBrainId={activeBrainId} />} />
399
437
  <Route path="/integrations" element={<IntegrationsPage activeBrainId={activeBrainId} />} />
400
438
  <Route path="/keys" element={<Navigate to="/secrets" replace />} />
401
439
  <Route path="/settings" element={<SettingsPage activeBrainId={activeBrainId} />} />
@@ -19,3 +19,4 @@ export * from './sessions'
19
19
  export * from './integrations'
20
20
  export * from './config'
21
21
  export * from './secrets'
22
+ export * from './plugins'
@@ -0,0 +1,167 @@
1
+ // ─── Plugins domain ───────────────────────────────────────────────────────────
2
+
3
+ import { apiFetch, API_BASE } from "./_base"
4
+
5
+ export interface PluginInfo {
6
+ id: string
7
+ name: string
8
+ version: string
9
+ description: string
10
+ valid: boolean
11
+ errors: string[]
12
+ dir: string
13
+ rating: number
14
+ reviewCount: number
15
+ installCount: string
16
+ userRating?: number | null
17
+ userReview?: string | null
18
+ verified?: boolean
19
+ categories: Array<{ name: string; description?: string }>
20
+ tasks: Array<{ intent: string; schedule: string }>
21
+ openwiki_hubs: Array<{ title: string; path: string }>
22
+ tools: Array<{ name: string; description?: string }>
23
+ cli?: { command: string; handler: string } | null
24
+ manifest?: Record<string, unknown>
25
+ }
26
+
27
+ export interface CatalogPlugin {
28
+ id: string
29
+ name: string
30
+ version: string
31
+ description: string
32
+ tags: string[]
33
+ author: string
34
+ sourceUrl: string
35
+ rating: number
36
+ reviewCount: number
37
+ installCount: string
38
+ userRating?: number | null
39
+ verified?: boolean
40
+ isInstalled: boolean
41
+ }
42
+
43
+ export interface PluginsResponse {
44
+ success: boolean
45
+ count: number
46
+ plugins: PluginInfo[]
47
+ }
48
+
49
+ export interface CatalogResponse {
50
+ success: boolean
51
+ catalog: CatalogPlugin[]
52
+ }
53
+
54
+ export async function fetchPlugins(): Promise<PluginInfo[]> {
55
+ try {
56
+ const res = await apiFetch(`${API_BASE}/api/plugins`)
57
+ if (!res.ok) return []
58
+ const data: PluginsResponse = await res.json()
59
+ return Array.isArray(data.plugins) ? data.plugins : []
60
+ } catch {
61
+ return []
62
+ }
63
+ }
64
+
65
+ export async function fetchPlugin(id: string): Promise<PluginInfo | null> {
66
+ try {
67
+ const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}`)
68
+ if (!res.ok) return null
69
+ const data = await res.json()
70
+ return data.plugin || null
71
+ } catch {
72
+ return null
73
+ }
74
+ }
75
+
76
+ export async function fetchPluginCatalog(): Promise<CatalogPlugin[]> {
77
+ try {
78
+ const res = await apiFetch(`${API_BASE}/api/plugins/catalog`)
79
+ if (!res.ok) return []
80
+ const data: CatalogResponse = await res.json()
81
+ return Array.isArray(data.catalog) ? data.catalog : []
82
+ } catch {
83
+ return []
84
+ }
85
+ }
86
+
87
+ export async function installPlugin(options: { source: string; link?: boolean; global?: boolean }): Promise<{ success: boolean; message?: string; error?: string }> {
88
+ try {
89
+ const res = await apiFetch(`${API_BASE}/api/plugins/install`, {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify(options)
93
+ })
94
+ const data = await res.json()
95
+ if (!res.ok) {
96
+ return { success: false, error: data.error || data.message || "Failed to install plugin" }
97
+ }
98
+ return { success: true, message: data.message }
99
+ } catch (err: any) {
100
+ return { success: false, error: err.message || "Network error" }
101
+ }
102
+ }
103
+
104
+ export async function removePlugin(id: string, global = false): Promise<{ success: boolean; message?: string; error?: string }> {
105
+ try {
106
+ const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}?global=${global ? "true" : "false"}`, {
107
+ method: "DELETE"
108
+ })
109
+ const data = await res.json()
110
+ if (!res.ok) {
111
+ return { success: false, error: data.error || data.message || "Failed to remove plugin" }
112
+ }
113
+ return { success: true, message: data.message }
114
+ } catch (err: any) {
115
+ return { success: false, error: err.message || "Network error" }
116
+ }
117
+ }
118
+
119
+ export async function ratePlugin(id: string, rating: number, review?: string): Promise<{ success: boolean; message?: string; error?: string }> {
120
+ try {
121
+ const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}/rate`, {
122
+ method: "POST",
123
+ headers: { "Content-Type": "application/json" },
124
+ body: JSON.stringify({ rating, review })
125
+ })
126
+ const data = await res.json()
127
+ if (!res.ok) {
128
+ return { success: false, error: data.error || data.message || "Failed to submit rating" }
129
+ }
130
+ return { success: true, message: data.message }
131
+ } catch (err: any) {
132
+ return { success: false, error: err.message || "Network error" }
133
+ }
134
+ }
135
+
136
+ export async function runPluginCommand(
137
+ id: string,
138
+ subcommand = "",
139
+ args: string[] = []
140
+ ): Promise<{ success: boolean; output?: string; error?: string }> {
141
+ try {
142
+ const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}/run`, {
143
+ method: "POST",
144
+ headers: { "Content-Type": "application/json" },
145
+ body: JSON.stringify({ subcommand, args })
146
+ })
147
+ const data = await res.json()
148
+ if (!res.ok) {
149
+ return { success: false, error: data.error || data.message || "Failed to execute plugin command" }
150
+ }
151
+ return { success: true, output: data.output || "" }
152
+ } catch (err: any) {
153
+ return { success: false, error: err.message || "Network error" }
154
+ }
155
+ }
156
+
157
+ export async function fetchPluginReadme(id: string): Promise<string> {
158
+ try {
159
+ const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}/readme`)
160
+ if (!res.ok) return ""
161
+ const data = await res.json()
162
+ return data.readme || ""
163
+ } catch {
164
+ return ""
165
+ }
166
+ }
167
+
@@ -62,14 +62,26 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
62
62
  const [nodeSearchQuery, setNodeSearchQuery] = useState('')
63
63
  const nodeSelectorRef = useRef<HTMLDivElement>(null)
64
64
 
65
+ const DEFAULT_GEMINI_MODELS = [
66
+ { id: 'gemini-3.8-flash-high', displayName: 'Gemini 3.8 Flash (High)' },
67
+ { id: 'gemini-3.8-flash-medium', displayName: 'Gemini 3.8 Flash (Medium)' },
68
+ { id: 'gemini-3.8-flash-low', displayName: 'Gemini 3.8 Flash (Low)' },
69
+ { id: 'gemini-3.7-flash-high', displayName: 'Gemini 3.7 Flash (High)' },
70
+ { id: 'gemini-3.7-flash-medium', displayName: 'Gemini 3.7 Flash (Medium)' },
71
+ { id: 'gemini-3.6-flash-high', displayName: 'Gemini 3.6 Flash (High)' },
72
+ { id: 'gemini-3.1-pro-high', displayName: 'Gemini 3.1 Pro (High)' },
73
+ { id: 'gemini-3.5-flash', displayName: 'Gemini 3.5 Flash' },
74
+ { id: 'gemini-3.5-pro', displayName: 'Gemini 3.5 Pro' },
75
+ ]
76
+
65
77
  // Model Selector state
66
78
  const [availableModels, setAvailableModels] = useState<string[]>([])
67
79
  const [selectedModel, setSelectedModel] = useState<string>(() => {
68
80
  const saved = localStorage.getItem('selectedModel');
69
81
  return (saved && saved !== 'gemini') ? saved : 'antigravity';
70
82
  })
71
- const [selectedSubModel, setSelectedSubModel] = useState<string>(() => localStorage.getItem('selectedSubModel') || '')
72
- const [geminiModels, setGeminiModels] = useState<{ id: string; displayName: string }[]>([])
83
+ const [selectedSubModel, setSelectedSubModel] = useState<string>(() => localStorage.getItem('selectedSubModel') || 'gemini-3.8-flash-high')
84
+ const [geminiModels, setGeminiModels] = useState<{ id: string; displayName: string }[]>(DEFAULT_GEMINI_MODELS)
73
85
  const [showModelDropdown, setShowModelDropdown] = useState(false)
74
86
  const dropdownRef = useRef<HTMLDivElement>(null)
75
87
 
@@ -144,19 +156,28 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
144
156
 
145
157
  fetchGeminiModels()
146
158
  .then(models => {
147
- setGeminiModels(models)
148
- if (models && models.length > 0) {
149
- const savedSub = localStorage.getItem('selectedSubModel');
150
- const exists = models.some(m => m.id === savedSub);
151
- if (savedSub && exists) {
152
- setSelectedSubModel(savedSub);
153
- } else {
154
- setSelectedSubModel(models[0].id);
155
- localStorage.setItem('selectedSubModel', models[0].id);
156
- }
159
+ const list = models && models.length > 0 ? models : DEFAULT_GEMINI_MODELS;
160
+ setGeminiModels(list);
161
+ const savedSub = localStorage.getItem('selectedSubModel');
162
+ const exists = list.some(m => m.id === savedSub);
163
+ if (savedSub && exists) {
164
+ setSelectedSubModel(savedSub);
165
+ } else {
166
+ setSelectedSubModel(list[0].id);
167
+ localStorage.setItem('selectedSubModel', list[0].id);
168
+ }
169
+ })
170
+ .catch(() => {
171
+ setGeminiModels(DEFAULT_GEMINI_MODELS);
172
+ const savedSub = localStorage.getItem('selectedSubModel');
173
+ const exists = DEFAULT_GEMINI_MODELS.some(m => m.id === savedSub);
174
+ if (savedSub && exists) {
175
+ setSelectedSubModel(savedSub);
176
+ } else {
177
+ setSelectedSubModel(DEFAULT_GEMINI_MODELS[0].id);
178
+ localStorage.setItem('selectedSubModel', DEFAULT_GEMINI_MODELS[0].id);
157
179
  }
158
180
  })
159
- .catch(console.error)
160
181
  // eslint-disable-next-line react-hooks/exhaustive-deps -- onBrainChange is stable from parent
161
182
  }, [])
162
183
 
@@ -380,7 +401,7 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
380
401
  return;
381
402
  }
382
403
 
383
- const modelWithSubModel = ['gemini', 'antigravity'].includes(selectedModel) && selectedSubModel
404
+ const modelWithSubModel = ['gemini', 'antigravity', 'agy'].includes(selectedModel) && selectedSubModel
384
405
  ? `${selectedModel}:${selectedSubModel}`
385
406
  : selectedModel
386
407
 
@@ -409,7 +430,7 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
409
430
  setLoading(true)
410
431
  abortControllerRef.current = new AbortController()
411
432
  try {
412
- const modelWithSubModel = ['gemini', 'antigravity'].includes(selectedModel) && selectedSubModel
433
+ const modelWithSubModel = ['gemini', 'antigravity', 'agy'].includes(selectedModel) && selectedSubModel
413
434
  ? `${selectedModel}:${selectedSubModel}`
414
435
  : selectedModel
415
436
 
@@ -599,7 +620,12 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
599
620
  }}
600
621
  title="Select CLI agent / provider"
601
622
  >
602
- <span style={{ textTransform: 'capitalize' }}>{selectedModel || 'Select Model'}</span>
623
+ <span style={{ textTransform: 'capitalize' }}>
624
+ {selectedModel || 'Select Model'}
625
+ {['gemini', 'antigravity', 'agy'].includes(selectedModel) && selectedSubModel
626
+ ? ` · ${geminiModels.find(m => m.id === selectedSubModel)?.displayName || selectedSubModel}`
627
+ : ''}
628
+ </span>
603
629
  <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
604
630
  <polyline points="6 9 12 15 18 9"></polyline>
605
631
  </svg>
@@ -629,6 +655,7 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
629
655
  <span className="model-selector-item-name" style={{ textTransform: 'capitalize' }}>{modelName}</span>
630
656
  <span className="model-selector-item-desc">
631
657
  {modelName === 'antigravity' && 'Google Antigravity'}
658
+ {modelName === 'agy' && 'Google Antigravity'}
632
659
  {modelName === 'grok' && 'xAI Grok Build CLI'}
633
660
  {modelName === 'gemini' && 'Google Gemini CLI'}
634
661
  {modelName === 'claude' && 'Anthropic Claude CLI'}
@@ -641,7 +668,7 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
641
668
  </div>
642
669
  )}
643
670
  </div>
644
- {['gemini', 'antigravity'].includes(selectedModel) && geminiModels.length > 0 && (
671
+ {['gemini', 'antigravity', 'agy'].includes(selectedModel) && geminiModels.length > 0 && (
645
672
  <div className="chat-header-submodel-selector" style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 16 }}>
646
673
  <span className="selector-label" style={{ fontSize: '12px', color: 'var(--text-tertiary)', fontWeight: 500 }}>Base:</span>
647
674
  <select
@@ -66,6 +66,13 @@ const PRESETS: Preset[] = [
66
66
  command: 'npx total-recall connect hermes',
67
67
  snippet: () => `Pushes truncated memory surface projections to autonomous Hermes.`
68
68
  },
69
+ {
70
+ id: 'dsh',
71
+ name: 'DeepSeek Harness (dsh)',
72
+ surface: 'AGENTS.md / ~/.dsh/memory/MEMORY.md',
73
+ command: 'npx total-recall connect dsh',
74
+ snippet: () => `Integrates SSSS memory and skills into DeepSeek Harness agent runtimes.`
75
+ },
69
76
  {
70
77
  id: 'openclaw',
71
78
  name: 'OpenClaw',
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import PluginsPage from './PluginsPage';
4
+
5
+ // Mock fetch globally
6
+ const mockFetch = vi.fn().mockResolvedValue({
7
+ ok: true,
8
+ json: async () => ({ installed: [], catalog: [] })
9
+ });
10
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
11
+
12
+ describe('PluginsPage', () => {
13
+ it('renders plugins page', () => {
14
+ render(<PluginsPage />);
15
+ expect(screen.getByText(/Plugin/i)).toBeInTheDocument();
16
+ });
17
+ });