neoagent 2.4.1-beta.11 → 2.4.1-beta.13

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.
@@ -313,26 +313,8 @@ class _SettingsPanelState extends State<SettingsPanel> {
313
313
  final routingModels = availableModels.isEmpty
314
314
  ? controller.supportedModels
315
315
  : availableModels;
316
- final modelChoices = <DropdownMenuItem<String>>[
317
- const DropdownMenuItem<String>(
318
- value: 'auto',
319
- child: Text(
320
- 'Smart Selector (Auto)',
321
- maxLines: 1,
322
- overflow: TextOverflow.ellipsis,
323
- ),
324
- ),
325
- ...routingModels.map(
326
- (model) => DropdownMenuItem<String>(
327
- value: model.id,
328
- child: Text(
329
- model.label,
330
- maxLines: 1,
331
- overflow: TextOverflow.ellipsis,
332
- ),
333
- ),
334
- ),
335
- ];
316
+ final List<_ModelPickerOption> modelChoices =
317
+ _modelPickerOptions(routingModels, allowAuto: true);
336
318
  final enabledSmartModels = _enabledModels
337
319
  .where((id) => routingModels.any((model) => model.id == id))
338
320
  .length;
@@ -872,7 +854,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
872
854
 
873
855
  Widget _buildModelsSection({
874
856
  required NeoAgentController controller,
875
- required List<DropdownMenuItem<String>> modelChoices,
857
+ required List<_ModelPickerOption> modelChoices,
876
858
  required List<ModelMeta> routingModels,
877
859
  required List<ModelMeta> availableModels,
878
860
  required int enabledSmartModels,
@@ -997,7 +979,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
997
979
  routingModels,
998
980
  allowAuto: true,
999
981
  ),
1000
- items: modelChoices,
982
+ options: modelChoices,
1001
983
  onChanged: (value) {
1002
984
  if (value != null) {
1003
985
  setState(() => _defaultChatModel = value);
@@ -1015,7 +997,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1015
997
  routingModels,
1016
998
  allowAuto: true,
1017
999
  ),
1018
- items: modelChoices,
1000
+ options: modelChoices,
1019
1001
  onChanged: (value) {
1020
1002
  if (value != null) {
1021
1003
  setState(() => _defaultSubagentModel = value);
@@ -1033,18 +1015,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1033
1015
  routingModels,
1034
1016
  allowAuto: false,
1035
1017
  ),
1036
- items: routingModels
1037
- .map(
1038
- (model) => DropdownMenuItem<String>(
1039
- value: model.id,
1040
- child: Text(
1041
- model.label,
1042
- maxLines: 1,
1043
- overflow: TextOverflow.ellipsis,
1044
- ),
1045
- ),
1046
- )
1047
- .toList(),
1018
+ options: _modelPickerOptions(routingModels),
1048
1019
  onChanged: (value) {
1049
1020
  if (value != null) {
1050
1021
  setState(() => _fallbackModel = value);
@@ -1114,7 +1085,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1114
1085
 
1115
1086
  Widget _buildVoiceAndRecordingSection({
1116
1087
  required NeoAgentController controller,
1117
- required List<DropdownMenuItem<String>> modelChoices,
1088
+ required List<_ModelPickerOption> modelChoices,
1118
1089
  required List<ModelMeta> routingModels,
1119
1090
  }) {
1120
1091
  final liveVoiceOptions =
@@ -1160,7 +1131,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1160
1131
  routingModels,
1161
1132
  allowAuto: true,
1162
1133
  ),
1163
- items: modelChoices,
1134
+ options: modelChoices,
1164
1135
  onChanged: (value) {
1165
1136
  if (value != null) {
1166
1137
  setState(
@@ -1176,7 +1147,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1176
1147
  label: 'Recording Transcription',
1177
1148
  icon: Icons.hearing_outlined,
1178
1149
  value: _defaultRecordingTranscriptionModel,
1179
- items: _recordingTranscriptionModelChoices(
1150
+ options: _recordingTranscriptionOptions(
1180
1151
  _defaultRecordingTranscriptionModel,
1181
1152
  ),
1182
1153
  onChanged: (value) {
@@ -1221,7 +1192,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1221
1192
  routingModels,
1222
1193
  allowAuto: true,
1223
1194
  ),
1224
- items: modelChoices,
1195
+ options: modelChoices,
1225
1196
  onChanged: (value) {
1226
1197
  if (value != null) {
1227
1198
  setState(() => _defaultSpeechModel = value);
@@ -1263,14 +1234,9 @@ class _SettingsPanelState extends State<SettingsPanel> {
1263
1234
  label: 'Live Provider',
1264
1235
  icon: Icons.call_outlined,
1265
1236
  value: _voiceLiveProvider,
1266
- items: const <String>['openai', 'gemini']
1267
- .map(
1268
- (value) => DropdownMenuItem<String>(
1269
- value: value,
1270
- child: Text(value),
1271
- ),
1272
- )
1273
- .toList(),
1237
+ options: _simplePickerOptions(
1238
+ const <String>['openai', 'gemini'],
1239
+ ),
1274
1240
  onChanged: (value) {
1275
1241
  if (value == null) return;
1276
1242
  setState(() {
@@ -1299,16 +1265,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
1299
1265
  label: 'Live Model',
1300
1266
  icon: Icons.speed_outlined,
1301
1267
  value: _voiceLiveModel,
1302
- items:
1303
- (_voiceLiveModelsByProvider[_voiceLiveProvider] ??
1304
- const <String>[])
1305
- .map(
1306
- (value) => DropdownMenuItem<String>(
1307
- value: value,
1308
- child: Text(value),
1309
- ),
1310
- )
1311
- .toList(),
1268
+ options: _simplePickerOptions(
1269
+ _voiceLiveModelsByProvider[_voiceLiveProvider] ??
1270
+ const <String>[],
1271
+ ),
1312
1272
  onChanged: (value) {
1313
1273
  if (value != null) {
1314
1274
  setState(() => _voiceLiveModel = value);
@@ -1323,14 +1283,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1323
1283
  label: 'Live Voice',
1324
1284
  icon: Icons.graphic_eq_outlined,
1325
1285
  value: _voiceLiveVoice,
1326
- items: liveVoiceOptions
1327
- .map(
1328
- (value) => DropdownMenuItem<String>(
1329
- value: value,
1330
- child: Text(value),
1331
- ),
1332
- )
1333
- .toList(),
1286
+ options: _simplePickerOptions(liveVoiceOptions),
1334
1287
  onChanged: (value) {
1335
1288
  if (value != null) {
1336
1289
  setState(() => _voiceLiveVoice = value);
@@ -2179,20 +2132,12 @@ class _SettingsPanelState extends State<SettingsPanel> {
2179
2132
  }
2180
2133
  }
2181
2134
 
2182
- List<DropdownMenuItem<String>> _recordingTranscriptionModelChoices(
2183
- String current,
2184
- ) {
2185
- const defaults = <String>['nova-3', 'nova-2-general'];
2186
- final normalizedCurrent = current.trim();
2187
- final values = <String>{...defaults};
2188
- if (normalizedCurrent.isNotEmpty) {
2189
- values.add(normalizedCurrent);
2190
- }
2191
- return values
2192
- .map(
2193
- (value) => DropdownMenuItem<String>(value: value, child: Text(value)),
2194
- )
2195
- .toList();
2135
+ List<_ModelPickerOption> _recordingTranscriptionOptions(String current) {
2136
+ const List<String> defaults = <String>['nova-3', 'nova-2-general'];
2137
+ final String normalizedCurrent = current.trim();
2138
+ final Set<String> values = <String>{...defaults};
2139
+ if (normalizedCurrent.isNotEmpty) values.add(normalizedCurrent);
2140
+ return _simplePickerOptions(values.toList());
2196
2141
  }
2197
2142
 
2198
2143
  // Shared helper: small "Test" button + inline result row.
@@ -2503,14 +2448,14 @@ class _RoutingSelectCard extends StatelessWidget {
2503
2448
  required this.label,
2504
2449
  required this.icon,
2505
2450
  required this.value,
2506
- required this.items,
2451
+ required this.options,
2507
2452
  required this.onChanged,
2508
2453
  });
2509
2454
 
2510
2455
  final String label;
2511
2456
  final IconData icon;
2512
2457
  final String value;
2513
- final List<DropdownMenuItem<String>> items;
2458
+ final List<_ModelPickerOption> options;
2514
2459
  final ValueChanged<String?> onChanged;
2515
2460
 
2516
2461
  @override
@@ -2533,12 +2478,11 @@ class _RoutingSelectCard extends StatelessWidget {
2533
2478
  ],
2534
2479
  ),
2535
2480
  const SizedBox(height: 10),
2536
- DropdownButtonFormField<String>(
2537
- initialValue: value,
2538
- items: items,
2539
- isExpanded: true,
2540
- decoration: const InputDecoration(isDense: true),
2481
+ _ModelPickerButton(
2482
+ value: value,
2483
+ options: options,
2541
2484
  onChanged: onChanged,
2485
+ dialogTitle: 'Select $label',
2542
2486
  ),
2543
2487
  ],
2544
2488
  ),
@@ -30,3 +30,27 @@ class AndroidApkDropZone extends StatelessWidget {
30
30
  );
31
31
  }
32
32
  }
33
+
34
+ /// Compact tile variant — fits inside an actions row.
35
+ class AndroidApkTile extends StatelessWidget {
36
+ const AndroidApkTile({
37
+ super.key,
38
+ required this.enabled,
39
+ required this.busy,
40
+ required this.onInstall,
41
+ });
42
+
43
+ final bool enabled;
44
+ final bool busy;
45
+ final AndroidApkInstallCallback onInstall;
46
+
47
+ @override
48
+ Widget build(BuildContext context) {
49
+ return buildAndroidApkTile(
50
+ context,
51
+ enabled: enabled,
52
+ busy: busy,
53
+ onInstall: onInstall,
54
+ );
55
+ }
56
+ }
@@ -14,3 +14,16 @@ Widget buildAndroidApkDropZone(
14
14
  }) {
15
15
  return const SizedBox.shrink();
16
16
  }
17
+
18
+ Widget buildAndroidApkTile(
19
+ BuildContext context, {
20
+ required bool enabled,
21
+ required bool busy,
22
+ required Future<void> Function({
23
+ required String filename,
24
+ required Uint8List bytes,
25
+ })
26
+ onInstall,
27
+ }) {
28
+ return const SizedBox.shrink();
29
+ }
@@ -346,3 +346,220 @@ bool _isSupportedInstallFile(String filename) {
346
346
  final normalized = filename.toLowerCase();
347
347
  return _supportedAndroidInstallExtensions.any(normalized.endsWith);
348
348
  }
349
+
350
+ // ─────────────────────────────────────────────────────────────────────────────
351
+ // Compact tile variant — used inside the Android actions box.
352
+ // Same file-picker / drag-and-drop logic but rendered as a small square tile
353
+ // that fits neatly alongside other action tiles.
354
+ // ─────────────────────────────────────────────────────────────────────────────
355
+
356
+ int _androidApkTileViewId = 0;
357
+
358
+ Widget buildAndroidApkTile(
359
+ BuildContext context, {
360
+ required bool enabled,
361
+ required bool busy,
362
+ required Future<void> Function({
363
+ required String filename,
364
+ required Uint8List bytes,
365
+ })
366
+ onInstall,
367
+ }) {
368
+ return _AndroidApkTileWeb(enabled: enabled, busy: busy, onInstall: onInstall);
369
+ }
370
+
371
+ class _AndroidApkTileWeb extends StatefulWidget {
372
+ const _AndroidApkTileWeb({
373
+ required this.enabled,
374
+ required this.busy,
375
+ required this.onInstall,
376
+ });
377
+
378
+ final bool enabled;
379
+ final bool busy;
380
+ final Future<void> Function({required String filename, required Uint8List bytes}) onInstall;
381
+
382
+ @override
383
+ State<_AndroidApkTileWeb> createState() => _AndroidApkTileWebState();
384
+ }
385
+
386
+ class _AndroidApkTileWebState extends State<_AndroidApkTileWeb> {
387
+ late final String _viewType;
388
+ late final html.DivElement _dropElement;
389
+ late final html.FileUploadInputElement _fileInput;
390
+ final List<StreamSubscription<dynamic>> _subs = [];
391
+ bool _dragActive = false;
392
+
393
+ @override
394
+ void initState() {
395
+ super.initState();
396
+ _viewType = 'neoagent-android-apk-tile-${_androidApkTileViewId++}';
397
+ _dropElement = html.DivElement()
398
+ ..setAttribute('role', 'button')
399
+ ..setAttribute('aria-label', 'Install APK — click or drop a .apk file')
400
+ ..tabIndex = 0
401
+ ..style.width = '100%'
402
+ ..style.height = '100%'
403
+ ..style.display = 'block'
404
+ ..style.background = 'rgba(0,0,0,0.001)'
405
+ ..style.cursor = 'pointer';
406
+ _fileInput = html.FileUploadInputElement()
407
+ ..accept = '.apk,.apks'
408
+ ..multiple = false
409
+ ..style.display = 'none';
410
+ _dropElement.append(_fileInput);
411
+
412
+ _subs.addAll([
413
+ _dropElement.onClick.listen((_) => _openPicker()),
414
+ _dropElement.onKeyDown.listen((e) {
415
+ if (e.key == 'Enter' || e.key == ' ') {
416
+ e.preventDefault();
417
+ _openPicker();
418
+ }
419
+ }),
420
+ _dropElement.onDragEnter.listen((e) {
421
+ e.preventDefault();
422
+ if (!_dragActive && mounted) setState(() => _dragActive = true);
423
+ }),
424
+ _dropElement.onDragOver.listen((e) {
425
+ e.preventDefault();
426
+ e.dataTransfer.dropEffect = 'copy';
427
+ if (!_dragActive && mounted) setState(() => _dragActive = true);
428
+ }),
429
+ _dropElement.onDragLeave.listen((e) {
430
+ e.preventDefault();
431
+ if (_dragActive && mounted) setState(() => _dragActive = false);
432
+ }),
433
+ _dropElement.onDrop.listen((e) {
434
+ e.preventDefault();
435
+ if (_dragActive && mounted) setState(() => _dragActive = false);
436
+ final files = e.dataTransfer.files;
437
+ if (files != null && files.isNotEmpty) unawaited(_handleFile(files.first));
438
+ }),
439
+ _fileInput.onChange.listen((_) {
440
+ final files = _fileInput.files;
441
+ if (files != null && files.isNotEmpty) unawaited(_handleFile(files.first));
442
+ }),
443
+ ]);
444
+
445
+ ui_web.platformViewRegistry.registerViewFactory(_viewType, (int _) => _dropElement);
446
+ }
447
+
448
+ @override
449
+ void dispose() {
450
+ for (final s in _subs) s.cancel();
451
+ _dropElement.remove();
452
+ super.dispose();
453
+ }
454
+
455
+ void _openPicker() {
456
+ if (!widget.enabled || widget.busy) return;
457
+ _fileInput.value = '';
458
+ _fileInput.click();
459
+ }
460
+
461
+ Future<void> _handleFile(html.File file) async {
462
+ if (!widget.enabled || widget.busy) return;
463
+ if (!_isSupportedInstallFile(file.name)) {
464
+ _showError('Only .apk or .apks files can be installed.');
465
+ return;
466
+ }
467
+ try {
468
+ final bytes = await _readFileBytes(file);
469
+ if (mounted) await widget.onInstall(filename: file.name, bytes: bytes);
470
+ } catch (e) {
471
+ _showError(e.toString().replaceFirst('Exception: ', ''));
472
+ }
473
+ }
474
+
475
+ Future<Uint8List> _readFileBytes(html.File file) {
476
+ final completer = Completer<Uint8List>();
477
+ final reader = html.FileReader();
478
+ reader.onLoad.listen((_) {
479
+ final result = reader.result;
480
+ if (result is ByteBuffer) { completer.complete(Uint8List.view(result)); return; }
481
+ if (result is Uint8List) { completer.complete(result); return; }
482
+ if (!completer.isCompleted) completer.completeError(StateError('Could not read the APK.'));
483
+ });
484
+ reader.onError.listen((_) {
485
+ if (!completer.isCompleted) completer.completeError(reader.error ?? StateError('Read error'));
486
+ });
487
+ reader.readAsArrayBuffer(file);
488
+ return completer.future;
489
+ }
490
+
491
+ void _showError(String msg) {
492
+ if (!mounted) return;
493
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(msg)));
494
+ }
495
+
496
+ @override
497
+ Widget build(BuildContext context) {
498
+ final cs = Theme.of(context).colorScheme;
499
+ final isDark = Theme.of(context).brightness == Brightness.dark;
500
+ final borderColor = _dragActive ? cs.primary : cs.outlineVariant;
501
+ final bgColor = _dragActive
502
+ ? cs.primary.withValues(alpha: isDark ? 0.16 : 0.10)
503
+ : widget.enabled
504
+ ? cs.surfaceContainerHighest.withValues(alpha: isDark ? 0.50 : 0.70)
505
+ : cs.surfaceContainerHighest.withValues(alpha: isDark ? 0.28 : 0.44);
506
+ final iconColor = _dragActive
507
+ ? cs.primary
508
+ : widget.enabled
509
+ ? cs.onSurface
510
+ : cs.onSurface.withValues(alpha: 0.38);
511
+ final labelColor = _dragActive ? cs.primary : cs.onSurfaceVariant;
512
+
513
+ return SizedBox(
514
+ width: 80,
515
+ height: 72,
516
+ child: Stack(
517
+ children: [
518
+ // Visual tile
519
+ Positioned.fill(
520
+ child: AnimatedContainer(
521
+ duration: const Duration(milliseconds: 150),
522
+ decoration: BoxDecoration(
523
+ color: bgColor,
524
+ borderRadius: BorderRadius.circular(12),
525
+ border: Border.all(color: borderColor),
526
+ ),
527
+ child: Column(
528
+ mainAxisAlignment: MainAxisAlignment.center,
529
+ children: [
530
+ Icon(
531
+ widget.busy
532
+ ? Icons.hourglass_top_rounded
533
+ : Icons.install_mobile_outlined,
534
+ size: 20,
535
+ color: iconColor,
536
+ ),
537
+ const SizedBox(height: 5),
538
+ Text(
539
+ widget.busy ? 'Installing…' : 'Install APK',
540
+ style: TextStyle(
541
+ fontSize: 10,
542
+ height: 1.2,
543
+ fontWeight: FontWeight.w600,
544
+ color: labelColor,
545
+ ),
546
+ textAlign: TextAlign.center,
547
+ maxLines: 2,
548
+ overflow: TextOverflow.ellipsis,
549
+ ),
550
+ ],
551
+ ),
552
+ ),
553
+ ),
554
+ // Transparent HTML element captures all drag / click events
555
+ Positioned.fill(
556
+ child: ClipRRect(
557
+ borderRadius: BorderRadius.circular(12),
558
+ child: HtmlElementView(viewType: _viewType),
559
+ ),
560
+ ),
561
+ ],
562
+ ),
563
+ );
564
+ }
565
+ }
package/lib/manager.js CHANGED
@@ -6,6 +6,7 @@ const crypto = require('crypto');
6
6
  const readline = require('readline');
7
7
  const { spawn, spawnSync } = require('child_process');
8
8
  const { CLAUDE_CODE_SCOPES } = require('../server/services/ai/providers/claudeCode');
9
+ const { GROK_OAUTH_SCOPES, GROK_OAUTH_CLIENT_ID } = require('../server/services/ai/providers/grokOauth');
9
10
  const {
10
11
  buildBundledWebClientIfPossible: buildWebClient,
11
12
  commandExists: sharedCommandExists,
@@ -945,10 +946,135 @@ async function cmdLoginClaudeCode() {
945
946
  cmdRestart();
946
947
  }
947
948
 
949
+ async function cmdLoginGrokOAuth() {
950
+ heading('Grok (xAI OAuth) Login');
951
+
952
+ const http = require('http');
953
+ const { URL: NodeURL } = require('url');
954
+
955
+ const clientId = GROK_OAUTH_CLIENT_ID;
956
+ const SCOPES = GROK_OAUTH_SCOPES;
957
+ const redirectPort = 56121;
958
+ const redirectUri = `http://127.0.0.1:${redirectPort}/callback`;
959
+
960
+ const codeVerifier = crypto.randomBytes(48).toString('base64url');
961
+ const codeChallenge = crypto
962
+ .createHash('sha256')
963
+ .update(codeVerifier)
964
+ .digest('base64url');
965
+ const state = crypto.randomBytes(16).toString('hex');
966
+
967
+ const authUrl = new URL('https://auth.x.ai/oauth2/authorize');
968
+ authUrl.searchParams.set('response_type', 'code');
969
+ authUrl.searchParams.set('client_id', clientId);
970
+ authUrl.searchParams.set('redirect_uri', redirectUri);
971
+ authUrl.searchParams.set('scope', SCOPES);
972
+ authUrl.searchParams.set('code_challenge', codeChallenge);
973
+ authUrl.searchParams.set('code_challenge_method', 'S256');
974
+ authUrl.searchParams.set('state', state);
975
+
976
+ console.log(`\n ${COLORS.cyan}Opening browser for Grok (xAI) authorization...${COLORS.reset}`);
977
+ console.log(` ${COLORS.dim}If the browser doesn't open, visit:${COLORS.reset}`);
978
+ console.log(` ${authUrl.toString()}\n`);
979
+
980
+ const openCmd = process.platform === 'darwin' ? 'open'
981
+ : process.platform === 'win32' ? 'start'
982
+ : 'xdg-open';
983
+ spawnSync(openCmd, [authUrl.toString()], { stdio: 'ignore' });
984
+
985
+ const authCode = await new Promise((resolve, reject) => {
986
+ const timeout = setTimeout(() => {
987
+ server.close();
988
+ reject(new Error('Grok OAuth authorization timed out after 5 minutes.'));
989
+ }, 5 * 60 * 1000);
990
+
991
+ const server = http.createServer((req, res) => {
992
+ try {
993
+ const reqUrl = new NodeURL(req.url, redirectUri);
994
+ const code = reqUrl.searchParams.get('code');
995
+ const returnedState = reqUrl.searchParams.get('state');
996
+ const error = reqUrl.searchParams.get('error');
997
+
998
+ if (error) {
999
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1000
+ res.end('<html><body><h2>Authorization failed.</h2><p>You can close this tab.</p></body></html>');
1001
+ clearTimeout(timeout);
1002
+ server.close();
1003
+ reject(new Error(`OAuth error: ${error}`));
1004
+ return;
1005
+ }
1006
+
1007
+ if (returnedState && returnedState !== state) {
1008
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1009
+ res.end('<html><body><h2>Authorization failed.</h2><p>State mismatch. You can close this tab.</p></body></html>');
1010
+ clearTimeout(timeout);
1011
+ server.close();
1012
+ reject(new Error('OAuth state mismatch — possible CSRF attempt.'));
1013
+ return;
1014
+ }
1015
+
1016
+ if (code) {
1017
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1018
+ res.end('<html><body><h2>Authorization successful!</h2><p>You can close this tab and return to the terminal.</p></body></html>');
1019
+ clearTimeout(timeout);
1020
+ server.close();
1021
+ resolve(code);
1022
+ }
1023
+ } catch (err) {
1024
+ res.writeHead(500);
1025
+ res.end('Internal error');
1026
+ }
1027
+ });
1028
+
1029
+ server.listen(redirectPort, '127.0.0.1', () => {
1030
+ logInfo(`Waiting for OAuth callback on ${redirectUri} ...`);
1031
+ });
1032
+ server.on('error', (err) => {
1033
+ clearTimeout(timeout);
1034
+ reject(new Error(`Could not start OAuth callback server on port ${redirectPort}: ${err.message}`));
1035
+ });
1036
+ });
1037
+
1038
+ logInfo('Exchanging authorization code for access token...');
1039
+ const tokenRes = await fetch('https://auth.x.ai/oauth2/token', {
1040
+ method: 'POST',
1041
+ headers: {
1042
+ 'Content-Type': 'application/x-www-form-urlencoded',
1043
+ 'Accept': 'application/json',
1044
+ },
1045
+ body: new URLSearchParams({
1046
+ grant_type: 'authorization_code',
1047
+ code: authCode,
1048
+ redirect_uri: redirectUri,
1049
+ client_id: clientId,
1050
+ code_verifier: codeVerifier,
1051
+ }),
1052
+ });
1053
+
1054
+ if (!tokenRes.ok) {
1055
+ const text = await tokenRes.text().catch(() => 'Unknown error');
1056
+ throw new Error(`Token exchange failed: HTTP ${tokenRes.status} — ${text}`);
1057
+ }
1058
+
1059
+ const tokenData = await tokenRes.json();
1060
+ const accessToken = tokenData.access_token;
1061
+ if (!accessToken) {
1062
+ throw new Error('Token exchange succeeded but no access_token was returned.');
1063
+ }
1064
+
1065
+ upsertEnvValue('GROK_OAUTH_ACCESS_TOKEN', accessToken);
1066
+ if (tokenData.refresh_token) {
1067
+ upsertEnvValue('GROK_OAUTH_REFRESH_TOKEN', tokenData.refresh_token);
1068
+ }
1069
+ logOk('Saved Grok OAuth tokens to .env');
1070
+ logInfo('Restarting NeoAgent to apply credentials...');
1071
+ cmdRestart();
1072
+ }
1073
+
948
1074
  async function cmdLogin(args = []) {
949
1075
  const provider = args[0];
950
- if (provider !== 'github-copilot' && provider !== 'openai-codex' && provider !== 'claude-code') {
951
- throw new Error(`Unsupported login provider: ${provider || 'none'}. Available: github-copilot, openai-codex, claude-code`);
1076
+ if (provider !== 'github-copilot' && provider !== 'openai-codex' && provider !== 'claude-code' && provider !== 'grok-oauth') {
1077
+ throw new Error(`Unsupported login provider: ${provider || 'none'}. Available: github-copilot, openai-codex, claude-code, grok-oauth`);
952
1078
  }
953
1079
 
954
1080
  if (provider === 'github-copilot') {
@@ -1054,6 +1180,8 @@ async function cmdLogin(args = []) {
1054
1180
  cmdRestart();
1055
1181
  } else if (provider === 'claude-code') {
1056
1182
  await cmdLoginClaudeCode();
1183
+ } else if (provider === 'grok-oauth') {
1184
+ await cmdLoginGrokOAuth();
1057
1185
  }
1058
1186
  }
1059
1187
 
@@ -1633,6 +1761,7 @@ function printHelp() {
1633
1761
  row('login github-copilot','Authenticate GitHub Copilot');
1634
1762
  row('login openai-codex', 'Authenticate OpenAI Codex');
1635
1763
  row('login claude-code', 'Authenticate Claude Code');
1764
+ row('login grok-oauth', 'Authenticate Grok (xAI OAuth)');
1636
1765
  console.log('');
1637
1766
 
1638
1767
  console.log(`${c.bold}Maintenance${c.reset}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "2.4.1-beta.11",
3
+ "version": "2.4.1-beta.13",
4
4
  "description": "Proactive personal AI agent with no limits",
5
5
  "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
@@ -1 +1 @@
1
- 178383462d4c7125ce032b3783b45564
1
+ da05ba4590c63eb13c0254be7a8b0840
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"4c525dac5ebe5971c5708ef73558ed8edcf4a3
37
37
 
38
38
  _flutter.loader.load({
39
39
  serviceWorkerSettings: {
40
- serviceWorkerVersion: "559469558" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
40
+ serviceWorkerVersion: "2422850760" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
41
41
  }
42
42
  });