clawport-ui 0.1.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 (132) hide show
  1. package/.env.example +35 -0
  2. package/BRANDING.md +131 -0
  3. package/CLAUDE.md +252 -0
  4. package/README.md +262 -0
  5. package/SETUP.md +337 -0
  6. package/app/agents/[id]/page.tsx +727 -0
  7. package/app/api/agents/route.ts +12 -0
  8. package/app/api/chat/[id]/route.ts +139 -0
  9. package/app/api/cron-runs/route.ts +13 -0
  10. package/app/api/crons/route.ts +12 -0
  11. package/app/api/kanban/chat/[id]/route.ts +119 -0
  12. package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
  13. package/app/api/memory/route.ts +12 -0
  14. package/app/api/transcribe/route.ts +37 -0
  15. package/app/api/tts/route.ts +42 -0
  16. package/app/chat/[id]/page.tsx +10 -0
  17. package/app/chat/page.tsx +200 -0
  18. package/app/crons/page.tsx +870 -0
  19. package/app/docs/page.tsx +399 -0
  20. package/app/favicon.ico +0 -0
  21. package/app/globals.css +692 -0
  22. package/app/kanban/page.tsx +327 -0
  23. package/app/layout.tsx +45 -0
  24. package/app/memory/page.tsx +685 -0
  25. package/app/page.tsx +817 -0
  26. package/app/providers.tsx +37 -0
  27. package/app/settings/page.tsx +901 -0
  28. package/app/settings-provider.tsx +209 -0
  29. package/components/AgentAvatar.tsx +54 -0
  30. package/components/AgentNode.tsx +122 -0
  31. package/components/Breadcrumbs.tsx +126 -0
  32. package/components/DynamicFavicon.tsx +62 -0
  33. package/components/ErrorState.tsx +97 -0
  34. package/components/FeedView.tsx +494 -0
  35. package/components/GlobalSearch.tsx +571 -0
  36. package/components/GridView.tsx +532 -0
  37. package/components/ManorMap.tsx +157 -0
  38. package/components/MobileSidebar.tsx +251 -0
  39. package/components/NavLinks.tsx +271 -0
  40. package/components/OnboardingWizard.tsx +1067 -0
  41. package/components/Sidebar.tsx +115 -0
  42. package/components/ThemeToggle.tsx +108 -0
  43. package/components/chat/AgentList.tsx +537 -0
  44. package/components/chat/ConversationView.tsx +1047 -0
  45. package/components/chat/FileAttachment.tsx +140 -0
  46. package/components/chat/MediaPreview.tsx +111 -0
  47. package/components/chat/VoiceMessage.tsx +139 -0
  48. package/components/crons/PipelineGraph.tsx +327 -0
  49. package/components/crons/WeeklySchedule.tsx +630 -0
  50. package/components/docs/AgentsSection.tsx +209 -0
  51. package/components/docs/ApiReferenceSection.tsx +256 -0
  52. package/components/docs/ArchitectureSection.tsx +221 -0
  53. package/components/docs/ComponentsSection.tsx +253 -0
  54. package/components/docs/CronSystemSection.tsx +235 -0
  55. package/components/docs/DocSection.tsx +346 -0
  56. package/components/docs/GettingStartedSection.tsx +169 -0
  57. package/components/docs/ThemingSection.tsx +257 -0
  58. package/components/docs/TroubleshootingSection.tsx +200 -0
  59. package/components/kanban/AgentPicker.tsx +321 -0
  60. package/components/kanban/CreateTicketModal.tsx +333 -0
  61. package/components/kanban/KanbanBoard.tsx +70 -0
  62. package/components/kanban/KanbanColumn.tsx +166 -0
  63. package/components/kanban/TicketCard.tsx +245 -0
  64. package/components/kanban/TicketDetailPanel.tsx +850 -0
  65. package/components/ui/badge.tsx +48 -0
  66. package/components/ui/button.tsx +64 -0
  67. package/components/ui/card.tsx +92 -0
  68. package/components/ui/dialog.tsx +158 -0
  69. package/components/ui/scroll-area.tsx +58 -0
  70. package/components/ui/separator.tsx +28 -0
  71. package/components/ui/skeleton.tsx +27 -0
  72. package/components/ui/tabs.tsx +91 -0
  73. package/components/ui/tooltip.tsx +57 -0
  74. package/components.json +23 -0
  75. package/docs/API.md +648 -0
  76. package/docs/COMPONENTS.md +1059 -0
  77. package/docs/THEMING.md +795 -0
  78. package/lib/agents-registry.ts +35 -0
  79. package/lib/agents.json +282 -0
  80. package/lib/agents.test.ts +367 -0
  81. package/lib/agents.ts +32 -0
  82. package/lib/anthropic.test.ts +422 -0
  83. package/lib/anthropic.ts +220 -0
  84. package/lib/api-error.ts +16 -0
  85. package/lib/audio-recorder.test.ts +72 -0
  86. package/lib/audio-recorder.ts +169 -0
  87. package/lib/conversations.test.ts +331 -0
  88. package/lib/conversations.ts +117 -0
  89. package/lib/cron-pipelines.test.ts +69 -0
  90. package/lib/cron-pipelines.ts +58 -0
  91. package/lib/cron-runs.test.ts +118 -0
  92. package/lib/cron-runs.ts +67 -0
  93. package/lib/cron-utils.test.ts +222 -0
  94. package/lib/cron-utils.ts +160 -0
  95. package/lib/crons.test.ts +502 -0
  96. package/lib/crons.ts +114 -0
  97. package/lib/env.test.ts +44 -0
  98. package/lib/env.ts +14 -0
  99. package/lib/kanban/automation.test.ts +245 -0
  100. package/lib/kanban/automation.ts +143 -0
  101. package/lib/kanban/chat-store.test.ts +149 -0
  102. package/lib/kanban/chat-store.ts +81 -0
  103. package/lib/kanban/store.test.ts +238 -0
  104. package/lib/kanban/store.ts +98 -0
  105. package/lib/kanban/types.ts +50 -0
  106. package/lib/kanban/useAgentWork.ts +78 -0
  107. package/lib/memory.ts +45 -0
  108. package/lib/multimodal.test.ts +219 -0
  109. package/lib/multimodal.ts +68 -0
  110. package/lib/pipeline.integration.test.ts +343 -0
  111. package/lib/sanitize.ts +194 -0
  112. package/lib/settings.test.ts +137 -0
  113. package/lib/settings.ts +94 -0
  114. package/lib/styles.ts +24 -0
  115. package/lib/themes.ts +9 -0
  116. package/lib/transcribe.test.ts +141 -0
  117. package/lib/transcribe.ts +111 -0
  118. package/lib/types.ts +66 -0
  119. package/lib/utils.ts +6 -0
  120. package/lib/validation.test.ts +132 -0
  121. package/lib/validation.ts +80 -0
  122. package/next.config.ts +7 -0
  123. package/package.json +56 -0
  124. package/postcss.config.mjs +7 -0
  125. package/public/file.svg +1 -0
  126. package/public/globe.svg +1 -0
  127. package/public/next.svg +1 -0
  128. package/public/vercel.svg +1 -0
  129. package/public/window.svg +1 -0
  130. package/scripts/setup.mjs +215 -0
  131. package/tsconfig.json +34 -0
  132. package/vitest.config.ts +17 -0
@@ -0,0 +1,35 @@
1
+ import { readFileSync, existsSync } from 'fs'
2
+ import { join } from 'path'
3
+ import bundledRegistry from '@/lib/agents.json'
4
+ import type { Agent } from '@/lib/types'
5
+
6
+ /** Raw agent data from JSON (everything except runtime-loaded soul and crons) */
7
+ export type AgentEntry = Omit<Agent, 'soul' | 'crons'>
8
+
9
+ /**
10
+ * Load the agent registry, checking for a user-provided override first.
11
+ *
12
+ * Resolution order:
13
+ * 1. $WORKSPACE_PATH/clawport/agents.json (user's own config)
14
+ * 2. Bundled lib/agents.json (default example registry)
15
+ *
16
+ * This lets any OpenClaw user customise their agent team without editing
17
+ * ClawPort source code -- just drop an agents.json into their workspace.
18
+ */
19
+ export function loadRegistry(): AgentEntry[] {
20
+ const workspacePath = process.env.WORKSPACE_PATH
21
+
22
+ if (workspacePath) {
23
+ const userRegistryPath = join(workspacePath, 'clawport', 'agents.json')
24
+ if (existsSync(userRegistryPath)) {
25
+ try {
26
+ const raw = readFileSync(userRegistryPath, 'utf-8')
27
+ return JSON.parse(raw) as AgentEntry[]
28
+ } catch {
29
+ // Malformed user JSON -- fall through to bundled default
30
+ }
31
+ }
32
+ }
33
+
34
+ return bundledRegistry as AgentEntry[]
35
+ }
@@ -0,0 +1,282 @@
1
+ [
2
+ {
3
+ "id": "jarvis",
4
+ "name": "Jarvis",
5
+ "title": "Orchestrator",
6
+ "reportsTo": null,
7
+ "directReports": ["vera", "lumen", "herald", "pulse", "echo", "sage", "kaze", "spark", "scribe"],
8
+ "soulPath": "SOUL.md",
9
+ "voiceId": "agL69Vji082CshT65Tcy",
10
+ "color": "#f5c518",
11
+ "emoji": "\ud83e\udd16",
12
+ "tools": ["exec", "read", "write", "edit", "web_search", "tts", "message", "sessions_spawn", "memory_search"],
13
+ "memoryPath": null,
14
+ "description": "Top-level orchestrator. Manages the team, holds memory, delivers briefings."
15
+ },
16
+ {
17
+ "id": "vera",
18
+ "name": "VERA",
19
+ "title": "Chief Strategy Officer",
20
+ "reportsTo": "jarvis",
21
+ "directReports": ["robin"],
22
+ "soulPath": "agents/vera/SOUL.md",
23
+ "voiceId": "EAHourGM2PqzHHl0Ywjp",
24
+ "color": "#a855f7",
25
+ "emoji": "\u265f\ufe0f",
26
+ "tools": ["web_search", "web_fetch", "read", "write", "sessions_spawn"],
27
+ "memoryPath": null,
28
+ "description": "CSO. Manages validation team. Decides what gets built and what gets killed."
29
+ },
30
+ {
31
+ "id": "robin",
32
+ "name": "Robin",
33
+ "title": "Field Intel Operator",
34
+ "reportsTo": "vera",
35
+ "directReports": ["trace", "proof"],
36
+ "soulPath": "agents/robin/SOUL.md",
37
+ "voiceId": "IRHApOXLvnW57QJPQH2P",
38
+ "color": "#3b82f6",
39
+ "emoji": "\ud83e\udd85",
40
+ "tools": ["web_search", "web_fetch", "read", "write", "message"],
41
+ "memoryPath": null,
42
+ "description": "Field operator. Competitive intel, opportunity scouting, lead signals."
43
+ },
44
+ {
45
+ "id": "trace",
46
+ "name": "TRACE",
47
+ "title": "Market Researcher",
48
+ "reportsTo": "robin",
49
+ "directReports": [],
50
+ "soulPath": "agents/trace/SOUL.md",
51
+ "voiceId": null,
52
+ "color": "#06b6d4",
53
+ "emoji": "\ud83d\udd0d",
54
+ "tools": ["web_search", "web_fetch", "read", "write"],
55
+ "memoryPath": null,
56
+ "description": "Market research. TAM, competitors, pricing benchmarks. Returns Market Briefs."
57
+ },
58
+ {
59
+ "id": "proof",
60
+ "name": "PROOF",
61
+ "title": "Validation Designer",
62
+ "reportsTo": "robin",
63
+ "directReports": [],
64
+ "soulPath": "agents/proof/SOUL.md",
65
+ "voiceId": null,
66
+ "color": "#06b6d4",
67
+ "emoji": "\u2705",
68
+ "tools": ["web_search", "web_fetch", "read", "write"],
69
+ "memoryPath": null,
70
+ "description": "Designs minimum viable tests. Writes outreach copy. Calls BUILD/KILL/PIVOT."
71
+ },
72
+ {
73
+ "id": "lumen",
74
+ "name": "LUMEN",
75
+ "title": "SEO Team Director",
76
+ "reportsTo": "jarvis",
77
+ "directReports": ["scout", "analyst", "strategist", "writer", "auditor"],
78
+ "soulPath": "agents/seo-team/SOUL.md",
79
+ "voiceId": "EVy5l1wEi54nXdQwAJJf",
80
+ "color": "#22c55e",
81
+ "emoji": "\ud83d\udd26",
82
+ "tools": ["web_search", "web_fetch", "read", "write", "exec"],
83
+ "memoryPath": null,
84
+ "description": "SEO Team Director. Runs SCOUT\u2192ANALYST\u2192STRATEGIST\u2192WRITER pipeline."
85
+ },
86
+ {
87
+ "id": "scout",
88
+ "name": "SCOUT",
89
+ "title": "Content Scout",
90
+ "reportsTo": "lumen",
91
+ "directReports": [],
92
+ "soulPath": null,
93
+ "voiceId": null,
94
+ "color": "#86efac",
95
+ "emoji": "\ud83d\uddfa\ufe0f",
96
+ "tools": ["web_search", "web_fetch", "read"],
97
+ "memoryPath": null,
98
+ "description": "Scouts trending topics, pulls RSS feeds, identifies content opportunities."
99
+ },
100
+ {
101
+ "id": "analyst",
102
+ "name": "ANALYST",
103
+ "title": "SEO Analyst",
104
+ "reportsTo": "lumen",
105
+ "directReports": [],
106
+ "soulPath": null,
107
+ "voiceId": null,
108
+ "color": "#86efac",
109
+ "emoji": "\ud83d\udcca",
110
+ "tools": ["web_search", "web_fetch", "read", "write"],
111
+ "memoryPath": null,
112
+ "description": "Keyword research, GSC data analysis, competitive gap identification."
113
+ },
114
+ {
115
+ "id": "strategist",
116
+ "name": "STRATEGIST",
117
+ "title": "Content Strategist",
118
+ "reportsTo": "lumen",
119
+ "directReports": [],
120
+ "soulPath": null,
121
+ "voiceId": null,
122
+ "color": "#86efac",
123
+ "emoji": "\ud83c\udfaf",
124
+ "tools": ["read", "write"],
125
+ "memoryPath": null,
126
+ "description": "Topic angle selection using SAGE and ECHO briefs."
127
+ },
128
+ {
129
+ "id": "writer",
130
+ "name": "WRITER",
131
+ "title": "Content Writer",
132
+ "reportsTo": "lumen",
133
+ "directReports": [],
134
+ "soulPath": null,
135
+ "voiceId": null,
136
+ "color": "#86efac",
137
+ "emoji": "\u270d\ufe0f",
138
+ "tools": ["read", "write"],
139
+ "memoryPath": null,
140
+ "description": "1500-2000 word posts in the operator's voice."
141
+ },
142
+ {
143
+ "id": "auditor",
144
+ "name": "AUDITOR",
145
+ "title": "Quality Gate",
146
+ "reportsTo": "lumen",
147
+ "directReports": [],
148
+ "soulPath": null,
149
+ "voiceId": null,
150
+ "color": "#86efac",
151
+ "emoji": "\ud83d\udee1\ufe0f",
152
+ "tools": ["read", "write"],
153
+ "memoryPath": null,
154
+ "description": "Pre-ship quality gate. 6-item checklist before publishing."
155
+ },
156
+ {
157
+ "id": "herald",
158
+ "name": "HERALD",
159
+ "title": "LinkedIn Content Director",
160
+ "reportsTo": "jarvis",
161
+ "directReports": ["quill", "maven"],
162
+ "soulPath": "agents/herald/SOUL.md",
163
+ "voiceId": null,
164
+ "color": "#f97316",
165
+ "emoji": "\ud83d\udce3",
166
+ "tools": ["web_search", "web_fetch", "read", "write", "message", "exec"],
167
+ "memoryPath": null,
168
+ "description": "LinkedIn content pipeline. Reads Pulse feed, picks angles, briefs QUILL."
169
+ },
170
+ {
171
+ "id": "quill",
172
+ "name": "QUILL",
173
+ "title": "LinkedIn Writer",
174
+ "reportsTo": "herald",
175
+ "directReports": [],
176
+ "soulPath": "agents/herald/sub-agents/QUILL.md",
177
+ "voiceId": null,
178
+ "color": "#fdba74",
179
+ "emoji": "\ud83d\udd8a\ufe0f",
180
+ "tools": ["read", "write"],
181
+ "memoryPath": null,
182
+ "description": "Writes LinkedIn posts in the operator's voice."
183
+ },
184
+ {
185
+ "id": "maven",
186
+ "name": "MAVEN",
187
+ "title": "LinkedIn Strategist",
188
+ "reportsTo": "herald",
189
+ "directReports": [],
190
+ "soulPath": "agents/herald/sub-agents/MAVEN.md",
191
+ "voiceId": null,
192
+ "color": "#fdba74",
193
+ "emoji": "\ud83e\udded",
194
+ "tools": ["web_search", "read", "write"],
195
+ "memoryPath": null,
196
+ "description": "Weekly LinkedIn strategy and content calendar."
197
+ },
198
+ {
199
+ "id": "pulse",
200
+ "name": "Pulse",
201
+ "title": "Trend Radar",
202
+ "reportsTo": "jarvis",
203
+ "directReports": [],
204
+ "soulPath": "agents/pulse/SOUL.md",
205
+ "voiceId": "eadgjmk4R4uojdsheG9t",
206
+ "color": "#eab308",
207
+ "emoji": "\ud83c\udf0a",
208
+ "tools": ["web_search", "web_fetch", "read", "write", "message"],
209
+ "memoryPath": null,
210
+ "description": "Hype radar. Monitors trending signals. Feeds hot topics to LUMEN."
211
+ },
212
+ {
213
+ "id": "echo",
214
+ "name": "ECHO",
215
+ "title": "Community Voice Monitor",
216
+ "reportsTo": "jarvis",
217
+ "directReports": [],
218
+ "soulPath": "agents/echo/SOUL.md",
219
+ "voiceId": null,
220
+ "color": "#14b8a6",
221
+ "emoji": "\ud83d\udce1",
222
+ "tools": ["web_fetch", "read", "write"],
223
+ "memoryPath": null,
224
+ "description": "Scans ICP subreddits weekly. Extracts verbatim customer language."
225
+ },
226
+ {
227
+ "id": "sage",
228
+ "name": "SAGE",
229
+ "title": "ICP & Market Expert",
230
+ "reportsTo": "jarvis",
231
+ "directReports": [],
232
+ "soulPath": "agents/sage/SOUL.md",
233
+ "voiceId": null,
234
+ "color": "#14b8a6",
235
+ "emoji": "\ud83e\uddd9",
236
+ "tools": ["read"],
237
+ "memoryPath": null,
238
+ "description": "Deep ICP and market knowledge. Injected into STRATEGIST and WRITER."
239
+ },
240
+ {
241
+ "id": "kaze",
242
+ "name": "KAZE",
243
+ "title": "Japan Flight Monitor",
244
+ "reportsTo": "jarvis",
245
+ "directReports": [],
246
+ "soulPath": "agents/kaze/SOUL.md",
247
+ "voiceId": null,
248
+ "color": "#60a5fa",
249
+ "emoji": "\u2708\ufe0f",
250
+ "tools": ["web_fetch", "message"],
251
+ "memoryPath": null,
252
+ "description": "Monitors MSP to Tokyo flights. Alerts on deals under $1400."
253
+ },
254
+ {
255
+ "id": "spark",
256
+ "name": "SPARK",
257
+ "title": "Tech Discovery",
258
+ "reportsTo": "jarvis",
259
+ "directReports": [],
260
+ "soulPath": "agents/spark/SOUL.md",
261
+ "voiceId": "xNtG3W2oqJs0cJZuTyBc",
262
+ "color": "#f59e0b",
263
+ "emoji": "\u26a1",
264
+ "tools": ["web_fetch", "web_search", "message"],
265
+ "memoryPath": null,
266
+ "description": "Finds cool OpenClaw builds. Reports every other day."
267
+ },
268
+ {
269
+ "id": "scribe",
270
+ "name": "SCRIBE",
271
+ "title": "Memory Architect",
272
+ "reportsTo": "jarvis",
273
+ "directReports": [],
274
+ "soulPath": "agents/scribe/SOUL.md",
275
+ "voiceId": null,
276
+ "color": "#94a3b8",
277
+ "emoji": "\ud83d\udcda",
278
+ "tools": ["read", "write", "exec"],
279
+ "memoryPath": null,
280
+ "description": "Weekly memory compression. Silent worker."
281
+ }
282
+ ]
@@ -0,0 +1,367 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
3
+
4
+ const { mockReadFileSync, mockExistsSync, bundledAgents } = vi.hoisted(() => ({
5
+ mockReadFileSync: vi.fn(),
6
+ mockExistsSync: vi.fn(),
7
+ bundledAgents: [
8
+ {
9
+ id: 'jarvis',
10
+ name: 'Jarvis',
11
+ title: 'Orchestrator',
12
+ reportsTo: null,
13
+ directReports: ['vera', 'lumen', 'pulse'],
14
+ soulPath: 'SOUL.md',
15
+ voiceId: 'agL69Vji082CshT65Tcy',
16
+ color: '#f5c518',
17
+ emoji: 'R',
18
+ tools: ['exec', 'read', 'write'],
19
+ memoryPath: null,
20
+ description: 'Top-level orchestrator.',
21
+ },
22
+ {
23
+ id: 'vera',
24
+ name: 'VERA',
25
+ title: 'Chief Strategy Officer',
26
+ reportsTo: 'jarvis',
27
+ directReports: ['robin'],
28
+ soulPath: 'agents/vera/SOUL.md',
29
+ voiceId: 'EAHourGM2PqzHHl0Ywjp',
30
+ color: '#a855f7',
31
+ emoji: 'P',
32
+ tools: ['web_search', 'read'],
33
+ memoryPath: null,
34
+ description: 'CSO. Decides what gets built.',
35
+ },
36
+ {
37
+ id: 'robin',
38
+ name: 'Robin',
39
+ title: 'Field Intel Operator',
40
+ reportsTo: 'vera',
41
+ directReports: [],
42
+ soulPath: 'agents/robin/SOUL.md',
43
+ voiceId: null,
44
+ color: '#3b82f6',
45
+ emoji: 'E',
46
+ tools: ['web_search'],
47
+ memoryPath: null,
48
+ description: 'Field operator.',
49
+ },
50
+ {
51
+ id: 'lumen',
52
+ name: 'LUMEN',
53
+ title: 'SEO Team Director',
54
+ reportsTo: 'jarvis',
55
+ directReports: ['scout'],
56
+ soulPath: 'agents/seo-team/SOUL.md',
57
+ voiceId: null,
58
+ color: '#22c55e',
59
+ emoji: 'L',
60
+ tools: ['web_search', 'read'],
61
+ memoryPath: null,
62
+ description: 'SEO Team Director.',
63
+ },
64
+ {
65
+ id: 'scout',
66
+ name: 'SCOUT',
67
+ title: 'Content Scout',
68
+ reportsTo: 'lumen',
69
+ directReports: [],
70
+ soulPath: null,
71
+ voiceId: null,
72
+ color: '#86efac',
73
+ emoji: 'S',
74
+ tools: ['web_search'],
75
+ memoryPath: null,
76
+ description: 'Scouts trending topics.',
77
+ },
78
+ {
79
+ id: 'pulse',
80
+ name: 'Pulse',
81
+ title: 'Trend Radar',
82
+ reportsTo: 'jarvis',
83
+ directReports: [],
84
+ soulPath: 'agents/pulse/SOUL.md',
85
+ voiceId: null,
86
+ color: '#eab308',
87
+ emoji: 'W',
88
+ tools: ['web_search'],
89
+ memoryPath: null,
90
+ description: 'Hype radar.',
91
+ },
92
+ {
93
+ id: 'kaze',
94
+ name: 'KAZE',
95
+ title: 'Japan Flight Monitor',
96
+ reportsTo: 'jarvis',
97
+ directReports: [],
98
+ soulPath: null,
99
+ voiceId: null,
100
+ color: '#60a5fa',
101
+ emoji: 'A',
102
+ tools: ['web_fetch'],
103
+ memoryPath: null,
104
+ description: 'Monitors flights.',
105
+ },
106
+ ],
107
+ }))
108
+
109
+ // Mock fs (Dependency Inversion -- no real file system access in tests)
110
+ vi.mock('fs', () => ({
111
+ readFileSync: mockReadFileSync,
112
+ existsSync: mockExistsSync,
113
+ default: { readFileSync: mockReadFileSync, existsSync: mockExistsSync },
114
+ }))
115
+
116
+ // Mock the bundled agents.json
117
+ vi.mock('@/lib/agents.json', () => ({
118
+ default: bundledAgents,
119
+ }))
120
+
121
+ // We need to import AFTER mocks are set up
122
+ import { getAgents, getAgent } from './agents'
123
+
124
+ beforeEach(() => {
125
+ vi.clearAllMocks()
126
+ vi.unstubAllEnvs()
127
+ // Default: no SOUL files exist on disk
128
+ mockExistsSync.mockReturnValue(false)
129
+ })
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Registry loading: bundled fallback vs workspace override
133
+ // ---------------------------------------------------------------------------
134
+
135
+ describe('agent registry loading', () => {
136
+ it('loads from bundled JSON when WORKSPACE_PATH is not set', async () => {
137
+ vi.stubEnv('WORKSPACE_PATH', '')
138
+ const agents = await getAgents()
139
+ expect(agents.length).toBe(bundledAgents.length)
140
+ expect(agents.map(a => a.id)).toContain('jarvis')
141
+ })
142
+
143
+ it('loads from bundled JSON when workspace override file does not exist', async () => {
144
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/test-workspace')
145
+ mockExistsSync.mockReturnValue(false)
146
+ const agents = await getAgents()
147
+ expect(agents.length).toBe(bundledAgents.length)
148
+ })
149
+
150
+ it('loads from workspace override when clawport/agents.json exists', async () => {
151
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/test-workspace')
152
+
153
+ const customAgents = [
154
+ {
155
+ id: 'custom-bot',
156
+ name: 'CustomBot',
157
+ title: 'Custom Agent',
158
+ reportsTo: null,
159
+ directReports: [],
160
+ soulPath: null,
161
+ voiceId: null,
162
+ color: '#ff0000',
163
+ emoji: 'C',
164
+ tools: ['read'],
165
+ memoryPath: null,
166
+ description: 'A custom agent.',
167
+ },
168
+ ]
169
+
170
+ mockExistsSync.mockImplementation((path: string) => {
171
+ if (path === '/tmp/test-workspace/clawport/agents.json') return true
172
+ return false
173
+ })
174
+ mockReadFileSync.mockImplementation((path: string) => {
175
+ if (path === '/tmp/test-workspace/clawport/agents.json') {
176
+ return JSON.stringify(customAgents)
177
+ }
178
+ throw new Error('ENOENT')
179
+ })
180
+
181
+ const agents = await getAgents()
182
+ expect(agents.length).toBe(1)
183
+ expect(agents[0].id).toBe('custom-bot')
184
+ expect(agents[0].name).toBe('CustomBot')
185
+ })
186
+
187
+ it('falls back to bundled JSON when workspace agents.json is malformed', async () => {
188
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/test-workspace')
189
+
190
+ mockExistsSync.mockImplementation((path: string) => {
191
+ if (path === '/tmp/test-workspace/clawport/agents.json') return true
192
+ return false
193
+ })
194
+ mockReadFileSync.mockImplementation((path: string) => {
195
+ if (path === '/tmp/test-workspace/clawport/agents.json') {
196
+ return '{ invalid json !!!'
197
+ }
198
+ throw new Error('ENOENT')
199
+ })
200
+
201
+ const agents = await getAgents()
202
+ // Should fall back to bundled agents, not crash
203
+ expect(agents.length).toBe(bundledAgents.length)
204
+ expect(agents.map(a => a.id)).toContain('jarvis')
205
+ })
206
+
207
+ it('falls back to bundled JSON when workspace agents.json read throws', async () => {
208
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/test-workspace')
209
+
210
+ mockExistsSync.mockImplementation((path: string) => {
211
+ if (path === '/tmp/test-workspace/clawport/agents.json') return true
212
+ return false
213
+ })
214
+ mockReadFileSync.mockImplementation((path: string) => {
215
+ if (path === '/tmp/test-workspace/clawport/agents.json') {
216
+ throw new Error('EACCES')
217
+ }
218
+ throw new Error('ENOENT')
219
+ })
220
+
221
+ const agents = await getAgents()
222
+ expect(agents.length).toBe(bundledAgents.length)
223
+ })
224
+ })
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // getAgents
228
+ // ---------------------------------------------------------------------------
229
+
230
+ describe('getAgents', () => {
231
+ it('returns all agents from the registry', async () => {
232
+ const agents = await getAgents()
233
+ expect(agents.length).toBeGreaterThan(0)
234
+ })
235
+
236
+ it('every agent has required fields', async () => {
237
+ const agents = await getAgents()
238
+ for (const agent of agents) {
239
+ expect(agent.id).toEqual(expect.any(String))
240
+ expect(agent.name).toEqual(expect.any(String))
241
+ expect(agent.title).toEqual(expect.any(String))
242
+ expect(agent.color).toMatch(/^#[0-9a-fA-F]{6}$/)
243
+ expect(agent.emoji).toEqual(expect.any(String))
244
+ expect(Array.isArray(agent.tools)).toBe(true)
245
+ expect(Array.isArray(agent.directReports)).toBe(true)
246
+ expect(Array.isArray(agent.crons)).toBe(true)
247
+ expect(agent.description).toEqual(expect.any(String))
248
+ }
249
+ })
250
+
251
+ it('includes known agents by id', async () => {
252
+ const agents = await getAgents()
253
+ const ids = agents.map(a => a.id)
254
+ expect(ids).toContain('jarvis')
255
+ expect(ids).toContain('vera')
256
+ expect(ids).toContain('lumen')
257
+ expect(ids).toContain('pulse')
258
+ expect(ids).toContain('kaze')
259
+ })
260
+
261
+ it('sets soul to null when WORKSPACE_PATH is not set', async () => {
262
+ vi.stubEnv('WORKSPACE_PATH', '')
263
+ const agents = await getAgents()
264
+ const jarvis = agents.find(a => a.id === 'jarvis')!
265
+ expect(jarvis.soulPath).toBeTruthy()
266
+ expect(jarvis.soul).toBeNull()
267
+ })
268
+
269
+ it('sets soul to null when soulPath file does not exist', async () => {
270
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
271
+ mockExistsSync.mockReturnValue(false)
272
+ const agents = await getAgents()
273
+ const jarvis = agents.find(a => a.id === 'jarvis')!
274
+ expect(jarvis.soulPath).toBeTruthy()
275
+ expect(jarvis.soul).toBeNull()
276
+ })
277
+
278
+ it('reads soul content when soulPath file exists', async () => {
279
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
280
+ mockExistsSync.mockImplementation((path: string) => {
281
+ // Only the SOUL file exists, not the workspace override
282
+ if (path === '/tmp/ws/clawport/agents.json') return false
283
+ return true
284
+ })
285
+ mockReadFileSync.mockReturnValue('# Jarvis SOUL')
286
+ const agents = await getAgents()
287
+ const jarvis = agents.find(a => a.id === 'jarvis')!
288
+ expect(jarvis.soul).toBe('# Jarvis SOUL')
289
+ })
290
+
291
+ it('sets soul to null when readFileSync throws', async () => {
292
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
293
+ mockExistsSync.mockImplementation((path: string) => {
294
+ if (path === '/tmp/ws/clawport/agents.json') return false
295
+ return true
296
+ })
297
+ mockReadFileSync.mockImplementation(() => { throw new Error('EACCES') })
298
+ const agents = await getAgents()
299
+ const jarvis = agents.find(a => a.id === 'jarvis')!
300
+ expect(jarvis.soul).toBeNull()
301
+ })
302
+
303
+ it('initializes crons as empty array for every agent', async () => {
304
+ const agents = await getAgents()
305
+ for (const agent of agents) {
306
+ expect(agent.crons).toEqual([])
307
+ }
308
+ })
309
+
310
+ it('agents with no soulPath get soul=null without reading fs', async () => {
311
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
312
+ mockExistsSync.mockReturnValue(false)
313
+ const agents = await getAgents()
314
+ const scout = agents.find(a => a.id === 'scout')!
315
+ expect(scout.soulPath).toBeNull()
316
+ expect(scout.soul).toBeNull()
317
+ })
318
+ })
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // getAgent
322
+ // ---------------------------------------------------------------------------
323
+
324
+ describe('getAgent', () => {
325
+ it('returns the correct agent by id', async () => {
326
+ const agent = await getAgent('vera')
327
+ expect(agent).not.toBeNull()
328
+ expect(agent!.id).toBe('vera')
329
+ expect(agent!.name).toBe('VERA')
330
+ expect(agent!.title).toBe('Chief Strategy Officer')
331
+ })
332
+
333
+ it('returns null for an unknown id', async () => {
334
+ const agent = await getAgent('nonexistent-agent')
335
+ expect(agent).toBeNull()
336
+ })
337
+
338
+ it('returns null for empty string', async () => {
339
+ const agent = await getAgent('')
340
+ expect(agent).toBeNull()
341
+ })
342
+
343
+ it('is case-sensitive (uppercase id returns null)', async () => {
344
+ const agent = await getAgent('VERA')
345
+ expect(agent).toBeNull()
346
+ })
347
+
348
+ it('returns agent with correct directReports', async () => {
349
+ const jarvis = await getAgent('jarvis')
350
+ expect(jarvis).not.toBeNull()
351
+ expect(jarvis!.directReports).toContain('vera')
352
+ expect(jarvis!.directReports).toContain('lumen')
353
+ expect(jarvis!.directReports).toContain('pulse')
354
+ })
355
+
356
+ it('returns agent with correct reportsTo chain', async () => {
357
+ const robin = await getAgent('robin')
358
+ expect(robin).not.toBeNull()
359
+ expect(robin!.reportsTo).toBe('vera')
360
+
361
+ const vera = await getAgent('vera')
362
+ expect(vera!.reportsTo).toBe('jarvis')
363
+
364
+ const jarvis = await getAgent('jarvis')
365
+ expect(jarvis!.reportsTo).toBeNull()
366
+ })
367
+ })