opencode-studio-server 1.1.2 → 1.2.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.
Files changed (3) hide show
  1. package/index.js +588 -820
  2. package/package.json +1 -1
  3. package/server.out +201 -0
package/index.js CHANGED
@@ -4,17 +4,15 @@ const bodyParser = require('body-parser');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
- const { exec, spawn } = require('child_process');
7
+ const { spawn, exec } = require('child_process');
8
8
 
9
9
  const app = express();
10
10
  const PORT = 3001;
11
- const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
11
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
12
12
 
13
- let lastActivityTime = Date.now();
14
13
  let idleTimer = null;
15
14
 
16
15
  function resetIdleTimer() {
17
- lastActivityTime = Date.now();
18
16
  if (idleTimer) clearTimeout(idleTimer);
19
17
  idleTimer = setTimeout(() => {
20
18
  console.log('Server idle for 30 minutes, shutting down...');
@@ -59,22 +57,127 @@ const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'p
59
57
  let pendingActionMemory = null;
60
58
 
61
59
  function loadStudioConfig() {
62
- if (!fs.existsSync(STUDIO_CONFIG_PATH)) {
63
- return {};
64
- }
60
+ const defaultConfig = {
61
+ disabledSkills: [],
62
+ disabledPlugins: [],
63
+ activeProfiles: {},
64
+ activeGooglePlugin: 'gemini',
65
+ availableGooglePlugins: [],
66
+ pluginModels: {
67
+ gemini: {
68
+ "gemini-3-pro-preview": {
69
+ "id": "gemini-3-pro-preview",
70
+ "name": "3 Pro (Gemini CLI)",
71
+ "reasoning": true,
72
+ "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } }
73
+ },
74
+ "gemini-2.5-flash": {
75
+ "id": "gemini-2.5-flash",
76
+ "name": "2.5 Flash (Gemini CLI)",
77
+ "reasoning": true,
78
+ "options": { "thinkingConfig": { "thinkingBudget": 8192, "includeThoughts": true } }
79
+ },
80
+ "gemini-2.0-flash": {
81
+ "id": "gemini-2.0-flash",
82
+ "name": "2.0 Flash (Free)",
83
+ "reasoning": false
84
+ },
85
+ "gemini-1.5-flash": {
86
+ "id": "gemini-1.5-flash",
87
+ "name": "1.5 Flash (Free)",
88
+ "reasoning": false
89
+ }
90
+ },
91
+ antigravity: {
92
+ "gemini-3-pro-preview": {
93
+ "id": "gemini-3-pro-preview",
94
+ "name": "3 Pro",
95
+ "release_date": "2025-11-18",
96
+ "reasoning": true,
97
+ "limit": { "context": 1000000, "output": 64000 },
98
+ "cost": { "input": 2, "output": 12, "cache_read": 0.2 },
99
+ "modalities": {
100
+ "input": ["text", "image", "video", "audio", "pdf"],
101
+ "output": ["text"]
102
+ },
103
+ "variants": {
104
+ "low": { "options": { "thinkingConfig": { "thinkingLevel": "low", "includeThoughts": true } } },
105
+ "medium": { "options": { "thinkingConfig": { "thinkingLevel": "medium", "includeThoughts": true } } },
106
+ "high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } }
107
+ }
108
+ },
109
+ "gemini-3-flash": {
110
+ "id": "gemini-3-flash",
111
+ "name": "3 Flash",
112
+ "release_date": "2025-12-17",
113
+ "reasoning": true,
114
+ "limit": { "context": 1048576, "output": 65536 },
115
+ "cost": { "input": 0.5, "output": 3, "cache_read": 0.05 },
116
+ "modalities": {
117
+ "input": ["text", "image", "video", "audio", "pdf"],
118
+ "output": ["text"]
119
+ },
120
+ "variants": {
121
+ "minimal": { "options": { "thinkingConfig": { "thinkingLevel": "minimal", "includeThoughts": true } } },
122
+ "low": { "options": { "thinkingConfig": { "thinkingLevel": "low", "includeThoughts": true } } },
123
+ "medium": { "options": { "thinkingConfig": { "thinkingLevel": "medium", "includeThoughts": true } } },
124
+ "high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } }
125
+ }
126
+ },
127
+ "gemini-2.5-flash-lite": {
128
+ "id": "gemini-2.5-flash-lite",
129
+ "name": "2.5 Flash Lite",
130
+ "reasoning": false
131
+ },
132
+ "gemini-claude-sonnet-4-5-thinking": {
133
+ "id": "gemini-claude-sonnet-4-5-thinking",
134
+ "name": "Sonnet 4.5",
135
+ "reasoning": true,
136
+ "limit": { "context": 200000, "output": 64000 },
137
+ "modalities": {
138
+ "input": ["text", "image", "pdf"],
139
+ "output": ["text"]
140
+ },
141
+ "variants": {
142
+ "none": { "reasoning": false, "options": { "thinkingConfig": { "includeThoughts": false } } },
143
+ "low": { "options": { "thinkingConfig": { "thinkingBudget": 4000, "includeThoughts": true } } },
144
+ "medium": { "options": { "thinkingConfig": { "thinkingBudget": 16000, "includeThoughts": true } } },
145
+ "high": { "options": { "thinkingConfig": { "thinkingBudget": 32000, "includeThoughts": true } } }
146
+ }
147
+ },
148
+ "gemini-claude-opus-4-5-thinking": {
149
+ "id": "gemini-claude-opus-4-5-thinking",
150
+ "name": "Opus 4.5",
151
+ "release_date": "2025-11-24",
152
+ "reasoning": true,
153
+ "limit": { "context": 200000, "output": 64000 },
154
+ "modalities": {
155
+ "input": ["text", "image", "pdf"],
156
+ "output": ["text"]
157
+ },
158
+ "variants": {
159
+ "low": { "options": { "thinkingConfig": { "thinkingBudget": 4000, "includeThoughts": true } } },
160
+ "medium": { "options": { "thinkingConfig": { "thinkingBudget": 16000, "includeThoughts": true } } },
161
+ "high": { "options": { "thinkingConfig": { "thinkingBudget": 32000, "includeThoughts": true } } }
162
+ }
163
+ }
164
+ }
165
+ }
166
+ };
167
+
168
+ if (!fs.existsSync(STUDIO_CONFIG_PATH)) return defaultConfig;
65
169
  try {
66
- return JSON.parse(fs.readFileSync(STUDIO_CONFIG_PATH, 'utf8'));
170
+ const config = JSON.parse(fs.readFileSync(STUDIO_CONFIG_PATH, 'utf8'));
171
+ return { ...defaultConfig, ...config };
67
172
  } catch {
68
- return {};
173
+ return defaultConfig;
69
174
  }
70
175
  }
71
176
 
72
177
  function saveStudioConfig(config) {
73
178
  try {
74
179
  const dir = path.dirname(STUDIO_CONFIG_PATH);
75
- if (!fs.existsSync(dir)) {
76
- fs.mkdirSync(dir, { recursive: true });
77
- }
180
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
78
181
  fs.writeFileSync(STUDIO_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
79
182
  return true;
80
183
  } catch (err) {
@@ -83,53 +186,16 @@ function saveStudioConfig(config) {
83
186
  }
84
187
  }
85
188
 
86
- function loadPendingAction() {
87
- if (pendingActionMemory) return pendingActionMemory;
88
-
89
- if (fs.existsSync(PENDING_ACTION_PATH)) {
90
- try {
91
- const action = JSON.parse(fs.readFileSync(PENDING_ACTION_PATH, 'utf8'));
92
- if (action.timestamp && Date.now() - action.timestamp < 60000) {
93
- pendingActionMemory = action;
94
- fs.unlinkSync(PENDING_ACTION_PATH);
95
- return action;
96
- }
97
- fs.unlinkSync(PENDING_ACTION_PATH);
98
- } catch {
99
- try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
100
- }
101
- }
102
- return null;
103
- }
104
-
105
- app.get('/api/pending-action', (req, res) => {
106
- const action = loadPendingAction();
107
- res.json({ action });
108
- });
109
-
110
- app.delete('/api/pending-action', (req, res) => {
111
- pendingActionMemory = null;
112
- if (fs.existsSync(PENDING_ACTION_PATH)) {
113
- try { fs.unlinkSync(PENDING_ACTION_PATH); } catch {}
114
- }
115
- res.json({ success: true });
116
- });
117
-
118
189
  const getPaths = () => {
119
190
  const platform = process.platform;
120
191
  const home = os.homedir();
121
-
122
- let candidates = [];
192
+ let candidates = [
193
+ path.join(home, '.config', 'opencode', 'opencode.json'),
194
+ path.join(home, '.local', 'share', 'opencode', 'opencode.json'),
195
+ path.join(home, '.opencode', 'opencode.json'),
196
+ ];
123
197
  if (platform === 'win32') {
124
- candidates = [
125
- path.join(process.env.APPDATA, 'opencode', 'opencode.json'),
126
- path.join(home, '.config', 'opencode', 'opencode.json'),
127
- ];
128
- } else {
129
- candidates = [
130
- path.join(home, '.config', 'opencode', 'opencode.json'),
131
- path.join(home, '.opencode', 'opencode.json'),
132
- ];
198
+ candidates.push(path.join(process.env.APPDATA, 'opencode', 'opencode.json'));
133
199
  }
134
200
 
135
201
  const studioConfig = loadStudioConfig();
@@ -147,20 +213,15 @@ const getPaths = () => {
147
213
  detected,
148
214
  manual: manualPath,
149
215
  current: manualPath || detected,
150
- candidates
216
+ candidates: [...new Set(candidates)]
151
217
  };
152
218
  };
153
219
 
154
- const getConfigPath = () => {
155
- const paths = getPaths();
156
- return paths.current;
157
- };
220
+ const getConfigPath = () => getPaths().current;
158
221
 
159
222
  const loadConfig = () => {
160
223
  const configPath = getConfigPath();
161
- if (!configPath || !fs.existsSync(configPath)) {
162
- return null;
163
- }
224
+ if (!configPath || !fs.existsSync(configPath)) return null;
164
225
  try {
165
226
  return JSON.parse(fs.readFileSync(configPath, 'utf8'));
166
227
  } catch {
@@ -170,28 +231,20 @@ const loadConfig = () => {
170
231
 
171
232
  const saveConfig = (config) => {
172
233
  const configPath = getConfigPath();
173
- if (!configPath) {
174
- throw new Error('No config path found');
175
- }
234
+ if (!configPath) throw new Error('No config path found');
176
235
  const dir = path.dirname(configPath);
177
- if (!fs.existsSync(dir)) {
178
- fs.mkdirSync(dir, { recursive: true });
179
- }
236
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
180
237
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
181
238
  };
182
239
 
183
- app.get('/api/health', (req, res) => {
184
- res.json({ status: 'ok' });
185
- });
240
+ app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
186
241
 
187
242
  app.post('/api/shutdown', (req, res) => {
188
243
  res.json({ success: true });
189
244
  setTimeout(() => process.exit(0), 100);
190
245
  });
191
246
 
192
- app.get('/api/paths', (req, res) => {
193
- res.json(getPaths());
194
- });
247
+ app.get('/api/paths', (req, res) => res.json(getPaths()));
195
248
 
196
249
  app.post('/api/paths', (req, res) => {
197
250
  const { configPath } = req.body;
@@ -203,880 +256,595 @@ app.post('/api/paths', (req, res) => {
203
256
 
204
257
  app.get('/api/config', (req, res) => {
205
258
  const config = loadConfig();
206
- if (!config) {
207
- return res.status(404).json({ error: 'Config not found' });
208
- }
259
+ if (!config) return res.status(404).json({ error: 'Config not found' });
209
260
  res.json(config);
210
261
  });
211
262
 
212
263
  app.post('/api/config', (req, res) => {
213
- const config = req.body;
214
264
  try {
215
- saveConfig(config);
265
+ saveConfig(req.body);
216
266
  res.json({ success: true });
217
267
  } catch (err) {
218
268
  res.status(500).json({ error: err.message });
219
269
  }
220
270
  });
221
271
 
222
- // Helper to get skill dir
223
272
  const getSkillDir = () => {
224
- const configPath = getConfigPath();
225
- if (!configPath) return null;
226
- return path.join(path.dirname(configPath), 'skill');
273
+ const cp = getConfigPath();
274
+ return cp ? path.join(path.dirname(cp), 'skill') : null;
227
275
  };
228
276
 
229
277
  app.get('/api/skills', (req, res) => {
230
- const skillDir = getSkillDir();
231
- if (!skillDir || !fs.existsSync(skillDir)) {
232
- return res.json([]);
233
- }
234
-
235
- const skills = [];
236
- const entries = fs.readdirSync(skillDir, { withFileTypes: true });
237
-
238
- for (const entry of entries) {
239
- if (entry.isDirectory()) {
240
- const skillPath = path.join(skillDir, entry.name, 'SKILL.md');
241
- if (fs.existsSync(skillPath)) {
242
- skills.push({
243
- name: entry.name,
244
- path: skillPath,
245
- enabled: !entry.name.endsWith('.disabled') // Simplified logic
246
- });
247
- }
248
- }
249
- }
278
+ const sd = getSkillDir();
279
+ if (!sd || !fs.existsSync(sd)) return res.json([]);
280
+ const skills = fs.readdirSync(sd, { withFileTypes: true })
281
+ .filter(e => e.isDirectory() && fs.existsSync(path.join(sd, e.name, 'SKILL.md')))
282
+ .map(e => ({ name: e.name, path: path.join(sd, e.name, 'SKILL.md'), enabled: !e.name.endsWith('.disabled') }));
250
283
  res.json(skills);
251
284
  });
252
285
 
253
286
  app.get('/api/skills/:name', (req, res) => {
254
- const skillDir = getSkillDir();
255
- if (!skillDir) return res.status(404).json({ error: 'No config' });
256
-
257
- const name = req.params.name;
258
- const skillPath = path.join(skillDir, name, 'SKILL.md');
259
-
260
- if (!fs.existsSync(skillPath)) {
261
- return res.status(404).json({ error: 'Skill not found' });
262
- }
263
-
264
- const content = fs.readFileSync(skillPath, 'utf8');
265
- res.json({ name, content });
287
+ const sd = getSkillDir();
288
+ const p = sd ? path.join(sd, req.params.name, 'SKILL.md') : null;
289
+ if (!p || !fs.existsSync(p)) return res.status(404).json({ error: 'Not found' });
290
+ res.json({ name: req.params.name, content: fs.readFileSync(p, 'utf8') });
266
291
  });
267
292
 
268
293
  app.post('/api/skills/:name', (req, res) => {
269
- const skillDir = getSkillDir();
270
- if (!skillDir) return res.status(404).json({ error: 'No config' });
271
-
272
- const name = req.params.name;
273
- const { content } = req.body;
274
- const dirPath = path.join(skillDir, name);
275
-
276
- if (!fs.existsSync(dirPath)) {
277
- fs.mkdirSync(dirPath, { recursive: true });
278
- }
279
-
280
- fs.writeFileSync(path.join(dirPath, 'SKILL.md'), content, 'utf8');
294
+ const sd = getSkillDir();
295
+ if (!sd) return res.status(404).json({ error: 'No config' });
296
+ const dp = path.join(sd, req.params.name);
297
+ if (!fs.existsSync(dp)) fs.mkdirSync(dp, { recursive: true });
298
+ fs.writeFileSync(path.join(dp, 'SKILL.md'), req.body.content, 'utf8');
281
299
  res.json({ success: true });
282
300
  });
283
301
 
284
302
  app.delete('/api/skills/:name', (req, res) => {
285
- const skillDir = getSkillDir();
286
- if (!skillDir) return res.status(404).json({ error: 'No config' });
287
-
288
- const name = req.params.name;
289
- const dirPath = path.join(skillDir, name);
290
-
291
- if (fs.existsSync(dirPath)) {
292
- fs.rmSync(dirPath, { recursive: true, force: true });
293
- }
303
+ const sd = getSkillDir();
304
+ const dp = sd ? path.join(sd, req.params.name) : null;
305
+ if (dp && fs.existsSync(dp)) fs.rmSync(dp, { recursive: true, force: true });
294
306
  res.json({ success: true });
295
307
  });
296
308
 
297
- app.post('/api/skills/:name/toggle', (req, res) => {
298
- // Simplified: renaming not implemented for now to avoid complexity
299
- // In real implementation we would rename folder to .disabled
300
- res.json({ success: true, enabled: true });
301
- });
302
-
303
- // Helper to get plugin dir
304
309
  const getPluginDir = () => {
305
- const configPath = getConfigPath();
306
- if (!configPath) return null;
307
- return path.join(path.dirname(configPath), 'plugin');
310
+ const cp = getConfigPath();
311
+ return cp ? path.join(path.dirname(cp), 'plugin') : null;
308
312
  };
309
313
 
310
314
  app.get('/api/plugins', (req, res) => {
311
- const pluginDir = getPluginDir();
312
- if (!pluginDir || !fs.existsSync(pluginDir)) {
313
- return res.json([]);
314
- }
315
-
315
+ const pd = getPluginDir();
316
+ const cp = getConfigPath();
317
+ const cr = cp ? path.dirname(cp) : null;
316
318
  const plugins = [];
317
- const entries = fs.readdirSync(pluginDir, { withFileTypes: true });
318
-
319
- for (const entry of entries) {
320
- if (entry.isDirectory()) {
321
- const jsPath = path.join(pluginDir, entry.name, 'index.js');
322
- const tsPath = path.join(pluginDir, entry.name, 'index.ts');
323
-
324
- if (fs.existsSync(jsPath) || fs.existsSync(tsPath)) {
325
- plugins.push({
326
- name: entry.name,
327
- path: fs.existsSync(jsPath) ? jsPath : tsPath,
328
- enabled: true
329
- });
319
+ const add = (name, p, enabled = true) => {
320
+ if (!plugins.some(pl => pl.name === name)) plugins.push({ name, path: p, enabled });
321
+ };
322
+
323
+ if (pd && fs.existsSync(pd)) {
324
+ fs.readdirSync(pd, { withFileTypes: true }).forEach(e => {
325
+ const fp = path.join(pd, e.name);
326
+ const st = fs.lstatSync(fp);
327
+ if (st.isDirectory()) {
328
+ const j = path.join(fp, 'index.js'), t = path.join(fp, 'index.ts');
329
+ if (fs.existsSync(j) || fs.existsSync(t)) add(e.name, fs.existsSync(j) ? j : t);
330
+ } else if ((st.isFile() || st.isSymbolicLink()) && /\.(js|ts)$/.test(e.name)) {
331
+ add(e.name.replace(/\.(js|ts)$/, ''), fp);
330
332
  }
331
- } else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.ts'))) {
332
- plugins.push({
333
- name: entry.name.replace(/\.(js|ts)$/, ''),
334
- path: path.join(pluginDir, entry.name),
335
- enabled: true
336
- });
337
- }
333
+ });
338
334
  }
339
- res.json(plugins);
340
- });
341
335
 
342
- app.get('/api/plugins/:name', (req, res) => {
343
- const pluginDir = getPluginDir();
344
- if (!pluginDir) return res.status(404).json({ error: 'No config' });
345
-
346
- const name = req.params.name;
347
- const dirPath = path.join(pluginDir, name);
348
-
349
- let content = '';
350
- let filename = '';
351
-
352
- if (fs.existsSync(path.join(dirPath, 'index.js'))) {
353
- content = fs.readFileSync(path.join(dirPath, 'index.js'), 'utf8');
354
- filename = 'index.js';
355
- } else if (fs.existsSync(path.join(dirPath, 'index.ts'))) {
356
- content = fs.readFileSync(path.join(dirPath, 'index.ts'), 'utf8');
357
- filename = 'index.ts';
358
- } else if (fs.existsSync(path.join(pluginDir, name + '.js'))) {
359
- content = fs.readFileSync(path.join(pluginDir, name + '.js'), 'utf8');
360
- filename = name + '.js';
361
- } else if (fs.existsSync(path.join(pluginDir, name + '.ts'))) {
362
- content = fs.readFileSync(path.join(pluginDir, name + '.ts'), 'utf8');
363
- filename = name + '.ts';
364
- } else {
365
- return res.status(404).json({ error: 'Plugin not found' });
336
+ if (cr && fs.existsSync(cr)) {
337
+ ['oh-my-opencode', 'superpowers', 'opencode-gemini-auth'].forEach(n => {
338
+ const fp = path.join(cr, n);
339
+ if (fs.existsSync(fp) && fs.statSync(fp).isDirectory()) add(n, fp);
340
+ });
366
341
  }
367
-
368
- res.json({ name, content, filename });
369
- });
370
342
 
371
- app.post('/api/plugins/:name', (req, res) => {
372
- const pluginDir = getPluginDir();
373
- if (!pluginDir) return res.status(404).json({ error: 'No config' });
374
-
375
- const name = req.params.name;
376
- const { content } = req.body;
377
- const dirPath = path.join(pluginDir, name);
378
-
379
- if (!fs.existsSync(dirPath)) {
380
- fs.mkdirSync(dirPath, { recursive: true });
343
+ const cfg = loadConfig();
344
+ if (cfg && Array.isArray(cfg.plugin)) {
345
+ cfg.plugin.forEach(n => {
346
+ if (!n.includes('/') && !n.includes('\\') && !/\.(js|ts)$/.test(n)) add(n, 'npm');
347
+ });
381
348
  }
382
-
383
- // Determine file extension based on content? Default to .js if new
384
- const filePath = path.join(dirPath, 'index.js');
385
- fs.writeFileSync(filePath, content, 'utf8');
386
- res.json({ success: true });
349
+ res.json(plugins);
387
350
  });
388
351
 
389
- app.delete('/api/plugins/:name', (req, res) => {
390
- const pluginDir = getPluginDir();
391
- if (!pluginDir) return res.status(404).json({ error: 'No config' });
392
-
393
- const name = req.params.name;
394
- const dirPath = path.join(pluginDir, name);
352
+ const getActiveGooglePlugin = () => {
353
+ const studio = loadStudioConfig();
354
+ return studio.activeGooglePlugin || null;
355
+ };
356
+
357
+ function loadAuthConfig() {
358
+ const paths = getPaths();
359
+ const allAuthConfigs = [];
395
360
 
396
- if (fs.existsSync(dirPath)) {
397
- fs.rmSync(dirPath, { recursive: true, force: true });
398
- } else if (fs.existsSync(path.join(pluginDir, name + '.js'))) {
399
- fs.unlinkSync(path.join(pluginDir, name + '.js'));
400
- } else if (fs.existsSync(path.join(pluginDir, name + '.ts'))) {
401
- fs.unlinkSync(path.join(pluginDir, name + '.ts'));
361
+ // Check all candidate directories for auth.json
362
+ paths.candidates.forEach(p => {
363
+ const ap = path.join(path.dirname(p), 'auth.json');
364
+ if (fs.existsSync(ap)) {
365
+ try {
366
+ allAuthConfigs.push(JSON.parse(fs.readFileSync(ap, 'utf8')));
367
+ } catch {}
368
+ }
369
+ });
370
+
371
+ // Also check current active directory if not in candidates
372
+ if (paths.current) {
373
+ const ap = path.join(path.dirname(paths.current), 'auth.json');
374
+ if (fs.existsSync(ap)) {
375
+ try {
376
+ allAuthConfigs.push(JSON.parse(fs.readFileSync(ap, 'utf8')));
377
+ } catch {}
378
+ }
402
379
  }
403
- res.json({ success: true });
404
- });
405
380
 
406
- app.post('/api/plugins/:name/toggle', (req, res) => {
407
- res.json({ success: true, enabled: true });
408
- });
381
+ if (allAuthConfigs.length === 0) return null;
409
382
 
410
- app.post('/api/fetch-url', async (req, res) => {
411
- const { url } = req.body;
412
- if (!url) return res.status(400).json({ error: 'URL required' });
383
+ // Merge all configs, later ones (priority) overwrite earlier ones
384
+ const cfg = Object.assign({}, ...allAuthConfigs);
413
385
 
414
- try {
415
- const fetch = (await import('node-fetch')).default;
416
- const response = await fetch(url);
417
- if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`);
418
- const content = await response.text();
419
- const filename = path.basename(new URL(url).pathname) || 'file.txt';
386
+ try {
387
+ const activePlugin = getActiveGooglePlugin();
420
388
 
421
- res.json({ content, filename, url });
422
- } catch (err) {
423
- res.status(500).json({ error: err.message });
424
- }
425
- });
389
+ // Find if any google auth exists in any merged config
390
+ const anyGoogleAuth = cfg.google || cfg['google.gemini'] || cfg['google.antigravity'];
391
+
392
+ if (anyGoogleAuth) {
393
+ // Ensure all 3 keys have at least some valid google auth data
394
+ const baseAuth = cfg.google || cfg['google.antigravity'] || cfg['google.gemini'];
395
+ if (!cfg.google) cfg.google = { ...baseAuth };
396
+ if (!cfg['google.antigravity']) cfg['google.antigravity'] = { ...baseAuth };
397
+ if (!cfg['google.gemini']) cfg['google.gemini'] = { ...baseAuth };
398
+ }
426
399
 
427
- app.post('/api/bulk-fetch', async (req, res) => {
428
- const { urls } = req.body;
429
- if (!Array.isArray(urls)) return res.status(400).json({ error: 'URLs array required' });
430
-
431
- const fetch = (await import('node-fetch')).default;
432
- const results = [];
433
-
434
- // Limit concurrency? For now sequential is safer
435
- for (const url of urls) {
436
- try {
437
- const response = await fetch(url);
438
- if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`);
439
- const content = await response.text();
440
- const filename = path.basename(new URL(url).pathname) || 'file.txt';
441
-
442
- // Try to extract name/description from content (simple regex for markdown/js)
443
- // This is basic heuristic
444
- results.push({
445
- url,
446
- success: true,
447
- content,
448
- filename,
449
- name: filename.replace(/\.(md|js|ts)$/, ''),
450
- });
451
- } catch (err) {
452
- results.push({
453
- url,
454
- success: false,
455
- error: err.message
456
- });
400
+ // Always ensure the main 'google' key reflects the active plugin if it exists
401
+ if (activePlugin === 'antigravity' && cfg['google.antigravity']) {
402
+ cfg.google = { ...cfg['google.antigravity'] };
403
+ } else if (activePlugin === 'gemini' && cfg['google.gemini']) {
404
+ cfg.google = { ...cfg['google.gemini'] };
457
405
  }
406
+
407
+ return cfg;
408
+ } catch { return cfg; }
409
+ }
410
+
411
+ const AUTH_PROFILES_DIR = path.join(HOME_DIR, '.config', 'opencode-studio', 'auth-profiles');
412
+ const listAuthProfiles = (p, activePlugin) => {
413
+ let ns = p;
414
+ if (p === 'google') {
415
+ ns = activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini';
458
416
  }
459
417
 
460
- res.json({ results });
418
+ const d = path.join(AUTH_PROFILES_DIR, ns);
419
+ if (!fs.existsSync(d)) return [];
420
+ try { return fs.readdirSync(d).filter(f => f.endsWith('.json')).map(f => f.replace('.json', '')); } catch { return []; }
421
+ };
422
+
423
+ app.get('/api/auth/providers', (req, res) => {
424
+ const providers = [
425
+ { id: 'google', name: 'Google', type: 'oauth', description: 'Google Gemini API' },
426
+ { id: 'anthropic', name: 'Anthropic', type: 'api', description: 'Claude models' },
427
+ { id: 'openai', name: 'OpenAI', type: 'api', description: 'GPT models' },
428
+ { id: 'xai', name: 'xAI', type: 'api', description: 'Grok models' },
429
+ { id: 'openrouter', name: 'OpenRouter', type: 'api', description: 'Unified LLM API' },
430
+ { id: 'github-copilot', name: 'GitHub Copilot', type: 'api', description: 'Copilot models' }
431
+ ];
432
+ res.json(providers);
461
433
  });
462
434
 
463
- app.get('/api/backup', (req, res) => {
464
- const studioConfig = loadStudioConfig();
465
- const opencodeConfig = loadConfig();
435
+ app.get('/api/auth', (req, res) => {
436
+ const authCfg = loadAuthConfig() || {};
437
+ const studio = loadStudioConfig();
438
+ const ac = studio.activeProfiles || {};
439
+ const credentials = [];
440
+ const activePlugin = studio.activeGooglePlugin;
441
+
442
+ // DEBUG LOGGING
443
+ console.log('--- Auth Debug ---');
444
+ console.log('Active Google Plugin:', activePlugin);
445
+ console.log('Found keys in authCfg:', Object.keys(authCfg));
446
+ if (authCfg.google) console.log('Google Auth found!');
447
+ if (authCfg['google.antigravity']) console.log('google.antigravity found!');
448
+ if (authCfg['google.gemini']) console.log('google.gemini found!');
466
449
 
467
- const skills = [];
468
- const skillDir = getSkillDir();
469
- if (skillDir && fs.existsSync(skillDir)) {
470
- const entries = fs.readdirSync(skillDir, { withFileTypes: true });
471
- for (const entry of entries) {
472
- if (entry.isDirectory()) {
473
- const p = path.join(skillDir, entry.name, 'SKILL.md');
474
- if (fs.existsSync(p)) {
475
- skills.push({ name: entry.name, content: fs.readFileSync(p, 'utf8') });
476
- }
477
- }
478
- }
479
- }
450
+ const providers = [
451
+ { id: 'google', name: 'Google', type: 'oauth' },
452
+ { id: 'anthropic', name: 'Anthropic', type: 'api' },
453
+ { id: 'openai', name: 'OpenAI', type: 'api' },
454
+ { id: 'xai', name: 'xAI', type: 'api' },
455
+ { id: 'openrouter', name: 'OpenRouter', type: 'api' },
456
+ { id: 'github-copilot', name: 'GitHub Copilot', type: 'api' }
457
+ ];
458
+
459
+ const opencodeCfg = loadConfig();
460
+ const currentPlugins = opencodeCfg?.plugin || [];
480
461
 
481
- const plugins = [];
482
- const pluginDir = getPluginDir();
483
- if (pluginDir && fs.existsSync(pluginDir)) {
484
- const entries = fs.readdirSync(pluginDir, { withFileTypes: true });
485
- for (const entry of entries) {
486
- if (entry.isDirectory()) {
487
- const p = path.join(pluginDir, entry.name, 'index.js');
488
- if (fs.existsSync(p)) {
489
- plugins.push({ name: entry.name, content: fs.readFileSync(p, 'utf8') });
490
- }
491
- }
492
- }
493
- }
462
+ let studioChanged = false;
463
+ if (!studio.availableGooglePlugins) studio.availableGooglePlugins = [];
494
464
 
495
- res.json({
496
- version: 1,
497
- timestamp: new Date().toISOString(),
498
- studioConfig,
499
- opencodeConfig,
500
- skills,
501
- plugins
502
- });
503
- });
465
+ if (currentPlugins.some(p => p.includes('gemini-auth')) && !studio.availableGooglePlugins.includes('gemini')) {
466
+ studio.availableGooglePlugins.push('gemini');
467
+ studioChanged = true;
468
+ }
469
+ if (currentPlugins.some(p => p.includes('antigravity-auth')) && !studio.availableGooglePlugins.includes('antigravity')) {
470
+ studio.availableGooglePlugins.push('antigravity');
471
+ studioChanged = true;
472
+ }
504
473
 
505
- app.post('/api/restore', (req, res) => {
506
- const backup = req.body;
474
+ const geminiProfiles = path.join(AUTH_PROFILES_DIR, 'google.gemini');
475
+ const antiProfiles = path.join(AUTH_PROFILES_DIR, 'google.antigravity');
507
476
 
508
- if (backup.studioConfig) {
509
- saveStudioConfig(backup.studioConfig);
477
+ if (fs.existsSync(geminiProfiles) && !studio.availableGooglePlugins.includes('gemini')) {
478
+ studio.availableGooglePlugins.push('gemini');
479
+ studioChanged = true;
510
480
  }
511
-
512
- if (backup.opencodeConfig) {
513
- saveConfig(backup.opencodeConfig);
481
+ if (fs.existsSync(antiProfiles) && !studio.availableGooglePlugins.includes('antigravity')) {
482
+ studio.availableGooglePlugins.push('antigravity');
483
+ studioChanged = true;
514
484
  }
515
485
 
516
- if (Array.isArray(backup.skills)) {
517
- const skillDir = getSkillDir();
518
- if (skillDir) {
519
- if (!fs.existsSync(skillDir)) fs.mkdirSync(skillDir, { recursive: true });
520
- for (const skill of backup.skills) {
521
- const dir = path.join(skillDir, skill.name);
522
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
523
- fs.writeFileSync(path.join(dir, 'SKILL.md'), skill.content, 'utf8');
524
- }
525
- }
526
- }
486
+ if (studioChanged) saveStudioConfig(studio);
487
+
488
+ providers.forEach(p => {
489
+ const saved = listAuthProfiles(p.id, activePlugin);
490
+ const curr = !!authCfg[p.id];
491
+ credentials.push({ ...p, active: ac[p.id] || (curr ? 'current' : null), profiles: saved, hasCurrentAuth: curr });
492
+ });
493
+ res.json({
494
+ ...authCfg,
495
+ credentials,
496
+ installedGooglePlugins: studio.availableGooglePlugins || [],
497
+ activeGooglePlugin: activePlugin
498
+ });
499
+ });
500
+
501
+ app.get('/api/auth/profiles', (req, res) => {
502
+ const authCfg = loadAuthConfig() || {};
503
+ const studio = loadStudioConfig();
504
+ const ac = studio.activeProfiles || {};
505
+ const activePlugin = studio.activeGooglePlugin;
506
+ const profiles = {};
507
+ const providers = ['google', 'anthropic', 'openai', 'xai', 'openrouter', 'together', 'mistral', 'deepseek', 'amazon-bedrock', 'azure', 'github-copilot'];
527
508
 
528
- if (Array.isArray(backup.plugins)) {
529
- const pluginDir = getPluginDir();
530
- if (pluginDir) {
531
- if (!fs.existsSync(pluginDir)) fs.mkdirSync(pluginDir, { recursive: true });
532
- for (const plugin of backup.plugins) {
533
- const dir = path.join(pluginDir, plugin.name);
534
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
535
- fs.writeFileSync(path.join(dir, 'index.js'), plugin.content, 'utf8');
536
- }
509
+ providers.forEach(p => {
510
+ const saved = listAuthProfiles(p, activePlugin);
511
+ const curr = authCfg[p];
512
+ if (saved.length > 0 || curr) {
513
+ profiles[p] = { active: ac[p], profiles: saved, hasCurrentAuth: !!curr };
537
514
  }
538
- }
539
-
540
- res.json({ success: true });
515
+ });
516
+ res.json(profiles);
541
517
  });
542
518
 
543
- // Auth Handlers
544
- function loadAuthConfig() {
545
- const configPath = getConfigPath();
546
- if (!configPath) return null;
519
+ app.post('/api/auth/profiles/:provider', (req, res) => {
520
+ const { provider } = req.params;
521
+ const { name } = req.body;
522
+ const activePlugin = getActiveGooglePlugin();
523
+ const namespace = provider === 'google'
524
+ ? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
525
+ : provider;
547
526
 
548
- // Auth config is usually in ~/.config/opencode/auth.json ?
549
- // Or inside opencode.json?
550
- // Based on opencode CLI, it seems to store auth in separate files or system keychain.
551
- // But for this studio, we might just look at opencode.json "providers" section or similar.
527
+ const auth = loadAuthConfig() || {};
528
+ const dir = path.join(AUTH_PROFILES_DIR, namespace);
529
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
552
530
 
553
- // ACTUALLY, opencode stores auth tokens in ~/.config/opencode/auth.json
554
- const authPath = path.join(path.dirname(configPath), 'auth.json');
555
- if (!fs.existsSync(authPath)) return null;
556
- try {
557
- return JSON.parse(fs.readFileSync(authPath, 'utf8'));
558
- } catch {
559
- return null;
560
- }
561
- }
562
-
563
- app.get('/api/auth', (req, res) => {
564
- const authConfig = loadAuthConfig();
565
- res.json(authConfig || {});
531
+ const profilePath = path.join(dir, `${name || Date.now()}.json`);
532
+ fs.writeFileSync(profilePath, JSON.stringify(auth[provider], null, 2), 'utf8');
533
+ res.json({ success: true, name: path.basename(profilePath, '.json') });
566
534
  });
567
535
 
568
- app.post('/api/auth/login', (req, res) => {
569
- const { provider } = req.body;
570
- // Launch opencode CLI auth login
571
- // This requires 'opencode' to be in PATH
536
+ app.post('/api/auth/profiles/:provider/:name/activate', (req, res) => {
537
+ const { provider, name } = req.params;
538
+ const activePlugin = getActiveGooglePlugin();
539
+ const namespace = provider === 'google'
540
+ ? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
541
+ : provider;
572
542
 
573
- const cmd = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
543
+ const profilePath = path.join(AUTH_PROFILES_DIR, namespace, `${name}.json`);
544
+ if (!fs.existsSync(profilePath)) return res.status(404).json({ error: 'Profile not found' });
574
545
 
575
- // We spawn it so it opens the browser
576
- const child = spawn(cmd, ['auth', 'login', provider], {
577
- stdio: 'inherit',
578
- shell: true
579
- });
546
+ const profileData = JSON.parse(fs.readFileSync(profilePath, 'utf8'));
547
+ const studio = loadStudioConfig();
548
+ if (!studio.activeProfiles) studio.activeProfiles = {};
549
+ studio.activeProfiles[provider] = name;
550
+ saveStudioConfig(studio);
580
551
 
581
- res.json({ success: true, message: 'Launched auth flow', note: 'Please check the terminal window where the server is running' });
582
- });
583
-
584
- app.delete('/api/auth/:provider', (req, res) => {
585
- // Logout logic
586
- // Maybe just delete from auth.json? Or run opencode auth logout?
587
- const { provider } = req.params;
588
- const cmd = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
552
+ const authCfg = loadAuthConfig() || {};
553
+ authCfg[provider] = profileData;
589
554
 
590
- spawn(cmd, ['auth', 'logout', provider], {
591
- stdio: 'inherit',
592
- shell: true
593
- });
555
+ // Save both to persistent namespaced key and the shared 'google' key
556
+ if (provider === 'google') {
557
+ const key = activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini';
558
+ authCfg[key] = profileData;
559
+ }
594
560
 
561
+ const cp = getConfigPath();
562
+ const ap = path.join(path.dirname(cp), 'auth.json');
563
+ fs.writeFileSync(ap, JSON.stringify(authCfg, null, 2), 'utf8');
595
564
  res.json({ success: true });
596
565
  });
597
566
 
598
- app.get('/api/auth/providers', (req, res) => {
599
- // List of supported providers (hardcoded for now as it's not easily discoverable via CLI)
600
- const providers = [
601
- { id: 'google', name: 'Google AI', type: 'oauth', description: 'Use Google Gemini models' },
602
- { id: 'anthropic', name: 'Anthropic', type: 'api', description: 'Use Claude models' },
603
- { id: 'openai', name: 'OpenAI', type: 'api', description: 'Use GPT models' },
604
- { id: 'xai', name: 'xAI', type: 'api', description: 'Use Grok models' },
605
- { id: 'groq', name: 'Groq', type: 'api', description: 'Fast inference' },
606
- { id: 'together', name: 'Together AI', type: 'api', description: 'Open source models' },
607
- { id: 'mistral', name: 'Mistral', type: 'api', description: 'Mistral models' },
608
- { id: 'deepseek', name: 'DeepSeek', type: 'api', description: 'DeepSeek models' },
609
- { id: 'openrouter', name: 'OpenRouter', type: 'api', description: 'Multiple providers' },
610
- { id: 'amazon-bedrock', name: 'Amazon Bedrock', type: 'api', description: 'AWS models' },
611
- { id: 'azure', name: 'Azure OpenAI', type: 'api', description: 'Azure GPT models' },
612
- ];
613
- res.json(providers);
567
+ app.delete('/api/auth/profiles/:provider/:name', (req, res) => {
568
+ const { provider, name } = req.params;
569
+ const activePlugin = getActiveGooglePlugin();
570
+ const namespace = provider === 'google'
571
+ ? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
572
+ : provider;
573
+
574
+ const profilePath = path.join(AUTH_PROFILES_DIR, namespace, `${name}.json`);
575
+ if (fs.existsSync(profilePath)) fs.unlinkSync(profilePath);
576
+ res.json({ success: true });
614
577
  });
615
578
 
616
- const AUTH_PROFILES_DIR = path.join(HOME_DIR, '.config', 'opencode-studio', 'auth-profiles');
617
-
618
- function ensureAuthProfilesDir() {
619
- if (!fs.existsSync(AUTH_PROFILES_DIR)) {
620
- fs.mkdirSync(AUTH_PROFILES_DIR, { recursive: true });
621
- }
622
- }
623
-
624
- function getProviderProfilesDir(provider) {
625
- return path.join(AUTH_PROFILES_DIR, provider);
626
- }
627
-
628
- function listAuthProfiles(provider) {
629
- const dir = getProviderProfilesDir(provider);
630
- if (!fs.existsSync(dir)) return [];
579
+ app.put('/api/auth/profiles/:provider/:name', (req, res) => {
580
+ const { provider, name } = req.params;
581
+ const { newName } = req.body;
582
+ const activePlugin = getActiveGooglePlugin();
583
+ const namespace = provider === 'google'
584
+ ? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
585
+ : provider;
631
586
 
632
- try {
633
- return fs.readdirSync(dir)
634
- .filter(f => f.endsWith('.json'))
635
- .map(f => f.replace('.json', ''));
636
- } catch {
637
- return [];
638
- }
639
- }
640
-
641
- function getNextProfileName(provider) {
642
- const existing = listAuthProfiles(provider);
643
- let num = 1;
644
- while (existing.includes(`account-${num}`)) {
645
- num++;
646
- }
647
- return `account-${num}`;
648
- }
649
-
650
- function saveAuthProfile(provider, profileName, data) {
651
- ensureAuthProfilesDir();
652
- const dir = getProviderProfilesDir(provider);
653
- if (!fs.existsSync(dir)) {
654
- fs.mkdirSync(dir, { recursive: true });
655
- }
656
- const filePath = path.join(dir, `${profileName}.json`);
657
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
658
- return true;
659
- }
660
-
661
- function loadAuthProfile(provider, profileName) {
662
- const filePath = path.join(getProviderProfilesDir(provider), `${profileName}.json`);
663
- if (!fs.existsSync(filePath)) return null;
664
- try {
665
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
666
- } catch {
667
- return null;
668
- }
669
- }
587
+ const oldPath = path.join(AUTH_PROFILES_DIR, namespace, `${name}.json`);
588
+ const newPath = path.join(AUTH_PROFILES_DIR, namespace, `${newName}.json`);
589
+ if (fs.existsSync(oldPath)) fs.renameSync(oldPath, newPath);
590
+ res.json({ success: true, name: newName });
591
+ });
670
592
 
671
- function deleteAuthProfile(provider, profileName) {
672
- const filePath = path.join(getProviderProfilesDir(provider), `${profileName}.json`);
673
- if (fs.existsSync(filePath)) {
674
- fs.unlinkSync(filePath);
675
- return true;
593
+ app.post('/api/auth/login', (req, res) => {
594
+ let { provider } = req.body;
595
+ if (typeof provider !== 'string') provider = "";
596
+
597
+ let cmd = 'opencode auth login';
598
+ if (provider) cmd += ` ${provider}`;
599
+
600
+ const platform = process.platform;
601
+ let terminalCmd;
602
+ if (platform === 'win32') {
603
+ terminalCmd = `start "" cmd /c "call ${cmd} || pause"`;
604
+ } else if (platform === 'darwin') {
605
+ terminalCmd = `osascript -e 'tell application "Terminal" to do script "${cmd}"'`;
606
+ } else {
607
+ terminalCmd = `x-terminal-emulator -e "${cmd}"`;
676
608
  }
677
- return false;
678
- }
679
-
680
- function getActiveProfiles() {
681
- const studioConfig = loadStudioConfig();
682
- return studioConfig.activeProfiles || {};
683
- }
684
-
685
- function setActiveProfile(provider, profileName) {
686
- const studioConfig = loadStudioConfig();
687
- studioConfig.activeProfiles = studioConfig.activeProfiles || {};
688
- studioConfig.activeProfiles[provider] = profileName;
689
- saveStudioConfig(studioConfig);
690
- }
691
-
692
- function verifyActiveProfile(p, n, c) { console.log("Verifying:", p, n); if (!n || !c) return false; const d = loadAuthProfile(p, n); if (d && c) { console.log("Profile refresh:", d.refresh); console.log("Current refresh:", c.refresh); } if (!d) return false; return JSON.stringify(d) === JSON.stringify(c); }
693
-
694
- app.get('/api/auth/profiles', (req, res) => {
695
- ensureAuthProfilesDir();
696
- const activeProfiles = getActiveProfiles();
697
- const authConfig = loadAuthConfig() || {};
698
609
 
699
- const profiles = {};
700
- const providers = [
701
- 'google', 'anthropic', 'openai', 'xai', 'groq',
702
- 'together', 'mistral', 'deepseek', 'openrouter',
703
- 'amazon-bedrock', 'azure'
704
- ];
705
-
706
- providers.forEach(p => {
707
- const saved = listAuthProfiles(p);
708
- const active = activeProfiles[p];
709
- const current = authConfig[p];
710
-
711
- if (saved.length > 0 || current) {
712
- profiles[p] = {
713
- active: active,
714
- saved: saved,
715
- hasCurrent: !!current
716
- };
610
+ console.log('Executing terminal command:', terminalCmd);
611
+
612
+ exec(terminalCmd, (err) => {
613
+ if (err) {
614
+ console.error('Failed to open terminal:', err);
615
+ return res.status(500).json({ error: 'Failed to open terminal', details: err.message });
717
616
  }
617
+ res.json({ success: true, message: 'Terminal opened', note: 'Complete login in the terminal window' });
718
618
  });
719
-
720
- res.json(profiles);
721
619
  });
722
620
 
723
- app.get('/api/debug/paths', (req, res) => {
724
- const home = os.homedir();
725
- const candidatePaths = [
726
- process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'opencode', 'storage', 'message') : null,
727
- path.join(home, '.local', 'share', 'opencode', 'storage', 'message'),
728
- path.join(home, '.opencode', 'storage', 'message')
729
- ].filter(p => p);
730
-
731
- const results = candidatePaths.map(p => ({
732
- path: p,
733
- exists: fs.existsSync(p),
734
- isDirectory: fs.existsSync(p) && fs.statSync(p).isDirectory(),
735
- fileCount: (fs.existsSync(p) && fs.statSync(p).isDirectory()) ? fs.readdirSync(p).length : 0
736
- }));
737
-
738
- res.json({
739
- home,
740
- platform: process.platform,
741
- candidates: results
742
- });
621
+ app.delete('/api/auth/:provider', (req, res) => {
622
+ const { provider } = req.params;
623
+ const authCfg = loadAuthConfig() || {};
624
+ delete authCfg[provider];
625
+ const cp = getConfigPath();
626
+ const ap = path.join(path.dirname(cp), 'auth.json');
627
+ fs.writeFileSync(ap, JSON.stringify(authCfg, null, 2), 'utf8');
628
+
629
+ const studio = loadStudioConfig();
630
+ if (studio.activeProfiles) delete studio.activeProfiles[provider];
631
+ saveStudioConfig(studio);
632
+
633
+ res.json({ success: true });
743
634
  });
744
635
 
745
636
  app.get('/api/usage', async (req, res) => {
746
637
  try {
747
- const { projectId: filterProjectId, granularity = 'daily' } = req.query;
638
+ const {projectId: fid, granularity = 'daily', range = '30d'} = req.query;
639
+ const cp = getConfigPath();
640
+ if (!cp) return res.json({ totalCost: 0, totalTokens: 0, byModel: [], byDay: [], byProject: [] });
641
+
748
642
  const home = os.homedir();
749
- const candidatePaths = [
750
- process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'opencode', 'storage', 'message') : null,
751
- path.join(home, '.local', 'share', 'opencode', 'storage', 'message'),
752
- path.join(home, '.opencode', 'storage', 'message')
753
- ].filter(p => p && fs.existsSync(p));
754
-
755
- const getSessionDir = (messageDir) => {
756
- return path.join(path.dirname(messageDir), 'session');
757
- };
758
-
759
- const sessionProjectMap = new Map();
760
-
761
- const loadProjects = (messageDir) => {
762
- const sessionDir = getSessionDir(messageDir);
763
- if (!fs.existsSync(sessionDir)) return;
764
-
765
- try {
766
- const projectDirs = fs.readdirSync(sessionDir);
767
-
768
- for (const projDir of projectDirs) {
769
- const fullProjPath = path.join(sessionDir, projDir);
770
- if (!fs.statSync(fullProjPath).isDirectory()) continue;
771
-
772
- const files = fs.readdirSync(fullProjPath);
773
- for (const file of files) {
774
- if (file.startsWith('ses_') && file.endsWith('.json')) {
775
- const sessionId = file.replace('.json', '');
776
- try {
777
- const meta = JSON.parse(fs.readFileSync(path.join(fullProjPath, file), 'utf8'));
778
- let projectName = 'Unknown Project';
779
- if (meta.directory) {
780
- projectName = path.basename(meta.directory);
781
- } else if (meta.projectID) {
782
- projectName = meta.projectID.substring(0, 8);
783
- }
784
- sessionProjectMap.set(sessionId, { name: projectName, id: meta.projectID || projDir });
785
- } catch (e) {}
786
- }
787
- }
788
- }
789
- } catch (e) {
790
- console.error('Error loading projects:', e);
643
+ const dataCandidates = [
644
+ path.dirname(cp),
645
+ path.join(home, '.local', 'share', 'opencode'),
646
+ path.join(home, '.opencode'),
647
+ process.env.APPDATA ? path.join(process.env.APPDATA, 'opencode') : null,
648
+ path.join(home, 'AppData', 'Local', 'opencode')
649
+ ].filter(Boolean);
650
+
651
+ let md = null;
652
+ let sd = null;
653
+
654
+ for (const d of dataCandidates) {
655
+ const mdp = path.join(d, 'storage', 'message');
656
+ const sdp = path.join(d, 'storage', 'session');
657
+ if (fs.existsSync(mdp)) {
658
+ md = mdp;
659
+ sd = sdp;
660
+ break;
791
661
  }
792
- };
793
-
794
- for (const logDir of candidatePaths) {
795
- loadProjects(logDir);
796
662
  }
797
663
 
798
- const stats = {
799
- totalCost: 0,
800
- totalTokens: 0,
801
- byModel: {},
802
- byTime: {},
803
- byProject: {}
804
- };
805
-
806
- const processedFiles = new Set();
807
-
808
- const processMessage = (filePath, sessionId) => {
809
- if (processedFiles.has(filePath)) return;
810
- processedFiles.add(filePath);
664
+ if (!md) return res.json({ totalCost: 0, totalTokens: 0, byModel: [], byDay: [], byProject: [] });
811
665
 
812
- try {
813
- const content = fs.readFileSync(filePath, 'utf8');
814
- const msg = JSON.parse(content);
815
-
816
- const model = msg.modelID || (msg.model && (msg.model.modelID || msg.model.id)) || 'unknown';
817
- const projectInfo = sessionProjectMap.get(sessionId) || { name: 'Unassigned', id: 'unknown' };
818
- const projectId = projectInfo.id || 'unknown';
819
-
820
- if (filterProjectId && filterProjectId !== 'all' && projectId !== filterProjectId) {
821
- return;
822
- }
823
-
824
- if (msg.role === 'assistant' && msg.tokens) {
825
- const cost = msg.cost || 0;
826
- const inputTokens = msg.tokens.input || 0;
827
- const outputTokens = msg.tokens.output || 0;
828
- const tokens = inputTokens + outputTokens;
829
-
830
- const timestamp = msg.time.created;
831
- const dateObj = new Date(timestamp);
832
- let timeKey;
833
-
834
- if (granularity === 'hourly') {
835
- timeKey = dateObj.toISOString().substring(0, 13) + ':00:00Z';
836
- } else if (granularity === 'weekly') {
837
- const day = dateObj.getDay();
838
- const diff = dateObj.getDate() - day + (day === 0 ? -6 : 1);
839
- const monday = new Date(dateObj.setDate(diff));
840
- timeKey = monday.toISOString().split('T')[0];
841
- } else {
842
- timeKey = dateObj.toISOString().split('T')[0];
843
- }
844
666
 
845
- if (tokens > 0) {
846
- stats.totalCost += cost;
847
- stats.totalTokens += tokens;
848
-
849
- if (!stats.byModel[model]) {
850
- stats.byModel[model] = { name: model, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
851
- }
852
- stats.byModel[model].cost += cost;
853
- stats.byModel[model].tokens += tokens;
854
- stats.byModel[model].inputTokens += inputTokens;
855
- stats.byModel[model].outputTokens += outputTokens;
856
-
857
- if (!stats.byTime[timeKey]) {
858
- stats.byTime[timeKey] = { date: timeKey, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
859
- }
860
- stats.byTime[timeKey].cost += cost;
861
- stats.byTime[timeKey].tokens += tokens;
862
- stats.byTime[timeKey].inputTokens += inputTokens;
863
- stats.byTime[timeKey].outputTokens += outputTokens;
864
-
865
- const projectName = projectInfo.name;
866
- if (!stats.byProject[projectId]) {
867
- stats.byProject[projectId] = { id: projectId, name: projectName, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
667
+ const pmap = new Map();
668
+ if (fs.existsSync(sd)) {
669
+ fs.readdirSync(sd).forEach(d => {
670
+ const fp = path.join(sd, d);
671
+ if (fs.statSync(fp).isDirectory()) {
672
+ fs.readdirSync(fp).forEach(f => {
673
+ if (f.startsWith('ses_') && f.endsWith('.json')) {
674
+ try {
675
+ const m = JSON.parse(fs.readFileSync(path.join(fp, f), 'utf8'));
676
+ pmap.set(f.replace('.json', ''), { name: m.directory ? path.basename(m.directory) : (m.projectID ? m.projectID.substring(0, 8) : 'Unknown'), id: m.projectID || d });
677
+ } catch {}
868
678
  }
869
- stats.byProject[projectId].cost += cost;
870
- stats.byProject[projectId].tokens += tokens;
871
- stats.byProject[projectId].inputTokens += inputTokens;
872
- stats.byProject[projectId].outputTokens += outputTokens;
873
- }
679
+ });
874
680
  }
875
- } catch (err) {
876
- }
877
- };
681
+ });
682
+ }
878
683
 
879
- for (const logDir of candidatePaths) {
880
- try {
881
- const sessions = fs.readdirSync(logDir);
882
- for (const session of sessions) {
883
- if (!session.startsWith('ses_')) continue;
884
-
885
- const sessionDir = path.join(logDir, session);
886
- if (fs.statSync(sessionDir).isDirectory()) {
887
- const messages = fs.readdirSync(sessionDir);
888
- for (const msgFile of messages) {
889
- if (msgFile.endsWith('.json')) {
890
- processMessage(path.join(sessionDir, msgFile), session);
891
- }
684
+ const stats = { totalCost: 0, totalTokens: 0, byModel: {}, byTime: {}, byProject: {} };
685
+ const seen = new Set();
686
+ const now = Date.now();
687
+ let min = 0;
688
+ if (range === '24h') min = now - 86400000;
689
+ else if (range === '7d') min = now - 604800000;
690
+ else if (range === '30d') min = now - 2592000000;
691
+ else if (range === '1y') min = now - 31536000000;
692
+
693
+ fs.readdirSync(md).forEach(s => {
694
+ if (!s.startsWith('ses_')) return;
695
+ const sp = path.join(md, s);
696
+ if (fs.statSync(sp).isDirectory()) {
697
+ fs.readdirSync(sp).forEach(f => {
698
+ if (!f.endsWith('.json') || seen.has(path.join(sp, f))) return;
699
+ seen.add(path.join(sp, f));
700
+ try {
701
+ const msg = JSON.parse(fs.readFileSync(path.join(sp, f), 'utf8'));
702
+ const pid = pmap.get(s)?.id || 'unknown';
703
+ if (fid && fid !== 'all' && pid !== fid) return;
704
+ if (min > 0 && msg.time.created < min) return;
705
+ if (msg.role === 'assistant' && msg.tokens) {
706
+ const c = msg.cost || 0, it = msg.tokens.input || 0, ot = msg.tokens.output || 0, t = it + ot;
707
+ const d = new Date(msg.time.created);
708
+ let tk;
709
+ if (granularity === 'hourly') tk = d.toISOString().substring(0, 13) + ':00:00Z';
710
+ else if (granularity === 'weekly') {
711
+ const day = d.getDay(), diff = d.getDate() - day + (day === 0 ? -6 : 1);
712
+ tk = new Date(d.setDate(diff)).toISOString().split('T')[0];
713
+ } else if (granularity === 'monthly') tk = d.toISOString().substring(0, 7) + '-01';
714
+ else tk = d.toISOString().split('T')[0];
715
+
716
+ const mid = msg.modelID || (msg.model && (msg.model.modelID || msg.model.id)) || 'unknown';
717
+ stats.totalCost += c; stats.totalTokens += t;
718
+ [stats.byModel, stats.byTime, stats.byProject].forEach((obj, i) => {
719
+ const key = i === 0 ? mid : (i === 1 ? tk : pid);
720
+ if (!obj[key]) obj[key] = { name: key, id: key, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
721
+ if (i === 2) obj[key].name = pmap.get(s)?.name || 'Unassigned';
722
+ obj[key].cost += c; obj[key].tokens += t; obj[key].inputTokens += it; obj[key].outputTokens += ot;
723
+ });
892
724
  }
893
- }
894
- }
895
- } catch (err) {
896
- console.error(`Error reading log dir ${logDir}:`, err);
725
+ } catch {}
726
+ });
897
727
  }
898
- }
728
+ });
899
729
 
900
- const response = {
730
+ res.json({
901
731
  totalCost: stats.totalCost,
902
732
  totalTokens: stats.totalTokens,
903
733
  byModel: Object.values(stats.byModel).sort((a, b) => b.cost - a.cost),
904
- byDay: Object.values(stats.byTime).sort((a, b) => a.date.localeCompare(b.date)),
734
+ byDay: Object.values(stats.byTime).sort((a, b) => a.name.localeCompare(b.name)).map(v => ({ ...v, date: v.name })),
905
735
  byProject: Object.values(stats.byProject).sort((a, b) => b.cost - a.cost)
906
- };
907
-
908
- res.json(response);
736
+ });
909
737
  } catch (error) {
910
- console.error('Error fetching usage stats:', error);
911
- res.status(500).json({ error: 'Failed to fetch usage stats' });
738
+ res.status(500).json({ error: 'Failed' });
912
739
  }
913
740
  });
914
741
 
915
- app.get('/api/auth/profiles/:provider', (req, res) => {
916
- const { provider } = req.params;
917
- const providerProfiles = listAuthProfiles(provider);
918
- const activeProfiles = getActiveProfiles();
919
- const authConfig = loadAuthConfig() || {};
920
-
921
- res.json({
922
- profiles: providerProfiles,
923
- active: (activeProfiles[provider] && verifyActiveProfile(provider, activeProfiles[provider], authConfig[provider])) ? activeProfiles[provider] : null,
924
- hasCurrentAuth: !!authConfig[provider],
925
- });
926
- });
927
-
928
- app.post('/api/auth/profiles/:provider', (req, res) => {
929
- const { provider } = req.params;
930
- const { name } = req.body;
931
-
932
- const authConfig = loadAuthConfig();
933
- if (!authConfig || !authConfig[provider]) {
934
- return res.status(400).json({ error: `No active auth for ${provider} to save` });
935
- }
936
-
937
- const profileName = name || getNextProfileName(provider);
938
- const data = authConfig[provider];
742
+ app.post('/api/auth/google/plugin', (req, res) => {
743
+ const { plugin } = req.body;
744
+ const studio = loadStudioConfig();
745
+ studio.activeGooglePlugin = plugin;
746
+ saveStudioConfig(studio);
939
747
 
940
748
  try {
941
- saveAuthProfile(provider, profileName, data);
942
- setActiveProfile(provider, profileName);
943
- res.json({ success: true, name: profileName });
944
- } catch (err) {
945
- res.status(500).json({ error: 'Failed to save profile', details: err.message });
946
- }
947
- });
749
+ const opencode = loadConfig();
750
+ if (opencode) {
751
+ if (opencode.provider?.google) {
752
+ const models = studio.pluginModels[plugin];
753
+ if (models) {
754
+ opencode.provider.google.models = models;
755
+ }
756
+ }
948
757
 
949
- app.post('/api/auth/profiles/:provider/:name/activate', (req, res) => {
950
- const { provider, name } = req.params;
951
-
952
- const profileData = loadAuthProfile(provider, name);
953
- if (!profileData) {
954
- return res.status(404).json({ error: 'Profile not found' });
955
- }
956
-
957
- const authConfig = loadAuthConfig() || {};
958
- authConfig[provider] = profileData;
959
-
960
- // Save to ~/.config/opencode/auth.json
961
- const configPath = getConfigPath();
962
- if (configPath) {
963
- const authPath = path.join(path.dirname(configPath), 'auth.json');
964
- try {
965
- fs.writeFileSync(authPath, JSON.stringify(authConfig, null, 2), 'utf8');
966
- setActiveProfile(provider, name);
967
- res.json({ success: true });
968
- } catch (err) {
969
- res.status(500).json({ error: 'Failed to write auth config', details: err.message });
758
+ if (!opencode.plugin) opencode.plugin = [];
759
+ const geminiPlugin = 'opencode-gemini-auth@latest';
760
+ const antigravityPlugin = 'opencode-google-antigravity-auth';
761
+
762
+ opencode.plugin = opencode.plugin.filter(p =>
763
+ !p.includes('gemini-auth') &&
764
+ !p.includes('antigravity-auth')
765
+ );
766
+
767
+ if (plugin === 'gemini') {
768
+ opencode.plugin.push(geminiPlugin);
769
+ } else if (plugin === 'antigravity') {
770
+ opencode.plugin.push(antigravityPlugin);
771
+ }
772
+
773
+ saveConfig(opencode);
970
774
  }
971
- } else {
972
- res.status(500).json({ error: 'Config path not found' });
973
- }
974
- });
975
775
 
976
- app.delete('/api/auth/profiles/:provider/:name', (req, res) => {
977
- const { provider, name } = req.params;
978
- const success = deleteAuthProfile(provider, name);
979
- if (success) {
980
- const activeProfiles = getActiveProfiles();
981
- if (activeProfiles[provider] === name) {
982
- const studioConfig = loadStudioConfig();
983
- if (studioConfig.activeProfiles) {
984
- delete studioConfig.activeProfiles[provider];
985
- saveStudioConfig(studioConfig);
776
+ const cp = getConfigPath();
777
+ if (cp) {
778
+ const ap = path.join(path.dirname(cp), 'auth.json');
779
+ if (fs.existsSync(ap)) {
780
+ const authCfg = JSON.parse(fs.readFileSync(ap, 'utf8'));
781
+ if (plugin === 'antigravity' && authCfg['google.antigravity']) {
782
+ authCfg.google = { ...authCfg['google.antigravity'] };
783
+ } else if (plugin === 'gemini' && authCfg['google.gemini']) {
784
+ authCfg.google = { ...authCfg['google.gemini'] };
785
+ }
786
+ fs.writeFileSync(ap, JSON.stringify(authCfg, null, 2), 'utf8');
986
787
  }
987
788
  }
988
- res.json({ success: true });
989
- } else {
990
- res.status(404).json({ error: 'Profile not found' });
789
+ } catch (err) {
790
+ console.error(err);
991
791
  }
792
+
793
+ res.json({ success: true, activePlugin: plugin });
992
794
  });
993
795
 
994
- app.put('/api/auth/profiles/:provider/:name', (req, res) => {
995
- const { provider, name } = req.params;
996
- const { newName } = req.body;
997
-
998
- if (!newName) return res.status(400).json({ error: 'New name required' });
999
-
1000
- const profileData = loadAuthProfile(provider, name);
1001
- if (!profileData) return res.status(404).json({ error: 'Profile not found' });
1002
-
1003
- const success = saveAuthProfile(provider, newName, profileData);
1004
- if (success) {
1005
- deleteAuthProfile(provider, name);
1006
-
1007
- const activeProfiles = getActiveProfiles();
1008
- if (activeProfiles[provider] === name) {
1009
- setActiveProfile(provider, newName);
1010
- }
1011
-
1012
- res.json({ success: true, name: newName });
1013
- } else {
1014
- res.status(500).json({ error: 'Failed to rename profile' });
1015
- }
796
+ app.get('/api/auth/google/plugin', (req, res) => {
797
+ const studio = loadStudioConfig();
798
+ res.json({ activePlugin: studio.activeGooglePlugin || null });
1016
799
  });
1017
800
 
1018
- app.post('/api/plugins/config/add', (req, res) => {
1019
- const { plugins } = req.body;
1020
- if (!Array.isArray(plugins) || plugins.length === 0) {
1021
- return res.status(400).json({ error: 'Plugins array required' });
801
+ app.get('/api/pending-action', (req, res) => {
802
+ if (pendingActionMemory) return res.json({ action: pendingActionMemory });
803
+ if (fs.existsSync(PENDING_ACTION_PATH)) {
804
+ try {
805
+ const action = JSON.parse(fs.readFileSync(PENDING_ACTION_PATH, 'utf8'));
806
+ return res.json({ action });
807
+ } catch {}
1022
808
  }
809
+ res.json({ action: null });
810
+ });
1023
811
 
1024
- const config = loadConfig();
1025
- if (!config) return res.status(404).json({ error: 'Config not found' });
1026
-
1027
- if (!config.plugins) config.plugins = {};
1028
-
1029
- const result = {
1030
- added: [],
1031
- skipped: []
1032
- };
1033
-
1034
- for (const pluginName of plugins) {
1035
- // Check if plugin exists in studio (for validation)
1036
- const pluginDir = getPluginDir();
1037
- const dirPath = path.join(pluginDir, pluginName);
1038
- const hasJs = fs.existsSync(path.join(dirPath, 'index.js'));
1039
- const hasTs = fs.existsSync(path.join(dirPath, 'index.ts'));
1040
-
1041
- if (!hasJs && !hasTs) {
1042
- result.skipped.push(`${pluginName} (not found)`);
1043
- continue;
1044
- }
812
+ app.delete('/api/pending-action', (req, res) => {
813
+ pendingActionMemory = null;
814
+ if (fs.existsSync(PENDING_ACTION_PATH)) fs.unlinkSync(PENDING_ACTION_PATH);
815
+ res.json({ success: true });
816
+ });
1045
817
 
1046
- // Add to config
1047
- // Default config for a plugin? Usually empty object or enabled: true
1048
- if (config.plugins[pluginName]) {
1049
- result.skipped.push(`${pluginName} (already configured)`);
818
+ app.post('/api/plugins/config/add', (req, res) => {
819
+ const { plugins } = req.body;
820
+ const opencode = loadConfig();
821
+ if (!opencode) return res.status(404).json({ error: 'Config not found' });
822
+
823
+ if (!opencode.plugin) opencode.plugin = [];
824
+ const added = [];
825
+ const skipped = [];
826
+
827
+ plugins.forEach(p => {
828
+ if (!opencode.plugin.includes(p)) {
829
+ opencode.plugin.push(p);
830
+ added.push(p);
831
+
832
+ const studio = loadStudioConfig();
833
+ if (p.includes('gemini-auth') && !studio.availableGooglePlugins.includes('gemini')) {
834
+ studio.availableGooglePlugins.push('gemini');
835
+ saveStudioConfig(studio);
836
+ }
837
+ if (p.includes('antigravity-auth') && !studio.availableGooglePlugins.includes('antigravity')) {
838
+ studio.availableGooglePlugins.push('antigravity');
839
+ saveStudioConfig(studio);
840
+ }
1050
841
  } else {
1051
- config.plugins[pluginName] = { enabled: true };
1052
- result.added.push(pluginName);
842
+ skipped.push(p);
1053
843
  }
1054
- }
1055
-
1056
- if (result.added.length > 0) {
1057
- saveConfig(config);
1058
- }
1059
-
1060
- res.json(result);
1061
- });
1062
-
1063
- app.delete('/api/plugins/config/:name', (req, res) => {
1064
- const pluginName = decodeURIComponent(req.params.name);
1065
- const config = loadConfig();
844
+ });
1066
845
 
1067
- if (!config || !config.plugins) {
1068
- return res.status(404).json({ error: 'Config not found or no plugins' });
1069
- }
1070
-
1071
- if (config.plugins[pluginName]) {
1072
- delete config.plugins[pluginName];
1073
- saveConfig(config);
1074
- res.json({ success: true });
1075
- } else {
1076
- res.status(404).json({ error: 'Plugin not in config' });
1077
- }
846
+ saveConfig(opencode);
847
+ res.json({ added, skipped });
1078
848
  });
1079
849
 
1080
- app.listen(PORT, () => {
1081
- console.log(`Server running at http://localhost:${PORT}`);
1082
- });
850
+ app.listen(PORT, () => console.log(`Server running at http://localhost:${PORT}`));