opencode-studio-server 1.0.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.
package/cli.js ADDED
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+
3
+ const path = require('path');
4
+ const { exec } = require('child_process');
5
+ const os = require('os');
6
+ const fs = require('fs');
7
+
8
+ const args = process.argv.slice(2);
9
+
10
+ // Handle --register flag
11
+ if (args.includes('--register')) {
12
+ require('./register-protocol');
13
+ process.exit(0);
14
+ }
15
+
16
+ // Parse protocol URL if provided
17
+ // Format: opencodestudio://action?param1=value1&param2=value2
18
+ const protocolArg = args.find(arg => arg.startsWith('opencodestudio://'));
19
+
20
+ let pendingAction = null;
21
+ let shouldOpenBrowser = false;
22
+
23
+ if (protocolArg) {
24
+ try {
25
+ // Parse the protocol URL
26
+ const urlStr = protocolArg.replace('opencodestudio://', 'http://localhost/');
27
+ const url = new URL(urlStr);
28
+ const action = url.pathname.replace(/^\//, '');
29
+ const params = Object.fromEntries(url.searchParams);
30
+
31
+ console.log(`Protocol action: ${action}`);
32
+ console.log(`Params:`, params);
33
+
34
+ switch (action) {
35
+ case 'launch':
36
+ // Just launch, optionally open browser
37
+ if (params.open === 'local') {
38
+ shouldOpenBrowser = true;
39
+ }
40
+ break;
41
+
42
+ case 'install-mcp':
43
+ // Queue MCP server installation
44
+ if (params.cmd || params.name) {
45
+ pendingAction = {
46
+ type: 'install-mcp',
47
+ name: params.name || 'MCP Server',
48
+ command: params.cmd ? decodeURIComponent(params.cmd) : null,
49
+ env: params.env ? JSON.parse(decodeURIComponent(params.env)) : null,
50
+ timestamp: Date.now(),
51
+ };
52
+ console.log(`Queued MCP install: ${pendingAction.name}`);
53
+ }
54
+ break;
55
+
56
+ case 'import-skill':
57
+ // Queue skill import from URL
58
+ if (params.url) {
59
+ pendingAction = {
60
+ type: 'import-skill',
61
+ url: decodeURIComponent(params.url),
62
+ name: params.name ? decodeURIComponent(params.name) : null,
63
+ timestamp: Date.now(),
64
+ };
65
+ console.log(`Queued skill import: ${params.url}`);
66
+ }
67
+ break;
68
+
69
+ case 'import-plugin':
70
+ // Queue plugin import from URL
71
+ if (params.url) {
72
+ pendingAction = {
73
+ type: 'import-plugin',
74
+ url: decodeURIComponent(params.url),
75
+ name: params.name ? decodeURIComponent(params.name) : null,
76
+ timestamp: Date.now(),
77
+ };
78
+ console.log(`Queued plugin import: ${params.url}`);
79
+ }
80
+ break;
81
+
82
+ default:
83
+ console.log(`Unknown action: ${action}`);
84
+ }
85
+ } catch (err) {
86
+ console.error('Failed to parse protocol URL:', err.message);
87
+ }
88
+ }
89
+
90
+ // Store pending action for server to pick up
91
+ if (pendingAction) {
92
+ const pendingFile = path.join(os.homedir(), '.config', 'opencode-studio', 'pending-action.json');
93
+ const dir = path.dirname(pendingFile);
94
+
95
+ if (!fs.existsSync(dir)) {
96
+ fs.mkdirSync(dir, { recursive: true });
97
+ }
98
+
99
+ fs.writeFileSync(pendingFile, JSON.stringify(pendingAction, null, 2), 'utf8');
100
+ console.log(`Pending action saved to: ${pendingFile}`);
101
+ }
102
+
103
+ // Open browser if requested (only for fully local mode)
104
+ if (shouldOpenBrowser) {
105
+ const openUrl = 'http://localhost:3000';
106
+ const platform = os.platform();
107
+
108
+ setTimeout(() => {
109
+ let cmd;
110
+ if (platform === 'win32') {
111
+ cmd = `start "" "${openUrl}"`;
112
+ } else if (platform === 'darwin') {
113
+ cmd = `open "${openUrl}"`;
114
+ } else {
115
+ cmd = `xdg-open "${openUrl}"`;
116
+ }
117
+
118
+ exec(cmd, (err) => {
119
+ if (err) {
120
+ console.log(`Open ${openUrl} in your browser`);
121
+ } else {
122
+ console.log(`Opened ${openUrl}`);
123
+ }
124
+ });
125
+ }, 1500); // Give server time to start
126
+ }
127
+
128
+ // Start the server
129
+ require('./index.js');
package/index.js ADDED
@@ -0,0 +1,879 @@
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
+
12
+ const ALLOWED_ORIGINS = [
13
+ 'http://localhost:3000',
14
+ 'http://127.0.0.1:3000',
15
+ /\.vercel\.app$/,
16
+ ];
17
+
18
+ app.use(cors({
19
+ origin: (origin, callback) => {
20
+ if (!origin) return callback(null, true);
21
+ const allowed = ALLOWED_ORIGINS.some(o =>
22
+ o instanceof RegExp ? o.test(origin) : o === origin
23
+ );
24
+ callback(null, allowed);
25
+ },
26
+ credentials: true,
27
+ }));
28
+ app.use(bodyParser.json({ limit: '50mb' }));
29
+ app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
30
+
31
+ const HOME_DIR = os.homedir();
32
+ const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'studio.json');
33
+ const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json');
34
+
35
+ let pendingActionMemory = null;
36
+
37
+ function loadPendingAction() {
38
+ if (pendingActionMemory) return pendingActionMemory;
39
+
40
+ if (fs.existsSync(PENDING_ACTION_PATH)) {
41
+ try {
42
+ const action = JSON.parse(fs.readFileSync(PENDING_ACTION_PATH, 'utf8'));
43
+ if (action.timestamp && Date.now() - action.timestamp < 60000) {
44
+ pendingActionMemory = action;
45
+ fs.unlinkSync(PENDING_ACTION_PATH);
46
+ return action;
47
+ }
48
+ fs.unlinkSync(PENDING_ACTION_PATH);
49
+ } catch {
50
+ try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function clearPendingAction() {
57
+ pendingActionMemory = null;
58
+ if (fs.existsSync(PENDING_ACTION_PATH)) {
59
+ try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
60
+ }
61
+ }
62
+
63
+ function setPendingAction(action) {
64
+ pendingActionMemory = { ...action, timestamp: Date.now() };
65
+ }
66
+
67
+ const AUTH_CANDIDATE_PATHS = [
68
+ path.join(HOME_DIR, '.local', 'share', 'opencode', 'auth.json'),
69
+ path.join(process.env.LOCALAPPDATA || '', 'opencode', 'auth.json'),
70
+ path.join(process.env.APPDATA || '', 'opencode', 'auth.json'),
71
+ ];
72
+
73
+ const CANDIDATE_PATHS = [
74
+ path.join(HOME_DIR, '.config', 'opencode'),
75
+ path.join(HOME_DIR, '.opencode'),
76
+ path.join(process.env.APPDATA || '', 'opencode'),
77
+ path.join(process.env.LOCALAPPDATA || '', 'opencode'),
78
+ path.join(HOME_DIR, 'AppData', 'Roaming', 'opencode'),
79
+ path.join(HOME_DIR, 'AppData', 'Local', 'opencode'),
80
+ ];
81
+
82
+ function detectConfigDir() {
83
+ for (const candidate of CANDIDATE_PATHS) {
84
+ const configFile = path.join(candidate, 'opencode.json');
85
+ if (fs.existsSync(configFile)) {
86
+ return candidate;
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function loadStudioConfig() {
93
+ if (fs.existsSync(STUDIO_CONFIG_PATH)) {
94
+ try {
95
+ return JSON.parse(fs.readFileSync(STUDIO_CONFIG_PATH, 'utf8'));
96
+ } catch {
97
+ return { disabledSkills: [], disabledPlugins: [] };
98
+ }
99
+ }
100
+ return { disabledSkills: [], disabledPlugins: [] };
101
+ }
102
+
103
+ function saveStudioConfig(config) {
104
+ const dir = path.dirname(STUDIO_CONFIG_PATH);
105
+ if (!fs.existsSync(dir)) {
106
+ fs.mkdirSync(dir, { recursive: true });
107
+ }
108
+ fs.writeFileSync(STUDIO_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
109
+ }
110
+
111
+ function getConfigDir() {
112
+ const studioConfig = loadStudioConfig();
113
+ if (studioConfig.configPath && fs.existsSync(studioConfig.configPath)) {
114
+ return studioConfig.configPath;
115
+ }
116
+ return detectConfigDir();
117
+ }
118
+
119
+ function getPaths() {
120
+ const configDir = getConfigDir();
121
+ if (!configDir) return null;
122
+ return {
123
+ configDir,
124
+ opencodeJson: path.join(configDir, 'opencode.json'),
125
+ skillDir: path.join(configDir, 'skill'),
126
+ pluginDir: path.join(configDir, 'plugin'),
127
+ };
128
+ }
129
+
130
+ function parseSkillFrontmatter(content) {
131
+ const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
132
+ if (!frontmatterMatch) {
133
+ return { frontmatter: {}, body: content };
134
+ }
135
+
136
+ const frontmatterText = frontmatterMatch[1];
137
+ const body = content.slice(frontmatterMatch[0].length).trim();
138
+ const frontmatter = {};
139
+
140
+ const lines = frontmatterText.split(/\r?\n/);
141
+ for (const line of lines) {
142
+ const colonIndex = line.indexOf(':');
143
+ if (colonIndex > 0) {
144
+ const key = line.slice(0, colonIndex).trim();
145
+ let value = line.slice(colonIndex + 1).trim();
146
+ if ((value.startsWith('"') && value.endsWith('"')) ||
147
+ (value.startsWith("'") && value.endsWith("'"))) {
148
+ value = value.slice(1, -1);
149
+ }
150
+ frontmatter[key] = value;
151
+ }
152
+ }
153
+
154
+ return { frontmatter, body };
155
+ }
156
+
157
+ function createSkillContent(name, description, body) {
158
+ return `---
159
+ name: ${name}
160
+ description: ${description}
161
+ ---
162
+
163
+ ${body}`;
164
+ }
165
+
166
+ console.log(`Detected config at: ${getConfigDir() || 'NOT FOUND'}`);
167
+
168
+ app.get('/api/health', (req, res) => {
169
+ res.json({ status: 'ok', timestamp: Date.now() });
170
+ });
171
+
172
+ app.get('/api/pending-action', (req, res) => {
173
+ const action = loadPendingAction();
174
+ res.json({ action });
175
+ });
176
+
177
+ app.delete('/api/pending-action', (req, res) => {
178
+ clearPendingAction();
179
+ res.json({ success: true });
180
+ });
181
+
182
+ app.post('/api/pending-action', (req, res) => {
183
+ const { action } = req.body;
184
+ if (action && action.type) {
185
+ setPendingAction(action);
186
+ res.json({ success: true });
187
+ } else {
188
+ res.status(400).json({ error: 'Invalid action' });
189
+ }
190
+ });
191
+
192
+ app.get('/api/paths', (req, res) => {
193
+ const detected = detectConfigDir();
194
+ const studioConfig = loadStudioConfig();
195
+ const current = getConfigDir();
196
+
197
+ res.json({
198
+ detected,
199
+ manual: studioConfig.configPath || null,
200
+ current,
201
+ candidates: CANDIDATE_PATHS,
202
+ });
203
+ });
204
+
205
+ app.post('/api/paths', (req, res) => {
206
+ const { configPath } = req.body;
207
+
208
+ if (configPath) {
209
+ const configFile = path.join(configPath, 'opencode.json');
210
+ if (!fs.existsSync(configFile)) {
211
+ return res.status(400).json({ error: 'opencode.json not found at specified path' });
212
+ }
213
+ }
214
+
215
+ const studioConfig = loadStudioConfig();
216
+ studioConfig.configPath = configPath || null;
217
+ saveStudioConfig(studioConfig);
218
+
219
+ res.json({ success: true, current: getConfigDir() });
220
+ });
221
+
222
+ app.get('/api/config', (req, res) => {
223
+ const paths = getPaths();
224
+ if (!paths) {
225
+ return res.status(404).json({ error: 'Opencode installation not found', notFound: true });
226
+ }
227
+
228
+ if (!fs.existsSync(paths.opencodeJson)) {
229
+ return res.status(404).json({ error: 'Config file not found' });
230
+ }
231
+
232
+ try {
233
+ const opencodeData = fs.readFileSync(paths.opencodeJson, 'utf8');
234
+ let opencodeConfig = JSON.parse(opencodeData);
235
+ let changed = false;
236
+
237
+ // Migration: Move root providers to model.providers
238
+ if (opencodeConfig.providers) {
239
+ if (typeof opencodeConfig.model !== 'string') {
240
+ const providers = opencodeConfig.providers;
241
+ delete opencodeConfig.providers;
242
+
243
+ opencodeConfig.model = {
244
+ ...(opencodeConfig.model || {}),
245
+ providers: providers
246
+ };
247
+
248
+ fs.writeFileSync(paths.opencodeJson, JSON.stringify(opencodeConfig, null, 2), 'utf8');
249
+ changed = true;
250
+ }
251
+ }
252
+
253
+ res.json(opencodeConfig);
254
+ } catch (err) {
255
+ res.status(500).json({ error: 'Failed to parse config', details: err.message });
256
+ }
257
+ });
258
+
259
+ app.post('/api/config', (req, res) => {
260
+ const paths = getPaths();
261
+ if (!paths) {
262
+ return res.status(404).json({ error: 'Opencode installation not found' });
263
+ }
264
+
265
+ try {
266
+ fs.writeFileSync(paths.opencodeJson, JSON.stringify(req.body, null, 2), 'utf8');
267
+ res.json({ success: true });
268
+ } catch (err) {
269
+ res.status(500).json({ error: 'Failed to write config', details: err.message });
270
+ }
271
+ });
272
+
273
+ app.get('/api/skills', (req, res) => {
274
+ const paths = getPaths();
275
+ if (!paths) return res.json([]);
276
+ if (!fs.existsSync(paths.skillDir)) return res.json([]);
277
+
278
+ const studioConfig = loadStudioConfig();
279
+ const disabledSkills = studioConfig.disabledSkills || [];
280
+ const skills = [];
281
+
282
+ const entries = fs.readdirSync(paths.skillDir, { withFileTypes: true });
283
+ for (const entry of entries) {
284
+ if (entry.isDirectory()) {
285
+ const skillFile = path.join(paths.skillDir, entry.name, 'SKILL.md');
286
+ if (fs.existsSync(skillFile)) {
287
+ try {
288
+ const content = fs.readFileSync(skillFile, 'utf8');
289
+ const { frontmatter } = parseSkillFrontmatter(content);
290
+ skills.push({
291
+ name: entry.name,
292
+ description: frontmatter.description || '',
293
+ enabled: !disabledSkills.includes(entry.name),
294
+ });
295
+ } catch {}
296
+ }
297
+ }
298
+ }
299
+
300
+ res.json(skills);
301
+ });
302
+
303
+ app.post('/api/skills/:name/toggle', (req, res) => {
304
+ const skillName = req.params.name;
305
+ const studioConfig = loadStudioConfig();
306
+ const disabledSkills = studioConfig.disabledSkills || [];
307
+
308
+ if (disabledSkills.includes(skillName)) {
309
+ studioConfig.disabledSkills = disabledSkills.filter(s => s !== skillName);
310
+ } else {
311
+ studioConfig.disabledSkills = [...disabledSkills, skillName];
312
+ }
313
+
314
+ saveStudioConfig(studioConfig);
315
+ res.json({ success: true, enabled: !studioConfig.disabledSkills.includes(skillName) });
316
+ });
317
+
318
+ app.get('/api/skills/:name', (req, res) => {
319
+ const paths = getPaths();
320
+ if (!paths) return res.status(404).json({ error: 'Opencode not found' });
321
+
322
+ const skillName = path.basename(req.params.name);
323
+ const skillDir = path.join(paths.skillDir, skillName);
324
+ const skillFile = path.join(skillDir, 'SKILL.md');
325
+
326
+ if (!fs.existsSync(skillFile)) {
327
+ return res.status(404).json({ error: 'Skill not found' });
328
+ }
329
+
330
+ const content = fs.readFileSync(skillFile, 'utf8');
331
+ const { frontmatter, body } = parseSkillFrontmatter(content);
332
+
333
+ res.json({
334
+ name: skillName,
335
+ description: frontmatter.description || '',
336
+ content: body,
337
+ rawContent: content,
338
+ });
339
+ });
340
+
341
+ app.post('/api/skills/:name', (req, res) => {
342
+ const paths = getPaths();
343
+ if (!paths) return res.status(404).json({ error: 'Opencode not found' });
344
+
345
+ const skillName = path.basename(req.params.name);
346
+ const { description, content } = req.body;
347
+
348
+ const skillDir = path.join(paths.skillDir, skillName);
349
+ if (!fs.existsSync(skillDir)) {
350
+ fs.mkdirSync(skillDir, { recursive: true });
351
+ }
352
+
353
+ const fullContent = createSkillContent(skillName, description || '', content || '');
354
+ const skillFile = path.join(skillDir, 'SKILL.md');
355
+ fs.writeFileSync(skillFile, fullContent, 'utf8');
356
+
357
+ res.json({ success: true });
358
+ });
359
+
360
+ app.delete('/api/skills/:name', (req, res) => {
361
+ const paths = getPaths();
362
+ if (!paths) return res.status(404).json({ error: 'Opencode not found' });
363
+
364
+ const skillName = path.basename(req.params.name);
365
+ const skillDir = path.join(paths.skillDir, skillName);
366
+ if (fs.existsSync(skillDir)) {
367
+ fs.rmSync(skillDir, { recursive: true, force: true });
368
+ }
369
+ res.json({ success: true });
370
+ });
371
+
372
+ app.get('/api/plugins', (req, res) => {
373
+ const paths = getPaths();
374
+ if (!paths) return res.json([]);
375
+
376
+ const studioConfig = loadStudioConfig();
377
+ const disabledPlugins = studioConfig.disabledPlugins || [];
378
+ const plugins = [];
379
+
380
+ if (fs.existsSync(paths.pluginDir)) {
381
+ const files = fs.readdirSync(paths.pluginDir).filter(f => f.endsWith('.js') || f.endsWith('.ts'));
382
+ for (const f of files) {
383
+ plugins.push({
384
+ name: f,
385
+ type: 'file',
386
+ enabled: !disabledPlugins.includes(f),
387
+ });
388
+ }
389
+ }
390
+
391
+ if (fs.existsSync(paths.opencodeJson)) {
392
+ try {
393
+ const config = JSON.parse(fs.readFileSync(paths.opencodeJson, 'utf8'));
394
+ if (Array.isArray(config.plugin)) {
395
+ for (const p of config.plugin) {
396
+ if (typeof p === 'string') {
397
+ plugins.push({
398
+ name: p,
399
+ type: 'npm',
400
+ enabled: !disabledPlugins.includes(p),
401
+ });
402
+ }
403
+ }
404
+ }
405
+ } catch {}
406
+ }
407
+
408
+ res.json(plugins);
409
+ });
410
+
411
+ app.post('/api/plugins/:name/toggle', (req, res) => {
412
+ const pluginName = req.params.name;
413
+ const studioConfig = loadStudioConfig();
414
+ const disabledPlugins = studioConfig.disabledPlugins || [];
415
+
416
+ if (disabledPlugins.includes(pluginName)) {
417
+ studioConfig.disabledPlugins = disabledPlugins.filter(p => p !== pluginName);
418
+ } else {
419
+ studioConfig.disabledPlugins = [...disabledPlugins, pluginName];
420
+ }
421
+
422
+ saveStudioConfig(studioConfig);
423
+ res.json({ success: true, enabled: !studioConfig.disabledPlugins.includes(pluginName) });
424
+ });
425
+
426
+ app.get('/api/plugins/:name', (req, res) => {
427
+ const paths = getPaths();
428
+ if (!paths) return res.status(404).json({ error: 'Opencode not found' });
429
+
430
+ const pluginName = path.basename(req.params.name);
431
+ const filePath = path.join(paths.pluginDir, pluginName);
432
+ if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Plugin not found' });
433
+
434
+ const content = fs.readFileSync(filePath, 'utf8');
435
+ res.json({ name: pluginName, content });
436
+ });
437
+
438
+ app.post('/api/plugins/:name', (req, res) => {
439
+ const paths = getPaths();
440
+ if (!paths) return res.status(404).json({ error: 'Opencode not found' });
441
+
442
+ if (!fs.existsSync(paths.pluginDir)) {
443
+ fs.mkdirSync(paths.pluginDir, { recursive: true });
444
+ }
445
+
446
+ const pluginName = path.basename(req.params.name);
447
+ const filePath = path.join(paths.pluginDir, pluginName);
448
+ fs.writeFileSync(filePath, req.body.content, 'utf8');
449
+ res.json({ success: true });
450
+ });
451
+
452
+ app.delete('/api/plugins/:name', (req, res) => {
453
+ const paths = getPaths();
454
+ if (!paths) return res.status(404).json({ error: 'Opencode not found' });
455
+
456
+ const pluginName = path.basename(req.params.name);
457
+ const filePath = path.join(paths.pluginDir, pluginName);
458
+ if (fs.existsSync(filePath)) {
459
+ fs.unlinkSync(filePath);
460
+ }
461
+ res.json({ success: true });
462
+ });
463
+
464
+ // Add npm-style plugins to opencode.json config
465
+ app.post('/api/plugins/config/add', (req, res) => {
466
+ const paths = getPaths();
467
+ if (!paths) return res.status(404).json({ error: 'Opencode not found' });
468
+
469
+ const { plugins } = req.body;
470
+
471
+ if (!plugins || !Array.isArray(plugins) || plugins.length === 0) {
472
+ return res.status(400).json({ error: 'plugins array is required' });
473
+ }
474
+
475
+ try {
476
+ let config = {};
477
+ if (fs.existsSync(paths.opencodeJson)) {
478
+ config = JSON.parse(fs.readFileSync(paths.opencodeJson, 'utf8'));
479
+ }
480
+
481
+ if (!config.plugin) {
482
+ config.plugin = [];
483
+ }
484
+
485
+ const added = [];
486
+ const skipped = [];
487
+
488
+ for (const plugin of plugins) {
489
+ if (typeof plugin !== 'string') continue;
490
+ const trimmed = plugin.trim();
491
+ if (!trimmed) continue;
492
+
493
+ if (config.plugin.includes(trimmed)) {
494
+ skipped.push(trimmed);
495
+ } else {
496
+ config.plugin.push(trimmed);
497
+ added.push(trimmed);
498
+ }
499
+ }
500
+
501
+ fs.writeFileSync(paths.opencodeJson, JSON.stringify(config, null, 2), 'utf8');
502
+
503
+ res.json({ success: true, added, skipped });
504
+ } catch (err) {
505
+ res.status(500).json({ error: 'Failed to update config', details: err.message });
506
+ }
507
+ });
508
+
509
+ app.delete('/api/plugins/config/:name', (req, res) => {
510
+ const paths = getPaths();
511
+ if (!paths) return res.status(404).json({ error: 'Opencode not found' });
512
+
513
+ const pluginName = req.params.name;
514
+
515
+ try {
516
+ let config = {};
517
+ if (fs.existsSync(paths.opencodeJson)) {
518
+ config = JSON.parse(fs.readFileSync(paths.opencodeJson, 'utf8'));
519
+ }
520
+
521
+ if (!Array.isArray(config.plugin)) {
522
+ return res.status(404).json({ error: 'Plugin not found in config' });
523
+ }
524
+
525
+ const index = config.plugin.indexOf(pluginName);
526
+ if (index === -1) {
527
+ return res.status(404).json({ error: 'Plugin not found in config' });
528
+ }
529
+
530
+ config.plugin.splice(index, 1);
531
+ fs.writeFileSync(paths.opencodeJson, JSON.stringify(config, null, 2), 'utf8');
532
+
533
+ res.json({ success: true });
534
+ } catch (err) {
535
+ res.status(500).json({ error: 'Failed to remove plugin', details: err.message });
536
+ }
537
+ });
538
+
539
+ app.get('/api/backup', (req, res) => {
540
+ const paths = getPaths();
541
+ if (!paths) {
542
+ return res.status(404).json({ error: 'Opencode installation not found' });
543
+ }
544
+
545
+ const backup = {
546
+ version: 2,
547
+ timestamp: new Date().toISOString(),
548
+ studioConfig: loadStudioConfig(),
549
+ opencodeConfig: null,
550
+ skills: [],
551
+ plugins: [],
552
+ };
553
+
554
+ if (fs.existsSync(paths.opencodeJson)) {
555
+ try {
556
+ backup.opencodeConfig = JSON.parse(fs.readFileSync(paths.opencodeJson, 'utf8'));
557
+ } catch {}
558
+ }
559
+
560
+ if (fs.existsSync(paths.skillDir)) {
561
+ const entries = fs.readdirSync(paths.skillDir, { withFileTypes: true });
562
+ for (const entry of entries) {
563
+ if (entry.isDirectory()) {
564
+ const skillFile = path.join(paths.skillDir, entry.name, 'SKILL.md');
565
+ if (fs.existsSync(skillFile)) {
566
+ try {
567
+ const content = fs.readFileSync(skillFile, 'utf8');
568
+ backup.skills.push({ name: entry.name, content });
569
+ } catch {}
570
+ }
571
+ }
572
+ }
573
+ }
574
+
575
+ if (fs.existsSync(paths.pluginDir)) {
576
+ const pluginFiles = fs.readdirSync(paths.pluginDir).filter(f => f.endsWith('.js') || f.endsWith('.ts'));
577
+ for (const file of pluginFiles) {
578
+ try {
579
+ const content = fs.readFileSync(path.join(paths.pluginDir, file), 'utf8');
580
+ backup.plugins.push({ name: file, content });
581
+ } catch {}
582
+ }
583
+ }
584
+
585
+ res.json(backup);
586
+ });
587
+
588
+ app.post('/api/fetch-url', async (req, res) => {
589
+ const { url } = req.body;
590
+
591
+ if (!url || typeof url !== 'string') {
592
+ return res.status(400).json({ error: 'URL is required' });
593
+ }
594
+
595
+ try {
596
+ const parsedUrl = new URL(url);
597
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
598
+ return res.status(400).json({ error: 'Only HTTP/HTTPS URLs are allowed' });
599
+ }
600
+
601
+ const response = await fetch(url, {
602
+ headers: { 'User-Agent': 'OpenCode-Studio/1.0' },
603
+ });
604
+
605
+ if (!response.ok) {
606
+ return res.status(response.status).json({ error: `Failed to fetch: ${response.statusText}` });
607
+ }
608
+
609
+ const content = await response.text();
610
+ const filename = parsedUrl.pathname.split('/').pop() || 'file';
611
+
612
+ res.json({ content, filename, url });
613
+ } catch (err) {
614
+ res.status(500).json({ error: 'Failed to fetch URL', details: err.message });
615
+ }
616
+ });
617
+
618
+ app.post('/api/bulk-fetch', async (req, res) => {
619
+ const { urls } = req.body;
620
+
621
+ if (!urls || !Array.isArray(urls) || urls.length === 0) {
622
+ return res.status(400).json({ error: 'urls array is required' });
623
+ }
624
+
625
+ if (urls.length > 50) {
626
+ return res.status(400).json({ error: 'Maximum 50 URLs allowed per request' });
627
+ }
628
+
629
+ const results = [];
630
+
631
+ for (const url of urls) {
632
+ if (!url || typeof url !== 'string') {
633
+ results.push({ url, success: false, error: 'Invalid URL' });
634
+ continue;
635
+ }
636
+
637
+ try {
638
+ const parsedUrl = new URL(url.trim());
639
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
640
+ results.push({ url, success: false, error: 'Only HTTP/HTTPS allowed' });
641
+ continue;
642
+ }
643
+
644
+ const response = await fetch(url.trim(), {
645
+ headers: { 'User-Agent': 'OpenCode-Studio/1.0' },
646
+ });
647
+
648
+ if (!response.ok) {
649
+ results.push({ url, success: false, error: `HTTP ${response.status}` });
650
+ continue;
651
+ }
652
+
653
+ const content = await response.text();
654
+ const filename = parsedUrl.pathname.split('/').pop() || 'file';
655
+ const { frontmatter, body } = parseSkillFrontmatter(content);
656
+
657
+ const pathParts = parsedUrl.pathname.split('/').filter(Boolean);
658
+ let extractedName = '';
659
+ const filenameIdx = pathParts.length - 1;
660
+ if (pathParts[filenameIdx]?.toLowerCase() === 'skill.md' && filenameIdx > 0) {
661
+ extractedName = pathParts[filenameIdx - 1];
662
+ } else {
663
+ extractedName = filename.replace(/\.(md|js|ts)$/i, '').toLowerCase();
664
+ }
665
+
666
+ results.push({
667
+ url,
668
+ success: true,
669
+ content,
670
+ body,
671
+ filename,
672
+ name: frontmatter.name || extractedName,
673
+ description: frontmatter.description || '',
674
+ });
675
+ } catch (err) {
676
+ results.push({ url, success: false, error: err.message });
677
+ }
678
+ }
679
+
680
+ res.json({ results });
681
+ });
682
+
683
+ app.post('/api/restore', (req, res) => {
684
+ const paths = getPaths();
685
+ if (!paths) {
686
+ return res.status(404).json({ error: 'Opencode installation not found' });
687
+ }
688
+
689
+ const backup = req.body;
690
+
691
+ if (!backup || (backup.version !== 1 && backup.version !== 2)) {
692
+ return res.status(400).json({ error: 'Invalid backup file' });
693
+ }
694
+
695
+ try {
696
+ if (backup.studioConfig) {
697
+ saveStudioConfig(backup.studioConfig);
698
+ }
699
+
700
+ if (backup.opencodeConfig) {
701
+ fs.writeFileSync(paths.opencodeJson, JSON.stringify(backup.opencodeConfig, null, 2), 'utf8');
702
+ }
703
+
704
+ if (backup.skills && backup.skills.length > 0) {
705
+ if (!fs.existsSync(paths.skillDir)) {
706
+ fs.mkdirSync(paths.skillDir, { recursive: true });
707
+ }
708
+ for (const skill of backup.skills) {
709
+ const skillName = path.basename(skill.name.replace('.md', ''));
710
+ const skillDir = path.join(paths.skillDir, skillName);
711
+ if (!fs.existsSync(skillDir)) {
712
+ fs.mkdirSync(skillDir, { recursive: true });
713
+ }
714
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skill.content, 'utf8');
715
+ }
716
+ }
717
+
718
+ if (backup.plugins && backup.plugins.length > 0) {
719
+ if (!fs.existsSync(paths.pluginDir)) {
720
+ fs.mkdirSync(paths.pluginDir, { recursive: true });
721
+ }
722
+ for (const plugin of backup.plugins) {
723
+ const pluginName = path.basename(plugin.name);
724
+ fs.writeFileSync(path.join(paths.pluginDir, pluginName), plugin.content, 'utf8');
725
+ }
726
+ }
727
+
728
+ res.json({ success: true });
729
+ } catch (err) {
730
+ res.status(500).json({ error: 'Failed to restore backup', details: err.message });
731
+ }
732
+ });
733
+
734
+ // Auth endpoints
735
+ function getAuthFile() {
736
+ for (const candidate of AUTH_CANDIDATE_PATHS) {
737
+ if (fs.existsSync(candidate)) {
738
+ return candidate;
739
+ }
740
+ }
741
+ return null;
742
+ }
743
+
744
+ function loadAuthConfig() {
745
+ const authFile = getAuthFile();
746
+ if (!authFile) return null;
747
+
748
+ try {
749
+ return JSON.parse(fs.readFileSync(authFile, 'utf8'));
750
+ } catch {
751
+ return null;
752
+ }
753
+ }
754
+
755
+ function saveAuthConfig(config) {
756
+ const authFile = getAuthFile();
757
+ if (!authFile) return false;
758
+
759
+ try {
760
+ fs.writeFileSync(authFile, JSON.stringify(config, null, 2), 'utf8');
761
+ return true;
762
+ } catch {
763
+ return false;
764
+ }
765
+ }
766
+
767
+ const PROVIDER_DISPLAY_NAMES = {
768
+ 'github-copilot': 'GitHub Copilot',
769
+ 'google': 'Google',
770
+ 'anthropic': 'Anthropic',
771
+ 'openai': 'OpenAI',
772
+ 'zai': 'Z.AI',
773
+ 'xai': 'xAI',
774
+ 'groq': 'Groq',
775
+ 'together': 'Together AI',
776
+ 'mistral': 'Mistral',
777
+ 'deepseek': 'DeepSeek',
778
+ 'openrouter': 'OpenRouter',
779
+ 'amazon-bedrock': 'Amazon Bedrock',
780
+ 'azure': 'Azure OpenAI',
781
+ };
782
+
783
+ app.get('/api/auth', (req, res) => {
784
+ const authConfig = loadAuthConfig();
785
+ const authFile = getAuthFile();
786
+
787
+ if (!authConfig) {
788
+ return res.json({
789
+ credentials: [],
790
+ authFile: null,
791
+ message: 'No auth file found'
792
+ });
793
+ }
794
+
795
+ const credentials = Object.entries(authConfig).map(([id, config]) => {
796
+ const isExpired = config.expires ? Date.now() > config.expires : false;
797
+ return {
798
+ id,
799
+ name: PROVIDER_DISPLAY_NAMES[id] || id,
800
+ type: config.type,
801
+ isExpired,
802
+ expiresAt: config.expires || null,
803
+ };
804
+ });
805
+
806
+ res.json({ credentials, authFile });
807
+ });
808
+
809
+ app.post('/api/auth/login', (req, res) => {
810
+ const { provider } = req.body;
811
+
812
+ if (!provider) {
813
+ return res.status(400).json({ error: 'Provider is required' });
814
+ }
815
+
816
+ if (!PROVIDER_DISPLAY_NAMES[provider]) {
817
+ return res.status(400).json({ error: 'Invalid provider' });
818
+ }
819
+
820
+ // Run opencode auth login - this opens browser
821
+ const child = spawn('opencode', ['auth', 'login', provider], {
822
+ stdio: 'inherit',
823
+ });
824
+
825
+ child.on('error', (err) => {
826
+ console.error('Failed to start auth login:', err);
827
+ });
828
+
829
+ // Return immediately - login happens in browser
830
+ res.json({
831
+ success: true,
832
+ message: `Opening browser for ${provider} login...`,
833
+ note: 'Complete authentication in your browser, then refresh this page.'
834
+ });
835
+ });
836
+
837
+ app.delete('/api/auth/:provider', (req, res) => {
838
+ const provider = req.params.provider;
839
+ const authConfig = loadAuthConfig();
840
+
841
+ if (!authConfig) {
842
+ return res.status(404).json({ error: 'No auth configuration found' });
843
+ }
844
+
845
+ if (!authConfig[provider]) {
846
+ return res.status(404).json({ error: `Provider ${provider} not found` });
847
+ }
848
+
849
+ delete authConfig[provider];
850
+
851
+ if (saveAuthConfig(authConfig)) {
852
+ res.json({ success: true });
853
+ } else {
854
+ res.status(500).json({ error: 'Failed to save auth configuration' });
855
+ }
856
+ });
857
+
858
+ // Get available providers for login
859
+ app.get('/api/auth/providers', (req, res) => {
860
+ const providers = [
861
+ { id: 'github-copilot', name: 'GitHub Copilot', type: 'oauth', description: 'Use GitHub Copilot API' },
862
+ { id: 'google', name: 'Google AI', type: 'oauth', description: 'Use Google Gemini models' },
863
+ { id: 'anthropic', name: 'Anthropic', type: 'api', description: 'Use Claude models' },
864
+ { id: 'openai', name: 'OpenAI', type: 'api', description: 'Use GPT models' },
865
+ { id: 'xai', name: 'xAI', type: 'api', description: 'Use Grok models' },
866
+ { id: 'groq', name: 'Groq', type: 'api', description: 'Fast inference' },
867
+ { id: 'together', name: 'Together AI', type: 'api', description: 'Open source models' },
868
+ { id: 'mistral', name: 'Mistral', type: 'api', description: 'Mistral models' },
869
+ { id: 'deepseek', name: 'DeepSeek', type: 'api', description: 'DeepSeek models' },
870
+ { id: 'openrouter', name: 'OpenRouter', type: 'api', description: 'Multiple providers' },
871
+ { id: 'amazon-bedrock', name: 'Amazon Bedrock', type: 'api', description: 'AWS models' },
872
+ { id: 'azure', name: 'Azure OpenAI', type: 'api', description: 'Azure GPT models' },
873
+ ];
874
+ res.json(providers);
875
+ });
876
+
877
+ app.listen(PORT, () => {
878
+ console.log(`Server running on http://localhost:${PORT}`);
879
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "opencode-studio-server",
3
+ "version": "1.0.0",
4
+ "description": "Backend server for OpenCode Studio - manages opencode configurations",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "opencode-studio-server": "cli.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node index.js",
11
+ "register": "node register-protocol.js",
12
+ "postinstall": "node register-protocol.js"
13
+ },
14
+ "keywords": [
15
+ "opencode",
16
+ "studio",
17
+ "config",
18
+ "manager"
19
+ ],
20
+ "author": "Microck",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/Microck/opencode-studio.git"
25
+ },
26
+ "type": "commonjs",
27
+ "dependencies": {
28
+ "body-parser": "^2.2.1",
29
+ "cors": "^2.8.5",
30
+ "express": "^5.2.1"
31
+ }
32
+ }
@@ -0,0 +1,94 @@
1
+ const { exec } = require('child_process');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const PROTOCOL = 'opencodestudio';
6
+
7
+ function registerWindows() {
8
+ const nodePath = process.execPath.replace(/\\/g, '\\\\');
9
+ const scriptPath = path.join(__dirname, 'cli.js').replace(/\\/g, '\\\\');
10
+ const command = `"${nodePath}" "${scriptPath}" "%1"`;
11
+
12
+ const regCommands = [
13
+ `reg add "HKCU\\Software\\Classes\\${PROTOCOL}" /ve /d "URL:OpenCode Studio Protocol" /f`,
14
+ `reg add "HKCU\\Software\\Classes\\${PROTOCOL}" /v "URL Protocol" /d "" /f`,
15
+ `reg add "HKCU\\Software\\Classes\\${PROTOCOL}\\shell\\open\\command" /ve /d "${command}" /f`,
16
+ ];
17
+
18
+ return new Promise((resolve, reject) => {
19
+ let completed = 0;
20
+ let failed = false;
21
+
22
+ for (const cmd of regCommands) {
23
+ exec(cmd, (err) => {
24
+ if (err && !failed) {
25
+ failed = true;
26
+ reject(err);
27
+ return;
28
+ }
29
+ completed++;
30
+ if (completed === regCommands.length && !failed) {
31
+ resolve();
32
+ }
33
+ });
34
+ }
35
+ });
36
+ }
37
+
38
+ function registerMac() {
39
+ console.log('macOS: Protocol registration requires app bundle.');
40
+ console.log('Use `npx opencode-studio-server` to start manually.');
41
+ return Promise.resolve();
42
+ }
43
+
44
+ function registerLinux() {
45
+ const nodePath = process.execPath;
46
+ const scriptPath = path.join(__dirname, 'cli.js');
47
+ const desktopEntry = `[Desktop Entry]
48
+ Name=OpenCode Studio
49
+ Exec=${nodePath} ${scriptPath} %u
50
+ Type=Application
51
+ Terminal=true
52
+ MimeType=x-scheme-handler/${PROTOCOL};
53
+ `;
54
+
55
+ const desktopPath = path.join(os.homedir(), '.local', 'share', 'applications', `${PROTOCOL}.desktop`);
56
+ const fs = require('fs');
57
+
58
+ try {
59
+ fs.mkdirSync(path.dirname(desktopPath), { recursive: true });
60
+ fs.writeFileSync(desktopPath, desktopEntry);
61
+
62
+ return new Promise((resolve) => {
63
+ exec(`xdg-mime default ${PROTOCOL}.desktop x-scheme-handler/${PROTOCOL}`, () => {
64
+ resolve();
65
+ });
66
+ });
67
+ } catch (err) {
68
+ return Promise.reject(err);
69
+ }
70
+ }
71
+
72
+ async function register() {
73
+ const platform = os.platform();
74
+
75
+ try {
76
+ if (platform === 'win32') {
77
+ await registerWindows();
78
+ } else if (platform === 'darwin') {
79
+ await registerMac();
80
+ } else {
81
+ await registerLinux();
82
+ }
83
+ console.log(`Protocol ${PROTOCOL}:// registered successfully`);
84
+ } catch (err) {
85
+ console.error('Protocol registration failed:', err.message);
86
+ console.log('You can still use: npx opencode-studio-server');
87
+ }
88
+ }
89
+
90
+ if (require.main === module) {
91
+ register();
92
+ }
93
+
94
+ module.exports = { register, PROTOCOL };