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 +129 -0
- package/index.js +879 -0
- package/package.json +32 -0
- package/register-protocol.js +94 -0
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¶m2=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 };
|