opencode-studio-server 2.0.0 → 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/index.js +655 -243
- package/package.json +1 -1
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/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');
|
|
@@ -40,23 +41,23 @@ const atomicWriteFileSync = (filePath, data, options = 'utf8') => {
|
|
|
40
41
|
}
|
|
41
42
|
};
|
|
42
43
|
|
|
43
|
-
const app = express();
|
|
44
|
-
const DEFAULT_PORT = 1920;
|
|
45
|
-
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
46
|
-
|
|
47
|
-
function findAvailablePort(startPort) {
|
|
48
|
-
const net = require('net');
|
|
49
|
-
return new Promise((resolve) => {
|
|
50
|
-
const server = net.createServer();
|
|
51
|
-
server.listen(startPort, () => {
|
|
52
|
-
server.once('close', () => resolve(startPort));
|
|
53
|
-
server.close();
|
|
54
|
-
});
|
|
55
|
-
server.on('error', () => {
|
|
56
|
-
findAvailablePort(startPort + 1).then(resolve);
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
}
|
|
44
|
+
const app = express();
|
|
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
|
+
}
|
|
60
61
|
|
|
61
62
|
let idleTimer = null;
|
|
62
63
|
|
|
@@ -75,17 +76,17 @@ app.use((req, res, next) => {
|
|
|
75
76
|
next();
|
|
76
77
|
});
|
|
77
78
|
|
|
78
|
-
const ALLOWED_ORIGINS = [
|
|
79
|
-
'http://localhost:1080',
|
|
80
|
-
'http://127.0.0.1:1080',
|
|
81
|
-
/^http:\/\/localhost:108\d$/,
|
|
82
|
-
/^http:\/\/127\.0\.0\.1:108\d$/,
|
|
83
|
-
'https://opencode-studio.vercel.app',
|
|
84
|
-
'https://opencode.micr.dev',
|
|
85
|
-
'https://opencode-studio.micr.dev',
|
|
86
|
-
/\.vercel\.app$/,
|
|
87
|
-
/\.micr\.dev$/,
|
|
88
|
-
];
|
|
79
|
+
const ALLOWED_ORIGINS = [
|
|
80
|
+
'http://localhost:1080',
|
|
81
|
+
'http://127.0.0.1:1080',
|
|
82
|
+
/^http:\/\/localhost:108\d$/,
|
|
83
|
+
/^http:\/\/127\.0\.0\.1:108\d$/,
|
|
84
|
+
'https://opencode-studio.vercel.app',
|
|
85
|
+
'https://opencode.micr.dev',
|
|
86
|
+
'https://opencode-studio.micr.dev',
|
|
87
|
+
/\.vercel\.app$/,
|
|
88
|
+
/\.micr\.dev$/,
|
|
89
|
+
];
|
|
89
90
|
|
|
90
91
|
app.use(cors({
|
|
91
92
|
origin: (origin, callback) => {
|
|
@@ -101,15 +102,47 @@ app.use(bodyParser.json({ limit: '50mb' }));
|
|
|
101
102
|
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
|
102
103
|
app.use(bodyParser.text({ type: ['text/*', 'application/yaml'], limit: '50mb' }));
|
|
103
104
|
|
|
104
|
-
const HOME_DIR = os.homedir();
|
|
105
|
-
const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'studio.json');
|
|
106
|
-
const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json');
|
|
107
|
-
const ANTIGRAVITY_ACCOUNTS_PATH = path.join(HOME_DIR, '.config', 'opencode', 'antigravity-accounts.json');
|
|
108
|
-
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();
|
|
109
114
|
|
|
110
|
-
let logWatcher = null;
|
|
111
|
-
let currentLogFile = null;
|
|
112
|
-
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
|
+
}
|
|
113
146
|
|
|
114
147
|
function setupLogWatcher() {
|
|
115
148
|
if (!fs.existsSync(LOG_DIR)) return;
|
|
@@ -188,11 +221,12 @@ function setupLogWatcher() {
|
|
|
188
221
|
});
|
|
189
222
|
}
|
|
190
223
|
|
|
191
|
-
function processLogLine(line) {
|
|
192
|
-
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
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'));
|
|
196
230
|
|
|
197
231
|
if (!isUsage && !isError) return;
|
|
198
232
|
|
|
@@ -304,12 +338,13 @@ function processLogLine(line) {
|
|
|
304
338
|
|
|
305
339
|
let pendingActionMemory = null;
|
|
306
340
|
|
|
307
|
-
function loadStudioConfig() {
|
|
308
|
-
const defaultConfig = {
|
|
309
|
-
disabledSkills: [],
|
|
310
|
-
disabledPlugins: [],
|
|
311
|
-
|
|
312
|
-
|
|
341
|
+
function loadStudioConfig() {
|
|
342
|
+
const defaultConfig = {
|
|
343
|
+
disabledSkills: [],
|
|
344
|
+
disabledPlugins: [],
|
|
345
|
+
disabledAgents: [],
|
|
346
|
+
activeProfiles: {},
|
|
347
|
+
activeGooglePlugin: 'gemini',
|
|
313
348
|
availableGooglePlugins: [],
|
|
314
349
|
presets: [],
|
|
315
350
|
githubRepo: null,
|
|
@@ -357,9 +392,9 @@ function loadStudioConfig() {
|
|
|
357
392
|
"variants": {
|
|
358
393
|
"low": { "options": { "thinkingConfig": { "thinkingLevel": "low", "includeThoughts": true } } },
|
|
359
394
|
"medium": { "options": { "thinkingConfig": { "thinkingLevel": "medium", "includeThoughts": true } } },
|
|
360
|
-
"high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
|
|
361
|
-
"xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
|
|
362
|
-
}
|
|
395
|
+
"high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
|
|
396
|
+
"xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
|
|
397
|
+
}
|
|
363
398
|
},
|
|
364
399
|
"gemini-3-flash": {
|
|
365
400
|
"id": "gemini-3-flash",
|
|
@@ -376,9 +411,9 @@ function loadStudioConfig() {
|
|
|
376
411
|
"minimal": { "options": { "thinkingConfig": { "thinkingLevel": "minimal", "includeThoughts": true } } },
|
|
377
412
|
"low": { "options": { "thinkingConfig": { "thinkingLevel": "low", "includeThoughts": true } } },
|
|
378
413
|
"medium": { "options": { "thinkingConfig": { "thinkingLevel": "medium", "includeThoughts": true } } },
|
|
379
|
-
"high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
|
|
380
|
-
"xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
|
|
381
|
-
}
|
|
414
|
+
"high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
|
|
415
|
+
"xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
|
|
416
|
+
}
|
|
382
417
|
},
|
|
383
418
|
"gemini-2.5-flash-lite": {
|
|
384
419
|
"id": "gemini-2.5-flash-lite",
|
|
@@ -399,9 +434,9 @@ function loadStudioConfig() {
|
|
|
399
434
|
"minimal": { "options": { "thinkingConfig": { "thinkingLevel": "minimal", "includeThoughts": true } } },
|
|
400
435
|
"low": { "options": { "thinkingConfig": { "thinkingLevel": "low", "includeThoughts": true } } },
|
|
401
436
|
"medium": { "options": { "thinkingConfig": { "thinkingLevel": "medium", "includeThoughts": true } } },
|
|
402
|
-
"high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
|
|
403
|
-
"xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
|
|
404
|
-
}
|
|
437
|
+
"high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
|
|
438
|
+
"xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
|
|
439
|
+
}
|
|
405
440
|
},
|
|
406
441
|
"opencode/glm-4.7-free": {
|
|
407
442
|
"id": "opencode/glm-4.7-free",
|
|
@@ -427,9 +462,9 @@ function loadStudioConfig() {
|
|
|
427
462
|
"none": { "reasoning": false, "options": { "thinkingConfig": { "includeThoughts": false } } },
|
|
428
463
|
"low": { "options": { "thinkingConfig": { "thinkingBudget": 4000, "includeThoughts": true } } },
|
|
429
464
|
"medium": { "options": { "thinkingConfig": { "thinkingBudget": 16000, "includeThoughts": true } } },
|
|
430
|
-
"high": { "options": { "thinkingConfig": { "thinkingBudget": 32000, "includeThoughts": true } } },
|
|
431
|
-
"xhigh": { "options": { "thinkingConfig": { "thinkingBudget": 64000, "includeThoughts": true } } }
|
|
432
|
-
}
|
|
465
|
+
"high": { "options": { "thinkingConfig": { "thinkingBudget": 32000, "includeThoughts": true } } },
|
|
466
|
+
"xhigh": { "options": { "thinkingConfig": { "thinkingBudget": 64000, "includeThoughts": true } } }
|
|
467
|
+
}
|
|
433
468
|
},
|
|
434
469
|
"gemini-claude-opus-4-5-thinking": {
|
|
435
470
|
"id": "gemini-claude-opus-4-5-thinking",
|
|
@@ -444,9 +479,9 @@ function loadStudioConfig() {
|
|
|
444
479
|
"variants": {
|
|
445
480
|
"low": { "options": { "thinkingConfig": { "thinkingBudget": 4000, "includeThoughts": true } } },
|
|
446
481
|
"medium": { "options": { "thinkingConfig": { "thinkingBudget": 16000, "includeThoughts": true } } },
|
|
447
|
-
"high": { "options": { "thinkingConfig": { "thinkingBudget": 32000, "includeThoughts": true } } },
|
|
448
|
-
"xhigh": { "options": { "thinkingConfig": { "thinkingBudget": 64000, "includeThoughts": true } } }
|
|
449
|
-
}
|
|
482
|
+
"high": { "options": { "thinkingConfig": { "thinkingBudget": 32000, "includeThoughts": true } } },
|
|
483
|
+
"xhigh": { "options": { "thinkingConfig": { "thinkingBudget": 64000, "includeThoughts": true } } }
|
|
484
|
+
}
|
|
450
485
|
}
|
|
451
486
|
}
|
|
452
487
|
}
|
|
@@ -511,15 +546,15 @@ const getPaths = () => {
|
|
|
511
546
|
}
|
|
512
547
|
}
|
|
513
548
|
|
|
514
|
-
const studioConfig = loadStudioConfig();
|
|
515
|
-
let manualPath = studioConfig.configPath;
|
|
516
|
-
|
|
517
|
-
if (manualPath && fs.existsSync(manualPath) && fs.statSync(manualPath).isDirectory()) {
|
|
518
|
-
const potentialFile = path.join(manualPath, 'opencode.json');
|
|
519
|
-
if (fs.existsSync(potentialFile)) {
|
|
520
|
-
manualPath = potentialFile;
|
|
521
|
-
}
|
|
522
|
-
}
|
|
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
|
+
}
|
|
523
558
|
|
|
524
559
|
let detected = null;
|
|
525
560
|
for (const p of candidates) {
|
|
@@ -543,7 +578,101 @@ const getOhMyOpenCodeConfigPath = () => {
|
|
|
543
578
|
return path.join(path.dirname(cp), 'oh-my-opencode.json');
|
|
544
579
|
};
|
|
545
580
|
|
|
546
|
-
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
|
+
};
|
|
547
676
|
|
|
548
677
|
const loadConfig = () => {
|
|
549
678
|
const configPath = getConfigPath();
|
|
@@ -625,20 +754,20 @@ app.get('/api/debug/auth', (req, res) => {
|
|
|
625
754
|
});
|
|
626
755
|
});
|
|
627
756
|
|
|
628
|
-
app.post('/api/paths', (req, res) => {
|
|
629
|
-
const { configPath } = req.body;
|
|
630
|
-
const studioConfig = loadStudioConfig();
|
|
631
|
-
|
|
632
|
-
if (configPath && fs.existsSync(configPath) && fs.statSync(configPath).isDirectory()) {
|
|
633
|
-
const potentialFile = path.join(configPath, 'opencode.json');
|
|
634
|
-
studioConfig.configPath = potentialFile;
|
|
635
|
-
} else {
|
|
636
|
-
studioConfig.configPath = configPath;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
saveStudioConfig(studioConfig);
|
|
640
|
-
res.json({ success: true, current: getConfigPath() });
|
|
641
|
-
});
|
|
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
|
+
});
|
|
642
771
|
|
|
643
772
|
app.get('/api/config', (req, res) => {
|
|
644
773
|
const config = loadConfig();
|
|
@@ -648,6 +777,9 @@ app.get('/api/config', (req, res) => {
|
|
|
648
777
|
|
|
649
778
|
app.post('/api/config', (req, res) => {
|
|
650
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
|
+
}
|
|
651
783
|
saveConfig(req.body);
|
|
652
784
|
triggerGitHubAutoSync();
|
|
653
785
|
res.json({ success: true });
|
|
@@ -655,6 +787,286 @@ app.post('/api/config', (req, res) => {
|
|
|
655
787
|
res.status(500).json({ error: err.message });
|
|
656
788
|
}
|
|
657
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
|
+
});
|
|
658
1070
|
|
|
659
1071
|
app.get('/api/backup', (req, res) => {
|
|
660
1072
|
try {
|
|
@@ -1110,11 +1522,11 @@ app.post('/api/ohmyopencode', (req, res) => {
|
|
|
1110
1522
|
|
|
1111
1523
|
saveOhMyOpenCodeConfig(currentConfig);
|
|
1112
1524
|
|
|
1113
|
-
const ohMyPath = getOhMyOpenCodeConfigPath();
|
|
1114
|
-
triggerGitHubAutoSync();
|
|
1115
|
-
res.json({
|
|
1116
|
-
success: true,
|
|
1117
|
-
path: ohMyPath,
|
|
1525
|
+
const ohMyPath = getOhMyOpenCodeConfigPath();
|
|
1526
|
+
triggerGitHubAutoSync();
|
|
1527
|
+
res.json({
|
|
1528
|
+
success: true,
|
|
1529
|
+
path: ohMyPath,
|
|
1118
1530
|
exists: true,
|
|
1119
1531
|
config: currentConfig,
|
|
1120
1532
|
preferences,
|
|
@@ -1218,107 +1630,107 @@ function execPromise(cmd, opts = {}) {
|
|
|
1218
1630
|
});
|
|
1219
1631
|
}
|
|
1220
1632
|
|
|
1221
|
-
|
|
1222
|
-
let autoSyncTimer = null;
|
|
1223
|
-
|
|
1224
|
-
async function performGitHubBackup(options = {}) {
|
|
1225
|
-
const { owner, repo, branch } = options;
|
|
1226
|
-
let tempDir = null;
|
|
1227
|
-
try {
|
|
1228
|
-
const token = await getGitHubToken();
|
|
1229
|
-
if (!token) throw new Error('Not logged in to gh CLI. Run: gh auth login');
|
|
1230
|
-
|
|
1231
|
-
const user = await getGitHubUser(token);
|
|
1232
|
-
if (!user) throw new Error('Failed to get GitHub user');
|
|
1233
|
-
|
|
1234
|
-
const studio = loadStudioConfig();
|
|
1235
|
-
|
|
1236
|
-
const finalOwner = owner || studio.githubBackup?.owner || user.login;
|
|
1237
|
-
const finalRepo = repo || studio.githubBackup?.repo;
|
|
1238
|
-
const finalBranch = branch || studio.githubBackup?.branch || 'main';
|
|
1239
|
-
|
|
1240
|
-
if (!finalRepo) throw new Error('No repo name provided');
|
|
1241
|
-
|
|
1242
|
-
const repoName = `${finalOwner}/${finalRepo}`;
|
|
1243
|
-
|
|
1244
|
-
await ensureGitHubRepo(token, repoName);
|
|
1245
|
-
|
|
1246
|
-
const opencodeConfig = getConfigPath();
|
|
1247
|
-
if (!opencodeConfig) throw new Error('No opencode config path found');
|
|
1248
|
-
|
|
1249
|
-
const opencodeDir = path.dirname(opencodeConfig);
|
|
1250
|
-
const studioDir = path.join(HOME_DIR, '.config', 'opencode-studio');
|
|
1251
|
-
|
|
1252
|
-
tempDir = path.join(os.tmpdir(), `opencode-backup-${Date.now()}`);
|
|
1253
|
-
fs.mkdirSync(tempDir, { recursive: true });
|
|
1254
|
-
|
|
1255
|
-
// Clone or init
|
|
1256
|
-
try {
|
|
1257
|
-
await execPromise(`git clone --depth 1 https://x-access-token:${token}@github.com/${repoName}.git .`, { cwd: tempDir });
|
|
1258
|
-
} catch (e) {
|
|
1259
|
-
// If clone fails (empty repo?), try init
|
|
1260
|
-
await execPromise('git init', { cwd: tempDir });
|
|
1261
|
-
await execPromise(`git remote add origin https://x-access-token:${token}@github.com/${repoName}.git`, { cwd: tempDir });
|
|
1262
|
-
await execPromise(`git checkout -b ${finalBranch}`, { cwd: tempDir });
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
const backupOpencodeDir = path.join(tempDir, 'opencode');
|
|
1266
|
-
const backupStudioDir = path.join(tempDir, 'opencode-studio');
|
|
1267
|
-
|
|
1268
|
-
if (fs.existsSync(backupOpencodeDir)) fs.rmSync(backupOpencodeDir, { recursive: true });
|
|
1269
|
-
if (fs.existsSync(backupStudioDir)) fs.rmSync(backupStudioDir, { recursive: true });
|
|
1270
|
-
|
|
1271
|
-
copyDirContents(opencodeDir, backupOpencodeDir);
|
|
1272
|
-
copyDirContents(studioDir, backupStudioDir);
|
|
1273
|
-
|
|
1274
|
-
await execPromise('git add -A', { cwd: tempDir });
|
|
1275
|
-
|
|
1276
|
-
const timestamp = new Date().toISOString();
|
|
1277
|
-
const commitMessage = `OpenCode Studio backup ${timestamp}`;
|
|
1278
|
-
|
|
1279
|
-
let result = { success: true, timestamp, url: `https://github.com/${repoName}` };
|
|
1280
|
-
|
|
1281
|
-
try {
|
|
1282
|
-
await execPromise(`git commit -m "${commitMessage}"`, { cwd: tempDir });
|
|
1283
|
-
await execPromise(`git push origin ${finalBranch}`, { cwd: tempDir });
|
|
1284
|
-
} catch (e) {
|
|
1285
|
-
if (e.message.includes('nothing to commit')) {
|
|
1286
|
-
result.message = 'No changes to backup';
|
|
1287
|
-
} else {
|
|
1288
|
-
throw e;
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
studio.githubBackup = { owner: finalOwner, repo: finalRepo, branch: finalBranch };
|
|
1293
|
-
studio.lastGithubBackup = timestamp;
|
|
1294
|
-
saveStudioConfig(studio);
|
|
1295
|
-
|
|
1296
|
-
return result;
|
|
1297
|
-
} finally {
|
|
1298
|
-
if (tempDir && fs.existsSync(tempDir)) {
|
|
1299
|
-
try { fs.rmSync(tempDir, { recursive: true }); } catch (e) {}
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
function triggerGitHubAutoSync() {
|
|
1305
|
-
const studio = loadStudioConfig();
|
|
1306
|
-
if (!studio.githubAutoSync) return;
|
|
1307
|
-
|
|
1308
|
-
if (autoSyncTimer) clearTimeout(autoSyncTimer);
|
|
1309
|
-
|
|
1310
|
-
console.log('[AutoSync] Change detected, scheduling GitHub backup in 15s...');
|
|
1311
|
-
autoSyncTimer = setTimeout(async () => {
|
|
1312
|
-
console.log('[AutoSync] Starting GitHub backup...');
|
|
1313
|
-
try {
|
|
1314
|
-
const result = await performGitHubBackup();
|
|
1315
|
-
console.log(`[AutoSync] Backup completed: ${result.message || 'Pushed to GitHub'}`);
|
|
1316
|
-
} catch (err) {
|
|
1317
|
-
console.error('[AutoSync] Backup failed:', err.message);
|
|
1318
|
-
}
|
|
1319
|
-
}, 15000); // 15s debounce
|
|
1320
|
-
}
|
|
1321
|
-
|
|
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
|
+
|
|
1322
1734
|
app.get('/api/github/backup/status', async (req, res) => {
|
|
1323
1735
|
try {
|
|
1324
1736
|
const token = await getGitHubToken();
|
|
@@ -1350,15 +1762,15 @@ app.get('/api/github/backup/status', async (req, res) => {
|
|
|
1350
1762
|
}
|
|
1351
1763
|
});
|
|
1352
1764
|
|
|
1353
|
-
app.post('/api/github/backup', async (req, res) => {
|
|
1354
|
-
try {
|
|
1355
|
-
const result = await performGitHubBackup(req.body);
|
|
1356
|
-
res.json(result);
|
|
1357
|
-
} catch (err) {
|
|
1358
|
-
console.error('GitHub backup error:', err);
|
|
1359
|
-
res.status(500).json({ error: err.message });
|
|
1360
|
-
}
|
|
1361
|
-
});
|
|
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
|
+
});
|
|
1362
1774
|
|
|
1363
1775
|
app.post('/api/github/restore', async (req, res) => {
|
|
1364
1776
|
let tempDir = null;
|
|
@@ -1417,10 +1829,10 @@ app.post('/api/github/restore', async (req, res) => {
|
|
|
1417
1829
|
app.post('/api/github/autosync', async (req, res) => {
|
|
1418
1830
|
const studio = loadStudioConfig();
|
|
1419
1831
|
const enabled = req.body.enabled;
|
|
1420
|
-
studio.githubAutoSync = enabled;
|
|
1421
|
-
saveStudioConfig(studio);
|
|
1422
|
-
if (enabled) triggerGitHubAutoSync();
|
|
1423
|
-
res.json({ success: true, enabled });
|
|
1832
|
+
studio.githubAutoSync = enabled;
|
|
1833
|
+
saveStudioConfig(studio);
|
|
1834
|
+
if (enabled) triggerGitHubAutoSync();
|
|
1835
|
+
res.json({ success: true, enabled });
|
|
1424
1836
|
});
|
|
1425
1837
|
|
|
1426
1838
|
const getSkillDir = () => {
|
|
@@ -1457,9 +1869,9 @@ app.post('/api/skills/:name', (req, res) => {
|
|
|
1457
1869
|
if (!sd) return res.status(404).json({ error: 'No config' });
|
|
1458
1870
|
const dp = path.join(sd, req.params.name);
|
|
1459
1871
|
if (!fs.existsSync(dp)) fs.mkdirSync(dp, { recursive: true });
|
|
1460
|
-
fs.writeFileSync(path.join(dp, 'SKILL.md'), req.body.content, 'utf8');
|
|
1461
|
-
triggerGitHubAutoSync();
|
|
1462
|
-
res.json({ success: true });
|
|
1872
|
+
fs.writeFileSync(path.join(dp, 'SKILL.md'), req.body.content, 'utf8');
|
|
1873
|
+
triggerGitHubAutoSync();
|
|
1874
|
+
res.json({ success: true });
|
|
1463
1875
|
});
|
|
1464
1876
|
|
|
1465
1877
|
app.delete('/api/skills/:name', (req, res) => {
|
|
@@ -1468,9 +1880,9 @@ app.delete('/api/skills/:name', (req, res) => {
|
|
|
1468
1880
|
}
|
|
1469
1881
|
const sd = getSkillDir();
|
|
1470
1882
|
const dp = sd ? path.join(sd, req.params.name) : null;
|
|
1471
|
-
if (dp && fs.existsSync(dp)) fs.rmSync(dp, { recursive: true, force: true });
|
|
1472
|
-
triggerGitHubAutoSync();
|
|
1473
|
-
res.json({ success: true });
|
|
1883
|
+
if (dp && fs.existsSync(dp)) fs.rmSync(dp, { recursive: true, force: true });
|
|
1884
|
+
triggerGitHubAutoSync();
|
|
1885
|
+
res.json({ success: true });
|
|
1474
1886
|
});
|
|
1475
1887
|
|
|
1476
1888
|
app.post('/api/skills/:name/toggle', (req, res) => {
|
|
@@ -1484,9 +1896,9 @@ app.post('/api/skills/:name/toggle', (req, res) => {
|
|
|
1484
1896
|
studio.disabledSkills.push(name);
|
|
1485
1897
|
}
|
|
1486
1898
|
|
|
1487
|
-
saveStudioConfig(studio);
|
|
1488
|
-
triggerGitHubAutoSync();
|
|
1489
|
-
res.json({ success: true, enabled: !studio.disabledSkills.includes(name) });
|
|
1899
|
+
saveStudioConfig(studio);
|
|
1900
|
+
triggerGitHubAutoSync();
|
|
1901
|
+
res.json({ success: true, enabled: !studio.disabledSkills.includes(name) });
|
|
1490
1902
|
});
|
|
1491
1903
|
|
|
1492
1904
|
const getPluginDir = () => {
|
|
@@ -1563,10 +1975,10 @@ app.post('/api/plugins/:name', (req, res) => {
|
|
|
1563
1975
|
if (!fs.existsSync(pd)) fs.mkdirSync(pd, { recursive: true });
|
|
1564
1976
|
|
|
1565
1977
|
// Default to .js if new
|
|
1566
|
-
const filePath = path.join(pd, name.endsWith('.js') || name.endsWith('.ts') ? name : name + '.js');
|
|
1567
|
-
atomicWriteFileSync(filePath, content);
|
|
1568
|
-
triggerGitHubAutoSync();
|
|
1569
|
-
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 });
|
|
1570
1982
|
});
|
|
1571
1983
|
|
|
1572
1984
|
app.delete('/api/plugins/:name', (req, res) => {
|
|
@@ -1591,10 +2003,10 @@ app.delete('/api/plugins/:name', (req, res) => {
|
|
|
1591
2003
|
}
|
|
1592
2004
|
}
|
|
1593
2005
|
|
|
1594
|
-
if (deleted) {
|
|
1595
|
-
triggerGitHubAutoSync();
|
|
1596
|
-
res.json({ success: true });
|
|
1597
|
-
} 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' });
|
|
1598
2010
|
});
|
|
1599
2011
|
|
|
1600
2012
|
app.post('/api/plugins/:name/toggle', (req, res) => {
|
|
@@ -1608,9 +2020,9 @@ app.post('/api/plugins/:name/toggle', (req, res) => {
|
|
|
1608
2020
|
studio.disabledPlugins.push(name);
|
|
1609
2021
|
}
|
|
1610
2022
|
|
|
1611
|
-
saveStudioConfig(studio);
|
|
1612
|
-
triggerGitHubAutoSync();
|
|
1613
|
-
res.json({ success: true, enabled: !studio.disabledPlugins.includes(name) });
|
|
2023
|
+
saveStudioConfig(studio);
|
|
2024
|
+
triggerGitHubAutoSync();
|
|
2025
|
+
res.json({ success: true, enabled: !studio.disabledPlugins.includes(name) });
|
|
1614
2026
|
});
|
|
1615
2027
|
|
|
1616
2028
|
const getActiveGooglePlugin = () => {
|
|
@@ -3504,23 +3916,23 @@ app.post('/api/presets/:id/apply', (req, res) => {
|
|
|
3504
3916
|
res.json({ success: true });
|
|
3505
3917
|
});
|
|
3506
3918
|
|
|
3507
|
-
// Start watcher on server start
|
|
3508
|
-
async function startServer() {
|
|
3509
|
-
['google', 'anthropic', 'openai', 'xai', 'openrouter', 'together', 'mistral', 'deepseek', 'amazon-bedrock', 'azure', 'github-copilot'].forEach(p => importCurrentAuthToPool(p));
|
|
3510
|
-
|
|
3511
|
-
const port = await findAvailablePort(DEFAULT_PORT);
|
|
3512
|
-
app.listen(port, () => {
|
|
3513
|
-
console.log(`Server running at http://localhost:${port}`);
|
|
3514
|
-
// Initial sync on startup if enabled
|
|
3515
|
-
setTimeout(() => {
|
|
3516
|
-
const studio = loadStudioConfig();
|
|
3517
|
-
if (studio.githubAutoSync) {
|
|
3518
|
-
console.log('[AutoSync] Triggering initial sync...');
|
|
3519
|
-
triggerGitHubAutoSync();
|
|
3520
|
-
}
|
|
3521
|
-
}, 5000);
|
|
3522
|
-
});
|
|
3523
|
-
}
|
|
3919
|
+
// Start watcher on server start
|
|
3920
|
+
async function startServer() {
|
|
3921
|
+
['google', 'anthropic', 'openai', 'xai', 'openrouter', 'together', 'mistral', 'deepseek', 'amazon-bedrock', 'azure', 'github-copilot'].forEach(p => importCurrentAuthToPool(p));
|
|
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);
|
|
3934
|
+
});
|
|
3935
|
+
}
|
|
3524
3936
|
|
|
3525
3937
|
if (require.main === module) {
|
|
3526
3938
|
startServer();
|