vibepulse 0.1.0 → 0.1.2
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 +7 -13
- package/bin/vibepulse.js +1 -0
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/docs/session-status-detection.md +258 -0
- package/next.config.ts +11 -0
- package/package.json +17 -11
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/readme-cover.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/opencode-config/route.ts +304 -0
- package/src/app/api/opencode-config/status/route.ts +31 -0
- package/src/app/api/opencode-events/route.ts +86 -0
- package/src/app/api/opencode-models/route.test.ts +135 -0
- package/src/app/api/opencode-models/route.ts +58 -0
- package/src/app/api/profiles/[id]/apply/route.ts +49 -0
- package/src/app/api/profiles/[id]/route.ts +160 -0
- package/src/app/api/profiles/route.ts +107 -0
- package/src/app/api/sessions/[id]/archive/route.ts +35 -0
- package/src/app/api/sessions/[id]/delete/route.ts +26 -0
- package/src/app/api/sessions/[id]/route.ts +45 -0
- package/src/app/api/sessions/route.ts +596 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +66 -0
- package/src/app/layout.tsx +37 -0
- package/src/app/page.tsx +239 -0
- package/src/components/ErrorBoundary.tsx +72 -0
- package/src/components/KanbanBoard.tsx +442 -0
- package/src/components/LoadingState.tsx +37 -0
- package/src/components/ProjectCard.tsx +382 -0
- package/src/components/QueryProvider.tsx +25 -0
- package/src/components/SessionCard.tsx +291 -0
- package/src/components/SessionList.tsx +60 -0
- package/src/components/opencode-config/AgentConfigForm.test.tsx +66 -0
- package/src/components/opencode-config/AgentConfigForm.tsx +445 -0
- package/src/components/opencode-config/AgentModelSelector.tsx +284 -0
- package/src/components/opencode-config/AgentsConfigPanel.tsx +162 -0
- package/src/components/opencode-config/ConfigButton.tsx +43 -0
- package/src/components/opencode-config/ConfigPanel.tsx +91 -0
- package/src/components/opencode-config/FullscreenConfigPanel.tsx +360 -0
- package/src/components/opencode-config/categories/CategoriesList.tsx +328 -0
- package/src/components/opencode-config/categories/CategoriesManager.test.tsx +97 -0
- package/src/components/opencode-config/categories/CategoriesManager.tsx +174 -0
- package/src/components/opencode-config/categories/CategoryConfigForm.tsx +384 -0
- package/src/components/opencode-config/profiles/ProfileCard.tsx +140 -0
- package/src/components/opencode-config/profiles/ProfileEditor.tsx +446 -0
- package/src/components/opencode-config/profiles/ProfileList.tsx +398 -0
- package/src/components/opencode-config/profiles/ProfileManager.test.tsx +122 -0
- package/src/components/opencode-config/profiles/ProfileManager.tsx +293 -0
- package/src/components/ui/Tabs.tsx +59 -0
- package/src/hooks/useOpencodeSync.ts +378 -0
- package/src/index.ts +2 -0
- package/src/lib/notificationSound.ts +266 -0
- package/src/lib/opencodeConfig.test.ts +81 -0
- package/src/lib/opencodeConfig.ts +48 -0
- package/src/lib/opencodeDiscovery.ts +154 -0
- package/src/lib/profiles/storage.ts +264 -0
- package/src/lib/transform.ts +84 -0
- package/src/test/setup.ts +8 -0
- package/src/types/index.ts +89 -0
- package/src/types/opencodeConfig.ts +133 -0
- package/src/types/testing-library-vitest.d.ts +17 -0
- package/tsconfig.json +34 -0
- package/tsconfig.lib.json +17 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { readConfig, writeConfig } from '@/lib/opencodeConfig';
|
|
3
|
+
|
|
4
|
+
// Allowed fields to expose in the API
|
|
5
|
+
const ALLOWED_AGENT_FIELDS = ['model', 'temperature', 'top_p', 'variant', 'prompt_append'] as const;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Filters an agent config to only include allowed fields
|
|
9
|
+
*/
|
|
10
|
+
function filterAgentConfig(agent: Record<string, unknown>): Record<string, unknown> {
|
|
11
|
+
const filtered: Record<string, unknown> = {};
|
|
12
|
+
|
|
13
|
+
for (const field of ALLOWED_AGENT_FIELDS) {
|
|
14
|
+
if (agent[field] !== undefined) {
|
|
15
|
+
filtered[field] = agent[field];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return filtered;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* GET /api/opencode-config
|
|
24
|
+
* Returns filtered agent configuration
|
|
25
|
+
* Only exposes: model, temperature, top_p
|
|
26
|
+
* Filters out sensitive fields (apiKey, token, password, etc.)
|
|
27
|
+
*/
|
|
28
|
+
export async function GET() {
|
|
29
|
+
try {
|
|
30
|
+
const config = await readConfig();
|
|
31
|
+
const agents = config.agents || {};
|
|
32
|
+
const filteredAgents: Record<string, Record<string, unknown>> = {};
|
|
33
|
+
|
|
34
|
+
for (const [agentName, agentConfig] of Object.entries(agents)) {
|
|
35
|
+
if (typeof agentConfig === 'object' && agentConfig !== null) {
|
|
36
|
+
filteredAgents[agentName] = filterAgentConfig(agentConfig as Record<string, unknown>);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const categories = config.categories || {};
|
|
41
|
+
const filteredCategories: Record<string, Record<string, unknown>> = {};
|
|
42
|
+
|
|
43
|
+
for (const [catName, catConfig] of Object.entries(categories)) {
|
|
44
|
+
if (typeof catConfig === 'object' && catConfig !== null && !Array.isArray(catConfig)) {
|
|
45
|
+
filteredCategories[catName] = filterAgentConfig(catConfig as Record<string, unknown>);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return NextResponse.json({
|
|
50
|
+
agents: filteredAgents,
|
|
51
|
+
categories: filteredCategories
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error('Error reading config:', error);
|
|
55
|
+
return NextResponse.json(
|
|
56
|
+
{ error: 'Internal server error' },
|
|
57
|
+
{ status: 500 }
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* POST /api/opencode-config
|
|
64
|
+
* Updates agent configuration with validation
|
|
65
|
+
* Only allows: model, temperature, top_p
|
|
66
|
+
* Rejects sensitive fields (apiKey, token, password, secret, etc.)
|
|
67
|
+
*/
|
|
68
|
+
export async function POST(request: NextRequest) {
|
|
69
|
+
try {
|
|
70
|
+
const body = await request.json();
|
|
71
|
+
|
|
72
|
+
// Validate request structure
|
|
73
|
+
if (!body || typeof body !== 'object') {
|
|
74
|
+
return NextResponse.json(
|
|
75
|
+
{ error: 'Invalid request body' },
|
|
76
|
+
{ status: 400 }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { agents, categories } = body;
|
|
81
|
+
|
|
82
|
+
// If neither agents nor categories provided, nothing to update
|
|
83
|
+
if (agents === undefined && categories === undefined) {
|
|
84
|
+
return NextResponse.json(
|
|
85
|
+
{ error: 'Missing agents or categories field' },
|
|
86
|
+
{ status: 400 }
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Validate agents is an object (if provided)
|
|
91
|
+
if (agents !== undefined && (typeof agents !== 'object' || agents === null || Array.isArray(agents))) {
|
|
92
|
+
return NextResponse.json(
|
|
93
|
+
{ error: 'Agents must be an object' },
|
|
94
|
+
{ status: 400 }
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Validate categories is an object (if provided)
|
|
99
|
+
if (categories !== undefined && (typeof categories !== 'object' || categories === null || Array.isArray(categories))) {
|
|
100
|
+
return NextResponse.json(
|
|
101
|
+
{ error: 'Categories must be an object' },
|
|
102
|
+
{ status: 400 }
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Read current config
|
|
107
|
+
const currentConfig = await readConfig();
|
|
108
|
+
const currentAgents = currentConfig.agents || {};
|
|
109
|
+
|
|
110
|
+
// Validate and merge agent updates
|
|
111
|
+
const updatedAgents: Record<string, Record<string, unknown>> = {};
|
|
112
|
+
|
|
113
|
+
for (const [name, config] of Object.entries(currentAgents)) {
|
|
114
|
+
if (typeof config === 'object' && config !== null && !Array.isArray(config)) {
|
|
115
|
+
updatedAgents[name] = config as Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (agents !== undefined) {
|
|
120
|
+
for (const [agentName, agentConfig] of Object.entries(agents)) {
|
|
121
|
+
if (typeof agentConfig !== 'object' || agentConfig === null || Array.isArray(agentConfig)) {
|
|
122
|
+
return NextResponse.json(
|
|
123
|
+
{ error: `Agent '${agentName}' config must be an object` },
|
|
124
|
+
{ status: 400 }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const config = agentConfig as Record<string, unknown>;
|
|
129
|
+
const disallowedFields: string[] = [];
|
|
130
|
+
|
|
131
|
+
for (const key of Object.keys(config)) {
|
|
132
|
+
const lowerKey = key.toLowerCase();
|
|
133
|
+
if (
|
|
134
|
+
lowerKey.includes('api') ||
|
|
135
|
+
lowerKey.includes('key') ||
|
|
136
|
+
lowerKey.includes('token') ||
|
|
137
|
+
lowerKey.includes('secret') ||
|
|
138
|
+
lowerKey.includes('password') ||
|
|
139
|
+
lowerKey.includes('auth') ||
|
|
140
|
+
lowerKey.includes('credential') ||
|
|
141
|
+
lowerKey.includes('private') ||
|
|
142
|
+
lowerKey.includes('cert')
|
|
143
|
+
) {
|
|
144
|
+
disallowedFields.push(key);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (disallowedFields.length > 0) {
|
|
149
|
+
return NextResponse.json(
|
|
150
|
+
{
|
|
151
|
+
error: `Agent '${agentName}' contains disallowed fields: ${disallowedFields.join(', ')}`
|
|
152
|
+
},
|
|
153
|
+
{ status: 403 }
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const validatedConfig: Record<string, unknown> = {};
|
|
158
|
+
|
|
159
|
+
for (const [field, value] of Object.entries(config)) {
|
|
160
|
+
const lowerField = field.toLowerCase();
|
|
161
|
+
|
|
162
|
+
if (lowerField === 'model') {
|
|
163
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
164
|
+
return NextResponse.json(
|
|
165
|
+
{ error: `Agent '${agentName}': model must be a non-empty string` },
|
|
166
|
+
{ status: 400 }
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
validatedConfig[field] = value;
|
|
170
|
+
} else if (lowerField === 'temperature') {
|
|
171
|
+
const temp = Number(value);
|
|
172
|
+
if (isNaN(temp) || temp < 0 || temp > 2) {
|
|
173
|
+
return NextResponse.json(
|
|
174
|
+
{ error: `Agent '${agentName}': temperature must be a number between 0 and 2` },
|
|
175
|
+
{ status: 400 }
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
validatedConfig[field] = temp;
|
|
179
|
+
} else if (lowerField === 'top_p') {
|
|
180
|
+
const topP = Number(value);
|
|
181
|
+
if (isNaN(topP) || topP < 0 || topP > 1) {
|
|
182
|
+
return NextResponse.json(
|
|
183
|
+
{ error: `Agent '${agentName}': top_p must be a number between 0 and 1` },
|
|
184
|
+
{ status: 400 }
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
validatedConfig[field] = topP;
|
|
188
|
+
} else if (lowerField === 'variant') {
|
|
189
|
+
if (typeof value !== 'string') {
|
|
190
|
+
return NextResponse.json(
|
|
191
|
+
{ error: `Agent '${agentName}': variant must be a string` },
|
|
192
|
+
{ status: 400 }
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
validatedConfig[field] = value;
|
|
196
|
+
} else if (lowerField === 'prompt_append') {
|
|
197
|
+
if (typeof value !== 'string') {
|
|
198
|
+
return NextResponse.json(
|
|
199
|
+
{ error: `Agent '${agentName}': prompt_append must be a string` },
|
|
200
|
+
{ status: 400 }
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
validatedConfig[field] = value;
|
|
204
|
+
} else {
|
|
205
|
+
return NextResponse.json(
|
|
206
|
+
{
|
|
207
|
+
error: `Agent '${agentName}': unknown field '${field}'. Allowed fields: model, temperature, top_p, variant, prompt_append`
|
|
208
|
+
},
|
|
209
|
+
{ status: 400 }
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
updatedAgents[agentName] = {
|
|
215
|
+
...(currentAgents[agentName] as Record<string, unknown> || {}),
|
|
216
|
+
...validatedConfig
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Process categories updates if provided
|
|
222
|
+
const updatedCategories: Record<string, Record<string, unknown>> = {};
|
|
223
|
+
const currentCategories = (currentConfig.categories || {}) as Record<string, Record<string, unknown>>;
|
|
224
|
+
|
|
225
|
+
for (const [name, config] of Object.entries(currentCategories)) {
|
|
226
|
+
if (typeof config === 'object' && config !== null && !Array.isArray(config)) {
|
|
227
|
+
updatedCategories[name] = config as Record<string, unknown>;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (categories !== undefined) {
|
|
232
|
+
for (const [categoryName, categoryConfig] of Object.entries(categories)) {
|
|
233
|
+
if (typeof categoryConfig !== 'object' || categoryConfig === null || Array.isArray(categoryConfig)) {
|
|
234
|
+
return NextResponse.json(
|
|
235
|
+
{ error: `Category '${categoryName}' config must be an object` },
|
|
236
|
+
{ status: 400 }
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const configObj = categoryConfig as Record<string, unknown>;
|
|
241
|
+
const validatedCategoryConfig: Record<string, unknown> = {};
|
|
242
|
+
|
|
243
|
+
for (const [field, value] of Object.entries(configObj)) {
|
|
244
|
+
if (field === 'model' || field === 'variant' || field === 'prompt_append' || field === 'description') {
|
|
245
|
+
if (value !== undefined && typeof value !== 'string') {
|
|
246
|
+
return NextResponse.json(
|
|
247
|
+
{ error: `Category '${categoryName}': '${field}' must be a string` },
|
|
248
|
+
{ status: 400 }
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
validatedCategoryConfig[field] = value;
|
|
252
|
+
} else if (field === 'temperature' || field === 'top_p') {
|
|
253
|
+
if (value !== undefined && typeof value !== 'number') {
|
|
254
|
+
return NextResponse.json(
|
|
255
|
+
{ error: `Category '${categoryName}': '${field}' must be a number` },
|
|
256
|
+
{ status: 400 }
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const numValue = value as number;
|
|
261
|
+
const temp = field === 'temperature' ? Math.max(0, Math.min(2, numValue)) : numValue;
|
|
262
|
+
const topP = field === 'top_p' ? Math.max(0, Math.min(1, numValue)) : numValue;
|
|
263
|
+
|
|
264
|
+
validatedCategoryConfig[field] = field === 'temperature' ? temp : topP;
|
|
265
|
+
} else {
|
|
266
|
+
return NextResponse.json(
|
|
267
|
+
{ error: `Category '${categoryName}': unknown field '${field}'` },
|
|
268
|
+
{ status: 400 }
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
updatedCategories[categoryName] = {
|
|
274
|
+
...((currentCategories[categoryName] as Record<string, unknown>) || {}),
|
|
275
|
+
...validatedCategoryConfig
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Update config and save
|
|
281
|
+
const newConfig = { ...currentConfig } as Record<string, unknown>;
|
|
282
|
+
if (agents !== undefined) newConfig.agents = updatedAgents;
|
|
283
|
+
if (categories !== undefined) newConfig.categories = updatedCategories;
|
|
284
|
+
|
|
285
|
+
// writeConfig type doesn't natively expose categories yet, safely bypassing
|
|
286
|
+
await writeConfig(
|
|
287
|
+
newConfig as {
|
|
288
|
+
agents?: Record<string, Record<string, unknown>>;
|
|
289
|
+
categories?: Record<string, Record<string, unknown>>;
|
|
290
|
+
}
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
return NextResponse.json(
|
|
294
|
+
{ success: true, agents: updatedAgents, categories: updatedCategories },
|
|
295
|
+
{ status: 200 }
|
|
296
|
+
);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.error('Error updating config:', error);
|
|
299
|
+
return NextResponse.json(
|
|
300
|
+
{ error: 'Internal server error' },
|
|
301
|
+
{ status: 500 }
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { detectConfig, CONFIG_PATH } from '@/lib/opencodeConfig';
|
|
2
|
+
import { exec } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
|
|
7
|
+
async function detectPlugin(): Promise<boolean> {
|
|
8
|
+
try {
|
|
9
|
+
// Check if oh-my-opencode CLI is available
|
|
10
|
+
await execAsync('opencode --version');
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function GET() {
|
|
18
|
+
const hasConfig = detectConfig();
|
|
19
|
+
const hasPlugin = await detectPlugin();
|
|
20
|
+
|
|
21
|
+
const response: { hasConfig: boolean; hasPlugin: boolean; path?: string } = {
|
|
22
|
+
hasConfig,
|
|
23
|
+
hasPlugin,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (hasConfig) {
|
|
27
|
+
response.path = CONFIG_PATH;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return Response.json(response);
|
|
31
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createOpencodeClient } from '@opencode-ai/sdk';
|
|
2
|
+
import { discoverOpencodePorts } from '@/lib/opencodeDiscovery';
|
|
3
|
+
|
|
4
|
+
export async function GET() {
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
const ports = discoverOpencodePorts();
|
|
7
|
+
|
|
8
|
+
if (!ports.length) {
|
|
9
|
+
return Response.json(
|
|
10
|
+
{
|
|
11
|
+
error: 'OpenCode server not found',
|
|
12
|
+
hint: 'Make sure OpenCode is running with an exposed API port. Example: opencode --port <PORT> (VibePulse auto-detects active ports).'
|
|
13
|
+
},
|
|
14
|
+
{ status: 503 }
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const stream = new ReadableStream({
|
|
20
|
+
async start(controller) {
|
|
21
|
+
let isClosed = false;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
// Connect to each port independently - failures don't block others
|
|
25
|
+
const results = await Promise.allSettled(ports.map(async port => {
|
|
26
|
+
const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` });
|
|
27
|
+
return client.global.event();
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const connectedStreams: AsyncIterable<unknown>[] = [];
|
|
31
|
+
for (const r of results) {
|
|
32
|
+
if (r.status === 'fulfilled') {
|
|
33
|
+
connectedStreams.push(r.value.stream);
|
|
34
|
+
} else {
|
|
35
|
+
console.warn('Failed to connect to OpenCode port:', r.reason);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!connectedStreams.length) {
|
|
40
|
+
console.error('All OpenCode port connections failed');
|
|
41
|
+
try { controller.close(); } catch { /* noop */ }
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tasks = connectedStreams.map(s => (async () => {
|
|
46
|
+
for await (const event of s) {
|
|
47
|
+
if (isClosed) break;
|
|
48
|
+
try {
|
|
49
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
|
50
|
+
} catch { break; }
|
|
51
|
+
}
|
|
52
|
+
})());
|
|
53
|
+
|
|
54
|
+
await Promise.allSettled(tasks);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error('Error in event stream:', error);
|
|
57
|
+
} finally {
|
|
58
|
+
isClosed = true;
|
|
59
|
+
try {
|
|
60
|
+
controller.close();
|
|
61
|
+
} catch {
|
|
62
|
+
// Connection may already be closed
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return new Response(stream, {
|
|
69
|
+
headers: {
|
|
70
|
+
'Content-Type': 'text/event-stream',
|
|
71
|
+
'Cache-Control': 'no-cache',
|
|
72
|
+
Connection: 'keep-alive',
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error('Error creating event stream:', error);
|
|
77
|
+
return Response.json(
|
|
78
|
+
{
|
|
79
|
+
error: 'Failed to create event stream',
|
|
80
|
+
details: error instanceof Error ? error.message : String(error),
|
|
81
|
+
hint: 'Make sure OpenCode is running with an exposed API port. Example: opencode --port <PORT> (VibePulse auto-detects active ports).'
|
|
82
|
+
},
|
|
83
|
+
{ status: 500 }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import type { ExecException } from 'child_process';
|
|
3
|
+
import { handleExecResult, GET, setExecFn } from './route';
|
|
4
|
+
|
|
5
|
+
type ExecCallback = (error: ExecException | null, stdout: string, stderr: string) => void;
|
|
6
|
+
type MockExecFn = (cmd: string, opts: unknown, callback: ExecCallback) => void;
|
|
7
|
+
|
|
8
|
+
describe('/api/opencode-models', () => {
|
|
9
|
+
describe('handleExecResult', () => {
|
|
10
|
+
it('should return error when CLI does not exist', () => {
|
|
11
|
+
const error = new Error('spawn opencode ENOENT') as ExecException;
|
|
12
|
+
const result = handleExecResult(error, '', 'command not found');
|
|
13
|
+
|
|
14
|
+
expect(result.source).toBe('error');
|
|
15
|
+
expect(result.models).toEqual([]);
|
|
16
|
+
expect(result.error).toBeTruthy();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should return error on timeout', () => {
|
|
20
|
+
const error = new Error('timeout') as ExecException;
|
|
21
|
+
const result = handleExecResult(error, '', '');
|
|
22
|
+
|
|
23
|
+
expect(result.source).toBe('error');
|
|
24
|
+
expect(result.models).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return error on empty output', () => {
|
|
28
|
+
const result = handleExecResult(null, '', '');
|
|
29
|
+
|
|
30
|
+
expect(result.source).toBe('error');
|
|
31
|
+
expect(result.models).toEqual([]);
|
|
32
|
+
expect(result.error).toContain('No models found');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should return models when only stderr but stdout is valid', () => {
|
|
36
|
+
const result = handleExecResult(null, 'anthropic/claude\nopenai/gpt-4', 'some warning from CLI');
|
|
37
|
+
|
|
38
|
+
expect(result.source).toBe('opencode');
|
|
39
|
+
expect(result.models).toContain('anthropic/claude');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should return error when only stderr and stdout is empty', () => {
|
|
43
|
+
const result = handleExecResult(null, '', 'some error in stderr');
|
|
44
|
+
|
|
45
|
+
expect(result.source).toBe('error');
|
|
46
|
+
expect(result.models).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should return CLI models in normal case', () => {
|
|
50
|
+
const result = handleExecResult(null, 'anthropic/claude\nopenai/gpt-4', '');
|
|
51
|
+
|
|
52
|
+
expect(result.source).toBe('opencode');
|
|
53
|
+
expect(result.models).toContain('anthropic/claude');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('GET API Integration Tests', () => {
|
|
58
|
+
const originalHome = process.env.HOME;
|
|
59
|
+
const originalPath = process.env.PATH;
|
|
60
|
+
|
|
61
|
+
let mockExec: ReturnType<typeof vi.fn<MockExecFn>>;
|
|
62
|
+
|
|
63
|
+
beforeAll(() => {
|
|
64
|
+
process.env.HOME = '/tmp';
|
|
65
|
+
process.env.PATH = '/usr/bin';
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(() => {
|
|
69
|
+
process.env.HOME = originalHome;
|
|
70
|
+
process.env.PATH = originalPath;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
mockExec = vi.fn<MockExecFn>();
|
|
75
|
+
setExecFn(mockExec as never);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
const { exec } = vi.importActual<typeof import('child_process')>('child_process') as never as { exec: MockExecFn };
|
|
80
|
+
setExecFn(exec as never);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should return source=opencode and real model list on successful GET', async () => {
|
|
84
|
+
mockExec.mockImplementation((_cmd: unknown, _opts: unknown, callback: ExecCallback) => {
|
|
85
|
+
callback(null, 'anthropic/claude-3.5-sonnet\nopenai/gpt-4o\ndeepseek/deepseek-chat\n', '');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const response = await GET();
|
|
89
|
+
const data = await response.json();
|
|
90
|
+
|
|
91
|
+
expect(response.status).toBe(200);
|
|
92
|
+
expect(data.source).toBe('opencode');
|
|
93
|
+
expect(data.models).toContain('anthropic/claude-3.5-sonnet');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should return source=opencode when CLI has stderr but stdout is valid', async () => {
|
|
97
|
+
mockExec.mockImplementation((_cmd: unknown, _opts: unknown, callback: ExecCallback) => {
|
|
98
|
+
callback(null, 'anthropic/claude-3.5-sonnet\n', 'Warning: newer version available');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const response = await GET();
|
|
102
|
+
const data = await response.json();
|
|
103
|
+
|
|
104
|
+
expect(response.status).toBe(200);
|
|
105
|
+
expect(data.source).toBe('opencode');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should return 503 error when CLI fails', async () => {
|
|
109
|
+
mockExec.mockImplementation((_cmd: unknown, _opts: unknown, callback: ExecCallback) => {
|
|
110
|
+
callback(new Error('spawn opencode ENOENT') as ExecException, '', 'command not found');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const response = await GET();
|
|
114
|
+
const data = await response.json();
|
|
115
|
+
|
|
116
|
+
expect(response.status).toBe(503);
|
|
117
|
+
expect(data.source).toBe('error');
|
|
118
|
+
expect(data.models).toEqual([]);
|
|
119
|
+
expect(data.error).toBeTruthy();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should return 503 error when GET returns empty models', async () => {
|
|
123
|
+
mockExec.mockImplementation((_cmd: unknown, _opts: unknown, callback: ExecCallback) => {
|
|
124
|
+
callback(null, '', '');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const response = await GET();
|
|
128
|
+
const data = await response.json();
|
|
129
|
+
|
|
130
|
+
expect(response.status).toBe(503);
|
|
131
|
+
expect(data.source).toBe('error');
|
|
132
|
+
expect(data.models).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { type ExecException } from 'child_process';
|
|
3
|
+
|
|
4
|
+
type ExecFn = (
|
|
5
|
+
command: string,
|
|
6
|
+
options: { timeout: number; env: NodeJS.ProcessEnv },
|
|
7
|
+
callback: (error: ExecException | null, stdout: string, stderr: string) => void
|
|
8
|
+
) => void;
|
|
9
|
+
|
|
10
|
+
let _execFn: ExecFn | null = null;
|
|
11
|
+
|
|
12
|
+
function getExecFn(): ExecFn {
|
|
13
|
+
if (_execFn) return _execFn;
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
15
|
+
return require('child_process').exec as ExecFn;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function setExecFn(fn: ExecFn | null) {
|
|
19
|
+
_execFn = fn;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function handleExecResult(
|
|
23
|
+
error: ExecException | null,
|
|
24
|
+
stdout: string,
|
|
25
|
+
stderr: string
|
|
26
|
+
): { models: string[]; source: string; error?: string } {
|
|
27
|
+
if (error) {
|
|
28
|
+
return { models: [], source: 'error', error: error.message || 'Failed to fetch models from CLI' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (stderr) {
|
|
32
|
+
console.warn('[opencode-models] stderr:', stderr);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const models = stdout.trim().split('\n').filter(line => line.includes('/'));
|
|
37
|
+
if (models.length === 0) {
|
|
38
|
+
return { models: [], source: 'error', error: 'No models found. Please check your OpenCode installation.' };
|
|
39
|
+
}
|
|
40
|
+
return { models, source: 'opencode' };
|
|
41
|
+
} catch {
|
|
42
|
+
return { models: [], source: 'error', error: 'Failed to parse models output' };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function GET(): Promise<Response> {
|
|
47
|
+
return new Promise<Response>((resolve) => {
|
|
48
|
+
const timeout = 5000;
|
|
49
|
+
|
|
50
|
+
const opencodePath = `${process.env.HOME}/.opencode/bin:${process.env.PATH}`;
|
|
51
|
+
|
|
52
|
+
getExecFn()('opencode models', { timeout, env: { ...process.env, PATH: opencodePath } }, (error, stdout, stderr) => {
|
|
53
|
+
const result = handleExecResult(error, stdout, stderr);
|
|
54
|
+
const status = result.error ? 503 : 200;
|
|
55
|
+
return resolve(NextResponse.json(result, { status }));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import {
|
|
3
|
+
readProfileConfig,
|
|
4
|
+
getProfileById,
|
|
5
|
+
setActiveProfileId,
|
|
6
|
+
} from '@/lib/profiles/storage';
|
|
7
|
+
import { readConfig, writeConfig } from '@/lib/opencodeConfig';
|
|
8
|
+
|
|
9
|
+
interface RouteParams {
|
|
10
|
+
params: Promise<{ id: string }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function POST(_request: NextRequest, { params }: RouteParams) {
|
|
14
|
+
try {
|
|
15
|
+
const { id } = await params;
|
|
16
|
+
const profile = await getProfileById(id);
|
|
17
|
+
|
|
18
|
+
if (!profile) {
|
|
19
|
+
return NextResponse.json(
|
|
20
|
+
{ error: 'Profile not found' },
|
|
21
|
+
{ status: 404 }
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const profileConfig = await readProfileConfig(id);
|
|
26
|
+
const currentConfig = await readConfig();
|
|
27
|
+
|
|
28
|
+
const mergedConfig = {
|
|
29
|
+
...currentConfig,
|
|
30
|
+
agents: profileConfig.agents,
|
|
31
|
+
categories: profileConfig.categories,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
await writeConfig(mergedConfig);
|
|
35
|
+
await setActiveProfileId(id);
|
|
36
|
+
|
|
37
|
+
return NextResponse.json({
|
|
38
|
+
message: 'Profile applied successfully',
|
|
39
|
+
profile,
|
|
40
|
+
config: profileConfig,
|
|
41
|
+
});
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('Error applying profile:', error);
|
|
44
|
+
return NextResponse.json(
|
|
45
|
+
{ error: 'Internal server error' },
|
|
46
|
+
{ status: 500 }
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|