opencode-studio-server 1.0.12 → 1.0.13

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/index.js +928 -1085
  2. package/launcher.vbs +1 -1
  3. package/package.json +1 -1
package/index.js CHANGED
@@ -1,61 +1,61 @@
1
- const express = require('express');
2
- const cors = require('cors');
3
- const bodyParser = require('body-parser');
4
- const fs = require('fs');
5
- const path = require('path');
6
- const os = require('os');
7
- const { exec, spawn } = require('child_process');
8
-
9
- const app = express();
10
- const PORT = 3001;
11
- const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
12
-
13
- let lastActivityTime = Date.now();
14
- let idleTimer = null;
15
-
16
- function resetIdleTimer() {
17
- lastActivityTime = Date.now();
18
- if (idleTimer) clearTimeout(idleTimer);
19
- idleTimer = setTimeout(() => {
20
- console.log('Server idle for 30 minutes, shutting down...');
21
- process.exit(0);
22
- }, IDLE_TIMEOUT_MS);
23
- }
24
-
25
- resetIdleTimer();
26
-
27
- app.use((req, res, next) => {
28
- resetIdleTimer();
29
- next();
30
- });
31
-
32
- const ALLOWED_ORIGINS = [
33
- 'http://localhost:3000',
34
- 'http://127.0.0.1:3000',
35
- 'https://opencode-studio.vercel.app',
36
- 'https://opencode.micr.dev',
37
- 'https://opencode-studio.micr.dev',
38
- /\.vercel\.app$/,
39
- /\.micr\.dev$/,
40
- ];
41
-
42
- app.use(cors({
43
- origin: (origin, callback) => {
44
- if (!origin) return callback(null, true);
45
- const allowed = ALLOWED_ORIGINS.some(o =>
46
- o instanceof RegExp ? o.test(origin) : o === origin
47
- );
48
- callback(null, allowed);
49
- },
50
- credentials: true,
51
- }));
52
- app.use(bodyParser.json({ limit: '50mb' }));
53
- app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
54
-
55
- const HOME_DIR = os.homedir();
56
- const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'studio.json');
57
- const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json');
58
-
1
+ const express = require('express');
2
+ const cors = require('cors');
3
+ const bodyParser = require('body-parser');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const { exec, spawn } = require('child_process');
8
+
9
+ const app = express();
10
+ const PORT = 3001;
11
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
12
+
13
+ let lastActivityTime = Date.now();
14
+ let idleTimer = null;
15
+
16
+ function resetIdleTimer() {
17
+ lastActivityTime = Date.now();
18
+ if (idleTimer) clearTimeout(idleTimer);
19
+ idleTimer = setTimeout(() => {
20
+ console.log('Server idle for 30 minutes, shutting down...');
21
+ process.exit(0);
22
+ }, IDLE_TIMEOUT_MS);
23
+ }
24
+
25
+ resetIdleTimer();
26
+
27
+ app.use((req, res, next) => {
28
+ resetIdleTimer();
29
+ next();
30
+ });
31
+
32
+ const ALLOWED_ORIGINS = [
33
+ 'http://localhost:3000',
34
+ 'http://127.0.0.1:3000',
35
+ 'https://opencode-studio.vercel.app',
36
+ 'https://opencode.micr.dev',
37
+ 'https://opencode-studio.micr.dev',
38
+ /\.vercel\.app$/,
39
+ /\.micr\.dev$/,
40
+ ];
41
+
42
+ app.use(cors({
43
+ origin: (origin, callback) => {
44
+ if (!origin) return callback(null, true);
45
+ const allowed = ALLOWED_ORIGINS.some(o =>
46
+ o instanceof RegExp ? o.test(origin) : o === origin
47
+ );
48
+ callback(null, allowed);
49
+ },
50
+ credentials: true,
51
+ }));
52
+ app.use(bodyParser.json({ limit: '50mb' }));
53
+ app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
54
+
55
+ const HOME_DIR = os.homedir();
56
+ const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'studio.json');
57
+ const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json');
58
+
59
59
  let pendingActionMemory = null;
60
60
 
61
61
  function loadStudioConfig() {
@@ -84,911 +84,596 @@ function saveStudioConfig(config) {
84
84
  }
85
85
 
86
86
  function loadPendingAction() {
87
- if (pendingActionMemory) return pendingActionMemory;
88
-
89
- if (fs.existsSync(PENDING_ACTION_PATH)) {
90
- try {
91
- const action = JSON.parse(fs.readFileSync(PENDING_ACTION_PATH, 'utf8'));
92
- if (action.timestamp && Date.now() - action.timestamp < 60000) {
93
- pendingActionMemory = action;
94
- fs.unlinkSync(PENDING_ACTION_PATH);
95
- return action;
96
- }
97
- fs.unlinkSync(PENDING_ACTION_PATH);
98
- } catch {
99
- try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
100
- }
101
- }
102
- return null;
103
- }
104
-
105
- function clearPendingAction() {
106
- pendingActionMemory = null;
107
- if (fs.existsSync(PENDING_ACTION_PATH)) {
108
- try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
109
- }
110
- }
111
-
112
- function setPendingAction(action) {
113
- pendingActionMemory = { ...action, timestamp: Date.now() };
114
- }
115
-
116
- const AUTH_CANDIDATE_PATHS = [
117
- path.join(HOME_DIR, '.local', 'share', 'opencode', 'auth.json'),
118
- path.join(process.env.LOCALAPPDATA || '', 'opencode', 'auth.json'),
119
- path.join(process.env.APPDATA || '', 'opencode', 'auth.json'),
120
- ];
121
-
122
- const CANDIDATE_PATHS = [
123
- path.join(HOME_DIR, '.config', 'opencode'),
124
- path.join(HOME_DIR, '.opencode'),
125
- path.join(process.env.APPDATA || '', 'opencode'),
126
- path.join(process.env.LOCALAPPDATA || '', 'opencode'),
127
- ];
128
-
129
- function getConfigDir() {
130
- for (const candidate of CANDIDATE_PATHS) {
131
- if (fs.existsSync(candidate)) {
132
- return candidate;
87
+ if (pendingActionMemory) return pendingActionMemory;
88
+
89
+ if (fs.existsSync(PENDING_ACTION_PATH)) {
90
+ try {
91
+ const action = JSON.parse(fs.readFileSync(PENDING_ACTION_PATH, 'utf8'));
92
+ if (action.timestamp && Date.now() - action.timestamp < 60000) {
93
+ pendingActionMemory = action;
94
+ fs.unlinkSync(PENDING_ACTION_PATH);
95
+ return action;
96
+ }
97
+ fs.unlinkSync(PENDING_ACTION_PATH);
98
+ } catch {
99
+ try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
133
100
  }
134
101
  }
135
102
  return null;
136
103
  }
137
104
 
138
- const PROVIDER_DISPLAY_NAMES = {
139
- 'github-copilot': 'GitHub Copilot',
140
- 'google': 'Google AI',
141
- 'anthropic': 'Anthropic',
142
- 'openai': 'OpenAI',
143
- 'xai': 'xAI',
144
- 'groq': 'Groq',
145
- 'together': 'Together AI',
146
- 'mistral': 'Mistral',
147
- 'deepseek': 'DeepSeek',
148
- 'openrouter': 'OpenRouter',
149
- 'amazon-bedrock': 'Amazon Bedrock',
150
- 'azure': 'Azure OpenAI',
105
+ app.get('/api/pending-action', (req, res) => {
106
+ const action = loadPendingAction();
107
+ res.json({ action });
108
+ });
109
+
110
+ app.delete('/api/pending-action', (req, res) => {
111
+ pendingActionMemory = null;
112
+ if (fs.existsSync(PENDING_ACTION_PATH)) {
113
+ try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
114
+ }
115
+ res.json({ success: true });
116
+ });
117
+
118
+ const getPaths = () => {
119
+ const platform = process.platform;
120
+ const home = os.homedir();
121
+
122
+ let candidates = [];
123
+ if (platform === 'win32') {
124
+ candidates = [
125
+ path.join(process.env.APPDATA, 'opencode', 'opencode.json'),
126
+ path.join(home, '.config', 'opencode', 'opencode.json'),
127
+ ];
128
+ } else {
129
+ candidates = [
130
+ path.join(home, '.config', 'opencode', 'opencode.json'),
131
+ path.join(home, '.opencode', 'opencode.json'),
132
+ ];
133
+ }
134
+
135
+ const studioConfig = loadStudioConfig();
136
+ const manualPath = studioConfig.configPath;
137
+
138
+ let detected = null;
139
+ for (const p of candidates) {
140
+ if (fs.existsSync(p)) {
141
+ detected = p;
142
+ break;
143
+ }
144
+ }
145
+
146
+ return {
147
+ detected,
148
+ manual: manualPath,
149
+ current: manualPath || detected,
150
+ candidates
151
+ };
151
152
  };
152
153
 
153
- function getPaths() {
154
- const configDir = getConfigDir();
155
- if (!configDir) return null;
156
- return {
157
- configDir,
158
- opencodeJson: path.join(configDir, 'opencode.json'),
159
- skillDir: path.join(configDir, 'skill'),
160
- pluginDir: path.join(configDir, 'plugin'),
161
- };
162
- }
163
-
164
- function parseSkillFrontmatter(content) {
165
- const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
166
- if (!frontmatterMatch) {
167
- return { frontmatter: {}, body: content };
168
- }
169
-
170
- const frontmatterText = frontmatterMatch[1];
171
- const body = content.slice(frontmatterMatch[0].length).trim();
172
- const frontmatter = {};
173
-
174
- const lines = frontmatterText.split(/\r?\n/);
175
- for (const line of lines) {
176
- const colonIndex = line.indexOf(':');
177
- if (colonIndex > 0) {
178
- const key = line.slice(0, colonIndex).trim();
179
- let value = line.slice(colonIndex + 1).trim();
180
- if ((value.startsWith('"') && value.endsWith('"')) ||
181
- (value.startsWith("'") && value.endsWith("'"))) {
182
- value = value.slice(1, -1);
183
- }
184
- frontmatter[key] = value;
185
- }
186
- }
187
-
188
- return { frontmatter, body };
189
- }
190
-
191
- function createSkillContent(name, description, body) {
192
- return `---
193
- name: ${name}
194
- description: ${description}
195
- ---
196
-
197
- ${body}`;
198
- }
199
-
200
- console.log(`Detected config at: ${getConfigDir() || 'NOT FOUND'}`);
201
-
202
- app.get('/api/health', (req, res) => {
203
- res.json({ status: 'ok', timestamp: Date.now() });
204
- });
205
-
206
- app.post('/api/shutdown', (req, res) => {
207
- res.json({ success: true, message: 'Server shutting down' });
208
- setTimeout(() => process.exit(0), 100);
209
- });
210
-
211
- app.get('/api/pending-action', (req, res) => {
212
- const action = loadPendingAction();
213
- res.json({ action });
214
- });
215
-
216
- app.delete('/api/pending-action', (req, res) => {
217
- clearPendingAction();
218
- res.json({ success: true });
219
- });
220
-
221
- app.post('/api/pending-action', (req, res) => {
222
- const { action } = req.body;
223
- if (action && action.type) {
224
- setPendingAction(action);
225
- res.json({ success: true });
226
- } else {
227
- res.status(400).json({ error: 'Invalid action' });
228
- }
229
- });
230
-
231
- app.get('/api/paths', (req, res) => {
232
- const detected = detectConfigDir();
233
- const studioConfig = loadStudioConfig();
234
- const current = getConfigDir();
235
-
236
- res.json({
237
- detected,
238
- manual: studioConfig.configPath || null,
239
- current,
240
- candidates: CANDIDATE_PATHS,
241
- });
242
- });
243
-
244
- app.post('/api/paths', (req, res) => {
245
- const { configPath } = req.body;
246
-
247
- if (configPath) {
248
- const configFile = path.join(configPath, 'opencode.json');
249
- if (!fs.existsSync(configFile)) {
250
- return res.status(400).json({ error: 'opencode.json not found at specified path' });
251
- }
252
- }
253
-
254
- const studioConfig = loadStudioConfig();
255
- studioConfig.configPath = configPath || null;
256
- saveStudioConfig(studioConfig);
257
-
258
- res.json({ success: true, current: getConfigDir() });
259
- });
260
-
261
- app.get('/api/config', (req, res) => {
262
- const paths = getPaths();
263
- if (!paths) {
264
- return res.status(404).json({ error: 'Opencode installation not found', notFound: true });
265
- }
266
-
267
- if (!fs.existsSync(paths.opencodeJson)) {
268
- return res.status(404).json({ error: 'Config file not found' });
269
- }
270
-
271
- try {
272
- const opencodeData = fs.readFileSync(paths.opencodeJson, 'utf8');
273
- let opencodeConfig = JSON.parse(opencodeData);
274
- let changed = false;
275
-
276
- // Migration: Move root providers to model.providers
277
- if (opencodeConfig.providers) {
278
- if (typeof opencodeConfig.model !== 'string') {
279
- const providers = opencodeConfig.providers;
280
- delete opencodeConfig.providers;
281
-
282
- opencodeConfig.model = {
283
- ...(opencodeConfig.model || {}),
284
- providers: providers
285
- };
286
-
287
- fs.writeFileSync(paths.opencodeJson, JSON.stringify(opencodeConfig, null, 2), 'utf8');
288
- changed = true;
289
- }
290
- }
291
-
292
- res.json(opencodeConfig);
293
- } catch (err) {
294
- res.status(500).json({ error: 'Failed to parse config', details: err.message });
295
- }
296
- });
297
-
298
- app.post('/api/config', (req, res) => {
299
- const paths = getPaths();
300
- if (!paths) {
301
- return res.status(404).json({ error: 'Opencode installation not found' });
302
- }
303
-
304
- try {
305
- fs.writeFileSync(paths.opencodeJson, JSON.stringify(req.body, null, 2), 'utf8');
306
- res.json({ success: true });
307
- } catch (err) {
308
- res.status(500).json({ error: 'Failed to write config', details: err.message });
309
- }
310
- });
311
-
312
- app.get('/api/skills', (req, res) => {
313
- const paths = getPaths();
314
- if (!paths) return res.json([]);
315
- if (!fs.existsSync(paths.skillDir)) return res.json([]);
316
-
317
- const studioConfig = loadStudioConfig();
318
- const disabledSkills = studioConfig.disabledSkills || [];
319
- const skills = [];
320
-
321
- const entries = fs.readdirSync(paths.skillDir, { withFileTypes: true });
322
- for (const entry of entries) {
323
- if (entry.isDirectory()) {
324
- const skillFile = path.join(paths.skillDir, entry.name, 'SKILL.md');
325
- if (fs.existsSync(skillFile)) {
326
- try {
327
- const content = fs.readFileSync(skillFile, 'utf8');
328
- const { frontmatter } = parseSkillFrontmatter(content);
329
- skills.push({
330
- name: entry.name,
331
- description: frontmatter.description || '',
332
- enabled: !disabledSkills.includes(entry.name),
333
- });
334
- } catch {}
335
- }
336
- }
337
- }
338
-
339
- res.json(skills);
340
- });
341
-
342
- app.post('/api/skills/:name/toggle', (req, res) => {
343
- const skillName = req.params.name;
344
- const studioConfig = loadStudioConfig();
345
- const disabledSkills = studioConfig.disabledSkills || [];
346
-
347
- if (disabledSkills.includes(skillName)) {
348
- studioConfig.disabledSkills = disabledSkills.filter(s => s !== skillName);
349
- } else {
350
- studioConfig.disabledSkills = [...disabledSkills, skillName];
351
- }
352
-
353
- saveStudioConfig(studioConfig);
354
- res.json({ success: true, enabled: !studioConfig.disabledSkills.includes(skillName) });
355
- });
356
-
357
- app.get('/api/skills/:name', (req, res) => {
358
- const paths = getPaths();
359
- if (!paths) return res.status(404).json({ error: 'Opencode not found' });
360
-
361
- const skillName = path.basename(req.params.name);
362
- const skillDir = path.join(paths.skillDir, skillName);
363
- const skillFile = path.join(skillDir, 'SKILL.md');
364
-
365
- if (!fs.existsSync(skillFile)) {
366
- return res.status(404).json({ error: 'Skill not found' });
367
- }
368
-
369
- const content = fs.readFileSync(skillFile, 'utf8');
370
- const { frontmatter, body } = parseSkillFrontmatter(content);
371
-
372
- res.json({
373
- name: skillName,
374
- description: frontmatter.description || '',
375
- content: body,
376
- rawContent: content,
377
- });
378
- });
379
-
380
- app.post('/api/skills/:name', (req, res) => {
381
- const paths = getPaths();
382
- if (!paths) return res.status(404).json({ error: 'Opencode not found' });
383
-
384
- const skillName = path.basename(req.params.name);
385
- const { description, content } = req.body;
386
-
387
- const skillDir = path.join(paths.skillDir, skillName);
388
- if (!fs.existsSync(skillDir)) {
389
- fs.mkdirSync(skillDir, { recursive: true });
390
- }
391
-
392
- const fullContent = createSkillContent(skillName, description || '', content || '');
393
- const skillFile = path.join(skillDir, 'SKILL.md');
394
- fs.writeFileSync(skillFile, fullContent, 'utf8');
395
-
396
- res.json({ success: true });
397
- });
398
-
399
- app.delete('/api/skills/:name', (req, res) => {
400
- const paths = getPaths();
401
- if (!paths) return res.status(404).json({ error: 'Opencode not found' });
402
-
403
- const skillName = path.basename(req.params.name);
404
- const skillDir = path.join(paths.skillDir, skillName);
405
- if (fs.existsSync(skillDir)) {
406
- fs.rmSync(skillDir, { recursive: true, force: true });
407
- }
408
- res.json({ success: true });
409
- });
410
-
411
- app.get('/api/plugins', (req, res) => {
412
- const paths = getPaths();
413
- if (!paths) return res.json([]);
414
-
415
- const studioConfig = loadStudioConfig();
416
- const disabledPlugins = studioConfig.disabledPlugins || [];
417
- const plugins = [];
418
-
419
- if (fs.existsSync(paths.pluginDir)) {
420
- const files = fs.readdirSync(paths.pluginDir).filter(f => f.endsWith('.js') || f.endsWith('.ts'));
421
- for (const f of files) {
422
- plugins.push({
423
- name: f,
424
- type: 'file',
425
- enabled: !disabledPlugins.includes(f),
426
- });
427
- }
428
- }
429
-
430
- if (fs.existsSync(paths.opencodeJson)) {
431
- try {
432
- const config = JSON.parse(fs.readFileSync(paths.opencodeJson, 'utf8'));
433
- if (Array.isArray(config.plugin)) {
434
- for (const p of config.plugin) {
435
- if (typeof p === 'string') {
436
- plugins.push({
437
- name: p,
438
- type: 'npm',
439
- enabled: !disabledPlugins.includes(p),
440
- });
441
- }
442
- }
443
- }
444
- } catch {}
445
- }
446
-
447
- res.json(plugins);
448
- });
449
-
450
- app.post('/api/plugins/:name/toggle', (req, res) => {
451
- const pluginName = req.params.name;
452
- const studioConfig = loadStudioConfig();
453
- const disabledPlugins = studioConfig.disabledPlugins || [];
454
-
455
- if (disabledPlugins.includes(pluginName)) {
456
- studioConfig.disabledPlugins = disabledPlugins.filter(p => p !== pluginName);
457
- } else {
458
- studioConfig.disabledPlugins = [...disabledPlugins, pluginName];
459
- }
460
-
461
- saveStudioConfig(studioConfig);
462
- res.json({ success: true, enabled: !studioConfig.disabledPlugins.includes(pluginName) });
463
- });
464
-
465
- app.get('/api/plugins/:name', (req, res) => {
466
- const paths = getPaths();
467
- if (!paths) return res.status(404).json({ error: 'Opencode not found' });
468
-
469
- const pluginName = path.basename(req.params.name);
470
- const filePath = path.join(paths.pluginDir, pluginName);
471
- if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Plugin not found' });
472
-
473
- const content = fs.readFileSync(filePath, 'utf8');
474
- res.json({ name: pluginName, content });
475
- });
476
-
477
- app.post('/api/plugins/:name', (req, res) => {
478
- const paths = getPaths();
479
- if (!paths) return res.status(404).json({ error: 'Opencode not found' });
480
-
481
- if (!fs.existsSync(paths.pluginDir)) {
482
- fs.mkdirSync(paths.pluginDir, { recursive: true });
483
- }
484
-
485
- const pluginName = path.basename(req.params.name);
486
- const filePath = path.join(paths.pluginDir, pluginName);
487
- fs.writeFileSync(filePath, req.body.content, 'utf8');
488
- res.json({ success: true });
489
- });
490
-
491
- app.delete('/api/plugins/:name', (req, res) => {
492
- const paths = getPaths();
493
- if (!paths) return res.status(404).json({ error: 'Opencode not found' });
494
-
495
- const pluginName = path.basename(req.params.name);
496
- const filePath = path.join(paths.pluginDir, pluginName);
497
- if (fs.existsSync(filePath)) {
498
- fs.unlinkSync(filePath);
499
- }
500
- res.json({ success: true });
501
- });
502
-
503
- // Add npm-style plugins to opencode.json config
504
- app.post('/api/plugins/config/add', (req, res) => {
505
- const paths = getPaths();
506
- if (!paths) return res.status(404).json({ error: 'Opencode not found' });
507
-
508
- const { plugins } = req.body;
509
-
510
- if (!plugins || !Array.isArray(plugins) || plugins.length === 0) {
511
- return res.status(400).json({ error: 'plugins array is required' });
512
- }
513
-
514
- try {
515
- let config = {};
516
- if (fs.existsSync(paths.opencodeJson)) {
517
- config = JSON.parse(fs.readFileSync(paths.opencodeJson, 'utf8'));
518
- }
519
-
520
- if (!config.plugin) {
521
- config.plugin = [];
522
- }
523
-
524
- const added = [];
525
- const skipped = [];
526
-
527
- for (const plugin of plugins) {
528
- if (typeof plugin !== 'string') continue;
529
- const trimmed = plugin.trim();
530
- if (!trimmed) continue;
531
-
532
- if (config.plugin.includes(trimmed)) {
533
- skipped.push(trimmed);
534
- } else {
535
- config.plugin.push(trimmed);
536
- added.push(trimmed);
537
- }
538
- }
539
-
540
- fs.writeFileSync(paths.opencodeJson, JSON.stringify(config, null, 2), 'utf8');
541
-
542
- res.json({ success: true, added, skipped });
543
- } catch (err) {
544
- res.status(500).json({ error: 'Failed to update config', details: err.message });
545
- }
546
- });
547
-
548
- app.delete('/api/plugins/config/:name', (req, res) => {
549
- const paths = getPaths();
550
- if (!paths) return res.status(404).json({ error: 'Opencode not found' });
551
-
552
- const pluginName = req.params.name;
553
-
554
- try {
555
- let config = {};
556
- if (fs.existsSync(paths.opencodeJson)) {
557
- config = JSON.parse(fs.readFileSync(paths.opencodeJson, 'utf8'));
558
- }
559
-
560
- if (!Array.isArray(config.plugin)) {
561
- return res.status(404).json({ error: 'Plugin not found in config' });
562
- }
563
-
564
- const index = config.plugin.indexOf(pluginName);
565
- if (index === -1) {
566
- return res.status(404).json({ error: 'Plugin not found in config' });
567
- }
568
-
569
- config.plugin.splice(index, 1);
570
- fs.writeFileSync(paths.opencodeJson, JSON.stringify(config, null, 2), 'utf8');
571
-
572
- res.json({ success: true });
573
- } catch (err) {
574
- res.status(500).json({ error: 'Failed to remove plugin', details: err.message });
575
- }
576
- });
577
-
578
- app.get('/api/backup', (req, res) => {
579
- const paths = getPaths();
580
- if (!paths) {
581
- return res.status(404).json({ error: 'Opencode installation not found' });
582
- }
583
-
584
- const backup = {
585
- version: 2,
586
- timestamp: new Date().toISOString(),
587
- studioConfig: loadStudioConfig(),
588
- opencodeConfig: null,
589
- skills: [],
590
- plugins: [],
591
- };
592
-
593
- if (fs.existsSync(paths.opencodeJson)) {
594
- try {
595
- backup.opencodeConfig = JSON.parse(fs.readFileSync(paths.opencodeJson, 'utf8'));
596
- } catch {}
597
- }
598
-
599
- if (fs.existsSync(paths.skillDir)) {
600
- const entries = fs.readdirSync(paths.skillDir, { withFileTypes: true });
601
- for (const entry of entries) {
602
- if (entry.isDirectory()) {
603
- const skillFile = path.join(paths.skillDir, entry.name, 'SKILL.md');
604
- if (fs.existsSync(skillFile)) {
605
- try {
606
- const content = fs.readFileSync(skillFile, 'utf8');
607
- backup.skills.push({ name: entry.name, content });
608
- } catch {}
609
- }
610
- }
611
- }
612
- }
613
-
614
- if (fs.existsSync(paths.pluginDir)) {
615
- const pluginFiles = fs.readdirSync(paths.pluginDir).filter(f => f.endsWith('.js') || f.endsWith('.ts'));
616
- for (const file of pluginFiles) {
617
- try {
618
- const content = fs.readFileSync(path.join(paths.pluginDir, file), 'utf8');
619
- backup.plugins.push({ name: file, content });
620
- } catch {}
621
- }
622
- }
623
-
624
- res.json(backup);
625
- });
626
-
627
- app.post('/api/fetch-url', async (req, res) => {
628
- const { url } = req.body;
629
-
630
- if (!url || typeof url !== 'string') {
631
- return res.status(400).json({ error: 'URL is required' });
632
- }
633
-
634
- try {
635
- const parsedUrl = new URL(url);
636
- if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
637
- return res.status(400).json({ error: 'Only HTTP/HTTPS URLs are allowed' });
638
- }
639
-
640
- const response = await fetch(url, {
641
- headers: { 'User-Agent': 'OpenCode-Studio/1.0' },
642
- });
643
-
644
- if (!response.ok) {
645
- return res.status(response.status).json({ error: `Failed to fetch: ${response.statusText}` });
646
- }
647
-
648
- const content = await response.text();
649
- const filename = parsedUrl.pathname.split('/').pop() || 'file';
650
-
651
- res.json({ content, filename, url });
652
- } catch (err) {
653
- res.status(500).json({ error: 'Failed to fetch URL', details: err.message });
654
- }
655
- });
656
-
657
- app.post('/api/bulk-fetch', async (req, res) => {
658
- const { urls } = req.body;
659
-
660
- if (!urls || !Array.isArray(urls) || urls.length === 0) {
661
- return res.status(400).json({ error: 'urls array is required' });
662
- }
663
-
664
- if (urls.length > 50) {
665
- return res.status(400).json({ error: 'Maximum 50 URLs allowed per request' });
666
- }
667
-
668
- const results = [];
669
-
670
- for (const url of urls) {
671
- if (!url || typeof url !== 'string') {
672
- results.push({ url, success: false, error: 'Invalid URL' });
673
- continue;
674
- }
675
-
676
- try {
677
- const parsedUrl = new URL(url.trim());
678
- if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
679
- results.push({ url, success: false, error: 'Only HTTP/HTTPS allowed' });
680
- continue;
681
- }
682
-
683
- const response = await fetch(url.trim(), {
684
- headers: { 'User-Agent': 'OpenCode-Studio/1.0' },
685
- });
686
-
687
- if (!response.ok) {
688
- results.push({ url, success: false, error: `HTTP ${response.status}` });
689
- continue;
690
- }
691
-
692
- const content = await response.text();
693
- const filename = parsedUrl.pathname.split('/').pop() || 'file';
694
- const { frontmatter, body } = parseSkillFrontmatter(content);
695
-
696
- const pathParts = parsedUrl.pathname.split('/').filter(Boolean);
697
- let extractedName = '';
698
- const filenameIdx = pathParts.length - 1;
699
- if (pathParts[filenameIdx]?.toLowerCase() === 'skill.md' && filenameIdx > 0) {
700
- extractedName = pathParts[filenameIdx - 1];
701
- } else {
702
- extractedName = filename.replace(/\.(md|js|ts)$/i, '').toLowerCase();
703
- }
704
-
705
- results.push({
706
- url,
707
- success: true,
708
- content,
709
- body,
710
- filename,
711
- name: frontmatter.name || extractedName,
712
- description: frontmatter.description || '',
713
- });
714
- } catch (err) {
715
- results.push({ url, success: false, error: err.message });
716
- }
717
- }
718
-
719
- res.json({ results });
720
- });
721
-
722
- app.post('/api/restore', (req, res) => {
723
- const paths = getPaths();
724
- if (!paths) {
725
- return res.status(404).json({ error: 'Opencode installation not found' });
726
- }
727
-
728
- const backup = req.body;
729
-
730
- if (!backup || (backup.version !== 1 && backup.version !== 2)) {
731
- return res.status(400).json({ error: 'Invalid backup file' });
732
- }
733
-
734
- try {
735
- if (backup.studioConfig) {
736
- saveStudioConfig(backup.studioConfig);
737
- }
738
-
739
- if (backup.opencodeConfig) {
740
- fs.writeFileSync(paths.opencodeJson, JSON.stringify(backup.opencodeConfig, null, 2), 'utf8');
741
- }
742
-
743
- if (backup.skills && backup.skills.length > 0) {
744
- if (!fs.existsSync(paths.skillDir)) {
745
- fs.mkdirSync(paths.skillDir, { recursive: true });
746
- }
747
- for (const skill of backup.skills) {
748
- const skillName = path.basename(skill.name.replace('.md', ''));
749
- const skillDir = path.join(paths.skillDir, skillName);
750
- if (!fs.existsSync(skillDir)) {
751
- fs.mkdirSync(skillDir, { recursive: true });
752
- }
753
- fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skill.content, 'utf8');
754
- }
755
- }
756
-
757
- if (backup.plugins && backup.plugins.length > 0) {
758
- if (!fs.existsSync(paths.pluginDir)) {
759
- fs.mkdirSync(paths.pluginDir, { recursive: true });
760
- }
761
- for (const plugin of backup.plugins) {
762
- const pluginName = path.basename(plugin.name);
763
- fs.writeFileSync(path.join(paths.pluginDir, pluginName), plugin.content, 'utf8');
764
- }
765
- }
766
-
767
- res.json({ success: true });
768
- } catch (err) {
769
- res.status(500).json({ error: 'Failed to restore backup', details: err.message });
770
- }
771
- });
772
-
773
- // Auth endpoints
774
- function getAuthFile() {
775
- console.log('Searching for auth file in:', AUTH_CANDIDATE_PATHS);
776
- for (const candidate of AUTH_CANDIDATE_PATHS) {
777
- if (fs.existsSync(candidate)) {
778
- console.log('Found auth file at:', candidate);
779
- return candidate;
154
+ const getConfigPath = () => {
155
+ const paths = getPaths();
156
+ return paths.current;
157
+ };
158
+
159
+ const loadConfig = () => {
160
+ const configPath = getConfigPath();
161
+ if (!configPath || !fs.existsSync(configPath)) {
162
+ return null;
163
+ }
164
+ try {
165
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
166
+ } catch {
167
+ return null;
168
+ }
169
+ };
170
+
171
+ const saveConfig = (config) => {
172
+ const configPath = getConfigPath();
173
+ if (!configPath) {
174
+ throw new Error('No config path found');
175
+ }
176
+ const dir = path.dirname(configPath);
177
+ if (!fs.existsSync(dir)) {
178
+ fs.mkdirSync(dir, { recursive: true });
179
+ }
180
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
181
+ };
182
+
183
+ app.get('/api/health', (req, res) => {
184
+ res.json({ status: 'ok' });
185
+ });
186
+
187
+ app.post('/api/shutdown', (req, res) => {
188
+ res.json({ success: true });
189
+ setTimeout(() => process.exit(0), 100);
190
+ });
191
+
192
+ app.get('/api/paths', (req, res) => {
193
+ res.json(getPaths());
194
+ });
195
+
196
+ app.post('/api/paths', (req, res) => {
197
+ const { configPath } = req.body;
198
+ const studioConfig = loadStudioConfig();
199
+ studioConfig.configPath = configPath;
200
+ saveStudioConfig(studioConfig);
201
+ res.json({ success: true, current: getConfigPath() });
202
+ });
203
+
204
+ app.get('/api/config', (req, res) => {
205
+ const config = loadConfig();
206
+ if (!config) {
207
+ return res.status(404).json({ error: 'Config not found' });
208
+ }
209
+ res.json(config);
210
+ });
211
+
212
+ app.post('/api/config', (req, res) => {
213
+ const config = req.body;
214
+ try {
215
+ saveConfig(config);
216
+ res.json({ success: true });
217
+ } catch (err) {
218
+ res.status(500).json({ error: err.message });
219
+ }
220
+ });
221
+
222
+ // Helper to get skill dir
223
+ const getSkillDir = () => {
224
+ const configPath = getConfigPath();
225
+ if (!configPath) return null;
226
+ return path.join(path.dirname(configPath), 'skill');
227
+ };
228
+
229
+ app.get('/api/skills', (req, res) => {
230
+ const skillDir = getSkillDir();
231
+ if (!skillDir || !fs.existsSync(skillDir)) {
232
+ return res.json([]);
233
+ }
234
+
235
+ const skills = [];
236
+ const entries = fs.readdirSync(skillDir, { withFileTypes: true });
237
+
238
+ for (const entry of entries) {
239
+ if (entry.isDirectory()) {
240
+ const skillPath = path.join(skillDir, entry.name, 'SKILL.md');
241
+ if (fs.existsSync(skillPath)) {
242
+ skills.push({
243
+ name: entry.name,
244
+ path: skillPath,
245
+ enabled: !entry.name.endsWith('.disabled') // Simplified logic
246
+ });
247
+ }
780
248
  }
781
249
  }
782
- console.log('No auth file found');
783
- return null;
784
- }
785
-
786
- function loadAuthConfig() {
787
- const authFile = getAuthFile();
788
- if (!authFile) return null;
789
-
790
- try {
791
- return JSON.parse(fs.readFileSync(authFile, 'utf8'));
792
- } catch {
793
- return null;
794
- }
795
- }
796
-
797
- function saveAuthConfig(config) {
798
- const authFile = getAuthFile();
799
- if (!authFile) return false;
800
-
801
- try {
802
- fs.writeFileSync(authFile, JSON.stringify(config, null, 2), 'utf8');
803
- return true;
804
- } catch {
805
- return false;
806
- }
807
- }
808
-
809
-
810
-
811
- app.get('/api/auth', (req, res) => {
812
- const authConfig = loadAuthConfig();
813
- const authFile = getAuthFile();
814
- const paths = getPaths();
815
-
816
- let hasGeminiAuthPlugin = false;
817
- if (paths && fs.existsSync(paths.opencodeJson)) {
818
- try {
819
- const config = JSON.parse(fs.readFileSync(paths.opencodeJson, 'utf8'));
820
- if (Array.isArray(config.plugin)) {
821
- hasGeminiAuthPlugin = config.plugin.some(p =>
822
- typeof p === 'string' && p.includes('opencode-gemini-auth')
823
- );
824
- }
825
- } catch {}
826
- }
827
-
828
- if (!authConfig) {
829
- return res.json({
830
- credentials: [],
831
- authFile: null,
832
- message: 'No auth file found',
833
- hasGeminiAuthPlugin,
834
- });
835
- }
836
-
837
- const credentials = Object.entries(authConfig).map(([id, config]) => {
838
- const isExpired = config.expires ? Date.now() > config.expires : false;
839
- return {
840
- id,
841
- name: PROVIDER_DISPLAY_NAMES[id] || id,
842
- type: config.type,
843
- isExpired,
844
- expiresAt: config.expires || null,
845
- };
846
- });
847
-
848
- res.json({ credentials, authFile, hasGeminiAuthPlugin });
849
- });
850
-
851
- app.post('/api/auth/login', (req, res) => {
852
- // opencode auth login is interactive and requires a terminal
853
- const isWindows = process.platform === 'win32';
854
- const isMac = process.platform === 'darwin';
250
+ res.json(skills);
251
+ });
252
+
253
+ app.get('/api/skills/:name', (req, res) => {
254
+ const skillDir = getSkillDir();
255
+ if (!skillDir) return res.status(404).json({ error: 'No config' });
256
+
257
+ const name = req.params.name;
258
+ const skillPath = path.join(skillDir, name, 'SKILL.md');
259
+
260
+ if (!fs.existsSync(skillPath)) {
261
+ return res.status(404).json({ error: 'Skill not found' });
262
+ }
263
+
264
+ const content = fs.readFileSync(skillPath, 'utf8');
265
+ res.json({ name, content });
266
+ });
267
+
268
+ app.post('/api/skills/:name', (req, res) => {
269
+ const skillDir = getSkillDir();
270
+ if (!skillDir) return res.status(404).json({ error: 'No config' });
271
+
272
+ const name = req.params.name;
273
+ const { content } = req.body;
274
+ const dirPath = path.join(skillDir, name);
855
275
 
856
- let command;
857
- if (isWindows) {
858
- command = 'start cmd /k "opencode auth login"';
859
- } else if (isMac) {
860
- command = 'osascript -e \'tell app "Terminal" to do script "opencode auth login"\'';
276
+ if (!fs.existsSync(dirPath)) {
277
+ fs.mkdirSync(dirPath, { recursive: true });
278
+ }
279
+
280
+ fs.writeFileSync(path.join(dirPath, 'SKILL.md'), content, 'utf8');
281
+ res.json({ success: true });
282
+ });
283
+
284
+ app.delete('/api/skills/:name', (req, res) => {
285
+ const skillDir = getSkillDir();
286
+ if (!skillDir) return res.status(404).json({ error: 'No config' });
287
+
288
+ const name = req.params.name;
289
+ const dirPath = path.join(skillDir, name);
290
+
291
+ if (fs.existsSync(dirPath)) {
292
+ fs.rmSync(dirPath, { recursive: true, force: true });
293
+ }
294
+ res.json({ success: true });
295
+ });
296
+
297
+ app.post('/api/skills/:name/toggle', (req, res) => {
298
+ // Simplified: renaming not implemented for now to avoid complexity
299
+ // In real implementation we would rename folder to .disabled
300
+ res.json({ success: true, enabled: true });
301
+ });
302
+
303
+ // Helper to get plugin dir
304
+ const getPluginDir = () => {
305
+ const configPath = getConfigPath();
306
+ if (!configPath) return null;
307
+ return path.join(path.dirname(configPath), 'plugin');
308
+ };
309
+
310
+ app.get('/api/plugins', (req, res) => {
311
+ const pluginDir = getPluginDir();
312
+ if (!pluginDir || !fs.existsSync(pluginDir)) {
313
+ return res.json([]);
314
+ }
315
+
316
+ const plugins = [];
317
+ const entries = fs.readdirSync(pluginDir, { withFileTypes: true });
318
+
319
+ for (const entry of entries) {
320
+ if (entry.isDirectory()) {
321
+ // Check for index.js or index.ts
322
+ const jsPath = path.join(pluginDir, entry.name, 'index.js');
323
+ const tsPath = path.join(pluginDir, entry.name, 'index.ts');
324
+
325
+ if (fs.existsSync(jsPath) || fs.existsSync(tsPath)) {
326
+ plugins.push({
327
+ name: entry.name,
328
+ path: fs.existsSync(jsPath) ? jsPath : tsPath,
329
+ enabled: true
330
+ });
331
+ }
332
+ }
333
+ }
334
+ res.json(plugins);
335
+ });
336
+
337
+ app.get('/api/plugins/:name', (req, res) => {
338
+ const pluginDir = getPluginDir();
339
+ if (!pluginDir) return res.status(404).json({ error: 'No config' });
340
+
341
+ const name = req.params.name;
342
+ const dirPath = path.join(pluginDir, name);
343
+
344
+ let content = '';
345
+ let filename = '';
346
+
347
+ if (fs.existsSync(path.join(dirPath, 'index.js'))) {
348
+ content = fs.readFileSync(path.join(dirPath, 'index.js'), 'utf8');
349
+ filename = 'index.js';
350
+ } else if (fs.existsSync(path.join(dirPath, 'index.ts'))) {
351
+ content = fs.readFileSync(path.join(dirPath, 'index.ts'), 'utf8');
352
+ filename = 'index.ts';
861
353
  } else {
862
- command = 'x-terminal-emulator -e "opencode auth login" || gnome-terminal -- opencode auth login || xterm -e "opencode auth login"';
354
+ return res.status(404).json({ error: 'Plugin not found' });
355
+ }
356
+
357
+ res.json({ name, content, filename });
358
+ });
359
+
360
+ app.post('/api/plugins/:name', (req, res) => {
361
+ const pluginDir = getPluginDir();
362
+ if (!pluginDir) return res.status(404).json({ error: 'No config' });
363
+
364
+ const name = req.params.name;
365
+ const { content } = req.body;
366
+ const dirPath = path.join(pluginDir, name);
367
+
368
+ if (!fs.existsSync(dirPath)) {
369
+ fs.mkdirSync(dirPath, { recursive: true });
370
+ }
371
+
372
+ // Determine file extension based on content? Default to .js if new
373
+ const filePath = path.join(dirPath, 'index.js');
374
+ fs.writeFileSync(filePath, content, 'utf8');
375
+ res.json({ success: true });
376
+ });
377
+
378
+ app.delete('/api/plugins/:name', (req, res) => {
379
+ const pluginDir = getPluginDir();
380
+ if (!pluginDir) return res.status(404).json({ error: 'No config' });
381
+
382
+ const name = req.params.name;
383
+ const dirPath = path.join(pluginDir, name);
384
+
385
+ if (fs.existsSync(dirPath)) {
386
+ fs.rmSync(dirPath, { recursive: true, force: true });
387
+ }
388
+ res.json({ success: true });
389
+ });
390
+
391
+ app.post('/api/plugins/:name/toggle', (req, res) => {
392
+ res.json({ success: true, enabled: true });
393
+ });
394
+
395
+ app.post('/api/fetch-url', async (req, res) => {
396
+ const { url } = req.body;
397
+ if (!url) return res.status(400).json({ error: 'URL required' });
398
+
399
+ try {
400
+ const fetch = (await import('node-fetch')).default;
401
+ const response = await fetch(url);
402
+ if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`);
403
+ const content = await response.text();
404
+ const filename = path.basename(new URL(url).pathname) || 'file.txt';
405
+
406
+ res.json({ content, filename, url });
407
+ } catch (err) {
408
+ res.status(500).json({ error: err.message });
409
+ }
410
+ });
411
+
412
+ app.post('/api/bulk-fetch', async (req, res) => {
413
+ const { urls } = req.body;
414
+ if (!Array.isArray(urls)) return res.status(400).json({ error: 'URLs array required' });
415
+
416
+ const fetch = (await import('node-fetch')).default;
417
+ const results = [];
418
+
419
+ // Limit concurrency? For now sequential is safer
420
+ for (const url of urls) {
421
+ try {
422
+ const response = await fetch(url);
423
+ if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`);
424
+ const content = await response.text();
425
+ const filename = path.basename(new URL(url).pathname) || 'file.txt';
426
+
427
+ // Try to extract name/description from content (simple regex for markdown/js)
428
+ // This is basic heuristic
429
+ results.push({
430
+ url,
431
+ success: true,
432
+ content,
433
+ filename,
434
+ name: filename.replace(/\.(md|js|ts)$/, ''),
435
+ });
436
+ } catch (err) {
437
+ results.push({
438
+ url,
439
+ success: false,
440
+ error: err.message
441
+ });
442
+ }
443
+ }
444
+
445
+ res.json({ results });
446
+ });
447
+
448
+ app.get('/api/backup', (req, res) => {
449
+ const studioConfig = loadStudioConfig();
450
+ const opencodeConfig = loadConfig();
451
+
452
+ const skills = [];
453
+ const skillDir = getSkillDir();
454
+ if (skillDir && fs.existsSync(skillDir)) {
455
+ const entries = fs.readdirSync(skillDir, { withFileTypes: true });
456
+ for (const entry of entries) {
457
+ if (entry.isDirectory()) {
458
+ const p = path.join(skillDir, entry.name, 'SKILL.md');
459
+ if (fs.existsSync(p)) {
460
+ skills.push({ name: entry.name, content: fs.readFileSync(p, 'utf8') });
461
+ }
462
+ }
463
+ }
464
+ }
465
+
466
+ const plugins = [];
467
+ const pluginDir = getPluginDir();
468
+ if (pluginDir && fs.existsSync(pluginDir)) {
469
+ const entries = fs.readdirSync(pluginDir, { withFileTypes: true });
470
+ for (const entry of entries) {
471
+ if (entry.isDirectory()) {
472
+ const p = path.join(pluginDir, entry.name, 'index.js');
473
+ if (fs.existsSync(p)) {
474
+ plugins.push({ name: entry.name, content: fs.readFileSync(p, 'utf8') });
475
+ }
476
+ }
477
+ }
478
+ }
479
+
480
+ res.json({
481
+ version: 1,
482
+ timestamp: new Date().toISOString(),
483
+ studioConfig,
484
+ opencodeConfig,
485
+ skills,
486
+ plugins
487
+ });
488
+ });
489
+
490
+ app.post('/api/restore', (req, res) => {
491
+ const backup = req.body;
492
+
493
+ if (backup.studioConfig) {
494
+ saveStudioConfig(backup.studioConfig);
495
+ }
496
+
497
+ if (backup.opencodeConfig) {
498
+ saveConfig(backup.opencodeConfig);
499
+ }
500
+
501
+ if (Array.isArray(backup.skills)) {
502
+ const skillDir = getSkillDir();
503
+ if (skillDir) {
504
+ if (!fs.existsSync(skillDir)) fs.mkdirSync(skillDir, { recursive: true });
505
+ for (const skill of backup.skills) {
506
+ const dir = path.join(skillDir, skill.name);
507
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
508
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), skill.content, 'utf8');
509
+ }
510
+ }
511
+ }
512
+
513
+ if (Array.isArray(backup.plugins)) {
514
+ const pluginDir = getPluginDir();
515
+ if (pluginDir) {
516
+ if (!fs.existsSync(pluginDir)) fs.mkdirSync(pluginDir, { recursive: true });
517
+ for (const plugin of backup.plugins) {
518
+ const dir = path.join(pluginDir, plugin.name);
519
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
520
+ fs.writeFileSync(path.join(dir, 'index.js'), plugin.content, 'utf8');
521
+ }
522
+ }
863
523
  }
864
524
 
865
- exec(command, { shell: true }, (err) => {
866
- if (err) console.error('Failed to open terminal:', err);
525
+ res.json({ success: true });
526
+ });
527
+
528
+ // Auth Handlers
529
+ function loadAuthConfig() {
530
+ const configPath = getConfigPath();
531
+ if (!configPath) return null;
532
+
533
+ // Auth config is usually in ~/.config/opencode/auth.json ?
534
+ // Or inside opencode.json?
535
+ // Based on opencode CLI, it seems to store auth in separate files or system keychain.
536
+ // But for this studio, we might just look at opencode.json "providers" section or similar.
537
+
538
+ // ACTUALLY, opencode stores auth tokens in ~/.config/opencode/auth.json
539
+ const authPath = path.join(path.dirname(configPath), 'auth.json');
540
+ if (!fs.existsSync(authPath)) return null;
541
+ try {
542
+ return JSON.parse(fs.readFileSync(authPath, 'utf8'));
543
+ } catch {
544
+ return null;
545
+ }
546
+ }
547
+
548
+ app.get('/api/auth', (req, res) => {
549
+ const authConfig = loadAuthConfig();
550
+ res.json(authConfig || {});
551
+ });
552
+
553
+ app.post('/api/auth/login', (req, res) => {
554
+ const { provider } = req.body;
555
+ // Launch opencode CLI auth login
556
+ // This requires 'opencode' to be in PATH
557
+
558
+ const cmd = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
559
+
560
+ // We spawn it so it opens the browser
561
+ const child = spawn(cmd, ['auth', 'login', provider], {
562
+ stdio: 'inherit',
563
+ shell: true
867
564
  });
868
565
 
869
- res.json({
870
- success: true,
871
- message: 'Opening terminal for authentication...',
872
- note: 'Complete authentication in the terminal window, then refresh this page.'
566
+ res.json({ success: true, message: 'Launched auth flow', note: 'Please check the terminal window where the server is running' });
567
+ });
568
+
569
+ app.delete('/api/auth/:provider', (req, res) => {
570
+ // Logout logic
571
+ // Maybe just delete from auth.json? Or run opencode auth logout?
572
+ const { provider } = req.params;
573
+ const cmd = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
574
+
575
+ spawn(cmd, ['auth', 'logout', provider], {
576
+ stdio: 'inherit',
577
+ shell: true
873
578
  });
579
+
580
+ res.json({ success: true });
874
581
  });
875
-
876
- app.delete('/api/auth/:provider', (req, res) => {
877
- const provider = req.params.provider;
878
- const authConfig = loadAuthConfig();
879
-
880
- if (!authConfig) {
881
- return res.status(404).json({ error: 'No auth configuration found' });
882
- }
883
-
884
- if (!authConfig[provider]) {
885
- return res.status(404).json({ error: `Provider ${provider} not found` });
886
- }
887
-
888
- delete authConfig[provider];
889
-
890
- if (saveAuthConfig(authConfig)) {
891
- res.json({ success: true });
892
- } else {
893
- res.status(500).json({ error: 'Failed to save auth configuration' });
894
- }
895
- });
896
-
897
- // Get available providers for login
898
- app.get('/api/auth/providers', (req, res) => {
899
- const providers = [
900
- { id: 'github-copilot', name: 'GitHub Copilot', type: 'oauth', description: 'Use GitHub Copilot API' },
901
- { id: 'google', name: 'Google AI', type: 'oauth', description: 'Use Google Gemini models' },
902
- { id: 'anthropic', name: 'Anthropic', type: 'api', description: 'Use Claude models' },
903
- { id: 'openai', name: 'OpenAI', type: 'api', description: 'Use GPT models' },
904
- { id: 'xai', name: 'xAI', type: 'api', description: 'Use Grok models' },
905
- { id: 'groq', name: 'Groq', type: 'api', description: 'Fast inference' },
906
- { id: 'together', name: 'Together AI', type: 'api', description: 'Open source models' },
907
- { id: 'mistral', name: 'Mistral', type: 'api', description: 'Mistral models' },
908
- { id: 'deepseek', name: 'DeepSeek', type: 'api', description: 'DeepSeek models' },
909
- { id: 'openrouter', name: 'OpenRouter', type: 'api', description: 'Multiple providers' },
910
- { id: 'amazon-bedrock', name: 'Amazon Bedrock', type: 'api', description: 'AWS models' },
911
- { id: 'azure', name: 'Azure OpenAI', type: 'api', description: 'Azure GPT models' },
912
- ];
913
- res.json(providers);
914
- });
915
-
916
- const AUTH_PROFILES_DIR = path.join(HOME_DIR, '.config', 'opencode-studio', 'auth-profiles');
917
-
918
- function ensureAuthProfilesDir() {
919
- if (!fs.existsSync(AUTH_PROFILES_DIR)) {
920
- fs.mkdirSync(AUTH_PROFILES_DIR, { recursive: true });
921
- }
922
- }
923
-
924
- function getProviderProfilesDir(provider) {
925
- return path.join(AUTH_PROFILES_DIR, provider);
926
- }
927
-
928
- function listAuthProfiles(provider) {
929
- const dir = getProviderProfilesDir(provider);
930
- if (!fs.existsSync(dir)) return [];
931
-
932
- try {
933
- return fs.readdirSync(dir)
934
- .filter(f => f.endsWith('.json'))
935
- .map(f => f.replace('.json', ''));
936
- } catch {
937
- return [];
938
- }
939
- }
940
-
941
- function getNextProfileName(provider) {
942
- const existing = listAuthProfiles(provider);
943
- let num = 1;
944
- while (existing.includes(`account-${num}`)) {
945
- num++;
946
- }
947
- return `account-${num}`;
948
- }
949
-
950
- function saveAuthProfile(provider, profileName, data) {
951
- ensureAuthProfilesDir();
952
- const dir = getProviderProfilesDir(provider);
953
- if (!fs.existsSync(dir)) {
954
- fs.mkdirSync(dir, { recursive: true });
955
- }
956
- const filePath = path.join(dir, `${profileName}.json`);
957
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
958
- return true;
959
- }
960
-
961
- function loadAuthProfile(provider, profileName) {
962
- const filePath = path.join(getProviderProfilesDir(provider), `${profileName}.json`);
963
- if (!fs.existsSync(filePath)) return null;
964
- try {
965
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
966
- } catch {
967
- return null;
968
- }
969
- }
970
-
971
- function deleteAuthProfile(provider, profileName) {
972
- const filePath = path.join(getProviderProfilesDir(provider), `${profileName}.json`);
973
- if (fs.existsSync(filePath)) {
974
- fs.unlinkSync(filePath);
975
- return true;
976
- }
977
- return false;
978
- }
979
-
980
- function getActiveProfiles() {
981
- const studioConfig = loadStudioConfig();
982
- return studioConfig.activeProfiles || {};
983
- }
984
-
985
- function setActiveProfile(provider, profileName) {
986
- const studioConfig = loadStudioConfig();
987
- studioConfig.activeProfiles = studioConfig.activeProfiles || {};
988
- studioConfig.activeProfiles[provider] = profileName;
989
- saveStudioConfig(studioConfig);
990
- }
991
-
582
+
583
+ app.get('/api/auth/providers', (req, res) => {
584
+ // List of supported providers (hardcoded for now as it's not easily discoverable via CLI)
585
+ const providers = [
586
+ { id: 'google', name: 'Google AI', type: 'oauth', description: 'Use Google Gemini models' },
587
+ { id: 'anthropic', name: 'Anthropic', type: 'api', description: 'Use Claude models' },
588
+ { id: 'openai', name: 'OpenAI', type: 'api', description: 'Use GPT models' },
589
+ { id: 'xai', name: 'xAI', type: 'api', description: 'Use Grok models' },
590
+ { id: 'groq', name: 'Groq', type: 'api', description: 'Fast inference' },
591
+ { id: 'together', name: 'Together AI', type: 'api', description: 'Open source models' },
592
+ { id: 'mistral', name: 'Mistral', type: 'api', description: 'Mistral models' },
593
+ { id: 'deepseek', name: 'DeepSeek', type: 'api', description: 'DeepSeek models' },
594
+ { id: 'openrouter', name: 'OpenRouter', type: 'api', description: 'Multiple providers' },
595
+ { id: 'amazon-bedrock', name: 'Amazon Bedrock', type: 'api', description: 'AWS models' },
596
+ { id: 'azure', name: 'Azure OpenAI', type: 'api', description: 'Azure GPT models' },
597
+ ];
598
+ res.json(providers);
599
+ });
600
+
601
+ const AUTH_PROFILES_DIR = path.join(HOME_DIR, '.config', 'opencode-studio', 'auth-profiles');
602
+
603
+ function ensureAuthProfilesDir() {
604
+ if (!fs.existsSync(AUTH_PROFILES_DIR)) {
605
+ fs.mkdirSync(AUTH_PROFILES_DIR, { recursive: true });
606
+ }
607
+ }
608
+
609
+ function getProviderProfilesDir(provider) {
610
+ return path.join(AUTH_PROFILES_DIR, provider);
611
+ }
612
+
613
+ function listAuthProfiles(provider) {
614
+ const dir = getProviderProfilesDir(provider);
615
+ if (!fs.existsSync(dir)) return [];
616
+
617
+ try {
618
+ return fs.readdirSync(dir)
619
+ .filter(f => f.endsWith('.json'))
620
+ .map(f => f.replace('.json', ''));
621
+ } catch {
622
+ return [];
623
+ }
624
+ }
625
+
626
+ function getNextProfileName(provider) {
627
+ const existing = listAuthProfiles(provider);
628
+ let num = 1;
629
+ while (existing.includes(`account-${num}`)) {
630
+ num++;
631
+ }
632
+ return `account-${num}`;
633
+ }
634
+
635
+ function saveAuthProfile(provider, profileName, data) {
636
+ ensureAuthProfilesDir();
637
+ const dir = getProviderProfilesDir(provider);
638
+ if (!fs.existsSync(dir)) {
639
+ fs.mkdirSync(dir, { recursive: true });
640
+ }
641
+ const filePath = path.join(dir, `${profileName}.json`);
642
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
643
+ return true;
644
+ }
645
+
646
+ function loadAuthProfile(provider, profileName) {
647
+ const filePath = path.join(getProviderProfilesDir(provider), `${profileName}.json`);
648
+ if (!fs.existsSync(filePath)) return null;
649
+ try {
650
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
651
+ } catch {
652
+ return null;
653
+ }
654
+ }
655
+
656
+ function deleteAuthProfile(provider, profileName) {
657
+ const filePath = path.join(getProviderProfilesDir(provider), `${profileName}.json`);
658
+ if (fs.existsSync(filePath)) {
659
+ fs.unlinkSync(filePath);
660
+ return true;
661
+ }
662
+ return false;
663
+ }
664
+
665
+ function getActiveProfiles() {
666
+ const studioConfig = loadStudioConfig();
667
+ return studioConfig.activeProfiles || {};
668
+ }
669
+
670
+ function setActiveProfile(provider, profileName) {
671
+ const studioConfig = loadStudioConfig();
672
+ studioConfig.activeProfiles = studioConfig.activeProfiles || {};
673
+ studioConfig.activeProfiles[provider] = profileName;
674
+ saveStudioConfig(studioConfig);
675
+ }
676
+
992
677
  function verifyActiveProfile(p, n, c) { console.log("Verifying:", p, n); if (!n || !c) return false; const d = loadAuthProfile(p, n); if (d && c) { console.log("Profile refresh:", d.refresh); console.log("Current refresh:", c.refresh); } if (!d) return false; return JSON.stringify(d) === JSON.stringify(c); }
993
678
 
994
679
  app.get('/api/auth/profiles', (req, res) => {
@@ -997,147 +682,305 @@ app.get('/api/auth/profiles', (req, res) => {
997
682
  const authConfig = loadAuthConfig() || {};
998
683
 
999
684
  const profiles = {};
1000
-
1001
- Object.keys(PROVIDER_DISPLAY_NAMES).forEach(provider => {
1002
- const providerProfiles = listAuthProfiles(provider);
1003
- profiles[provider] = {
1004
- profiles: providerProfiles,
1005
- active: (activeProfiles[provider] && verifyActiveProfile(provider, activeProfiles[provider], authConfig[provider])) ? activeProfiles[provider] : null,
1006
- hasCurrentAuth: !!authConfig[provider],
1007
- };
1008
- });
1009
-
1010
- res.json(profiles);
1011
- });
1012
-
1013
- app.get('/api/auth/profiles/:provider', (req, res) => {
1014
- const { provider } = req.params;
1015
- const providerProfiles = listAuthProfiles(provider);
1016
- const activeProfiles = getActiveProfiles();
1017
- const authConfig = loadAuthConfig() || {};
1018
-
1019
- res.json({
1020
- profiles: providerProfiles,
1021
- active: (activeProfiles[provider] && verifyActiveProfile(provider, activeProfiles[provider], authConfig[provider])) ? activeProfiles[provider] : null,
1022
- hasCurrentAuth: !!authConfig[provider],
1023
- });
1024
- });
1025
-
1026
- app.post('/api/auth/profiles/:provider', (req, res) => {
1027
- const { provider } = req.params;
1028
- const { name } = req.body;
1029
-
1030
- const authConfig = loadAuthConfig();
1031
- if (!authConfig || !authConfig[provider]) {
1032
- return res.status(400).json({ error: `No active auth for ${provider} to save` });
1033
- }
1034
-
1035
- const profileName = name || getNextProfileName(provider);
1036
- const data = authConfig[provider];
1037
-
1038
- try {
1039
- saveAuthProfile(provider, profileName, data);
1040
- setActiveProfile(provider, profileName);
1041
- res.json({ success: true, name: profileName });
1042
- } catch (err) {
1043
- res.status(500).json({ error: 'Failed to save profile', details: err.message });
1044
- }
1045
- });
1046
-
1047
- app.post('/api/auth/profiles/:provider/:name/activate', (req, res) => {
1048
- const { provider, name } = req.params;
1049
-
1050
- const profileData = loadAuthProfile(provider, name);
1051
- if (!profileData) {
1052
- return res.status(404).json({ error: 'Profile not found' });
1053
- }
1054
-
1055
- const authConfig = loadAuthConfig() || {};
1056
- authConfig[provider] = profileData;
1057
-
1058
- if (saveAuthConfig(authConfig)) {
1059
- setActiveProfile(provider, name);
1060
- res.json({ success: true });
1061
- } else {
1062
- res.status(500).json({ error: 'Failed to activate profile' });
1063
- }
1064
- });
1065
-
1066
- app.delete('/api/auth/profiles/:provider/:name', (req, res) => {
1067
- const { provider, name } = req.params;
1068
-
1069
- if (deleteAuthProfile(provider, name)) {
1070
- const activeProfiles = getActiveProfiles();
1071
- if (activeProfiles[provider] === name) {
1072
- const studioConfig = loadStudioConfig();
1073
- delete studioConfig.activeProfiles[provider];
1074
- saveStudioConfig(studioConfig);
1075
- }
1076
- res.json({ success: true });
1077
- } else {
1078
- res.status(404).json({ error: 'Profile not found' });
1079
- }
1080
- });
1081
-
1082
- app.put('/api/auth/profiles/:provider/:name', (req, res) => {
1083
- const { provider, name } = req.params;
1084
- const { newName } = req.body;
1085
-
1086
- if (!newName || newName === name) {
1087
- return res.status(400).json({ error: 'New name is required and must be different' });
1088
- }
1089
-
1090
- const profileData = loadAuthProfile(provider, name);
1091
- if (!profileData) {
1092
- return res.status(404).json({ error: 'Profile not found' });
1093
- }
1094
-
1095
- const existingProfiles = listAuthProfiles(provider);
1096
- if (existingProfiles.includes(newName)) {
1097
- return res.status(400).json({ error: 'Profile name already exists' });
1098
- }
1099
-
1100
- try {
1101
- saveAuthProfile(provider, newName, profileData);
1102
- deleteAuthProfile(provider, name);
1103
-
1104
- const activeProfiles = getActiveProfiles();
1105
- if (activeProfiles[provider] === name) {
1106
- setActiveProfile(provider, newName);
1107
- }
1108
-
1109
- res.json({ success: true, name: newName });
1110
- } catch (err) {
1111
- res.status(500).json({ error: 'Failed to rename profile', details: err.message });
1112
- }
1113
- });
1114
-
1115
- app.listen(PORT, () => {
1116
- const url = 'https://opencode-studio.micr.dev';
1117
- console.log(`Server running on http://localhost:${PORT}`);
1118
- console.log(`To manage your OpenCode configuration, visit: ${url}`);
1119
- console.log('\nPress [Enter] to open in your browser...');
1120
-
1121
- process.stdin.resume();
1122
- process.stdin.on('data', (data) => {
1123
- const input = data.toString();
1124
- if (input === '\n' || input === '\r\n' || input === '\r') {
1125
- const platform = os.platform();
1126
- let cmd;
1127
- if (platform === 'win32') {
1128
- cmd = `start "" "${url}"`;
1129
- } else if (platform === 'darwin') {
1130
- cmd = `open "${url}"`;
1131
- } else {
1132
- cmd = `xdg-open "${url}"`;
685
+ const providers = [
686
+ 'google', 'anthropic', 'openai', 'xai', 'groq',
687
+ 'together', 'mistral', 'deepseek', 'openrouter',
688
+ 'amazon-bedrock', 'azure'
689
+ ];
690
+
691
+ providers.forEach(p => {
692
+ const saved = listAuthProfiles(p);
693
+ const active = activeProfiles[p];
694
+ const current = authConfig[p];
695
+
696
+ if (saved.length > 0 || current) {
697
+ profiles[p] = {
698
+ active: active,
699
+ saved: saved,
700
+ hasCurrent: !!current
701
+ };
702
+ }
703
+ });
704
+
705
+ res.json(profiles);
706
+ });
707
+
708
+ app.get('/api/debug/paths', (req, res) => {
709
+ const home = os.homedir();
710
+ const candidatePaths = [
711
+ process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'opencode', 'storage', 'message') : null,
712
+ path.join(home, '.local', 'share', 'opencode', 'storage', 'message'),
713
+ path.join(home, '.opencode', 'storage', 'message')
714
+ ].filter(p => p);
715
+
716
+ const results = candidatePaths.map(p => ({
717
+ path: p,
718
+ exists: fs.existsSync(p),
719
+ isDirectory: fs.existsSync(p) && fs.statSync(p).isDirectory(),
720
+ fileCount: (fs.existsSync(p) && fs.statSync(p).isDirectory()) ? fs.readdirSync(p).length : 0
721
+ }));
722
+
723
+ res.json({
724
+ home,
725
+ platform: process.platform,
726
+ candidates: results
727
+ });
728
+ });
729
+
730
+ app.get('/api/usage', async (req, res) => {
731
+ try {
732
+ const home = os.homedir();
733
+ const candidatePaths = [
734
+ process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'opencode', 'storage', 'message') : null,
735
+ path.join(home, '.local', 'share', 'opencode', 'storage', 'message'),
736
+ path.join(home, '.opencode', 'storage', 'message')
737
+ ].filter(p => p && fs.existsSync(p));
738
+
739
+ const stats = {
740
+ totalCost: 0,
741
+ totalTokens: 0,
742
+ byModel: {},
743
+ byDay: {}
744
+ };
745
+
746
+ const processedFiles = new Set();
747
+
748
+ const processMessage = (filePath) => {
749
+ if (processedFiles.has(filePath)) return;
750
+ processedFiles.add(filePath);
751
+
752
+ try {
753
+ const content = fs.readFileSync(filePath, 'utf8');
754
+ const msg = JSON.parse(content);
755
+
756
+ const model = msg.modelID || (msg.model && (msg.model.modelID || msg.model.id)) || 'unknown';
757
+
758
+ if (msg.role === 'assistant' && msg.tokens) {
759
+ const cost = msg.cost || 0;
760
+ const tokens = (msg.tokens.input || 0) + (msg.tokens.output || 0);
761
+ const date = new Date(msg.time.created).toISOString().split('T')[0];
762
+
763
+ if (tokens > 0) {
764
+ stats.totalCost += cost;
765
+ stats.totalTokens += tokens;
766
+
767
+ if (!stats.byModel[model]) {
768
+ stats.byModel[model] = { name: model, cost: 0, tokens: 0 };
769
+ }
770
+ stats.byModel[model].cost += cost;
771
+ stats.byModel[model].tokens += tokens;
772
+
773
+ if (!stats.byDay[date]) {
774
+ stats.byDay[date] = { date, cost: 0, tokens: 0 };
775
+ }
776
+ stats.byDay[date].cost += cost;
777
+ stats.byDay[date].tokens += tokens;
778
+ }
779
+ }
780
+ } catch (err) {
1133
781
  }
1134
- exec(cmd, (err) => {
1135
- if (err) {
1136
- console.log(`Failed to open browser. Please visit: ${url}`);
1137
- } else {
1138
- console.log('Opening browser...');
782
+ };
783
+
784
+ for (const logDir of candidatePaths) {
785
+ try {
786
+ const sessions = fs.readdirSync(logDir);
787
+ for (const session of sessions) {
788
+ if (!session.startsWith('ses_')) continue;
789
+
790
+ const sessionDir = path.join(logDir, session);
791
+ if (fs.statSync(sessionDir).isDirectory()) {
792
+ const messages = fs.readdirSync(sessionDir);
793
+ for (const msgFile of messages) {
794
+ if (msgFile.endsWith('.json')) {
795
+ processMessage(path.join(sessionDir, msgFile));
796
+ }
797
+ }
798
+ }
1139
799
  }
1140
- });
800
+ } catch (err) {
801
+ console.error(`Error reading log dir ${logDir}:`, err);
802
+ }
1141
803
  }
804
+
805
+ const response = {
806
+ totalCost: stats.totalCost,
807
+ totalTokens: stats.totalTokens,
808
+ byModel: Object.values(stats.byModel).sort((a, b) => b.cost - a.cost),
809
+ byDay: Object.values(stats.byDay).sort((a, b) => a.date.localeCompare(b.date))
810
+ };
811
+
812
+ res.json(response);
813
+ } catch (error) {
814
+ console.error('Error fetching usage stats:', error);
815
+ res.status(500).json({ error: 'Failed to fetch usage stats' });
816
+ }
817
+ });
818
+
819
+ app.get('/api/auth/profiles/:provider', (req, res) => {
820
+ const { provider } = req.params;
821
+ const providerProfiles = listAuthProfiles(provider);
822
+ const activeProfiles = getActiveProfiles();
823
+ const authConfig = loadAuthConfig() || {};
824
+
825
+ res.json({
826
+ profiles: providerProfiles,
827
+ active: (activeProfiles[provider] && verifyActiveProfile(provider, activeProfiles[provider], authConfig[provider])) ? activeProfiles[provider] : null,
828
+ hasCurrentAuth: !!authConfig[provider],
1142
829
  });
1143
830
  });
831
+
832
+ app.post('/api/auth/profiles/:provider', (req, res) => {
833
+ const { provider } = req.params;
834
+ const { name } = req.body;
835
+
836
+ const authConfig = loadAuthConfig();
837
+ if (!authConfig || !authConfig[provider]) {
838
+ return res.status(400).json({ error: `No active auth for ${provider} to save` });
839
+ }
840
+
841
+ const profileName = name || getNextProfileName(provider);
842
+ const data = authConfig[provider];
843
+
844
+ try {
845
+ saveAuthProfile(provider, profileName, data);
846
+ setActiveProfile(provider, profileName);
847
+ res.json({ success: true, name: profileName });
848
+ } catch (err) {
849
+ res.status(500).json({ error: 'Failed to save profile', details: err.message });
850
+ }
851
+ });
852
+
853
+ app.post('/api/auth/profiles/:provider/:name/activate', (req, res) => {
854
+ const { provider, name } = req.params;
855
+
856
+ const profileData = loadAuthProfile(provider, name);
857
+ if (!profileData) {
858
+ return res.status(404).json({ error: 'Profile not found' });
859
+ }
860
+
861
+ const authConfig = loadAuthConfig() || {};
862
+ authConfig[provider] = profileData;
863
+
864
+ // Save to ~/.config/opencode/auth.json
865
+ const configPath = getConfigPath();
866
+ if (configPath) {
867
+ const authPath = path.join(path.dirname(configPath), 'auth.json');
868
+ try {
869
+ fs.writeFileSync(authPath, JSON.stringify(authConfig, null, 2), 'utf8');
870
+ setActiveProfile(provider, name);
871
+ res.json({ success: true });
872
+ } catch (err) {
873
+ res.status(500).json({ error: 'Failed to write auth config', details: err.message });
874
+ }
875
+ } else {
876
+ res.status(500).json({ error: 'Config path not found' });
877
+ }
878
+ });
879
+
880
+ app.delete('/api/auth/profiles/:provider/:name', (req, res) => {
881
+ const { provider, name } = req.params;
882
+ const success = deleteAuthProfile(provider, name);
883
+ if (success) {
884
+ const activeProfiles = getActiveProfiles();
885
+ if (activeProfiles[provider] === name) {
886
+ const studioConfig = loadStudioConfig();
887
+ if (studioConfig.activeProfiles) {
888
+ delete studioConfig.activeProfiles[provider];
889
+ saveStudioConfig(studioConfig);
890
+ }
891
+ }
892
+ res.json({ success: true });
893
+ } else {
894
+ res.status(404).json({ error: 'Profile not found' });
895
+ }
896
+ });
897
+
898
+ app.put('/api/auth/profiles/:provider/:name', (req, res) => {
899
+ const { provider, name } = req.params;
900
+ const { newName } = req.body;
901
+
902
+ if (!newName) return res.status(400).json({ error: 'New name required' });
903
+
904
+ const profileData = loadAuthProfile(provider, name);
905
+ if (!profileData) return res.status(404).json({ error: 'Profile not found' });
906
+
907
+ const success = saveAuthProfile(provider, newName, profileData);
908
+ if (success) {
909
+ deleteAuthProfile(provider, name);
910
+
911
+ const activeProfiles = getActiveProfiles();
912
+ if (activeProfiles[provider] === name) {
913
+ setActiveProfile(provider, newName);
914
+ }
915
+
916
+ res.json({ success: true, name: newName });
917
+ } else {
918
+ res.status(500).json({ error: 'Failed to rename profile' });
919
+ }
920
+ });
921
+
922
+ app.post('/api/plugins/config/add', (req, res) => {
923
+ const { plugins } = req.body;
924
+ if (!Array.isArray(plugins) || plugins.length === 0) {
925
+ return res.status(400).json({ error: 'Plugins array required' });
926
+ }
927
+
928
+ const config = loadConfig();
929
+ if (!config) return res.status(404).json({ error: 'Config not found' });
930
+
931
+ if (!config.plugins) config.plugins = {};
932
+
933
+ const result = {
934
+ added: [],
935
+ skipped: []
936
+ };
937
+
938
+ for (const pluginName of plugins) {
939
+ // Check if plugin exists in studio (for validation)
940
+ const pluginDir = getPluginDir();
941
+ const dirPath = path.join(pluginDir, pluginName);
942
+ const hasJs = fs.existsSync(path.join(dirPath, 'index.js'));
943
+ const hasTs = fs.existsSync(path.join(dirPath, 'index.ts'));
944
+
945
+ if (!hasJs && !hasTs) {
946
+ result.skipped.push(`${pluginName} (not found)`);
947
+ continue;
948
+ }
949
+
950
+ // Add to config
951
+ // Default config for a plugin? Usually empty object or enabled: true
952
+ if (config.plugins[pluginName]) {
953
+ result.skipped.push(`${pluginName} (already configured)`);
954
+ } else {
955
+ config.plugins[pluginName] = { enabled: true };
956
+ result.added.push(pluginName);
957
+ }
958
+ }
959
+
960
+ if (result.added.length > 0) {
961
+ saveConfig(config);
962
+ }
963
+
964
+ res.json(result);
965
+ });
966
+
967
+ app.delete('/api/plugins/config/:name', (req, res) => {
968
+ const pluginName = decodeURIComponent(req.params.name);
969
+ const config = loadConfig();
970
+
971
+ if (!config || !config.plugins) {
972
+ return res.status(404).json({ error: 'Config not found or no plugins' });
973
+ }
974
+
975
+ if (config.plugins[pluginName]) {
976
+ delete config.plugins[pluginName];
977
+ saveConfig(config);
978
+ res.json({ success: true });
979
+ } else {
980
+ res.status(404).json({ error: 'Plugin not in config' });
981
+ }
982
+ });
983
+
984
+ app.listen(PORT, () => {
985
+ console.log(`Server running at http://localhost:${PORT}`);
986
+ });