agentgui 1.0.199 → 1.0.201

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.199",
3
+ "version": "1.0.201",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -28,6 +28,7 @@
28
28
  "busboy": "^1.6.0",
29
29
  "express": "^5.2.1",
30
30
  "fsbrowse": "^0.2.13",
31
+ "google-auth-library": "^10.5.0",
31
32
  "onnxruntime-node": "^1.24.1",
32
33
  "webtalk": "github:AnEntrypoint/webtalk",
33
34
  "ws": "^8.14.2"
package/server.js CHANGED
@@ -3,10 +3,13 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
5
  import zlib from 'zlib';
6
+ import net from 'net';
7
+ import crypto from 'crypto';
6
8
  import { fileURLToPath } from 'url';
7
9
  import { WebSocketServer } from 'ws';
8
10
  import { execSync, spawn } from 'child_process';
9
11
  import { createRequire } from 'module';
12
+ import { OAuth2Client } from 'google-auth-library';
10
13
  import { queries } from './database.js';
11
14
  import { runClaudeWithStreaming } from './lib/claude-runner.js';
12
15
  let speechModule = null;
@@ -180,6 +183,350 @@ function discoverAgents() {
180
183
 
181
184
  const discoveredAgents = discoverAgents();
182
185
 
186
+ const GEMINI_SCOPES = [
187
+ 'https://www.googleapis.com/auth/cloud-platform',
188
+ 'https://www.googleapis.com/auth/userinfo.email',
189
+ 'https://www.googleapis.com/auth/userinfo.profile',
190
+ ];
191
+
192
+ function getGeminiOAuthCreds() {
193
+ try {
194
+ const geminiPath = execSync('which gemini', { encoding: 'utf8' }).trim();
195
+ const realPath = fs.realpathSync(geminiPath);
196
+ const pkgRoot = path.resolve(path.dirname(realPath), '..');
197
+ const oauth2Path = path.join(pkgRoot, 'node_modules', '@google', 'gemini-cli-core', 'dist', 'src', 'code_assist', 'oauth2.js');
198
+ const src = fs.readFileSync(oauth2Path, 'utf8');
199
+ const idMatch = src.match(/OAUTH_CLIENT_ID\s*=\s*['"]([^'"]+)['"]/);
200
+ const secretMatch = src.match(/OAUTH_CLIENT_SECRET\s*=\s*['"]([^'"]+)['"]/);
201
+ if (idMatch && secretMatch) return { clientId: idMatch[1], clientSecret: secretMatch[1] };
202
+ } catch {}
203
+ try {
204
+ const npmCacheDirs = [
205
+ path.join(os.homedir(), '.npm', '_npx'),
206
+ path.join(os.homedir(), '.cache', '.npm', '_npx'),
207
+ process.env.NPM_CACHE ? path.join(process.env.NPM_CACHE, '_npx') : null,
208
+ ].filter(Boolean);
209
+ for (const cacheDir of npmCacheDirs) {
210
+ if (!fs.existsSync(cacheDir)) continue;
211
+ for (const d of fs.readdirSync(cacheDir).filter(d => !d.startsWith('.'))) {
212
+ const oauth2Path = path.join(cacheDir, d, 'node_modules', '@google', 'gemini-cli-core', 'dist', 'src', 'code_assist', 'oauth2.js');
213
+ if (fs.existsSync(oauth2Path)) {
214
+ const src = fs.readFileSync(oauth2Path, 'utf8');
215
+ const idMatch = src.match(/OAUTH_CLIENT_ID\s*=\s*['"]([^'"]+)['"]/);
216
+ const secretMatch = src.match(/OAUTH_CLIENT_SECRET\s*=\s*['"]([^'"]+)['"]/);
217
+ if (idMatch && secretMatch) return { clientId: idMatch[1], clientSecret: secretMatch[1] };
218
+ }
219
+ }
220
+ }
221
+ } catch {}
222
+ return null;
223
+ }
224
+ const GEMINI_DIR = path.join(os.homedir(), '.gemini');
225
+ const GEMINI_OAUTH_FILE = path.join(GEMINI_DIR, 'oauth_creds.json');
226
+ const GEMINI_ACCOUNTS_FILE = path.join(GEMINI_DIR, 'google_accounts.json');
227
+
228
+ let geminiOAuthState = { status: 'idle', error: null, email: null };
229
+ let geminiOAuthCallbackServer = null;
230
+
231
+ function getAvailablePort() {
232
+ return new Promise((resolve, reject) => {
233
+ const srv = net.createServer();
234
+ srv.listen(0, () => {
235
+ const port = srv.address().port;
236
+ srv.close(() => resolve(port));
237
+ });
238
+ srv.on('error', reject);
239
+ });
240
+ }
241
+
242
+ function saveGeminiCredentials(tokens, email) {
243
+ if (!fs.existsSync(GEMINI_DIR)) fs.mkdirSync(GEMINI_DIR, { recursive: true });
244
+ fs.writeFileSync(GEMINI_OAUTH_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
245
+ try { fs.chmodSync(GEMINI_OAUTH_FILE, 0o600); } catch (_) {}
246
+
247
+ let accounts = { active: null, old: [] };
248
+ try {
249
+ if (fs.existsSync(GEMINI_ACCOUNTS_FILE)) {
250
+ accounts = JSON.parse(fs.readFileSync(GEMINI_ACCOUNTS_FILE, 'utf8'));
251
+ }
252
+ } catch (_) {}
253
+
254
+ if (email) {
255
+ if (accounts.active && accounts.active !== email && !accounts.old.includes(accounts.active)) {
256
+ accounts.old.push(accounts.active);
257
+ }
258
+ accounts.active = email;
259
+ }
260
+ fs.writeFileSync(GEMINI_ACCOUNTS_FILE, JSON.stringify(accounts, null, 2), { mode: 0o600 });
261
+ }
262
+
263
+ function geminiOAuthResultPage(title, message, success) {
264
+ const color = success ? '#10b981' : '#ef4444';
265
+ const icon = success ? '✓' : '✗';
266
+ return `<!DOCTYPE html><html><head><title>${title}</title></head>
267
+ <body style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#111827;font-family:system-ui,sans-serif;color:white;">
268
+ <div style="text-align:center;max-width:400px;padding:2rem;">
269
+ <div style="font-size:4rem;color:${color};margin-bottom:1rem;">${icon}</div>
270
+ <h1 style="font-size:1.5rem;margin-bottom:0.5rem;">${title}</h1>
271
+ <p style="color:#9ca3af;">${message}</p>
272
+ <p style="color:#6b7280;margin-top:1rem;font-size:0.875rem;">You can close this tab.</p>
273
+ </div></body></html>`;
274
+ }
275
+
276
+ async function startGeminiOAuth() {
277
+ if (geminiOAuthCallbackServer) {
278
+ try { geminiOAuthCallbackServer.close(); } catch (_) {}
279
+ geminiOAuthCallbackServer = null;
280
+ }
281
+
282
+ const creds = getGeminiOAuthCreds();
283
+ if (!creds) throw new Error('Could not find Gemini CLI OAuth credentials. Install gemini CLI first.');
284
+
285
+ const port = await getAvailablePort();
286
+ const redirectUri = `http://127.0.0.1:${port}/oauth2callback`;
287
+ const state = crypto.randomBytes(32).toString('hex');
288
+
289
+ const client = new OAuth2Client({
290
+ clientId: creds.clientId,
291
+ clientSecret: creds.clientSecret,
292
+ });
293
+
294
+ const authUrl = client.generateAuthUrl({
295
+ redirect_uri: redirectUri,
296
+ access_type: 'offline',
297
+ scope: GEMINI_SCOPES,
298
+ state,
299
+ });
300
+
301
+ geminiOAuthState = { status: 'pending', error: null, email: null };
302
+
303
+ return new Promise((resolve, reject) => {
304
+ const cbServer = http.createServer(async (req, res) => {
305
+ try {
306
+ const reqUrl = new URL(req.url, `http://127.0.0.1:${port}`);
307
+ if (reqUrl.pathname !== '/oauth2callback') {
308
+ res.writeHead(404);
309
+ res.end('Not found');
310
+ return;
311
+ }
312
+
313
+ const error = reqUrl.searchParams.get('error');
314
+ if (error) {
315
+ const desc = reqUrl.searchParams.get('error_description') || error;
316
+ geminiOAuthState = { status: 'error', error: desc, email: null };
317
+ res.writeHead(200, { 'Content-Type': 'text/html' });
318
+ res.end(geminiOAuthResultPage('Authentication Failed', desc, false));
319
+ cbServer.close();
320
+ return;
321
+ }
322
+
323
+ if (reqUrl.searchParams.get('state') !== state) {
324
+ geminiOAuthState = { status: 'error', error: 'State mismatch', email: null };
325
+ res.writeHead(200, { 'Content-Type': 'text/html' });
326
+ res.end(geminiOAuthResultPage('Authentication Failed', 'State mismatch.', false));
327
+ cbServer.close();
328
+ return;
329
+ }
330
+
331
+ const code = reqUrl.searchParams.get('code');
332
+ if (!code) {
333
+ geminiOAuthState = { status: 'error', error: 'No authorization code', email: null };
334
+ res.writeHead(200, { 'Content-Type': 'text/html' });
335
+ res.end(geminiOAuthResultPage('Authentication Failed', 'No authorization code received.', false));
336
+ cbServer.close();
337
+ return;
338
+ }
339
+
340
+ const { tokens } = await client.getToken({ code, redirect_uri: redirectUri });
341
+ client.setCredentials(tokens);
342
+
343
+ let email = '';
344
+ try {
345
+ const { token } = await client.getAccessToken();
346
+ if (token) {
347
+ const resp = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
348
+ headers: { Authorization: `Bearer ${token}` }
349
+ });
350
+ if (resp.ok) {
351
+ const info = await resp.json();
352
+ email = info.email || '';
353
+ }
354
+ }
355
+ } catch (_) {}
356
+
357
+ saveGeminiCredentials(tokens, email);
358
+ geminiOAuthState = { status: 'success', error: null, email };
359
+
360
+ res.writeHead(200, { 'Content-Type': 'text/html' });
361
+ res.end(geminiOAuthResultPage('Authentication Successful', email ? `Signed in as ${email}` : 'Gemini CLI credentials saved.', true));
362
+ cbServer.close();
363
+ } catch (e) {
364
+ geminiOAuthState = { status: 'error', error: e.message, email: null };
365
+ res.writeHead(200, { 'Content-Type': 'text/html' });
366
+ res.end(geminiOAuthResultPage('Authentication Failed', e.message, false));
367
+ cbServer.close();
368
+ }
369
+ });
370
+
371
+ cbServer.on('error', (err) => {
372
+ geminiOAuthState = { status: 'error', error: err.message, email: null };
373
+ reject(err);
374
+ });
375
+
376
+ cbServer.listen(port, '127.0.0.1', () => {
377
+ geminiOAuthCallbackServer = cbServer;
378
+ resolve(authUrl);
379
+ });
380
+
381
+ setTimeout(() => {
382
+ if (geminiOAuthState.status === 'pending') {
383
+ geminiOAuthState = { status: 'error', error: 'Authentication timed out', email: null };
384
+ try { cbServer.close(); } catch (_) {}
385
+ }
386
+ }, 5 * 60 * 1000);
387
+ });
388
+ }
389
+
390
+ function getGeminiOAuthStatus() {
391
+ try {
392
+ if (fs.existsSync(GEMINI_OAUTH_FILE)) {
393
+ const creds = JSON.parse(fs.readFileSync(GEMINI_OAUTH_FILE, 'utf8'));
394
+ if (creds.refresh_token || creds.access_token) {
395
+ let email = '';
396
+ try {
397
+ if (fs.existsSync(GEMINI_ACCOUNTS_FILE)) {
398
+ const accts = JSON.parse(fs.readFileSync(GEMINI_ACCOUNTS_FILE, 'utf8'));
399
+ email = accts.active || '';
400
+ }
401
+ } catch (_) {}
402
+ return { hasKey: true, apiKey: email || '****oauth', defaultModel: '', path: GEMINI_OAUTH_FILE, authMethod: 'oauth' };
403
+ }
404
+ }
405
+ } catch (_) {}
406
+ return null;
407
+ }
408
+
409
+ const PROVIDER_CONFIGS = {
410
+ 'anthropic': {
411
+ name: 'Anthropic', configPaths: [
412
+ path.join(os.homedir(), '.claude.json'),
413
+ path.join(os.homedir(), '.config', 'claude', 'settings.json'),
414
+ path.join(os.homedir(), '.anthropic.json')
415
+ ],
416
+ configFormat: (apiKey, model) => ({ api_key: apiKey, default_model: model })
417
+ },
418
+ 'openai': {
419
+ name: 'OpenAI', configPaths: [
420
+ path.join(os.homedir(), '.openai.json'),
421
+ path.join(os.homedir(), '.config', 'openai', 'api-key')
422
+ ],
423
+ configFormat: (apiKey, model) => ({ apiKey, defaultModel: model })
424
+ },
425
+ 'google': {
426
+ name: 'Google Gemini', configPaths: [
427
+ path.join(os.homedir(), '.gemini.json'),
428
+ path.join(os.homedir(), '.config', 'gemini', 'credentials.json')
429
+ ],
430
+ configFormat: (apiKey, model) => ({ api_key: apiKey, default_model: model })
431
+ },
432
+ 'openrouter': {
433
+ name: 'OpenRouter', configPaths: [
434
+ path.join(os.homedir(), '.openrouter.json'),
435
+ path.join(os.homedir(), '.config', 'openrouter', 'config.json')
436
+ ],
437
+ configFormat: (apiKey, model) => ({ api_key: apiKey, default_model: model })
438
+ },
439
+ 'github': {
440
+ name: 'GitHub Models', configPaths: [
441
+ path.join(os.homedir(), '.github.json'),
442
+ path.join(os.homedir(), '.config', 'github-copilot.json')
443
+ ],
444
+ configFormat: (apiKey, model) => ({ github_token: apiKey, default_model: model })
445
+ },
446
+ 'azure': {
447
+ name: 'Azure OpenAI', configPaths: [
448
+ path.join(os.homedir(), '.azure.json'),
449
+ path.join(os.homedir(), '.config', 'azure-openai', 'config.json')
450
+ ],
451
+ configFormat: (apiKey, model) => ({ api_key: apiKey, endpoint: '', default_model: model })
452
+ },
453
+ 'anthropic-claude-code': {
454
+ name: 'Claude Code Max', configPaths: [
455
+ path.join(os.homedir(), '.claude', 'max.json'),
456
+ path.join(os.homedir(), '.config', 'claude-code', 'max.json')
457
+ ],
458
+ configFormat: (apiKey, model) => ({ api_key: apiKey, plan: 'max', default_model: model })
459
+ },
460
+ 'opencode': {
461
+ name: 'OpenCode', configPaths: [
462
+ path.join(os.homedir(), '.opencode', 'config.json'),
463
+ path.join(os.homedir(), '.config', 'opencode', 'config.json')
464
+ ],
465
+ configFormat: (apiKey, model) => ({ api_key: apiKey, default_model: model, providers: ['anthropic', 'openai', 'google'] })
466
+ },
467
+ 'proxypilot': {
468
+ name: 'ProxyPilot', configPaths: [
469
+ path.join(os.homedir(), '.proxypilot', 'config.json'),
470
+ path.join(os.homedir(), '.config', 'proxypilot', 'config.json')
471
+ ],
472
+ configFormat: (apiKey, model) => ({ api_key: apiKey, default_model: model })
473
+ }
474
+ };
475
+
476
+ function maskKey(key) {
477
+ if (!key || key.length < 8) return '****';
478
+ return '****' + key.slice(-4);
479
+ }
480
+
481
+ function getProviderConfigs() {
482
+ const configs = {};
483
+ for (const [providerId, config] of Object.entries(PROVIDER_CONFIGS)) {
484
+ if (providerId === 'google') {
485
+ const oauthStatus = getGeminiOAuthStatus();
486
+ if (oauthStatus) {
487
+ configs[providerId] = { name: config.name, ...oauthStatus };
488
+ continue;
489
+ }
490
+ }
491
+ for (const configPath of config.configPaths) {
492
+ try {
493
+ if (fs.existsSync(configPath)) {
494
+ const content = fs.readFileSync(configPath, 'utf8');
495
+ const parsed = JSON.parse(content);
496
+ const rawKey = parsed.api_key || parsed.apiKey || parsed.github_token || '';
497
+ configs[providerId] = {
498
+ name: config.name,
499
+ apiKey: maskKey(rawKey),
500
+ hasKey: !!rawKey,
501
+ defaultModel: parsed.default_model || parsed.defaultModel || '',
502
+ path: configPath
503
+ };
504
+ break;
505
+ }
506
+ } catch (_) {}
507
+ }
508
+ if (!configs[providerId]) {
509
+ configs[providerId] = { name: config.name, apiKey: '', hasKey: false, defaultModel: '', path: '' };
510
+ }
511
+ }
512
+ return configs;
513
+ }
514
+
515
+ function saveProviderConfig(providerId, apiKey, defaultModel) {
516
+ const config = PROVIDER_CONFIGS[providerId];
517
+ if (!config) throw new Error('Unknown provider: ' + providerId);
518
+ const configPath = config.configPaths[0];
519
+ const configDir = path.dirname(configPath);
520
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
521
+ let existing = {};
522
+ try {
523
+ if (fs.existsSync(configPath)) existing = JSON.parse(fs.readFileSync(configPath, 'utf8'));
524
+ } catch (_) {}
525
+ const merged = { ...existing, ...config.configFormat(apiKey, defaultModel) };
526
+ fs.writeFileSync(configPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
527
+ return configPath;
528
+ }
529
+
183
530
  function parseBody(req) {
184
531
  return new Promise((resolve, reject) => {
185
532
  let body = '';
@@ -678,15 +1025,29 @@ const server = http.createServer(async (req, res) => {
678
1025
  status.detail = 'no credentials';
679
1026
  }
680
1027
  } else if (agent.id === 'gemini') {
1028
+ const oauthFile = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
681
1029
  const acctFile = path.join(os.homedir(), '.gemini', 'google_accounts.json');
1030
+ let hasOAuth = false;
1031
+ if (fs.existsSync(oauthFile)) {
1032
+ try {
1033
+ const creds = JSON.parse(fs.readFileSync(oauthFile, 'utf-8'));
1034
+ if (creds.refresh_token || creds.access_token) hasOAuth = true;
1035
+ } catch (_) {}
1036
+ }
682
1037
  if (fs.existsSync(acctFile)) {
683
1038
  const accts = JSON.parse(fs.readFileSync(acctFile, 'utf-8'));
684
1039
  if (accts.active) {
685
1040
  status.authenticated = true;
686
1041
  status.detail = accts.active;
1042
+ } else if (hasOAuth) {
1043
+ status.authenticated = true;
1044
+ status.detail = 'oauth';
687
1045
  } else {
688
1046
  status.detail = 'logged out';
689
1047
  }
1048
+ } else if (hasOAuth) {
1049
+ status.authenticated = true;
1050
+ status.detail = 'oauth';
690
1051
  } else {
691
1052
  status.detail = 'no credentials';
692
1053
  }
@@ -711,16 +1072,60 @@ const server = http.createServer(async (req, res) => {
711
1072
  return;
712
1073
  }
713
1074
 
1075
+ if (pathOnly === '/api/gemini-oauth/start' && req.method === 'POST') {
1076
+ try {
1077
+ const authUrl = await startGeminiOAuth();
1078
+ sendJSON(req, res, 200, { authUrl });
1079
+ } catch (e) {
1080
+ sendJSON(req, res, 500, { error: e.message });
1081
+ }
1082
+ return;
1083
+ }
1084
+
1085
+ if (pathOnly === '/api/gemini-oauth/status' && req.method === 'GET') {
1086
+ sendJSON(req, res, 200, geminiOAuthState);
1087
+ return;
1088
+ }
1089
+
714
1090
  const agentAuthMatch = pathOnly.match(/^\/api\/agents\/([^/]+)\/auth$/);
715
1091
  if (agentAuthMatch && req.method === 'POST') {
716
1092
  const agentId = agentAuthMatch[1];
717
1093
  const agent = discoveredAgents.find(a => a.id === agentId);
718
1094
  if (!agent) { sendJSON(req, res, 404, { error: 'Agent not found' }); return; }
719
1095
 
1096
+ if (agentId === 'gemini') {
1097
+ try {
1098
+ const authUrl = await startGeminiOAuth();
1099
+ const conversationId = '__agent_auth__';
1100
+ broadcastSync({ type: 'script_started', conversationId, script: 'auth-gemini', agentId: 'gemini', timestamp: Date.now() });
1101
+ broadcastSync({ type: 'script_output', conversationId, data: `\x1b[36mOpening Google OAuth in your browser...\x1b[0m\r\n\r\nIf it doesn't open automatically, visit:\r\n${authUrl}\r\n`, stream: 'stdout', timestamp: Date.now() });
1102
+
1103
+ const pollId = setInterval(() => {
1104
+ if (geminiOAuthState.status === 'success') {
1105
+ clearInterval(pollId);
1106
+ const email = geminiOAuthState.email || '';
1107
+ broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[32mAuthentication successful${email ? ' (' + email + ')' : ''}\x1b[0m\r\n`, stream: 'stdout', timestamp: Date.now() });
1108
+ broadcastSync({ type: 'script_stopped', conversationId, code: 0, timestamp: Date.now() });
1109
+ } else if (geminiOAuthState.status === 'error') {
1110
+ clearInterval(pollId);
1111
+ broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[31mAuthentication failed: ${geminiOAuthState.error}\x1b[0m\r\n`, stream: 'stderr', timestamp: Date.now() });
1112
+ broadcastSync({ type: 'script_stopped', conversationId, code: 1, error: geminiOAuthState.error, timestamp: Date.now() });
1113
+ }
1114
+ }, 1000);
1115
+
1116
+ setTimeout(() => clearInterval(pollId), 5 * 60 * 1000);
1117
+
1118
+ sendJSON(req, res, 200, { ok: true, agentId, authUrl });
1119
+ return;
1120
+ } catch (e) {
1121
+ sendJSON(req, res, 500, { error: e.message });
1122
+ return;
1123
+ }
1124
+ }
1125
+
720
1126
  const authCommands = {
721
1127
  'claude-code': { cmd: 'claude', args: ['setup-token'] },
722
1128
  'opencode': { cmd: 'opencode', args: ['auth', 'login'] },
723
- 'gemini': { cmd: 'gemini', args: [] }
724
1129
  };
725
1130
  const authCmd = authCommands[agentId];
726
1131
  if (!authCmd) { sendJSON(req, res, 400, { error: 'No auth command for this agent' }); return; }
@@ -755,6 +1160,33 @@ const server = http.createServer(async (req, res) => {
755
1160
  return;
756
1161
  }
757
1162
 
1163
+ if (pathOnly === '/api/auth/configs' && req.method === 'GET') {
1164
+ const configs = getProviderConfigs();
1165
+ sendJSON(req, res, 200, configs);
1166
+ return;
1167
+ }
1168
+
1169
+ if (pathOnly === '/api/auth/save-config' && req.method === 'POST') {
1170
+ try {
1171
+ const body = await parseBody(req);
1172
+ const { providerId, apiKey, defaultModel } = body || {};
1173
+ if (typeof providerId !== 'string' || !providerId.length || providerId.length > 100) {
1174
+ sendJSON(req, res, 400, { error: 'Invalid providerId' }); return;
1175
+ }
1176
+ if (typeof apiKey !== 'string' || !apiKey.length || apiKey.length > 10000) {
1177
+ sendJSON(req, res, 400, { error: 'Invalid apiKey' }); return;
1178
+ }
1179
+ if (defaultModel !== undefined && (typeof defaultModel !== 'string' || defaultModel.length > 200)) {
1180
+ sendJSON(req, res, 400, { error: 'Invalid defaultModel' }); return;
1181
+ }
1182
+ const configPath = saveProviderConfig(providerId, apiKey, defaultModel || '');
1183
+ sendJSON(req, res, 200, { success: true, path: configPath });
1184
+ } catch (err) {
1185
+ sendJSON(req, res, 400, { error: err.message });
1186
+ }
1187
+ return;
1188
+ }
1189
+
758
1190
  if (pathOnly === '/api/import/claude-code' && req.method === 'GET') {
759
1191
  const result = queries.importClaudeCodeConversations();
760
1192
  sendJSON(req, res, 200, { imported: result });
package/static/index.html CHANGED
@@ -452,7 +452,7 @@
452
452
  transition: background-color 0.15s, color 0.15s;
453
453
  }
454
454
  .header-icon-btn:hover { background-color: var(--color-bg-primary); color: var(--color-text-primary); }
455
- .header-icon-btn svg { width: 18px; height: 18px; }
455
+ .header-icon-btn svg { width: 20px; height: 20px; }
456
456
  #scriptStartBtn { color: var(--color-success); }
457
457
  #scriptStartBtn:hover { background-color: rgba(16,185,129,0.1); color: var(--color-success); }
458
458
  .script-dev-btn { color: var(--color-info); }
@@ -466,7 +466,7 @@
466
466
  .agent-auth-btn:hover { background-color: var(--color-bg-primary); }
467
467
  .agent-auth-dropdown {
468
468
  position: absolute; top: 100%; right: 0; z-index: 100;
469
- min-width: 200px; padding: 0.25rem 0;
469
+ min-width: 260px; padding: 0.25rem 0;
470
470
  background: var(--color-bg-secondary); border: 1px solid var(--color-border);
471
471
  border-radius: 0.5rem; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
472
472
  display: none;
@@ -485,6 +485,11 @@
485
485
  .agent-auth-dot.ok { background: var(--color-success); }
486
486
  .agent-auth-dot.missing { background: var(--color-warning); }
487
487
  .agent-auth-dot.unknown { background: var(--color-text-secondary); }
488
+ .agent-auth-section-header {
489
+ padding: 0.375rem 0.75rem; font-size: 0.6875rem; font-weight: 600;
490
+ text-transform: uppercase; letter-spacing: 0.05em;
491
+ color: var(--color-text-secondary); user-select: none;
492
+ }
488
493
 
489
494
  .terminal-container {
490
495
  flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #1e1e1e;
@@ -2,103 +2,157 @@
2
2
  var BASE = window.__BASE_URL || '';
3
3
  var btn = document.getElementById('agentAuthBtn');
4
4
  var dropdown = document.getElementById('agentAuthDropdown');
5
- var agents = [];
6
- var authRunning = false;
5
+ var agents = [], providers = {}, authRunning = false, editingProvider = null;
7
6
  var AUTH_CONV_ID = '__agent_auth__';
8
7
 
9
8
  function init() {
10
9
  if (!btn || !dropdown) return;
10
+ btn.style.display = 'flex';
11
11
  btn.addEventListener('click', toggleDropdown);
12
12
  document.addEventListener('click', function(e) {
13
- if (!btn.contains(e.target)) closeDropdown();
13
+ if (!btn.contains(e.target) && !dropdown.contains(e.target)) closeDropdown();
14
14
  });
15
- window.addEventListener('conversation-selected', function() { fetchAuthStatus(); });
15
+ window.addEventListener('conversation-selected', function() { refresh(); });
16
16
  window.addEventListener('ws-message', onWsMessage);
17
- fetchAuthStatus();
17
+ refresh();
18
18
  }
19
19
 
20
+ function refresh() { fetchAuthStatus(); fetchProviderConfigs(); }
21
+
20
22
  function fetchAuthStatus() {
21
- fetch(BASE + '/api/agents/auth-status')
22
- .then(function(r) { return r.json(); })
23
- .then(function(data) {
24
- agents = data.agents || [];
25
- updateButton();
26
- renderDropdown();
27
- })
23
+ fetch(BASE + '/api/agents/auth-status').then(function(r) { return r.json(); })
24
+ .then(function(data) { agents = data.agents || []; updateButton(); renderDropdown(); })
25
+ .catch(function() {});
26
+ }
27
+
28
+ function fetchProviderConfigs() {
29
+ fetch(BASE + '/api/auth/configs').then(function(r) { return r.json(); })
30
+ .then(function(data) { providers = data || {}; updateButton(); renderDropdown(); })
28
31
  .catch(function() {});
29
32
  }
30
33
 
31
34
  function updateButton() {
32
- if (agents.length === 0) { btn.style.display = 'none'; return; }
33
35
  btn.style.display = 'flex';
34
- var allOk = agents.every(function(a) { return a.authenticated; });
35
- var anyMissing = agents.some(function(a) { return !a.authenticated; });
36
- btn.classList.toggle('auth-ok', allOk);
37
- btn.classList.toggle('auth-warn', anyMissing);
36
+ var agentOk = agents.length === 0 || agents.every(function(a) { return a.authenticated; });
37
+ var pkeys = Object.keys(providers);
38
+ var provOk = pkeys.length === 0 || pkeys.some(function(k) { return providers[k].hasKey; });
39
+ var anyWarn = agents.some(function(a) { return !a.authenticated; }) ||
40
+ pkeys.some(function(k) { return !providers[k].hasKey; });
41
+ btn.classList.toggle('auth-ok', agentOk && provOk && (agents.length > 0 || pkeys.length > 0));
42
+ btn.classList.toggle('auth-warn', anyWarn);
38
43
  }
39
44
 
40
45
  function renderDropdown() {
41
46
  dropdown.innerHTML = '';
42
- agents.forEach(function(agent) {
43
- var item = document.createElement('button');
44
- item.className = 'agent-auth-item';
45
- var dotClass = agent.authenticated ? 'ok' : (agent.detail === 'unknown' ? 'unknown' : 'missing');
46
- item.innerHTML = '<span class="agent-auth-dot ' + dotClass + '"></span>' +
47
- '<span>' + escapeHtml(agent.name) + '</span>' +
48
- '<span style="margin-left:auto;font-size:0.7rem;color:var(--color-text-secondary)">' + escapeHtml(agent.detail) + '</span>';
49
- item.addEventListener('click', function(e) {
50
- e.stopPropagation();
51
- closeDropdown();
52
- triggerAuth(agent.id);
47
+ if (agents.length > 0) {
48
+ appendHeader('Agent CLI Auth');
49
+ agents.forEach(function(agent) {
50
+ var dotClass = agent.authenticated ? 'ok' : (agent.detail === 'unknown' ? 'unknown' : 'missing');
51
+ var item = makeItem(dotClass, agent.name, agent.detail);
52
+ item.addEventListener('click', function(e) { e.stopPropagation(); closeDropdown(); triggerAuth(agent.id); });
53
+ dropdown.appendChild(item);
54
+ });
55
+ }
56
+ var pkeys = Object.keys(providers);
57
+ if (pkeys.length > 0) {
58
+ if (agents.length > 0) appendSep();
59
+ appendHeader('Provider Keys');
60
+ pkeys.forEach(function(pid) {
61
+ var p = providers[pid];
62
+ var item = makeItem(p.hasKey ? 'ok' : 'missing', p.name || pid, p.hasKey ? p.apiKey : 'not set');
63
+ item.style.flexWrap = 'wrap';
64
+ item.addEventListener('click', function(e) { e.stopPropagation(); toggleEdit(pid); });
65
+ dropdown.appendChild(item);
66
+ if (editingProvider === pid) dropdown.appendChild(makeEditForm(pid));
53
67
  });
54
- dropdown.appendChild(item);
68
+ }
69
+ }
70
+
71
+ function appendHeader(text) {
72
+ var h = document.createElement('div');
73
+ h.className = 'agent-auth-section-header';
74
+ h.textContent = text;
75
+ dropdown.appendChild(h);
76
+ }
77
+
78
+ function appendSep() {
79
+ var s = document.createElement('div');
80
+ s.style.cssText = 'height:1px;background:var(--color-border);margin:0.25rem 0;';
81
+ dropdown.appendChild(s);
82
+ }
83
+
84
+ function makeItem(dotClass, name, detail) {
85
+ var el = document.createElement('button');
86
+ el.className = 'agent-auth-item';
87
+ el.innerHTML = '<span class="agent-auth-dot ' + dotClass + '"></span><span>' + esc(name) +
88
+ '</span><span style="margin-left:auto;font-size:0.7rem;color:var(--color-text-secondary)">' + esc(detail) + '</span>';
89
+ return el;
90
+ }
91
+
92
+ function makeEditForm(pid) {
93
+ var form = document.createElement('div');
94
+ form.style.cssText = 'width:100%;padding:0.375rem 0.75rem;display:flex;gap:0.375rem;';
95
+ var input = document.createElement('input');
96
+ input.type = 'password'; input.placeholder = 'API key';
97
+ input.style.cssText = 'flex:1;min-width:0;padding:0.25rem 0.5rem;font-size:0.75rem;border:1px solid var(--color-border);border-radius:0.25rem;background:var(--color-bg-primary);color:var(--color-text-primary);outline:none;';
98
+ input.addEventListener('click', function(e) { e.stopPropagation(); });
99
+ var saveBtn = document.createElement('button');
100
+ saveBtn.textContent = 'Save';
101
+ saveBtn.style.cssText = 'padding:0.25rem 0.5rem;font-size:0.7rem;font-weight:600;background:var(--color-primary);color:white;border:none;border-radius:0.25rem;cursor:pointer;flex-shrink:0;';
102
+ saveBtn.addEventListener('click', function(e) {
103
+ e.stopPropagation();
104
+ var key = input.value.trim();
105
+ if (!key) return;
106
+ saveBtn.disabled = true; saveBtn.textContent = '...';
107
+ saveProviderKey(pid, key);
55
108
  });
109
+ form.appendChild(input); form.appendChild(saveBtn);
110
+ setTimeout(function() { input.focus(); }, 50);
111
+ return form;
112
+ }
113
+
114
+ function toggleEdit(pid) { editingProvider = editingProvider === pid ? null : pid; renderDropdown(); }
115
+
116
+ function saveProviderKey(providerId, apiKey) {
117
+ fetch(BASE + '/api/auth/save-config', {
118
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
119
+ body: JSON.stringify({ providerId: providerId, apiKey: apiKey, defaultModel: '' })
120
+ }).then(function(r) { return r.json(); }).then(function(data) {
121
+ if (data.success) { editingProvider = null; fetchProviderConfigs(); }
122
+ }).catch(function() { editingProvider = null; renderDropdown(); });
56
123
  }
57
124
 
58
125
  function toggleDropdown(e) {
59
126
  e.stopPropagation();
127
+ if (!dropdown.classList.contains('open')) { editingProvider = null; refresh(); }
60
128
  dropdown.classList.toggle('open');
61
129
  }
62
130
 
63
- function closeDropdown() {
64
- dropdown.classList.remove('open');
65
- }
131
+ function closeDropdown() { dropdown.classList.remove('open'); editingProvider = null; }
66
132
 
67
133
  function triggerAuth(agentId) {
68
134
  if (authRunning) return;
69
135
  fetch(BASE + '/api/agents/' + agentId + '/auth', {
70
- method: 'POST',
71
- headers: { 'Content-Type': 'application/json' },
72
- body: '{}'
73
- })
74
- .then(function(r) { return r.json(); })
75
- .then(function(data) {
136
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}'
137
+ }).then(function(r) { return r.json(); }).then(function(data) {
76
138
  if (data.ok) {
77
- authRunning = true;
78
- showTerminalTab();
79
- switchToTerminalView();
139
+ authRunning = true; showTerminalTab(); switchToTerminalView();
80
140
  var term = getTerminal();
81
- if (term) {
82
- term.clear();
83
- term.writeln('\x1b[36m[authenticating ' + agentId + ']\x1b[0m\r\n');
141
+ if (term) { term.clear(); term.writeln('\x1b[36m[authenticating ' + agentId + ']\x1b[0m\r\n'); }
142
+ if (data.authUrl) {
143
+ window.open(data.authUrl, '_blank');
84
144
  }
85
145
  }
86
- })
87
- .catch(function() {});
146
+ }).catch(function() {});
88
147
  }
89
148
 
90
149
  function onWsMessage(e) {
91
150
  var data = e.detail;
92
151
  if (!data || data.conversationId !== AUTH_CONV_ID) return;
93
152
  if (data.type === 'script_started') {
94
- authRunning = true;
95
- showTerminalTab();
96
- switchToTerminalView();
153
+ authRunning = true; showTerminalTab(); switchToTerminalView();
97
154
  var term = getTerminal();
98
- if (term) {
99
- term.clear();
100
- term.writeln('\x1b[36m[authenticating ' + (data.agentId || '') + ']\x1b[0m\r\n');
101
- }
155
+ if (term) { term.clear(); term.writeln('\x1b[36m[authenticating ' + (data.agentId || '') + ']\x1b[0m\r\n'); }
102
156
  } else if (data.type === 'script_output') {
103
157
  showTerminalTab();
104
158
  var term = getTerminal();
@@ -108,37 +162,20 @@
108
162
  var term = getTerminal();
109
163
  var msg = data.error ? data.error : ('exited with code ' + (data.code || 0));
110
164
  if (term) term.writeln('\r\n\x1b[90m[auth ' + msg + ']\x1b[0m');
111
- setTimeout(fetchAuthStatus, 1000);
165
+ setTimeout(refresh, 1000);
112
166
  }
113
167
  }
114
168
 
115
- function showTerminalTab() {
116
- var tabBtn = document.getElementById('terminalTabBtn');
117
- if (tabBtn) tabBtn.style.display = '';
118
- }
119
-
169
+ function showTerminalTab() { var t = document.getElementById('terminalTabBtn'); if (t) t.style.display = ''; }
120
170
  function switchToTerminalView() {
121
171
  var bar = document.getElementById('viewToggleBar');
122
172
  if (!bar) return;
123
- var termBtn = bar.querySelector('[data-view="terminal"]');
124
- if (termBtn) termBtn.click();
125
- }
126
-
127
- function getTerminal() {
128
- return window.scriptRunner ? window.scriptRunner.getTerminal() : null;
129
- }
130
-
131
- function escapeHtml(s) {
132
- var d = document.createElement('div');
133
- d.textContent = s;
134
- return d.innerHTML;
135
- }
136
-
137
- if (document.readyState === 'loading') {
138
- document.addEventListener('DOMContentLoaded', init);
139
- } else {
140
- init();
173
+ var t = bar.querySelector('[data-view="terminal"]'); if (t) t.click();
141
174
  }
175
+ function getTerminal() { return window.scriptRunner ? window.scriptRunner.getTerminal() : null; }
176
+ function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
142
177
 
143
- window.agentAuth = { refresh: fetchAuthStatus };
178
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
179
+ else init();
180
+ window.agentAuth = { refresh: refresh };
144
181
  })();