opencode-studio-server 1.0.12 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.js +558 -715
  2. package/launcher.vbs +6 -6
  3. package/package.json +1 -1
package/index.js CHANGED
@@ -56,34 +56,34 @@ const HOME_DIR = os.homedir();
56
56
  const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'studio.json');
57
57
  const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json');
58
58
 
59
- let pendingActionMemory = null;
60
-
61
- function loadStudioConfig() {
62
- if (!fs.existsSync(STUDIO_CONFIG_PATH)) {
63
- return {};
64
- }
65
- try {
66
- return JSON.parse(fs.readFileSync(STUDIO_CONFIG_PATH, 'utf8'));
67
- } catch {
68
- return {};
69
- }
70
- }
71
-
72
- function saveStudioConfig(config) {
73
- try {
74
- const dir = path.dirname(STUDIO_CONFIG_PATH);
75
- if (!fs.existsSync(dir)) {
76
- fs.mkdirSync(dir, { recursive: true });
77
- }
78
- fs.writeFileSync(STUDIO_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
79
- return true;
80
- } catch (err) {
81
- console.error('Failed to save studio config:', err);
82
- return false;
83
- }
84
- }
85
-
86
- function loadPendingAction() {
59
+ let pendingActionMemory = null;
60
+
61
+ function loadStudioConfig() {
62
+ if (!fs.existsSync(STUDIO_CONFIG_PATH)) {
63
+ return {};
64
+ }
65
+ try {
66
+ return JSON.parse(fs.readFileSync(STUDIO_CONFIG_PATH, 'utf8'));
67
+ } catch {
68
+ return {};
69
+ }
70
+ }
71
+
72
+ function saveStudioConfig(config) {
73
+ try {
74
+ const dir = path.dirname(STUDIO_CONFIG_PATH);
75
+ if (!fs.existsSync(dir)) {
76
+ fs.mkdirSync(dir, { recursive: true });
77
+ }
78
+ fs.writeFileSync(STUDIO_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
79
+ return true;
80
+ } catch (err) {
81
+ console.error('Failed to save studio config:', err);
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function loadPendingAction() {
87
87
  if (pendingActionMemory) return pendingActionMemory;
88
88
 
89
89
  if (fs.existsSync(PENDING_ACTION_PATH)) {
@@ -102,802 +102,487 @@ function loadPendingAction() {
102
102
  return null;
103
103
  }
104
104
 
105
- function clearPendingAction() {
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) => {
106
111
  pendingActionMemory = null;
107
112
  if (fs.existsSync(PENDING_ACTION_PATH)) {
108
113
  try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
109
114
  }
110
- }
115
+ res.json({ success: true });
116
+ });
111
117
 
112
- function setPendingAction(action) {
113
- pendingActionMemory = { ...action, timestamp: Date.now() };
114
- }
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
+ }
115
134
 
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
- ];
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
+ }
121
145
 
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;
133
- }
134
- }
135
- return null;
136
- }
137
-
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',
151
- };
152
-
153
- function getPaths() {
154
- const configDir = getConfigDir();
155
- if (!configDir) return null;
156
146
  return {
157
- configDir,
158
- opencodeJson: path.join(configDir, 'opencode.json'),
159
- skillDir: path.join(configDir, 'skill'),
160
- pluginDir: path.join(configDir, 'plugin'),
147
+ detected,
148
+ manual: manualPath,
149
+ current: manualPath || detected,
150
+ candidates
161
151
  };
162
- }
152
+ };
163
153
 
164
- function parseSkillFrontmatter(content) {
165
- const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
166
- if (!frontmatterMatch) {
167
- return { frontmatter: {}, body: content };
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;
168
163
  }
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
- }
164
+ try {
165
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
166
+ } catch {
167
+ return null;
186
168
  }
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
- }
169
+ };
199
170
 
200
- console.log(`Detected config at: ${getConfigDir() || 'NOT FOUND'}`);
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
+ };
201
182
 
202
183
  app.get('/api/health', (req, res) => {
203
- res.json({ status: 'ok', timestamp: Date.now() });
184
+ res.json({ status: 'ok' });
204
185
  });
205
186
 
206
187
  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
188
  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
- }
189
+ setTimeout(() => process.exit(0), 100);
229
190
  });
230
191
 
231
192
  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
- });
193
+ res.json(getPaths());
242
194
  });
243
195
 
244
196
  app.post('/api/paths', (req, res) => {
245
197
  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
198
  const studioConfig = loadStudioConfig();
255
- studioConfig.configPath = configPath || null;
199
+ studioConfig.configPath = configPath;
256
200
  saveStudioConfig(studioConfig);
257
-
258
- res.json({ success: true, current: getConfigDir() });
201
+ res.json({ success: true, current: getConfigPath() });
259
202
  });
260
203
 
261
204
  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 });
205
+ const config = loadConfig();
206
+ if (!config) {
207
+ return res.status(404).json({ error: 'Config not found' });
295
208
  }
209
+ res.json(config);
296
210
  });
297
211
 
298
212
  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
-
213
+ const config = req.body;
304
214
  try {
305
- fs.writeFileSync(paths.opencodeJson, JSON.stringify(req.body, null, 2), 'utf8');
215
+ saveConfig(config);
306
216
  res.json({ success: true });
307
217
  } catch (err) {
308
- res.status(500).json({ error: 'Failed to write config', details: err.message });
218
+ res.status(500).json({ error: err.message });
309
219
  }
310
220
  });
311
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
+
312
229
  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([]);
230
+ const skillDir = getSkillDir();
231
+ if (!skillDir || !fs.existsSync(skillDir)) {
232
+ return res.json([]);
233
+ }
316
234
 
317
- const studioConfig = loadStudioConfig();
318
- const disabledSkills = studioConfig.disabledSkills || [];
319
235
  const skills = [];
236
+ const entries = fs.readdirSync(skillDir, { withFileTypes: true });
320
237
 
321
- const entries = fs.readdirSync(paths.skillDir, { withFileTypes: true });
322
238
  for (const entry of entries) {
323
239
  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 {}
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
+ });
335
247
  }
336
248
  }
337
249
  }
338
-
339
250
  res.json(skills);
340
251
  });
341
252
 
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
253
  app.get('/api/skills/:name', (req, res) => {
358
- const paths = getPaths();
359
- if (!paths) return res.status(404).json({ error: 'Opencode not found' });
254
+ const skillDir = getSkillDir();
255
+ if (!skillDir) return res.status(404).json({ error: 'No config' });
360
256
 
361
- const skillName = path.basename(req.params.name);
362
- const skillDir = path.join(paths.skillDir, skillName);
363
- const skillFile = path.join(skillDir, 'SKILL.md');
257
+ const name = req.params.name;
258
+ const skillPath = path.join(skillDir, name, 'SKILL.md');
364
259
 
365
- if (!fs.existsSync(skillFile)) {
260
+ if (!fs.existsSync(skillPath)) {
366
261
  return res.status(404).json({ error: 'Skill not found' });
367
262
  }
368
263
 
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
- });
264
+ const content = fs.readFileSync(skillPath, 'utf8');
265
+ res.json({ name, content });
378
266
  });
379
267
 
380
268
  app.post('/api/skills/:name', (req, res) => {
381
- const paths = getPaths();
382
- if (!paths) return res.status(404).json({ error: 'Opencode not found' });
269
+ const skillDir = getSkillDir();
270
+ if (!skillDir) return res.status(404).json({ error: 'No config' });
383
271
 
384
- const skillName = path.basename(req.params.name);
385
- const { description, content } = req.body;
272
+ const name = req.params.name;
273
+ const { content } = req.body;
274
+ const dirPath = path.join(skillDir, name);
386
275
 
387
- const skillDir = path.join(paths.skillDir, skillName);
388
- if (!fs.existsSync(skillDir)) {
389
- fs.mkdirSync(skillDir, { recursive: true });
276
+ if (!fs.existsSync(dirPath)) {
277
+ fs.mkdirSync(dirPath, { recursive: true });
390
278
  }
391
279
 
392
- const fullContent = createSkillContent(skillName, description || '', content || '');
393
- const skillFile = path.join(skillDir, 'SKILL.md');
394
- fs.writeFileSync(skillFile, fullContent, 'utf8');
395
-
280
+ fs.writeFileSync(path.join(dirPath, 'SKILL.md'), content, 'utf8');
396
281
  res.json({ success: true });
397
282
  });
398
283
 
399
284
  app.delete('/api/skills/:name', (req, res) => {
400
- const paths = getPaths();
401
- if (!paths) return res.status(404).json({ error: 'Opencode not found' });
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);
402
290
 
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 });
291
+ if (fs.existsSync(dirPath)) {
292
+ fs.rmSync(dirPath, { recursive: true, force: true });
407
293
  }
408
294
  res.json({ success: true });
409
295
  });
410
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
+
411
310
  app.get('/api/plugins', (req, res) => {
412
- const paths = getPaths();
413
- if (!paths) return res.json([]);
311
+ const pluginDir = getPluginDir();
312
+ if (!pluginDir || !fs.existsSync(pluginDir)) {
313
+ return res.json([]);
314
+ }
414
315
 
415
- const studioConfig = loadStudioConfig();
416
- const disabledPlugins = studioConfig.disabledPlugins || [];
417
316
  const plugins = [];
317
+ const entries = fs.readdirSync(pluginDir, { withFileTypes: true });
418
318
 
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
- }
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
+ });
443
331
  }
444
- } catch {}
332
+ }
445
333
  }
446
-
447
334
  res.json(plugins);
448
335
  });
449
336
 
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
337
  app.get('/api/plugins/:name', (req, res) => {
466
- const paths = getPaths();
467
- if (!paths) return res.status(404).json({ error: 'Opencode not found' });
338
+ const pluginDir = getPluginDir();
339
+ if (!pluginDir) return res.status(404).json({ error: 'No config' });
468
340
 
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' });
341
+ const name = req.params.name;
342
+ const dirPath = path.join(pluginDir, name);
472
343
 
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' });
344
+ let content = '';
345
+ let filename = '';
480
346
 
481
- if (!fs.existsSync(paths.pluginDir)) {
482
- fs.mkdirSync(paths.pluginDir, { recursive: true });
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';
353
+ } else {
354
+ return res.status(404).json({ error: 'Plugin not found' });
483
355
  }
484
356
 
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 });
357
+ res.json({ name, content, filename });
489
358
  });
490
359
 
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' });
360
+ app.post('/api/plugins/:name', (req, res) => {
361
+ const pluginDir = getPluginDir();
362
+ if (!pluginDir) return res.status(404).json({ error: 'No config' });
507
363
 
508
- const { plugins } = req.body;
364
+ const name = req.params.name;
365
+ const { content } = req.body;
366
+ const dirPath = path.join(pluginDir, name);
509
367
 
510
- if (!plugins || !Array.isArray(plugins) || plugins.length === 0) {
511
- return res.status(400).json({ error: 'plugins array is required' });
368
+ if (!fs.existsSync(dirPath)) {
369
+ fs.mkdirSync(dirPath, { recursive: true });
512
370
  }
513
371
 
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
- }
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 });
546
376
  });
547
377
 
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' });
378
+ app.delete('/api/plugins/:name', (req, res) => {
379
+ const pluginDir = getPluginDir();
380
+ if (!pluginDir) return res.status(404).json({ error: 'No config' });
551
381
 
552
- const pluginName = req.params.name;
382
+ const name = req.params.name;
383
+ const dirPath = path.join(pluginDir, name);
553
384
 
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 });
385
+ if (fs.existsSync(dirPath)) {
386
+ fs.rmSync(dirPath, { recursive: true, force: true });
575
387
  }
388
+ res.json({ success: true });
576
389
  });
577
390
 
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);
391
+ app.post('/api/plugins/:name/toggle', (req, res) => {
392
+ res.json({ success: true, enabled: true });
625
393
  });
626
394
 
627
395
  app.post('/api/fetch-url', async (req, res) => {
628
396
  const { url } = req.body;
629
-
630
- if (!url || typeof url !== 'string') {
631
- return res.status(400).json({ error: 'URL is required' });
632
- }
397
+ if (!url) return res.status(400).json({ error: 'URL required' });
633
398
 
634
399
  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
-
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}`);
648
403
  const content = await response.text();
649
- const filename = parsedUrl.pathname.split('/').pop() || 'file';
404
+ const filename = path.basename(new URL(url).pathname) || 'file.txt';
650
405
 
651
406
  res.json({ content, filename, url });
652
407
  } catch (err) {
653
- res.status(500).json({ error: 'Failed to fetch URL', details: err.message });
408
+ res.status(500).json({ error: err.message });
654
409
  }
655
410
  });
656
411
 
657
412
  app.post('/api/bulk-fetch', async (req, res) => {
658
413
  const { urls } = req.body;
414
+ if (!Array.isArray(urls)) return res.status(400).json({ error: 'URLs array required' });
659
415
 
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
-
416
+ const fetch = (await import('node-fetch')).default;
668
417
  const results = [];
669
418
 
419
+ // Limit concurrency? For now sequential is safer
670
420
  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
421
  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
-
422
+ const response = await fetch(url);
423
+ if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`);
692
424
  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
- }
425
+ const filename = path.basename(new URL(url).pathname) || 'file.txt';
704
426
 
427
+ // Try to extract name/description from content (simple regex for markdown/js)
428
+ // This is basic heuristic
705
429
  results.push({
706
430
  url,
707
431
  success: true,
708
432
  content,
709
- body,
710
433
  filename,
711
- name: frontmatter.name || extractedName,
712
- description: frontmatter.description || '',
434
+ name: filename.replace(/\.(md|js|ts)$/, ''),
713
435
  });
714
436
  } catch (err) {
715
- results.push({ url, success: false, error: err.message });
437
+ results.push({
438
+ url,
439
+ success: false,
440
+ error: err.message
441
+ });
716
442
  }
717
443
  }
718
444
 
719
445
  res.json({ results });
720
446
  });
721
447
 
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' });
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
+ }
726
464
  }
727
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) => {
728
491
  const backup = req.body;
729
492
 
730
- if (!backup || (backup.version !== 1 && backup.version !== 2)) {
731
- return res.status(400).json({ error: 'Invalid backup file' });
493
+ if (backup.studioConfig) {
494
+ saveStudioConfig(backup.studioConfig);
732
495
  }
733
496
 
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
- }
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 });
747
505
  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');
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');
754
509
  }
755
510
  }
756
-
757
- if (backup.plugins && backup.plugins.length > 0) {
758
- if (!fs.existsSync(paths.pluginDir)) {
759
- fs.mkdirSync(paths.pluginDir, { recursive: true });
760
- }
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 });
761
517
  for (const plugin of backup.plugins) {
762
- const pluginName = path.basename(plugin.name);
763
- fs.writeFileSync(path.join(paths.pluginDir, pluginName), plugin.content, 'utf8');
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');
764
521
  }
765
522
  }
766
-
767
- res.json({ success: true });
768
- } catch (err) {
769
- res.status(500).json({ error: 'Failed to restore backup', details: err.message });
770
523
  }
524
+
525
+ res.json({ success: true });
771
526
  });
772
527
 
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;
780
- }
781
- }
782
- console.log('No auth file found');
783
- return null;
784
- }
785
-
528
+ // Auth Handlers
786
529
  function loadAuthConfig() {
787
- const authFile = getAuthFile();
788
- if (!authFile) return null;
530
+ const configPath = getConfigPath();
531
+ if (!configPath) return null;
789
532
 
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;
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.
800
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;
801
541
  try {
802
- fs.writeFileSync(authFile, JSON.stringify(config, null, 2), 'utf8');
803
- return true;
542
+ return JSON.parse(fs.readFileSync(authPath, 'utf8'));
804
543
  } catch {
805
- return false;
544
+ return null;
806
545
  }
807
546
  }
808
547
 
809
-
810
-
811
548
  app.get('/api/auth', (req, res) => {
812
549
  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
- }
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
827
557
 
828
- if (!authConfig) {
829
- return res.json({
830
- credentials: [],
831
- authFile: null,
832
- message: 'No auth file found',
833
- hasGeminiAuthPlugin,
834
- });
835
- }
558
+ const cmd = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
836
559
 
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
- };
560
+ // We spawn it so it opens the browser
561
+ const child = spawn(cmd, ['auth', 'login', provider], {
562
+ stdio: 'inherit',
563
+ shell: true
846
564
  });
847
565
 
848
- res.json({ credentials, authFile, hasGeminiAuthPlugin });
566
+ res.json({ success: true, message: 'Launched auth flow', note: 'Please check the terminal window where the server is running' });
849
567
  });
850
568
 
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';
855
-
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"\'';
861
- } else {
862
- command = 'x-terminal-emulator -e "opencode auth login" || gnome-terminal -- opencode auth login || xterm -e "opencode auth login"';
863
- }
864
-
865
- exec(command, { shell: true }, (err) => {
866
- if (err) console.error('Failed to open terminal:', err);
867
- });
868
-
869
- res.json({
870
- success: true,
871
- message: 'Opening terminal for authentication...',
872
- note: 'Complete authentication in the terminal window, then refresh this page.'
873
- });
874
- });
875
-
876
569
  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
- }
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';
887
574
 
888
- delete authConfig[provider];
575
+ spawn(cmd, ['auth', 'logout', provider], {
576
+ stdio: 'inherit',
577
+ shell: true
578
+ });
889
579
 
890
- if (saveAuthConfig(authConfig)) {
891
- res.json({ success: true });
892
- } else {
893
- res.status(500).json({ error: 'Failed to save auth configuration' });
894
- }
580
+ res.json({ success: true });
895
581
  });
896
582
 
897
- // Get available providers for login
898
583
  app.get('/api/auth/providers', (req, res) => {
584
+ // List of supported providers (hardcoded for now as it's not easily discoverable via CLI)
899
585
  const providers = [
900
- { id: 'github-copilot', name: 'GitHub Copilot', type: 'oauth', description: 'Use GitHub Copilot API' },
901
586
  { id: 'google', name: 'Google AI', type: 'oauth', description: 'Use Google Gemini models' },
902
587
  { id: 'anthropic', name: 'Anthropic', type: 'api', description: 'Use Claude models' },
903
588
  { id: 'openai', name: 'OpenAI', type: 'api', description: 'Use GPT models' },
@@ -989,27 +674,148 @@ function setActiveProfile(provider, profileName) {
989
674
  saveStudioConfig(studioConfig);
990
675
  }
991
676
 
992
- 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
-
994
- app.get('/api/auth/profiles', (req, res) => {
995
- ensureAuthProfilesDir();
996
- const activeProfiles = getActiveProfiles();
997
- const authConfig = loadAuthConfig() || {};
998
-
999
- const profiles = {};
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); }
678
+
679
+ app.get('/api/auth/profiles', (req, res) => {
680
+ ensureAuthProfilesDir();
681
+ const activeProfiles = getActiveProfiles();
682
+ const authConfig = loadAuthConfig() || {};
1000
683
 
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
- };
684
+ const profiles = {};
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
+ }
1008
703
  });
1009
704
 
1010
705
  res.json(profiles);
1011
706
  });
1012
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) {
781
+ }
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
+ }
799
+ }
800
+ } catch (err) {
801
+ console.error(`Error reading log dir ${logDir}:`, err);
802
+ }
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
+
1013
819
  app.get('/api/auth/profiles/:provider', (req, res) => {
1014
820
  const { provider } = req.params;
1015
821
  const providerProfiles = listAuthProfiles(provider);
@@ -1055,23 +861,33 @@ app.post('/api/auth/profiles/:provider/:name/activate', (req, res) => {
1055
861
  const authConfig = loadAuthConfig() || {};
1056
862
  authConfig[provider] = profileData;
1057
863
 
1058
- if (saveAuthConfig(authConfig)) {
1059
- setActiveProfile(provider, name);
1060
- res.json({ success: true });
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
+ }
1061
875
  } else {
1062
- res.status(500).json({ error: 'Failed to activate profile' });
876
+ res.status(500).json({ error: 'Config path not found' });
1063
877
  }
1064
878
  });
1065
879
 
1066
880
  app.delete('/api/auth/profiles/:provider/:name', (req, res) => {
1067
881
  const { provider, name } = req.params;
1068
-
1069
- if (deleteAuthProfile(provider, name)) {
882
+ const success = deleteAuthProfile(provider, name);
883
+ if (success) {
1070
884
  const activeProfiles = getActiveProfiles();
1071
885
  if (activeProfiles[provider] === name) {
1072
886
  const studioConfig = loadStudioConfig();
1073
- delete studioConfig.activeProfiles[provider];
1074
- saveStudioConfig(studioConfig);
887
+ if (studioConfig.activeProfiles) {
888
+ delete studioConfig.activeProfiles[provider];
889
+ saveStudioConfig(studioConfig);
890
+ }
1075
891
  }
1076
892
  res.json({ success: true });
1077
893
  } else {
@@ -1083,22 +899,13 @@ app.put('/api/auth/profiles/:provider/:name', (req, res) => {
1083
899
  const { provider, name } = req.params;
1084
900
  const { newName } = req.body;
1085
901
 
1086
- if (!newName || newName === name) {
1087
- return res.status(400).json({ error: 'New name is required and must be different' });
1088
- }
902
+ if (!newName) return res.status(400).json({ error: 'New name required' });
1089
903
 
1090
904
  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
- }
905
+ if (!profileData) return res.status(404).json({ error: 'Profile not found' });
1099
906
 
1100
- try {
1101
- saveAuthProfile(provider, newName, profileData);
907
+ const success = saveAuthProfile(provider, newName, profileData);
908
+ if (success) {
1102
909
  deleteAuthProfile(provider, name);
1103
910
 
1104
911
  const activeProfiles = getActiveProfiles();
@@ -1107,37 +914,73 @@ app.put('/api/auth/profiles/:provider/:name', (req, res) => {
1107
914
  }
1108
915
 
1109
916
  res.json({ success: true, name: newName });
1110
- } catch (err) {
1111
- res.status(500).json({ error: 'Failed to rename profile', details: err.message });
917
+ } else {
918
+ res.status(500).json({ error: 'Failed to rename profile' });
1112
919
  }
1113
920
  });
1114
921
 
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}"`;
1133
- }
1134
- exec(cmd, (err) => {
1135
- if (err) {
1136
- console.log(`Failed to open browser. Please visit: ${url}`);
1137
- } else {
1138
- console.log('Opening browser...');
1139
- }
1140
- });
1141
- }
1142
- });
1143
- });
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
+ });