opencode-studio-server 1.28.3 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/README.md +1 -1
- package/cli.js +39 -19
- package/index.js +643 -213
- package/package.json +4 -4
package/AGENTS.md
CHANGED
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Start the server manually:
|
|
|
24
24
|
opencode-studio-server
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
The server runs on port **
|
|
27
|
+
The server runs on port **1920** (auto-detects next available) and provides an API for managing your local OpenCode configuration.
|
|
28
28
|
|
|
29
29
|
## Features
|
|
30
30
|
|
package/cli.js
CHANGED
|
@@ -4,9 +4,27 @@ const path = require('path');
|
|
|
4
4
|
const { exec } = require('child_process');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const fs = require('fs');
|
|
7
|
+
const net = require('net');
|
|
7
8
|
|
|
8
9
|
const args = process.argv.slice(2);
|
|
9
10
|
|
|
11
|
+
async function findAvailablePort(startPort, maxTries = 10) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const server = net.createServer();
|
|
14
|
+
server.listen(startPort, () => {
|
|
15
|
+
server.once('close', () => resolve(startPort));
|
|
16
|
+
server.close();
|
|
17
|
+
});
|
|
18
|
+
server.on('error', () => {
|
|
19
|
+
if (maxTries > 1) {
|
|
20
|
+
findAvailablePort(startPort + 1, maxTries - 1).then(resolve);
|
|
21
|
+
} else {
|
|
22
|
+
resolve(startPort);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
10
28
|
// Handle --register flag
|
|
11
29
|
if (args.includes('--register')) {
|
|
12
30
|
require('./register-protocol');
|
|
@@ -103,27 +121,29 @@ if (pendingAction) {
|
|
|
103
121
|
|
|
104
122
|
// Open browser if requested (only for fully local mode)
|
|
105
123
|
if (shouldOpenBrowser) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
cmd = `xdg-open "${openUrl}"`;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
exec(cmd, (err) => {
|
|
120
|
-
if (err) {
|
|
121
|
-
console.log(`Open ${openUrl} in your browser`);
|
|
124
|
+
findAvailablePort(1080).then(port => {
|
|
125
|
+
const openUrl = `http://localhost:${port}`;
|
|
126
|
+
const platform = os.platform();
|
|
127
|
+
|
|
128
|
+
setTimeout(() => {
|
|
129
|
+
let cmd;
|
|
130
|
+
if (platform === 'win32') {
|
|
131
|
+
cmd = `start "" "${openUrl}"`;
|
|
132
|
+
} else if (platform === 'darwin') {
|
|
133
|
+
cmd = `open "${openUrl}"`;
|
|
122
134
|
} else {
|
|
123
|
-
|
|
135
|
+
cmd = `xdg-open "${openUrl}"`;
|
|
124
136
|
}
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
|
|
138
|
+
exec(cmd, (err) => {
|
|
139
|
+
if (err) {
|
|
140
|
+
console.log(`Open ${openUrl} in your browser`);
|
|
141
|
+
} else {
|
|
142
|
+
console.log(`Opened ${openUrl}`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}, 2000);
|
|
146
|
+
});
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
// Start the server
|
package/index.js
CHANGED
|
@@ -5,7 +5,8 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const crypto = require('crypto');
|
|
8
|
-
const { spawn, exec } = require('child_process');
|
|
8
|
+
const { spawn, exec, execSync } = require('child_process');
|
|
9
|
+
const yaml = require('js-yaml');
|
|
9
10
|
|
|
10
11
|
const pkg = require('./package.json');
|
|
11
12
|
const profileManager = require('./profile-manager');
|
|
@@ -41,8 +42,22 @@ const atomicWriteFileSync = (filePath, data, options = 'utf8') => {
|
|
|
41
42
|
};
|
|
42
43
|
|
|
43
44
|
const app = express();
|
|
44
|
-
const
|
|
45
|
-
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
45
|
+
const DEFAULT_PORT = 1920;
|
|
46
|
+
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
47
|
+
|
|
48
|
+
function findAvailablePort(startPort) {
|
|
49
|
+
const net = require('net');
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const server = net.createServer();
|
|
52
|
+
server.listen(startPort, () => {
|
|
53
|
+
server.once('close', () => resolve(startPort));
|
|
54
|
+
server.close();
|
|
55
|
+
});
|
|
56
|
+
server.on('error', () => {
|
|
57
|
+
findAvailablePort(startPort + 1).then(resolve);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
46
61
|
|
|
47
62
|
let idleTimer = null;
|
|
48
63
|
|
|
@@ -62,8 +77,10 @@ app.use((req, res, next) => {
|
|
|
62
77
|
});
|
|
63
78
|
|
|
64
79
|
const ALLOWED_ORIGINS = [
|
|
65
|
-
'http://localhost:
|
|
66
|
-
'http://127.0.0.1:
|
|
80
|
+
'http://localhost:1080',
|
|
81
|
+
'http://127.0.0.1:1080',
|
|
82
|
+
/^http:\/\/localhost:108\d$/,
|
|
83
|
+
/^http:\/\/127\.0\.0\.1:108\d$/,
|
|
67
84
|
'https://opencode-studio.vercel.app',
|
|
68
85
|
'https://opencode.micr.dev',
|
|
69
86
|
'https://opencode-studio.micr.dev',
|
|
@@ -85,15 +102,47 @@ app.use(bodyParser.json({ limit: '50mb' }));
|
|
|
85
102
|
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
|
86
103
|
app.use(bodyParser.text({ type: ['text/*', 'application/yaml'], limit: '50mb' }));
|
|
87
104
|
|
|
88
|
-
const HOME_DIR = os.homedir();
|
|
89
|
-
const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'studio.json');
|
|
90
|
-
const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json');
|
|
91
|
-
const ANTIGRAVITY_ACCOUNTS_PATH = path.join(HOME_DIR, '.config', 'opencode', 'antigravity-accounts.json');
|
|
92
|
-
const LOG_DIR = path.join(HOME_DIR, '.local', 'share', 'opencode', 'log');
|
|
105
|
+
const HOME_DIR = os.homedir();
|
|
106
|
+
const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'studio.json');
|
|
107
|
+
const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json');
|
|
108
|
+
const ANTIGRAVITY_ACCOUNTS_PATH = path.join(HOME_DIR, '.config', 'opencode', 'antigravity-accounts.json');
|
|
109
|
+
const LOG_DIR = path.join(HOME_DIR, '.local', 'share', 'opencode', 'log');
|
|
110
|
+
|
|
111
|
+
const LOG_BUFFER_SIZE = 100;
|
|
112
|
+
const logBuffer = [];
|
|
113
|
+
const logSubscribers = new Set();
|
|
93
114
|
|
|
94
|
-
let logWatcher = null;
|
|
95
|
-
let currentLogFile = null;
|
|
96
|
-
let logReadStream = null;
|
|
115
|
+
let logWatcher = null;
|
|
116
|
+
let currentLogFile = null;
|
|
117
|
+
let logReadStream = null;
|
|
118
|
+
|
|
119
|
+
function enqueueLogLine(line) {
|
|
120
|
+
const entry = { timestamp: Date.now(), line };
|
|
121
|
+
logBuffer.push(entry);
|
|
122
|
+
if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift();
|
|
123
|
+
|
|
124
|
+
for (const sub of logSubscribers) {
|
|
125
|
+
sub.queue.push(`data: ${JSON.stringify(entry)}\n\n`);
|
|
126
|
+
flushSubscriber(sub);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function flushSubscriber(sub) {
|
|
131
|
+
if (sub.draining || sub.closed) return;
|
|
132
|
+
while (sub.queue.length > 0) {
|
|
133
|
+
const chunk = sub.queue[0];
|
|
134
|
+
const ok = sub.res.write(chunk);
|
|
135
|
+
if (!ok) {
|
|
136
|
+
sub.draining = true;
|
|
137
|
+
sub.res.once('drain', () => {
|
|
138
|
+
sub.draining = false;
|
|
139
|
+
flushSubscriber(sub);
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
sub.queue.shift();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
97
146
|
|
|
98
147
|
function setupLogWatcher() {
|
|
99
148
|
if (!fs.existsSync(LOG_DIR)) return;
|
|
@@ -172,11 +221,12 @@ function setupLogWatcher() {
|
|
|
172
221
|
});
|
|
173
222
|
}
|
|
174
223
|
|
|
175
|
-
function processLogLine(line) {
|
|
176
|
-
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
const
|
|
224
|
+
function processLogLine(line) {
|
|
225
|
+
enqueueLogLine(line);
|
|
226
|
+
// Detect LLM usage: service=llm providerID=... modelID=...
|
|
227
|
+
// Example: service=llm providerID=openai modelID=gpt-5.2-codex sessionID=...
|
|
228
|
+
const isUsage = line.includes('service=llm') && line.includes('stream');
|
|
229
|
+
const isError = line.includes('service=llm') && (line.includes('error=') || line.includes('status=429'));
|
|
180
230
|
|
|
181
231
|
if (!isUsage && !isError) return;
|
|
182
232
|
|
|
@@ -288,12 +338,13 @@ function processLogLine(line) {
|
|
|
288
338
|
|
|
289
339
|
let pendingActionMemory = null;
|
|
290
340
|
|
|
291
|
-
function loadStudioConfig() {
|
|
292
|
-
const defaultConfig = {
|
|
293
|
-
disabledSkills: [],
|
|
294
|
-
disabledPlugins: [],
|
|
295
|
-
|
|
296
|
-
|
|
341
|
+
function loadStudioConfig() {
|
|
342
|
+
const defaultConfig = {
|
|
343
|
+
disabledSkills: [],
|
|
344
|
+
disabledPlugins: [],
|
|
345
|
+
disabledAgents: [],
|
|
346
|
+
activeProfiles: {},
|
|
347
|
+
activeGooglePlugin: 'gemini',
|
|
297
348
|
availableGooglePlugins: [],
|
|
298
349
|
presets: [],
|
|
299
350
|
githubRepo: null,
|
|
@@ -341,9 +392,9 @@ function loadStudioConfig() {
|
|
|
341
392
|
"variants": {
|
|
342
393
|
"low": { "options": { "thinkingConfig": { "thinkingLevel": "low", "includeThoughts": true } } },
|
|
343
394
|
"medium": { "options": { "thinkingConfig": { "thinkingLevel": "medium", "includeThoughts": true } } },
|
|
344
|
-
"high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
|
|
345
|
-
"xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
|
|
346
|
-
}
|
|
395
|
+
"high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
|
|
396
|
+
"xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
|
|
397
|
+
}
|
|
347
398
|
},
|
|
348
399
|
"gemini-3-flash": {
|
|
349
400
|
"id": "gemini-3-flash",
|
|
@@ -360,9 +411,9 @@ function loadStudioConfig() {
|
|
|
360
411
|
"minimal": { "options": { "thinkingConfig": { "thinkingLevel": "minimal", "includeThoughts": true } } },
|
|
361
412
|
"low": { "options": { "thinkingConfig": { "thinkingLevel": "low", "includeThoughts": true } } },
|
|
362
413
|
"medium": { "options": { "thinkingConfig": { "thinkingLevel": "medium", "includeThoughts": true } } },
|
|
363
|
-
"high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
|
|
364
|
-
"xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
|
|
365
|
-
}
|
|
414
|
+
"high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
|
|
415
|
+
"xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
|
|
416
|
+
}
|
|
366
417
|
},
|
|
367
418
|
"gemini-2.5-flash-lite": {
|
|
368
419
|
"id": "gemini-2.5-flash-lite",
|
|
@@ -383,9 +434,9 @@ function loadStudioConfig() {
|
|
|
383
434
|
"minimal": { "options": { "thinkingConfig": { "thinkingLevel": "minimal", "includeThoughts": true } } },
|
|
384
435
|
"low": { "options": { "thinkingConfig": { "thinkingLevel": "low", "includeThoughts": true } } },
|
|
385
436
|
"medium": { "options": { "thinkingConfig": { "thinkingLevel": "medium", "includeThoughts": true } } },
|
|
386
|
-
"high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
|
|
387
|
-
"xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
|
|
388
|
-
}
|
|
437
|
+
"high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
|
|
438
|
+
"xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
|
|
439
|
+
}
|
|
389
440
|
},
|
|
390
441
|
"opencode/glm-4.7-free": {
|
|
391
442
|
"id": "opencode/glm-4.7-free",
|
|
@@ -411,9 +462,9 @@ function loadStudioConfig() {
|
|
|
411
462
|
"none": { "reasoning": false, "options": { "thinkingConfig": { "includeThoughts": false } } },
|
|
412
463
|
"low": { "options": { "thinkingConfig": { "thinkingBudget": 4000, "includeThoughts": true } } },
|
|
413
464
|
"medium": { "options": { "thinkingConfig": { "thinkingBudget": 16000, "includeThoughts": true } } },
|
|
414
|
-
"high": { "options": { "thinkingConfig": { "thinkingBudget": 32000, "includeThoughts": true } } },
|
|
415
|
-
"xhigh": { "options": { "thinkingConfig": { "thinkingBudget": 64000, "includeThoughts": true } } }
|
|
416
|
-
}
|
|
465
|
+
"high": { "options": { "thinkingConfig": { "thinkingBudget": 32000, "includeThoughts": true } } },
|
|
466
|
+
"xhigh": { "options": { "thinkingConfig": { "thinkingBudget": 64000, "includeThoughts": true } } }
|
|
467
|
+
}
|
|
417
468
|
},
|
|
418
469
|
"gemini-claude-opus-4-5-thinking": {
|
|
419
470
|
"id": "gemini-claude-opus-4-5-thinking",
|
|
@@ -428,9 +479,9 @@ function loadStudioConfig() {
|
|
|
428
479
|
"variants": {
|
|
429
480
|
"low": { "options": { "thinkingConfig": { "thinkingBudget": 4000, "includeThoughts": true } } },
|
|
430
481
|
"medium": { "options": { "thinkingConfig": { "thinkingBudget": 16000, "includeThoughts": true } } },
|
|
431
|
-
"high": { "options": { "thinkingConfig": { "thinkingBudget": 32000, "includeThoughts": true } } },
|
|
432
|
-
"xhigh": { "options": { "thinkingConfig": { "thinkingBudget": 64000, "includeThoughts": true } } }
|
|
433
|
-
}
|
|
482
|
+
"high": { "options": { "thinkingConfig": { "thinkingBudget": 32000, "includeThoughts": true } } },
|
|
483
|
+
"xhigh": { "options": { "thinkingConfig": { "thinkingBudget": 64000, "includeThoughts": true } } }
|
|
484
|
+
}
|
|
434
485
|
}
|
|
435
486
|
}
|
|
436
487
|
}
|
|
@@ -495,15 +546,15 @@ const getPaths = () => {
|
|
|
495
546
|
}
|
|
496
547
|
}
|
|
497
548
|
|
|
498
|
-
const studioConfig = loadStudioConfig();
|
|
499
|
-
let manualPath = studioConfig.configPath;
|
|
500
|
-
|
|
501
|
-
if (manualPath && fs.existsSync(manualPath) && fs.statSync(manualPath).isDirectory()) {
|
|
502
|
-
const potentialFile = path.join(manualPath, 'opencode.json');
|
|
503
|
-
if (fs.existsSync(potentialFile)) {
|
|
504
|
-
manualPath = potentialFile;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
549
|
+
const studioConfig = loadStudioConfig();
|
|
550
|
+
let manualPath = studioConfig.configPath;
|
|
551
|
+
|
|
552
|
+
if (manualPath && fs.existsSync(manualPath) && fs.statSync(manualPath).isDirectory()) {
|
|
553
|
+
const potentialFile = path.join(manualPath, 'opencode.json');
|
|
554
|
+
if (fs.existsSync(potentialFile)) {
|
|
555
|
+
manualPath = potentialFile;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
507
558
|
|
|
508
559
|
let detected = null;
|
|
509
560
|
for (const p of candidates) {
|
|
@@ -527,7 +578,101 @@ const getOhMyOpenCodeConfigPath = () => {
|
|
|
527
578
|
return path.join(path.dirname(cp), 'oh-my-opencode.json');
|
|
528
579
|
};
|
|
529
580
|
|
|
530
|
-
const getConfigPath = () => getPaths().current;
|
|
581
|
+
const getConfigPath = () => getPaths().current;
|
|
582
|
+
|
|
583
|
+
const getAgentDirs = () => {
|
|
584
|
+
const dirs = [];
|
|
585
|
+
const configPath = getConfigPath();
|
|
586
|
+
const configDir = configPath ? path.dirname(configPath) : null;
|
|
587
|
+
|
|
588
|
+
if (configDir) {
|
|
589
|
+
dirs.push(path.join(configDir, '.opencode', 'agents'));
|
|
590
|
+
dirs.push(path.join(configDir, '.opencode', 'agent'));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
594
|
+
if (xdg) {
|
|
595
|
+
dirs.push(path.join(xdg, 'opencode', 'agents'));
|
|
596
|
+
dirs.push(path.join(xdg, 'opencode', 'agent'));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
dirs.push(path.join(HOME_DIR, '.config', 'opencode', 'agents'));
|
|
600
|
+
dirs.push(path.join(HOME_DIR, '.config', 'opencode', 'agent'));
|
|
601
|
+
|
|
602
|
+
return [...new Set(dirs)];
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const parseAgentMarkdown = (content) => {
|
|
606
|
+
const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?([\s\S]*)$/);
|
|
607
|
+
if (!match) return { data: {}, body: content };
|
|
608
|
+
let data = {};
|
|
609
|
+
try {
|
|
610
|
+
data = yaml.load(match[1]) || {};
|
|
611
|
+
} catch {
|
|
612
|
+
data = {};
|
|
613
|
+
}
|
|
614
|
+
return { data, body: match[2] || '' };
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const buildAgentMarkdown = (frontmatter, body) => {
|
|
618
|
+
const yamlText = yaml.dump(frontmatter, { lineWidth: 120, noRefs: true, quotingType: '"' });
|
|
619
|
+
const content = body || '';
|
|
620
|
+
return `---\n${yamlText}---\n\n${content}`;
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const validatePermissionValue = (value) => {
|
|
624
|
+
const allowed = ['ask', 'allow', 'deny'];
|
|
625
|
+
if (value === undefined || value === null) return true;
|
|
626
|
+
if (typeof value === 'string') return allowed.includes(value);
|
|
627
|
+
if (typeof value !== 'object') return false;
|
|
628
|
+
|
|
629
|
+
const keys = Object.keys(value);
|
|
630
|
+
const isAllowDeny = keys.every((k) => k === 'allow' || k === 'deny');
|
|
631
|
+
if (isAllowDeny) {
|
|
632
|
+
return (!value.allow || Array.isArray(value.allow)) && (!value.deny || Array.isArray(value.deny));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
for (const v of Object.values(value)) {
|
|
636
|
+
if (typeof v === 'string') {
|
|
637
|
+
if (!allowed.includes(v)) return false;
|
|
638
|
+
} else if (typeof v === 'object') {
|
|
639
|
+
if (!validatePermissionValue(v)) return false;
|
|
640
|
+
} else if (v !== undefined && v !== null) {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return true;
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const findRulesFile = () => {
|
|
648
|
+
const configPath = getConfigPath();
|
|
649
|
+
if (!configPath) return { path: null, source: 'none' };
|
|
650
|
+
|
|
651
|
+
let dir = path.dirname(configPath);
|
|
652
|
+
let last = null;
|
|
653
|
+
while (dir && dir !== last) {
|
|
654
|
+
const agentsPath = path.join(dir, 'AGENTS.md');
|
|
655
|
+
if (fs.existsSync(agentsPath)) return { path: agentsPath, source: 'AGENTS.md' };
|
|
656
|
+
const claudePath = path.join(dir, 'CLAUDE.md');
|
|
657
|
+
if (fs.existsSync(claudePath)) return { path: claudePath, source: 'CLAUDE.md' };
|
|
658
|
+
last = dir;
|
|
659
|
+
dir = path.dirname(dir);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return { path: null, source: 'none' };
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const detectTool = (tool) => {
|
|
666
|
+
const command = process.platform === 'win32' ? `where ${tool}` : `which ${tool}`;
|
|
667
|
+
try {
|
|
668
|
+
const output = execSync(command, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
669
|
+
if (!output) return { name: tool, available: false };
|
|
670
|
+
const first = output.split(/\r?\n/)[0];
|
|
671
|
+
return { name: tool, available: true, path: first };
|
|
672
|
+
} catch {
|
|
673
|
+
return { name: tool, available: false };
|
|
674
|
+
}
|
|
675
|
+
};
|
|
531
676
|
|
|
532
677
|
const loadConfig = () => {
|
|
533
678
|
const configPath = getConfigPath();
|
|
@@ -609,20 +754,20 @@ app.get('/api/debug/auth', (req, res) => {
|
|
|
609
754
|
});
|
|
610
755
|
});
|
|
611
756
|
|
|
612
|
-
app.post('/api/paths', (req, res) => {
|
|
613
|
-
const { configPath } = req.body;
|
|
614
|
-
const studioConfig = loadStudioConfig();
|
|
615
|
-
|
|
616
|
-
if (configPath && fs.existsSync(configPath) && fs.statSync(configPath).isDirectory()) {
|
|
617
|
-
const potentialFile = path.join(configPath, 'opencode.json');
|
|
618
|
-
studioConfig.configPath = potentialFile;
|
|
619
|
-
} else {
|
|
620
|
-
studioConfig.configPath = configPath;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
saveStudioConfig(studioConfig);
|
|
624
|
-
res.json({ success: true, current: getConfigPath() });
|
|
625
|
-
});
|
|
757
|
+
app.post('/api/paths', (req, res) => {
|
|
758
|
+
const { configPath } = req.body;
|
|
759
|
+
const studioConfig = loadStudioConfig();
|
|
760
|
+
|
|
761
|
+
if (configPath && fs.existsSync(configPath) && fs.statSync(configPath).isDirectory()) {
|
|
762
|
+
const potentialFile = path.join(configPath, 'opencode.json');
|
|
763
|
+
studioConfig.configPath = potentialFile;
|
|
764
|
+
} else {
|
|
765
|
+
studioConfig.configPath = configPath;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
saveStudioConfig(studioConfig);
|
|
769
|
+
res.json({ success: true, current: getConfigPath() });
|
|
770
|
+
});
|
|
626
771
|
|
|
627
772
|
app.get('/api/config', (req, res) => {
|
|
628
773
|
const config = loadConfig();
|
|
@@ -632,6 +777,9 @@ app.get('/api/config', (req, res) => {
|
|
|
632
777
|
|
|
633
778
|
app.post('/api/config', (req, res) => {
|
|
634
779
|
try {
|
|
780
|
+
if (!validatePermissionValue(req.body?.permission)) {
|
|
781
|
+
return res.status(400).json({ error: 'Invalid permission value. Must be ask, allow, or deny.' });
|
|
782
|
+
}
|
|
635
783
|
saveConfig(req.body);
|
|
636
784
|
triggerGitHubAutoSync();
|
|
637
785
|
res.json({ success: true });
|
|
@@ -639,6 +787,286 @@ app.post('/api/config', (req, res) => {
|
|
|
639
787
|
res.status(500).json({ error: err.message });
|
|
640
788
|
}
|
|
641
789
|
});
|
|
790
|
+
|
|
791
|
+
app.get('/api/agents', (req, res) => {
|
|
792
|
+
try {
|
|
793
|
+
const config = loadConfig() || {};
|
|
794
|
+
const studio = loadStudioConfig();
|
|
795
|
+
const disabledAgents = studio.disabledAgents || [];
|
|
796
|
+
const agentMap = new Map();
|
|
797
|
+
|
|
798
|
+
const configAgents = config.agent || {};
|
|
799
|
+
for (const [name, agentConfig] of Object.entries(configAgents)) {
|
|
800
|
+
agentMap.set(name, {
|
|
801
|
+
name,
|
|
802
|
+
source: 'json',
|
|
803
|
+
disabled: disabledAgents.includes(name),
|
|
804
|
+
...agentConfig,
|
|
805
|
+
permission: agentConfig.permission || agentConfig.permissions,
|
|
806
|
+
permissions: agentConfig.permission || agentConfig.permissions
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
for (const dir of getAgentDirs()) {
|
|
811
|
+
if (!fs.existsSync(dir)) continue;
|
|
812
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
|
|
813
|
+
files.forEach((file) => {
|
|
814
|
+
const fp = path.join(dir, file);
|
|
815
|
+
const content = fs.readFileSync(fp, 'utf8');
|
|
816
|
+
const { data, body } = parseAgentMarkdown(content);
|
|
817
|
+
const name = path.basename(file, '.md');
|
|
818
|
+
agentMap.set(name, {
|
|
819
|
+
name,
|
|
820
|
+
source: 'markdown',
|
|
821
|
+
disabled: disabledAgents.includes(name),
|
|
822
|
+
description: data.description,
|
|
823
|
+
mode: data.mode,
|
|
824
|
+
model: data.model,
|
|
825
|
+
temperature: data.temperature,
|
|
826
|
+
tools: data.tools,
|
|
827
|
+
permission: data.permission,
|
|
828
|
+
permissions: data.permission,
|
|
829
|
+
maxSteps: data.maxSteps,
|
|
830
|
+
disable: data.disable,
|
|
831
|
+
hidden: data.hidden,
|
|
832
|
+
prompt: body
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
['build', 'plan'].forEach((name) => {
|
|
838
|
+
if (!agentMap.has(name)) {
|
|
839
|
+
agentMap.set(name, { name, source: 'builtin', mode: 'primary', disabled: disabledAgents.includes(name) });
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
res.json({ agents: Array.from(agentMap.values()) });
|
|
844
|
+
} catch (err) {
|
|
845
|
+
res.status(500).json({ error: err.message });
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
app.post('/api/agents', (req, res) => {
|
|
850
|
+
try {
|
|
851
|
+
const { name, config: agentConfig, source, scope } = req.body || {};
|
|
852
|
+
if (!name || typeof name !== 'string') return res.status(400).json({ error: 'Missing agent name' });
|
|
853
|
+
if (!/^[a-zA-Z0-9 _-]+$/.test(name)) return res.status(400).json({ error: 'Invalid agent name' });
|
|
854
|
+
|
|
855
|
+
const config = loadConfig() || {};
|
|
856
|
+
if (!config.agent) config.agent = {};
|
|
857
|
+
|
|
858
|
+
const normalizedConfig = { ...(agentConfig || {}) };
|
|
859
|
+
if (normalizedConfig.permissions && !normalizedConfig.permission) {
|
|
860
|
+
normalizedConfig.permission = normalizedConfig.permissions;
|
|
861
|
+
delete normalizedConfig.permissions;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const shouldWriteMarkdown = source === 'markdown' || !!normalizedConfig?.mode;
|
|
865
|
+
if (shouldWriteMarkdown) {
|
|
866
|
+
const configPath = getConfigPath();
|
|
867
|
+
const baseDir = configPath ? path.dirname(configPath) : HOME_DIR;
|
|
868
|
+
const projectDir = path.join(baseDir, '.opencode', 'agents');
|
|
869
|
+
const globalDir = path.join(HOME_DIR, '.config', 'opencode', 'agents');
|
|
870
|
+
const targetDir = scope === 'project' ? projectDir : globalDir;
|
|
871
|
+
if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
|
|
872
|
+
|
|
873
|
+
const frontmatter = {
|
|
874
|
+
description: normalizedConfig?.description,
|
|
875
|
+
mode: normalizedConfig?.mode,
|
|
876
|
+
model: normalizedConfig?.model,
|
|
877
|
+
temperature: normalizedConfig?.temperature,
|
|
878
|
+
tools: normalizedConfig?.tools,
|
|
879
|
+
permission: normalizedConfig?.permission,
|
|
880
|
+
maxSteps: normalizedConfig?.maxSteps,
|
|
881
|
+
disable: normalizedConfig?.disable,
|
|
882
|
+
hidden: normalizedConfig?.hidden
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
const markdown = buildAgentMarkdown(frontmatter, normalizedConfig?.prompt || '');
|
|
886
|
+
atomicWriteFileSync(path.join(targetDir, `${name}.md`), markdown);
|
|
887
|
+
|
|
888
|
+
if (config.agent[name]) {
|
|
889
|
+
delete config.agent[name];
|
|
890
|
+
saveConfig(config);
|
|
891
|
+
}
|
|
892
|
+
} else {
|
|
893
|
+
config.agent[name] = normalizedConfig;
|
|
894
|
+
saveConfig(config);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
triggerGitHubAutoSync();
|
|
898
|
+
res.json({ success: true });
|
|
899
|
+
} catch (err) {
|
|
900
|
+
res.status(500).json({ error: err.message });
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
app.put('/api/agents/:name', (req, res) => {
|
|
905
|
+
try {
|
|
906
|
+
const { name } = req.params;
|
|
907
|
+
const { config: agentConfig } = req.body || {};
|
|
908
|
+
|
|
909
|
+
const normalizedConfig = { ...(agentConfig || {}) };
|
|
910
|
+
if (normalizedConfig.permissions && !normalizedConfig.permission) {
|
|
911
|
+
normalizedConfig.permission = normalizedConfig.permissions;
|
|
912
|
+
delete normalizedConfig.permissions;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const config = loadConfig() || {};
|
|
916
|
+
if (!config.agent) config.agent = {};
|
|
917
|
+
|
|
918
|
+
const markdownDirs = getAgentDirs().filter((d) => fs.existsSync(d));
|
|
919
|
+
const markdownPath = markdownDirs
|
|
920
|
+
.map((d) => path.join(d, `${name}.md`))
|
|
921
|
+
.find((p) => fs.existsSync(p));
|
|
922
|
+
|
|
923
|
+
if (markdownPath) {
|
|
924
|
+
const frontmatter = {
|
|
925
|
+
description: normalizedConfig?.description,
|
|
926
|
+
mode: normalizedConfig?.mode,
|
|
927
|
+
model: normalizedConfig?.model,
|
|
928
|
+
temperature: normalizedConfig?.temperature,
|
|
929
|
+
tools: normalizedConfig?.tools,
|
|
930
|
+
permission: normalizedConfig?.permission,
|
|
931
|
+
maxSteps: normalizedConfig?.maxSteps,
|
|
932
|
+
disable: normalizedConfig?.disable,
|
|
933
|
+
hidden: normalizedConfig?.hidden
|
|
934
|
+
};
|
|
935
|
+
const markdown = buildAgentMarkdown(frontmatter, normalizedConfig?.prompt || '');
|
|
936
|
+
atomicWriteFileSync(markdownPath, markdown);
|
|
937
|
+
} else {
|
|
938
|
+
config.agent[name] = normalizedConfig;
|
|
939
|
+
saveConfig(config);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
triggerGitHubAutoSync();
|
|
943
|
+
res.json({ success: true });
|
|
944
|
+
} catch (err) {
|
|
945
|
+
res.status(500).json({ error: err.message });
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
app.delete('/api/agents/:name', (req, res) => {
|
|
950
|
+
try {
|
|
951
|
+
const { name } = req.params;
|
|
952
|
+
const config = loadConfig() || {};
|
|
953
|
+
|
|
954
|
+
if (config.agent && config.agent[name]) {
|
|
955
|
+
delete config.agent[name];
|
|
956
|
+
saveConfig(config);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
for (const dir of getAgentDirs()) {
|
|
960
|
+
const fp = path.join(dir, `${name}.md`);
|
|
961
|
+
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
triggerGitHubAutoSync();
|
|
965
|
+
res.json({ success: true });
|
|
966
|
+
} catch (err) {
|
|
967
|
+
res.status(500).json({ error: err.message });
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
app.post('/api/agents/:name/toggle', (req, res) => {
|
|
972
|
+
try {
|
|
973
|
+
const { name } = req.params;
|
|
974
|
+
const studio = loadStudioConfig();
|
|
975
|
+
const disabled = new Set(studio.disabledAgents || []);
|
|
976
|
+
if (disabled.has(name)) disabled.delete(name); else disabled.add(name);
|
|
977
|
+
studio.disabledAgents = Array.from(disabled);
|
|
978
|
+
saveStudioConfig(studio);
|
|
979
|
+
res.json({ success: true, disabled: studio.disabledAgents.includes(name) });
|
|
980
|
+
} catch (err) {
|
|
981
|
+
res.status(500).json({ error: err.message });
|
|
982
|
+
}
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
app.get('/api/logs/stream', (req, res) => {
|
|
986
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
987
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
988
|
+
res.setHeader('Connection', 'keep-alive');
|
|
989
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
990
|
+
if (res.flushHeaders) res.flushHeaders();
|
|
991
|
+
|
|
992
|
+
setupLogWatcher();
|
|
993
|
+
|
|
994
|
+
const sub = { res, queue: [], draining: false, closed: false };
|
|
995
|
+
logSubscribers.add(sub);
|
|
996
|
+
|
|
997
|
+
logBuffer.forEach((entry) => {
|
|
998
|
+
sub.queue.push(`data: ${JSON.stringify(entry)}\n\n`);
|
|
999
|
+
});
|
|
1000
|
+
flushSubscriber(sub);
|
|
1001
|
+
|
|
1002
|
+
const keepalive = setInterval(() => {
|
|
1003
|
+
if (sub.closed) return;
|
|
1004
|
+
res.write(': keepalive\n\n');
|
|
1005
|
+
}, 20000);
|
|
1006
|
+
|
|
1007
|
+
req.on('close', () => {
|
|
1008
|
+
sub.closed = true;
|
|
1009
|
+
clearInterval(keepalive);
|
|
1010
|
+
logSubscribers.delete(sub);
|
|
1011
|
+
try { res.end(); } catch {}
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
app.get('/api/system/tools', (req, res) => {
|
|
1016
|
+
const knownTools = [
|
|
1017
|
+
'go', 'gofmt', 'gopls',
|
|
1018
|
+
'rust-analyzer', 'rustfmt',
|
|
1019
|
+
'prettier', 'eslint',
|
|
1020
|
+
'typescript-language-server', 'tsserver',
|
|
1021
|
+
'pyright', 'ruff', 'python',
|
|
1022
|
+
'clangd', 'clang-format',
|
|
1023
|
+
'dart', 'jdtls',
|
|
1024
|
+
'kotlin-language-server', 'ktlint',
|
|
1025
|
+
'deno', 'lua-language-server',
|
|
1026
|
+
'ocamllsp', 'nixd',
|
|
1027
|
+
'swift', 'sourcekit-lsp'
|
|
1028
|
+
];
|
|
1029
|
+
|
|
1030
|
+
const tools = knownTools.map((tool) => detectTool(tool));
|
|
1031
|
+
res.json(tools);
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
app.get('/api/project/rules', (req, res) => {
|
|
1035
|
+
try {
|
|
1036
|
+
const found = findRulesFile();
|
|
1037
|
+
if (!found.path) return res.json({ content: '', source: 'none', path: null });
|
|
1038
|
+
const content = fs.readFileSync(found.path, 'utf8');
|
|
1039
|
+
res.json({ content, source: found.source, path: found.path });
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
res.status(500).json({ error: err.message });
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
app.post('/api/project/rules', (req, res) => {
|
|
1046
|
+
try {
|
|
1047
|
+
const { content, source } = req.body || {};
|
|
1048
|
+
const configPath = getConfigPath();
|
|
1049
|
+
if (!configPath) return res.status(400).json({ error: 'No config path found' });
|
|
1050
|
+
|
|
1051
|
+
const targetName = source === 'CLAUDE.md' ? 'CLAUDE.md' : 'AGENTS.md';
|
|
1052
|
+
const found = findRulesFile();
|
|
1053
|
+
|
|
1054
|
+
let targetPath = null;
|
|
1055
|
+
if (found.path && path.basename(found.path) === targetName) {
|
|
1056
|
+
targetPath = found.path;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
if (!targetPath) {
|
|
1060
|
+
const baseDir = path.dirname(configPath);
|
|
1061
|
+
targetPath = path.join(baseDir, targetName);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
atomicWriteFileSync(targetPath, content || '');
|
|
1065
|
+
res.json({ success: true, path: targetPath, source: targetName });
|
|
1066
|
+
} catch (err) {
|
|
1067
|
+
res.status(500).json({ error: err.message });
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
642
1070
|
|
|
643
1071
|
app.get('/api/backup', (req, res) => {
|
|
644
1072
|
try {
|
|
@@ -1094,11 +1522,11 @@ app.post('/api/ohmyopencode', (req, res) => {
|
|
|
1094
1522
|
|
|
1095
1523
|
saveOhMyOpenCodeConfig(currentConfig);
|
|
1096
1524
|
|
|
1097
|
-
const ohMyPath = getOhMyOpenCodeConfigPath();
|
|
1098
|
-
triggerGitHubAutoSync();
|
|
1099
|
-
res.json({
|
|
1100
|
-
success: true,
|
|
1101
|
-
path: ohMyPath,
|
|
1525
|
+
const ohMyPath = getOhMyOpenCodeConfigPath();
|
|
1526
|
+
triggerGitHubAutoSync();
|
|
1527
|
+
res.json({
|
|
1528
|
+
success: true,
|
|
1529
|
+
path: ohMyPath,
|
|
1102
1530
|
exists: true,
|
|
1103
1531
|
config: currentConfig,
|
|
1104
1532
|
preferences,
|
|
@@ -1202,107 +1630,107 @@ function execPromise(cmd, opts = {}) {
|
|
|
1202
1630
|
});
|
|
1203
1631
|
}
|
|
1204
1632
|
|
|
1205
|
-
|
|
1206
|
-
let autoSyncTimer = null;
|
|
1207
|
-
|
|
1208
|
-
async function performGitHubBackup(options = {}) {
|
|
1209
|
-
const { owner, repo, branch } = options;
|
|
1210
|
-
let tempDir = null;
|
|
1211
|
-
try {
|
|
1212
|
-
const token = await getGitHubToken();
|
|
1213
|
-
if (!token) throw new Error('Not logged in to gh CLI. Run: gh auth login');
|
|
1214
|
-
|
|
1215
|
-
const user = await getGitHubUser(token);
|
|
1216
|
-
if (!user) throw new Error('Failed to get GitHub user');
|
|
1217
|
-
|
|
1218
|
-
const studio = loadStudioConfig();
|
|
1219
|
-
|
|
1220
|
-
const finalOwner = owner || studio.githubBackup?.owner || user.login;
|
|
1221
|
-
const finalRepo = repo || studio.githubBackup?.repo;
|
|
1222
|
-
const finalBranch = branch || studio.githubBackup?.branch || 'main';
|
|
1223
|
-
|
|
1224
|
-
if (!finalRepo) throw new Error('No repo name provided');
|
|
1225
|
-
|
|
1226
|
-
const repoName = `${finalOwner}/${finalRepo}`;
|
|
1227
|
-
|
|
1228
|
-
await ensureGitHubRepo(token, repoName);
|
|
1229
|
-
|
|
1230
|
-
const opencodeConfig = getConfigPath();
|
|
1231
|
-
if (!opencodeConfig) throw new Error('No opencode config path found');
|
|
1232
|
-
|
|
1233
|
-
const opencodeDir = path.dirname(opencodeConfig);
|
|
1234
|
-
const studioDir = path.join(HOME_DIR, '.config', 'opencode-studio');
|
|
1235
|
-
|
|
1236
|
-
tempDir = path.join(os.tmpdir(), `opencode-backup-${Date.now()}`);
|
|
1237
|
-
fs.mkdirSync(tempDir, { recursive: true });
|
|
1238
|
-
|
|
1239
|
-
// Clone or init
|
|
1240
|
-
try {
|
|
1241
|
-
await execPromise(`git clone --depth 1 https://x-access-token:${token}@github.com/${repoName}.git .`, { cwd: tempDir });
|
|
1242
|
-
} catch (e) {
|
|
1243
|
-
// If clone fails (empty repo?), try init
|
|
1244
|
-
await execPromise('git init', { cwd: tempDir });
|
|
1245
|
-
await execPromise(`git remote add origin https://x-access-token:${token}@github.com/${repoName}.git`, { cwd: tempDir });
|
|
1246
|
-
await execPromise(`git checkout -b ${finalBranch}`, { cwd: tempDir });
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
const backupOpencodeDir = path.join(tempDir, 'opencode');
|
|
1250
|
-
const backupStudioDir = path.join(tempDir, 'opencode-studio');
|
|
1251
|
-
|
|
1252
|
-
if (fs.existsSync(backupOpencodeDir)) fs.rmSync(backupOpencodeDir, { recursive: true });
|
|
1253
|
-
if (fs.existsSync(backupStudioDir)) fs.rmSync(backupStudioDir, { recursive: true });
|
|
1254
|
-
|
|
1255
|
-
copyDirContents(opencodeDir, backupOpencodeDir);
|
|
1256
|
-
copyDirContents(studioDir, backupStudioDir);
|
|
1257
|
-
|
|
1258
|
-
await execPromise('git add -A', { cwd: tempDir });
|
|
1259
|
-
|
|
1260
|
-
const timestamp = new Date().toISOString();
|
|
1261
|
-
const commitMessage = `OpenCode Studio backup ${timestamp}`;
|
|
1262
|
-
|
|
1263
|
-
let result = { success: true, timestamp, url: `https://github.com/${repoName}` };
|
|
1264
|
-
|
|
1265
|
-
try {
|
|
1266
|
-
await execPromise(`git commit -m "${commitMessage}"`, { cwd: tempDir });
|
|
1267
|
-
await execPromise(`git push origin ${finalBranch}`, { cwd: tempDir });
|
|
1268
|
-
} catch (e) {
|
|
1269
|
-
if (e.message.includes('nothing to commit')) {
|
|
1270
|
-
result.message = 'No changes to backup';
|
|
1271
|
-
} else {
|
|
1272
|
-
throw e;
|
|
1273
|
-
}
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
studio.githubBackup = { owner: finalOwner, repo: finalRepo, branch: finalBranch };
|
|
1277
|
-
studio.lastGithubBackup = timestamp;
|
|
1278
|
-
saveStudioConfig(studio);
|
|
1279
|
-
|
|
1280
|
-
return result;
|
|
1281
|
-
} finally {
|
|
1282
|
-
if (tempDir && fs.existsSync(tempDir)) {
|
|
1283
|
-
try { fs.rmSync(tempDir, { recursive: true }); } catch (e) {}
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
function triggerGitHubAutoSync() {
|
|
1289
|
-
const studio = loadStudioConfig();
|
|
1290
|
-
if (!studio.githubAutoSync) return;
|
|
1291
|
-
|
|
1292
|
-
if (autoSyncTimer) clearTimeout(autoSyncTimer);
|
|
1293
|
-
|
|
1294
|
-
console.log('[AutoSync] Change detected, scheduling GitHub backup in 15s...');
|
|
1295
|
-
autoSyncTimer = setTimeout(async () => {
|
|
1296
|
-
console.log('[AutoSync] Starting GitHub backup...');
|
|
1297
|
-
try {
|
|
1298
|
-
const result = await performGitHubBackup();
|
|
1299
|
-
console.log(`[AutoSync] Backup completed: ${result.message || 'Pushed to GitHub'}`);
|
|
1300
|
-
} catch (err) {
|
|
1301
|
-
console.error('[AutoSync] Backup failed:', err.message);
|
|
1302
|
-
}
|
|
1303
|
-
}, 15000); // 15s debounce
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1633
|
+
|
|
1634
|
+
let autoSyncTimer = null;
|
|
1635
|
+
|
|
1636
|
+
async function performGitHubBackup(options = {}) {
|
|
1637
|
+
const { owner, repo, branch } = options;
|
|
1638
|
+
let tempDir = null;
|
|
1639
|
+
try {
|
|
1640
|
+
const token = await getGitHubToken();
|
|
1641
|
+
if (!token) throw new Error('Not logged in to gh CLI. Run: gh auth login');
|
|
1642
|
+
|
|
1643
|
+
const user = await getGitHubUser(token);
|
|
1644
|
+
if (!user) throw new Error('Failed to get GitHub user');
|
|
1645
|
+
|
|
1646
|
+
const studio = loadStudioConfig();
|
|
1647
|
+
|
|
1648
|
+
const finalOwner = owner || studio.githubBackup?.owner || user.login;
|
|
1649
|
+
const finalRepo = repo || studio.githubBackup?.repo;
|
|
1650
|
+
const finalBranch = branch || studio.githubBackup?.branch || 'main';
|
|
1651
|
+
|
|
1652
|
+
if (!finalRepo) throw new Error('No repo name provided');
|
|
1653
|
+
|
|
1654
|
+
const repoName = `${finalOwner}/${finalRepo}`;
|
|
1655
|
+
|
|
1656
|
+
await ensureGitHubRepo(token, repoName);
|
|
1657
|
+
|
|
1658
|
+
const opencodeConfig = getConfigPath();
|
|
1659
|
+
if (!opencodeConfig) throw new Error('No opencode config path found');
|
|
1660
|
+
|
|
1661
|
+
const opencodeDir = path.dirname(opencodeConfig);
|
|
1662
|
+
const studioDir = path.join(HOME_DIR, '.config', 'opencode-studio');
|
|
1663
|
+
|
|
1664
|
+
tempDir = path.join(os.tmpdir(), `opencode-backup-${Date.now()}`);
|
|
1665
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
1666
|
+
|
|
1667
|
+
// Clone or init
|
|
1668
|
+
try {
|
|
1669
|
+
await execPromise(`git clone --depth 1 https://x-access-token:${token}@github.com/${repoName}.git .`, { cwd: tempDir });
|
|
1670
|
+
} catch (e) {
|
|
1671
|
+
// If clone fails (empty repo?), try init
|
|
1672
|
+
await execPromise('git init', { cwd: tempDir });
|
|
1673
|
+
await execPromise(`git remote add origin https://x-access-token:${token}@github.com/${repoName}.git`, { cwd: tempDir });
|
|
1674
|
+
await execPromise(`git checkout -b ${finalBranch}`, { cwd: tempDir });
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
const backupOpencodeDir = path.join(tempDir, 'opencode');
|
|
1678
|
+
const backupStudioDir = path.join(tempDir, 'opencode-studio');
|
|
1679
|
+
|
|
1680
|
+
if (fs.existsSync(backupOpencodeDir)) fs.rmSync(backupOpencodeDir, { recursive: true });
|
|
1681
|
+
if (fs.existsSync(backupStudioDir)) fs.rmSync(backupStudioDir, { recursive: true });
|
|
1682
|
+
|
|
1683
|
+
copyDirContents(opencodeDir, backupOpencodeDir);
|
|
1684
|
+
copyDirContents(studioDir, backupStudioDir);
|
|
1685
|
+
|
|
1686
|
+
await execPromise('git add -A', { cwd: tempDir });
|
|
1687
|
+
|
|
1688
|
+
const timestamp = new Date().toISOString();
|
|
1689
|
+
const commitMessage = `OpenCode Studio backup ${timestamp}`;
|
|
1690
|
+
|
|
1691
|
+
let result = { success: true, timestamp, url: `https://github.com/${repoName}` };
|
|
1692
|
+
|
|
1693
|
+
try {
|
|
1694
|
+
await execPromise(`git commit -m "${commitMessage}"`, { cwd: tempDir });
|
|
1695
|
+
await execPromise(`git push origin ${finalBranch}`, { cwd: tempDir });
|
|
1696
|
+
} catch (e) {
|
|
1697
|
+
if (e.message.includes('nothing to commit')) {
|
|
1698
|
+
result.message = 'No changes to backup';
|
|
1699
|
+
} else {
|
|
1700
|
+
throw e;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
studio.githubBackup = { owner: finalOwner, repo: finalRepo, branch: finalBranch };
|
|
1705
|
+
studio.lastGithubBackup = timestamp;
|
|
1706
|
+
saveStudioConfig(studio);
|
|
1707
|
+
|
|
1708
|
+
return result;
|
|
1709
|
+
} finally {
|
|
1710
|
+
if (tempDir && fs.existsSync(tempDir)) {
|
|
1711
|
+
try { fs.rmSync(tempDir, { recursive: true }); } catch (e) {}
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function triggerGitHubAutoSync() {
|
|
1717
|
+
const studio = loadStudioConfig();
|
|
1718
|
+
if (!studio.githubAutoSync) return;
|
|
1719
|
+
|
|
1720
|
+
if (autoSyncTimer) clearTimeout(autoSyncTimer);
|
|
1721
|
+
|
|
1722
|
+
console.log('[AutoSync] Change detected, scheduling GitHub backup in 15s...');
|
|
1723
|
+
autoSyncTimer = setTimeout(async () => {
|
|
1724
|
+
console.log('[AutoSync] Starting GitHub backup...');
|
|
1725
|
+
try {
|
|
1726
|
+
const result = await performGitHubBackup();
|
|
1727
|
+
console.log(`[AutoSync] Backup completed: ${result.message || 'Pushed to GitHub'}`);
|
|
1728
|
+
} catch (err) {
|
|
1729
|
+
console.error('[AutoSync] Backup failed:', err.message);
|
|
1730
|
+
}
|
|
1731
|
+
}, 15000); // 15s debounce
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1306
1734
|
app.get('/api/github/backup/status', async (req, res) => {
|
|
1307
1735
|
try {
|
|
1308
1736
|
const token = await getGitHubToken();
|
|
@@ -1334,15 +1762,15 @@ app.get('/api/github/backup/status', async (req, res) => {
|
|
|
1334
1762
|
}
|
|
1335
1763
|
});
|
|
1336
1764
|
|
|
1337
|
-
app.post('/api/github/backup', async (req, res) => {
|
|
1338
|
-
try {
|
|
1339
|
-
const result = await performGitHubBackup(req.body);
|
|
1340
|
-
res.json(result);
|
|
1341
|
-
} catch (err) {
|
|
1342
|
-
console.error('GitHub backup error:', err);
|
|
1343
|
-
res.status(500).json({ error: err.message });
|
|
1344
|
-
}
|
|
1345
|
-
});
|
|
1765
|
+
app.post('/api/github/backup', async (req, res) => {
|
|
1766
|
+
try {
|
|
1767
|
+
const result = await performGitHubBackup(req.body);
|
|
1768
|
+
res.json(result);
|
|
1769
|
+
} catch (err) {
|
|
1770
|
+
console.error('GitHub backup error:', err);
|
|
1771
|
+
res.status(500).json({ error: err.message });
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1346
1774
|
|
|
1347
1775
|
app.post('/api/github/restore', async (req, res) => {
|
|
1348
1776
|
let tempDir = null;
|
|
@@ -1401,10 +1829,10 @@ app.post('/api/github/restore', async (req, res) => {
|
|
|
1401
1829
|
app.post('/api/github/autosync', async (req, res) => {
|
|
1402
1830
|
const studio = loadStudioConfig();
|
|
1403
1831
|
const enabled = req.body.enabled;
|
|
1404
|
-
studio.githubAutoSync = enabled;
|
|
1405
|
-
saveStudioConfig(studio);
|
|
1406
|
-
if (enabled) triggerGitHubAutoSync();
|
|
1407
|
-
res.json({ success: true, enabled });
|
|
1832
|
+
studio.githubAutoSync = enabled;
|
|
1833
|
+
saveStudioConfig(studio);
|
|
1834
|
+
if (enabled) triggerGitHubAutoSync();
|
|
1835
|
+
res.json({ success: true, enabled });
|
|
1408
1836
|
});
|
|
1409
1837
|
|
|
1410
1838
|
const getSkillDir = () => {
|
|
@@ -1441,9 +1869,9 @@ app.post('/api/skills/:name', (req, res) => {
|
|
|
1441
1869
|
if (!sd) return res.status(404).json({ error: 'No config' });
|
|
1442
1870
|
const dp = path.join(sd, req.params.name);
|
|
1443
1871
|
if (!fs.existsSync(dp)) fs.mkdirSync(dp, { recursive: true });
|
|
1444
|
-
fs.writeFileSync(path.join(dp, 'SKILL.md'), req.body.content, 'utf8');
|
|
1445
|
-
triggerGitHubAutoSync();
|
|
1446
|
-
res.json({ success: true });
|
|
1872
|
+
fs.writeFileSync(path.join(dp, 'SKILL.md'), req.body.content, 'utf8');
|
|
1873
|
+
triggerGitHubAutoSync();
|
|
1874
|
+
res.json({ success: true });
|
|
1447
1875
|
});
|
|
1448
1876
|
|
|
1449
1877
|
app.delete('/api/skills/:name', (req, res) => {
|
|
@@ -1452,9 +1880,9 @@ app.delete('/api/skills/:name', (req, res) => {
|
|
|
1452
1880
|
}
|
|
1453
1881
|
const sd = getSkillDir();
|
|
1454
1882
|
const dp = sd ? path.join(sd, req.params.name) : null;
|
|
1455
|
-
if (dp && fs.existsSync(dp)) fs.rmSync(dp, { recursive: true, force: true });
|
|
1456
|
-
triggerGitHubAutoSync();
|
|
1457
|
-
res.json({ success: true });
|
|
1883
|
+
if (dp && fs.existsSync(dp)) fs.rmSync(dp, { recursive: true, force: true });
|
|
1884
|
+
triggerGitHubAutoSync();
|
|
1885
|
+
res.json({ success: true });
|
|
1458
1886
|
});
|
|
1459
1887
|
|
|
1460
1888
|
app.post('/api/skills/:name/toggle', (req, res) => {
|
|
@@ -1468,9 +1896,9 @@ app.post('/api/skills/:name/toggle', (req, res) => {
|
|
|
1468
1896
|
studio.disabledSkills.push(name);
|
|
1469
1897
|
}
|
|
1470
1898
|
|
|
1471
|
-
saveStudioConfig(studio);
|
|
1472
|
-
triggerGitHubAutoSync();
|
|
1473
|
-
res.json({ success: true, enabled: !studio.disabledSkills.includes(name) });
|
|
1899
|
+
saveStudioConfig(studio);
|
|
1900
|
+
triggerGitHubAutoSync();
|
|
1901
|
+
res.json({ success: true, enabled: !studio.disabledSkills.includes(name) });
|
|
1474
1902
|
});
|
|
1475
1903
|
|
|
1476
1904
|
const getPluginDir = () => {
|
|
@@ -1547,10 +1975,10 @@ app.post('/api/plugins/:name', (req, res) => {
|
|
|
1547
1975
|
if (!fs.existsSync(pd)) fs.mkdirSync(pd, { recursive: true });
|
|
1548
1976
|
|
|
1549
1977
|
// Default to .js if new
|
|
1550
|
-
const filePath = path.join(pd, name.endsWith('.js') || name.endsWith('.ts') ? name : name + '.js');
|
|
1551
|
-
atomicWriteFileSync(filePath, content);
|
|
1552
|
-
triggerGitHubAutoSync();
|
|
1553
|
-
res.json({ success: true });
|
|
1978
|
+
const filePath = path.join(pd, name.endsWith('.js') || name.endsWith('.ts') ? name : name + '.js');
|
|
1979
|
+
atomicWriteFileSync(filePath, content);
|
|
1980
|
+
triggerGitHubAutoSync();
|
|
1981
|
+
res.json({ success: true });
|
|
1554
1982
|
});
|
|
1555
1983
|
|
|
1556
1984
|
app.delete('/api/plugins/:name', (req, res) => {
|
|
@@ -1575,10 +2003,10 @@ app.delete('/api/plugins/:name', (req, res) => {
|
|
|
1575
2003
|
}
|
|
1576
2004
|
}
|
|
1577
2005
|
|
|
1578
|
-
if (deleted) {
|
|
1579
|
-
triggerGitHubAutoSync();
|
|
1580
|
-
res.json({ success: true });
|
|
1581
|
-
} else res.status(404).json({ error: 'Plugin not found' });
|
|
2006
|
+
if (deleted) {
|
|
2007
|
+
triggerGitHubAutoSync();
|
|
2008
|
+
res.json({ success: true });
|
|
2009
|
+
} else res.status(404).json({ error: 'Plugin not found' });
|
|
1582
2010
|
});
|
|
1583
2011
|
|
|
1584
2012
|
app.post('/api/plugins/:name/toggle', (req, res) => {
|
|
@@ -1592,9 +2020,9 @@ app.post('/api/plugins/:name/toggle', (req, res) => {
|
|
|
1592
2020
|
studio.disabledPlugins.push(name);
|
|
1593
2021
|
}
|
|
1594
2022
|
|
|
1595
|
-
saveStudioConfig(studio);
|
|
1596
|
-
triggerGitHubAutoSync();
|
|
1597
|
-
res.json({ success: true, enabled: !studio.disabledPlugins.includes(name) });
|
|
2023
|
+
saveStudioConfig(studio);
|
|
2024
|
+
triggerGitHubAutoSync();
|
|
2025
|
+
res.json({ success: true, enabled: !studio.disabledPlugins.includes(name) });
|
|
1598
2026
|
});
|
|
1599
2027
|
|
|
1600
2028
|
const getActiveGooglePlugin = () => {
|
|
@@ -3489,18 +3917,20 @@ app.post('/api/presets/:id/apply', (req, res) => {
|
|
|
3489
3917
|
});
|
|
3490
3918
|
|
|
3491
3919
|
// Start watcher on server start
|
|
3492
|
-
function startServer() {
|
|
3920
|
+
async function startServer() {
|
|
3493
3921
|
['google', 'anthropic', 'openai', 'xai', 'openrouter', 'together', 'mistral', 'deepseek', 'amazon-bedrock', 'azure', 'github-copilot'].forEach(p => importCurrentAuthToPool(p));
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3922
|
+
|
|
3923
|
+
const port = await findAvailablePort(DEFAULT_PORT);
|
|
3924
|
+
app.listen(port, () => {
|
|
3925
|
+
console.log(`Server running at http://localhost:${port}`);
|
|
3926
|
+
// Initial sync on startup if enabled
|
|
3927
|
+
setTimeout(() => {
|
|
3928
|
+
const studio = loadStudioConfig();
|
|
3929
|
+
if (studio.githubAutoSync) {
|
|
3930
|
+
console.log('[AutoSync] Triggering initial sync...');
|
|
3931
|
+
triggerGitHubAutoSync();
|
|
3932
|
+
}
|
|
3933
|
+
}, 5000);
|
|
3504
3934
|
});
|
|
3505
3935
|
}
|
|
3506
3936
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "opencode-studio-server",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Backend server for OpenCode Studio - manages opencode configurations",
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-studio-server",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Backend server for OpenCode Studio - manages opencode configurations",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"opencode-studio-server": "cli.js"
|