opencode-studio-server 1.1.2 → 1.1.3

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 (2) hide show
  1. package/index.js +100 -56
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -8,7 +8,7 @@ const { exec, spawn } = require('child_process');
8
8
 
9
9
  const app = express();
10
10
  const PORT = 3001;
11
- const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
11
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
12
12
 
13
13
  let lastActivityTime = Date.now();
14
14
  let idleTimer = null;
@@ -124,11 +124,13 @@ const getPaths = () => {
124
124
  candidates = [
125
125
  path.join(process.env.APPDATA, 'opencode', 'opencode.json'),
126
126
  path.join(home, '.config', 'opencode', 'opencode.json'),
127
+ path.join(home, '.local', 'share', 'opencode', 'opencode.json'),
127
128
  ];
128
129
  } else {
129
130
  candidates = [
130
131
  path.join(home, '.config', 'opencode', 'opencode.json'),
131
132
  path.join(home, '.opencode', 'opencode.json'),
133
+ path.join(home, '.local', 'share', 'opencode', 'opencode.json'),
132
134
  ];
133
135
  }
134
136
 
@@ -219,7 +221,6 @@ app.post('/api/config', (req, res) => {
219
221
  }
220
222
  });
221
223
 
222
- // Helper to get skill dir
223
224
  const getSkillDir = () => {
224
225
  const configPath = getConfigPath();
225
226
  if (!configPath) return null;
@@ -242,7 +243,7 @@ app.get('/api/skills', (req, res) => {
242
243
  skills.push({
243
244
  name: entry.name,
244
245
  path: skillPath,
245
- enabled: !entry.name.endsWith('.disabled') // Simplified logic
246
+ enabled: !entry.name.endsWith('.disabled')
246
247
  });
247
248
  }
248
249
  }
@@ -295,12 +296,9 @@ app.delete('/api/skills/:name', (req, res) => {
295
296
  });
296
297
 
297
298
  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
299
  res.json({ success: true, enabled: true });
301
300
  });
302
301
 
303
- // Helper to get plugin dir
304
302
  const getPluginDir = () => {
305
303
  const configPath = getConfigPath();
306
304
  if (!configPath) return null;
@@ -309,33 +307,46 @@ const getPluginDir = () => {
309
307
 
310
308
  app.get('/api/plugins', (req, res) => {
311
309
  const pluginDir = getPluginDir();
312
- if (!pluginDir || !fs.existsSync(pluginDir)) {
313
- return res.json([]);
314
- }
310
+ const configPath = getConfigPath();
311
+ const configRoot = configPath ? path.dirname(configPath) : null;
315
312
 
316
313
  const plugins = [];
317
- const entries = fs.readdirSync(pluginDir, { withFileTypes: true });
318
314
 
319
- for (const entry of entries) {
320
- if (entry.isDirectory()) {
321
- const jsPath = path.join(pluginDir, entry.name, 'index.js');
322
- const tsPath = path.join(pluginDir, entry.name, 'index.ts');
323
-
324
- if (fs.existsSync(jsPath) || fs.existsSync(tsPath)) {
325
- plugins.push({
326
- name: entry.name,
327
- path: fs.existsSync(jsPath) ? jsPath : tsPath,
328
- enabled: true
329
- });
315
+ const addPlugin = (name, p, enabled = true) => {
316
+ if (!plugins.some(pl => pl.name === name)) {
317
+ plugins.push({ name, path: p, enabled });
318
+ }
319
+ };
320
+
321
+ if (pluginDir && fs.existsSync(pluginDir)) {
322
+ const entries = fs.readdirSync(pluginDir, { withFileTypes: true });
323
+ for (const entry of entries) {
324
+ const fullPath = path.join(pluginDir, entry.name);
325
+ const stats = fs.lstatSync(fullPath);
326
+ if (stats.isDirectory()) {
327
+ const jsPath = path.join(fullPath, 'index.js');
328
+ const tsPath = path.join(fullPath, 'index.ts');
329
+ if (fs.existsSync(jsPath) || fs.existsSync(tsPath)) {
330
+ addPlugin(entry.name, fs.existsSync(jsPath) ? jsPath : tsPath);
331
+ }
332
+ } else if ((stats.isFile() || stats.isSymbolicLink()) && (entry.name.endsWith('.js') || entry.name.endsWith('.ts'))) {
333
+ addPlugin(entry.name.replace(/\.(js|ts)$/, ''), fullPath);
334
+ }
335
+ }
336
+ }
337
+
338
+ if (configRoot && fs.existsSync(configRoot)) {
339
+ const rootEntries = fs.readdirSync(configRoot, { withFileTypes: true });
340
+ const knownPlugins = ['oh-my-opencode', 'superpowers', 'opencode-gemini-auth'];
341
+
342
+ for (const entry of rootEntries) {
343
+ if (knownPlugins.includes(entry.name) && entry.isDirectory()) {
344
+ const fullPath = path.join(configRoot, entry.name);
345
+ addPlugin(entry.name, fullPath);
330
346
  }
331
- } else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.ts'))) {
332
- plugins.push({
333
- name: entry.name.replace(/\.(js|ts)$/, ''),
334
- path: path.join(pluginDir, entry.name),
335
- enabled: true
336
- });
337
347
  }
338
348
  }
349
+
339
350
  res.json(plugins);
340
351
  });
341
352
 
@@ -380,7 +391,6 @@ app.post('/api/plugins/:name', (req, res) => {
380
391
  fs.mkdirSync(dirPath, { recursive: true });
381
392
  }
382
393
 
383
- // Determine file extension based on content? Default to .js if new
384
394
  const filePath = path.join(dirPath, 'index.js');
385
395
  fs.writeFileSync(filePath, content, 'utf8');
386
396
  res.json({ success: true });
@@ -431,7 +441,6 @@ app.post('/api/bulk-fetch', async (req, res) => {
431
441
  const fetch = (await import('node-fetch')).default;
432
442
  const results = [];
433
443
 
434
- // Limit concurrency? For now sequential is safer
435
444
  for (const url of urls) {
436
445
  try {
437
446
  const response = await fetch(url);
@@ -439,8 +448,6 @@ app.post('/api/bulk-fetch', async (req, res) => {
439
448
  const content = await response.text();
440
449
  const filename = path.basename(new URL(url).pathname) || 'file.txt';
441
450
 
442
- // Try to extract name/description from content (simple regex for markdown/js)
443
- // This is basic heuristic
444
451
  results.push({
445
452
  url,
446
453
  success: true,
@@ -540,17 +547,10 @@ app.post('/api/restore', (req, res) => {
540
547
  res.json({ success: true });
541
548
  });
542
549
 
543
- // Auth Handlers
544
550
  function loadAuthConfig() {
545
551
  const configPath = getConfigPath();
546
552
  if (!configPath) return null;
547
553
 
548
- // Auth config is usually in ~/.config/opencode/auth.json ?
549
- // Or inside opencode.json?
550
- // Based on opencode CLI, it seems to store auth in separate files or system keychain.
551
- // But for this studio, we might just look at opencode.json "providers" section or similar.
552
-
553
- // ACTUALLY, opencode stores auth tokens in ~/.config/opencode/auth.json
554
554
  const authPath = path.join(path.dirname(configPath), 'auth.json');
555
555
  if (!fs.existsSync(authPath)) return null;
556
556
  try {
@@ -561,18 +561,50 @@ function loadAuthConfig() {
561
561
  }
562
562
 
563
563
  app.get('/api/auth', (req, res) => {
564
- const authConfig = loadAuthConfig();
565
- res.json(authConfig || {});
564
+ const authConfig = loadAuthConfig() || {};
565
+ const activeProfiles = getActiveProfiles();
566
+
567
+ const credentials = [];
568
+ const providers = [
569
+ { id: 'google', name: 'Google AI', type: 'oauth' },
570
+ { id: 'anthropic', name: 'Anthropic', type: 'api' },
571
+ { id: 'openai', name: 'OpenAI', type: 'api' },
572
+ { id: 'xai', name: 'xAI', type: 'api' },
573
+ { id: 'groq', name: 'Groq', type: 'api' },
574
+ { id: 'together', name: 'Together AI', type: 'api' },
575
+ { id: 'mistral', name: 'Mistral', type: 'api' },
576
+ { id: 'deepseek', name: 'DeepSeek', type: 'api' },
577
+ { id: 'openrouter', name: 'OpenRouter', type: 'api' },
578
+ { id: 'amazon-bedrock', name: 'Amazon Bedrock', type: 'api' },
579
+ { id: 'azure', name: 'Azure OpenAI', type: 'api' },
580
+ { id: 'github-copilot', name: 'GitHub Copilot', type: 'api' }
581
+ ];
582
+
583
+ providers.forEach(p => {
584
+ const profileInfo = listAuthProfiles(p.id);
585
+ const hasCurrent = !!authConfig[p.id];
586
+ const hasProfiles = profileInfo.length > 0;
587
+
588
+ if (hasCurrent || hasProfiles) {
589
+ credentials.push({
590
+ ...p,
591
+ active: activeProfiles[p.id] || (hasCurrent ? 'current' : null)
592
+ });
593
+ }
594
+ });
595
+
596
+ res.json({
597
+ ...authConfig,
598
+ credentials,
599
+ authFile: path.join(path.dirname(getConfigPath() || ''), 'auth.json'),
600
+ hasGeminiAuthPlugin: true
601
+ });
566
602
  });
567
603
 
568
604
  app.post('/api/auth/login', (req, res) => {
569
605
  const { provider } = req.body;
570
- // Launch opencode CLI auth login
571
- // This requires 'opencode' to be in PATH
572
-
573
606
  const cmd = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
574
607
 
575
- // We spawn it so it opens the browser
576
608
  const child = spawn(cmd, ['auth', 'login', provider], {
577
609
  stdio: 'inherit',
578
610
  shell: true
@@ -582,8 +614,6 @@ app.post('/api/auth/login', (req, res) => {
582
614
  });
583
615
 
584
616
  app.delete('/api/auth/:provider', (req, res) => {
585
- // Logout logic
586
- // Maybe just delete from auth.json? Or run opencode auth logout?
587
617
  const { provider } = req.params;
588
618
  const cmd = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
589
619
 
@@ -596,7 +626,6 @@ app.delete('/api/auth/:provider', (req, res) => {
596
626
  });
597
627
 
598
628
  app.get('/api/auth/providers', (req, res) => {
599
- // List of supported providers (hardcoded for now as it's not easily discoverable via CLI)
600
629
  const providers = [
601
630
  { id: 'google', name: 'Google AI', type: 'oauth', description: 'Use Google Gemini models' },
602
631
  { id: 'anthropic', name: 'Anthropic', type: 'api', description: 'Use Claude models' },
@@ -697,13 +726,18 @@ app.get('/api/auth/profiles', (req, res) => {
697
726
  const authConfig = loadAuthConfig() || {};
698
727
 
699
728
  const profiles = {};
700
- const providers = [
729
+
730
+ const savedProviders = fs.existsSync(AUTH_PROFILES_DIR) ? fs.readdirSync(AUTH_PROFILES_DIR) : [];
731
+
732
+ const standardProviders = [
701
733
  'google', 'anthropic', 'openai', 'xai', 'groq',
702
734
  'together', 'mistral', 'deepseek', 'openrouter',
703
- 'amazon-bedrock', 'azure'
735
+ 'amazon-bedrock', 'azure', 'github-copilot'
704
736
  ];
705
737
 
706
- providers.forEach(p => {
738
+ const allProviders = [...new Set([...savedProviders, ...standardProviders])];
739
+
740
+ allProviders.forEach(p => {
707
741
  const saved = listAuthProfiles(p);
708
742
  const active = activeProfiles[p];
709
743
  const current = authConfig[p];
@@ -711,8 +745,10 @@ app.get('/api/auth/profiles', (req, res) => {
711
745
  if (saved.length > 0 || current) {
712
746
  profiles[p] = {
713
747
  active: active,
748
+ profiles: saved,
714
749
  saved: saved,
715
- hasCurrent: !!current
750
+ hasCurrent: !!current,
751
+ hasCurrentAuth: !!current
716
752
  };
717
753
  }
718
754
  });
@@ -744,7 +780,7 @@ app.get('/api/debug/paths', (req, res) => {
744
780
 
745
781
  app.get('/api/usage', async (req, res) => {
746
782
  try {
747
- const { projectId: filterProjectId, granularity = 'daily' } = req.query;
783
+ const { projectId: filterProjectId, granularity = 'daily', range = '30d' } = req.query;
748
784
  const home = os.homedir();
749
785
  const candidatePaths = [
750
786
  process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'opencode', 'storage', 'message') : null,
@@ -804,6 +840,12 @@ app.get('/api/usage', async (req, res) => {
804
840
  };
805
841
 
806
842
  const processedFiles = new Set();
843
+ const now = Date.now();
844
+ let minTimestamp = 0;
845
+
846
+ if (range === '24h') minTimestamp = now - 24 * 60 * 60 * 1000;
847
+ else if (range === '7d') minTimestamp = now - 7 * 24 * 60 * 60 * 1000;
848
+ else if (range === '30d') minTimestamp = now - 30 * 24 * 60 * 60 * 1000;
807
849
 
808
850
  const processMessage = (filePath, sessionId) => {
809
851
  if (processedFiles.has(filePath)) return;
@@ -820,6 +862,10 @@ app.get('/api/usage', async (req, res) => {
820
862
  if (filterProjectId && filterProjectId !== 'all' && projectId !== filterProjectId) {
821
863
  return;
822
864
  }
865
+
866
+ if (minTimestamp > 0 && msg.time.created < minTimestamp) {
867
+ return;
868
+ }
823
869
 
824
870
  if (msg.role === 'assistant' && msg.tokens) {
825
871
  const cost = msg.cost || 0;
@@ -838,6 +884,8 @@ app.get('/api/usage', async (req, res) => {
838
884
  const diff = dateObj.getDate() - day + (day === 0 ? -6 : 1);
839
885
  const monday = new Date(dateObj.setDate(diff));
840
886
  timeKey = monday.toISOString().split('T')[0];
887
+ } else if (granularity === 'monthly') {
888
+ timeKey = dateObj.toISOString().substring(0, 7) + '-01';
841
889
  } else {
842
890
  timeKey = dateObj.toISOString().split('T')[0];
843
891
  }
@@ -957,7 +1005,6 @@ app.post('/api/auth/profiles/:provider/:name/activate', (req, res) => {
957
1005
  const authConfig = loadAuthConfig() || {};
958
1006
  authConfig[provider] = profileData;
959
1007
 
960
- // Save to ~/.config/opencode/auth.json
961
1008
  const configPath = getConfigPath();
962
1009
  if (configPath) {
963
1010
  const authPath = path.join(path.dirname(configPath), 'auth.json');
@@ -1032,7 +1079,6 @@ app.post('/api/plugins/config/add', (req, res) => {
1032
1079
  };
1033
1080
 
1034
1081
  for (const pluginName of plugins) {
1035
- // Check if plugin exists in studio (for validation)
1036
1082
  const pluginDir = getPluginDir();
1037
1083
  const dirPath = path.join(pluginDir, pluginName);
1038
1084
  const hasJs = fs.existsSync(path.join(dirPath, 'index.js'));
@@ -1043,8 +1089,6 @@ app.post('/api/plugins/config/add', (req, res) => {
1043
1089
  continue;
1044
1090
  }
1045
1091
 
1046
- // Add to config
1047
- // Default config for a plugin? Usually empty object or enabled: true
1048
1092
  if (config.plugins[pluginName]) {
1049
1093
  result.skipped.push(`${pluginName} (already configured)`);
1050
1094
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-studio-server",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Backend server for OpenCode Studio - manages opencode configurations",
5
5
  "main": "index.js",
6
6
  "bin": {