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.
- package/index.js +100 -56
- 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;
|
|
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')
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|