groove-dev 0.27.70 → 0.27.72

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 (45) hide show
  1. package/CLAUDE.md +0 -7
  2. package/MOE_TRAINING_PIPELINE.md +720 -0
  3. package/node_modules/@groove-dev/cli/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/src/api.js +299 -21
  6. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  7. package/node_modules/@groove-dev/daemon/src/providers/base.js +8 -0
  8. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +54 -0
  9. package/node_modules/@groove-dev/daemon/src/providers/codex.js +16 -0
  10. package/node_modules/@groove-dev/daemon/src/providers/gemini.js +14 -0
  11. package/node_modules/@groove-dev/daemon/src/providers/index.js +36 -0
  12. package/node_modules/@groove-dev/gui/dist/assets/index-74E3YTkT.css +1 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/{index-D5BpdcWS.js → index-CHSXqfwy.js} +1736 -1735
  14. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  15. package/node_modules/@groove-dev/gui/package.json +1 -1
  16. package/node_modules/@groove-dev/gui/src/components/editor/code-editor.jsx +5 -5
  17. package/node_modules/@groove-dev/gui/src/components/editor/editor-tabs.jsx +4 -4
  18. package/node_modules/@groove-dev/gui/src/components/editor/file-tree.jsx +11 -0
  19. package/node_modules/@groove-dev/gui/src/components/settings/ProviderSetupWizard.jsx +480 -0
  20. package/node_modules/@groove-dev/gui/src/stores/groove.js +112 -4
  21. package/node_modules/@groove-dev/gui/src/views/editor.jsx +10 -2
  22. package/node_modules/@groove-dev/gui/src/views/settings.jsx +258 -84
  23. package/package.json +1 -1
  24. package/packages/cli/package.json +1 -1
  25. package/packages/daemon/package.json +1 -1
  26. package/packages/daemon/src/api.js +299 -21
  27. package/packages/daemon/src/index.js +3 -0
  28. package/packages/daemon/src/providers/base.js +8 -0
  29. package/packages/daemon/src/providers/claude-code.js +54 -0
  30. package/packages/daemon/src/providers/codex.js +16 -0
  31. package/packages/daemon/src/providers/gemini.js +14 -0
  32. package/packages/daemon/src/providers/index.js +36 -0
  33. package/packages/gui/dist/assets/index-74E3YTkT.css +1 -0
  34. package/packages/gui/dist/assets/{index-D5BpdcWS.js → index-CHSXqfwy.js} +1736 -1735
  35. package/packages/gui/dist/index.html +2 -2
  36. package/packages/gui/package.json +1 -1
  37. package/packages/gui/src/components/editor/code-editor.jsx +5 -5
  38. package/packages/gui/src/components/editor/editor-tabs.jsx +4 -4
  39. package/packages/gui/src/components/editor/file-tree.jsx +11 -0
  40. package/packages/gui/src/components/settings/ProviderSetupWizard.jsx +480 -0
  41. package/packages/gui/src/stores/groove.js +112 -4
  42. package/packages/gui/src/views/editor.jsx +10 -2
  43. package/packages/gui/src/views/settings.jsx +258 -84
  44. package/node_modules/@groove-dev/gui/dist/assets/index-oQ0ejlfH.css +0 -1
  45. package/packages/gui/dist/assets/index-oQ0ejlfH.css +0 -1
@@ -2,7 +2,7 @@
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
4
  import express from 'express';
5
- import { resolve, dirname, join, sep, relative } from 'path';
5
+ import { resolve, dirname, join, sep, relative, isAbsolute } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync, renameSync, rmSync, createReadStream, copyFileSync, realpathSync } from 'fs';
8
8
  import { spawn, execFile, execFileSync } from 'child_process';
@@ -10,7 +10,7 @@ import { createHash, randomUUID } from 'crypto';
10
10
  import { hostname, networkInterfaces, homedir } from 'os';
11
11
  import { StringDecoder } from 'string_decoder';
12
12
  import { lookup as mimeLookup } from './mimetypes.js';
13
- import { listProviders, getProvider } from './providers/index.js';
13
+ import { listProviders, getProvider, clearInstallCache, getProviderMetadata, getProviderPath, setProviderPaths } from './providers/index.js';
14
14
  import { OllamaProvider } from './providers/ollama.js';
15
15
  import { ClaudeCodeProvider } from './providers/claude-code.js';
16
16
  import { supportsSignalFlag, compareSemver, parseSemver } from './providers/groove-network.js';
@@ -441,12 +441,18 @@ export function createApi(app, daemon) {
441
441
  // List available providers
442
442
  app.get('/api/providers', (req, res) => {
443
443
  const providers = listProviders();
444
- // Enrich with credential status
445
444
  for (const p of providers) {
446
445
  p.hasKey = daemon.credentials.hasKey(p.id);
447
446
  if (p.id === 'claude-code') {
448
447
  p.authStatus = ClaudeCodeProvider.getAuthStatus();
449
448
  }
449
+ const meta = getProviderMetadata(p.id);
450
+ if (meta) {
451
+ p.setupGuide = meta.setupGuide;
452
+ p.authMethods = meta.authMethods;
453
+ }
454
+ const customPath = getProviderPath(p.id);
455
+ if (customPath) p.providerPath = customPath;
450
456
  }
451
457
  res.json(providers);
452
458
  });
@@ -576,6 +582,271 @@ export function createApi(app, daemon) {
576
582
  }
577
583
  });
578
584
 
585
+ // --- Provider Management (install, login, set-path, verify) ---
586
+
587
+ const MANAGEABLE_PROVIDERS = new Set(['claude-code', 'codex', 'gemini']);
588
+
589
+ app.post('/api/providers/:id/install', (req, res) => {
590
+ const { id } = req.params;
591
+ if (!MANAGEABLE_PROVIDERS.has(id)) {
592
+ return res.status(400).json({ error: `Invalid provider. Valid: ${[...MANAGEABLE_PROVIDERS].join(', ')}` });
593
+ }
594
+
595
+ const INSTALL_PACKAGES = {
596
+ 'claude-code': '@anthropic-ai/claude-code',
597
+ 'codex': '@openai/codex',
598
+ 'gemini': '@google/gemini-cli',
599
+ };
600
+ const pkg = INSTALL_PACKAGES[id];
601
+
602
+ res.setHeader('Content-Type', 'application/x-ndjson');
603
+ res.setHeader('Transfer-Encoding', 'chunked');
604
+ res.setHeader('Cache-Control', 'no-cache');
605
+
606
+ const write = (obj) => {
607
+ try { res.write(JSON.stringify(obj) + '\n'); } catch { /* client disconnected */ }
608
+ };
609
+
610
+ write({ status: 'installing', output: `Installing ${pkg}...`, progress: 0 });
611
+
612
+ const proc = spawn('npm', ['install', '-g', pkg], {
613
+ stdio: ['ignore', 'pipe', 'pipe'],
614
+ shell: true,
615
+ env: { ...process.env, NODE_ENV: undefined },
616
+ });
617
+
618
+ let output = '';
619
+ let errOutput = '';
620
+
621
+ proc.stdout.on('data', (data) => {
622
+ output += data.toString();
623
+ write({ status: 'installing', output: data.toString().trim(), progress: 50 });
624
+ });
625
+
626
+ proc.stderr.on('data', (data) => {
627
+ errOutput += data.toString();
628
+ const line = data.toString().trim();
629
+ if (line) write({ status: 'installing', output: line, progress: 50 });
630
+ });
631
+
632
+ proc.on('close', (code) => {
633
+ clearInstallCache();
634
+ const providerObj = getProvider(id);
635
+ const installed = providerObj ? providerObj.constructor.isInstalled() : false;
636
+
637
+ if (code === 0 && installed) {
638
+ write({ status: 'complete', output: `${pkg} installed successfully`, progress: 100, installed: true });
639
+ daemon.audit.log('provider.install', { provider: id, pkg, success: true });
640
+ daemon.broadcast({ type: 'provider:status-changed', provider: id });
641
+ } else {
642
+ const reason = code !== 0
643
+ ? (errOutput || output).slice(-500)
644
+ : 'Install succeeded but provider binary not found in PATH';
645
+ write({ status: 'error', output: reason, progress: 100, installed: false });
646
+ daemon.audit.log('provider.install', { provider: id, pkg, success: false, code });
647
+ }
648
+ res.end();
649
+ });
650
+
651
+ proc.on('error', (err) => {
652
+ write({ status: 'error', output: `Failed to start npm: ${err.message}`, progress: 100, installed: false });
653
+ res.end();
654
+ });
655
+
656
+ req.on('close', () => {
657
+ try { proc.kill(); } catch { /* already exited */ }
658
+ });
659
+ });
660
+
661
+ app.post('/api/providers/:id/login', async (req, res) => {
662
+ const { id } = req.params;
663
+ if (!MANAGEABLE_PROVIDERS.has(id)) {
664
+ return res.status(400).json({ error: `Invalid provider. Valid: ${[...MANAGEABLE_PROVIDERS].join(', ')}` });
665
+ }
666
+
667
+ if (id === 'gemini') {
668
+ return res.json({ status: 'not-supported', message: 'Gemini uses API key authentication. Set your key in Settings.' });
669
+ }
670
+
671
+ if (id === 'claude-code') {
672
+ const providerObj = getProvider(id);
673
+ if (!providerObj || !providerObj.constructor.isInstalled()) {
674
+ return res.status(400).json({ error: 'Claude Code is not installed. Install it first.' });
675
+ }
676
+ daemon.audit.log('provider.login.started', { provider: id });
677
+ try {
678
+ const result = await ClaudeCodeProvider.startLogin();
679
+ clearInstallCache();
680
+ daemon.broadcast({ type: 'provider:status-changed', provider: id });
681
+ return res.json(result);
682
+ } catch (err) {
683
+ return res.status(500).json({ status: 'error', error: err.message });
684
+ }
685
+ }
686
+
687
+ if (id === 'codex') {
688
+ const providerObj = getProvider(id);
689
+ if (!providerObj || !providerObj.constructor.isInstalled()) {
690
+ return res.status(400).json({ error: 'Codex is not installed. Install it first.' });
691
+ }
692
+
693
+ const { method, key } = req.body || {};
694
+
695
+ if (key) {
696
+ daemon.audit.log('provider.login.started', { provider: id, method: 'api-key' });
697
+ try {
698
+ const result = await providerObj.constructor.onKeySet(key);
699
+ clearInstallCache();
700
+ daemon.broadcast({ type: 'provider:status-changed', provider: id });
701
+ return res.json({ status: result.ok ? 'authenticated' : 'error', ...result });
702
+ } catch (err) {
703
+ return res.status(500).json({ status: 'error', error: err.message });
704
+ }
705
+ }
706
+
707
+ if (method === 'chatgpt-plus') {
708
+ daemon.audit.log('provider.login.started', { provider: id, method: 'chatgpt-plus' });
709
+ return new Promise((resolve) => {
710
+ let responded = false;
711
+ const respond = (data, status) => {
712
+ if (responded) return;
713
+ responded = true;
714
+ clearInstallCache();
715
+ daemon.broadcast({ type: 'provider:status-changed', provider: id });
716
+ if (status) res.status(status).json(data);
717
+ else res.json(data);
718
+ resolve();
719
+ };
720
+
721
+ const proc = spawn('codex', ['login'], {
722
+ stdio: ['ignore', 'pipe', 'pipe'],
723
+ shell: true,
724
+ });
725
+ let stdout = '';
726
+ let stderr = '';
727
+ proc.stdout.on('data', (d) => { stdout += d.toString(); });
728
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
729
+
730
+ const timeout = setTimeout(() => {
731
+ const urlMatch = (stdout + stderr).match(/https:\/\/\S+/);
732
+ respond(urlMatch
733
+ ? { status: 'pending', url: urlMatch[0] }
734
+ : { status: 'pending', message: 'Login started — check your browser' });
735
+ }, 5000);
736
+
737
+ proc.on('close', (code) => {
738
+ clearTimeout(timeout);
739
+ respond(code === 0
740
+ ? { status: 'authenticated' }
741
+ : { status: 'error', error: stderr.slice(-200) || `Login failed (exit ${code})` });
742
+ });
743
+
744
+ proc.on('error', (err) => {
745
+ clearTimeout(timeout);
746
+ respond({ status: 'error', error: err.message }, 500);
747
+ });
748
+ });
749
+ }
750
+
751
+ return res.status(400).json({ error: 'Provide either { key: "..." } or { method: "chatgpt-plus" }' });
752
+ }
753
+ });
754
+
755
+ app.post('/api/providers/:id/set-path', async (req, res) => {
756
+ const { id } = req.params;
757
+ if (!MANAGEABLE_PROVIDERS.has(id)) {
758
+ return res.status(400).json({ error: `Invalid provider. Valid: ${[...MANAGEABLE_PROVIDERS].join(', ')}` });
759
+ }
760
+
761
+ const { path: customPath } = req.body || {};
762
+ if (!customPath || typeof customPath !== 'string') {
763
+ return res.status(400).json({ error: 'path is required' });
764
+ }
765
+ if (customPath.length > 500) {
766
+ return res.status(400).json({ error: 'Path too long' });
767
+ }
768
+ if (!isAbsolute(customPath)) {
769
+ return res.status(400).json({ error: 'Path must be absolute' });
770
+ }
771
+
772
+ if (!existsSync(customPath)) {
773
+ return res.status(400).json({ error: `Path does not exist: ${customPath}` });
774
+ }
775
+
776
+ try {
777
+ const stat = statSync(customPath);
778
+ if (!stat.isFile()) {
779
+ return res.status(400).json({ error: 'Path must point to a file, not a directory' });
780
+ }
781
+ const mode = stat.mode;
782
+ const isExecutable = !!(mode & 0o111);
783
+ if (!isExecutable) {
784
+ return res.status(400).json({ error: 'File is not executable' });
785
+ }
786
+ } catch (err) {
787
+ return res.status(400).json({ error: `Cannot stat path: ${err.message}` });
788
+ }
789
+
790
+ if (!daemon.config.providerPaths) daemon.config.providerPaths = {};
791
+ daemon.config.providerPaths[id] = customPath;
792
+
793
+ const { saveConfig } = await import('./firstrun.js');
794
+ saveConfig(daemon.grooveDir, daemon.config);
795
+
796
+ setProviderPaths(daemon.config.providerPaths);
797
+ clearInstallCache();
798
+
799
+ daemon.audit.log('provider.setPath', { provider: id, path: customPath });
800
+ daemon.broadcast({ type: 'provider:status-changed', provider: id });
801
+
802
+ res.json({ ok: true, path: customPath });
803
+ });
804
+
805
+ app.post('/api/providers/:id/verify', async (req, res) => {
806
+ const { id } = req.params;
807
+ if (!MANAGEABLE_PROVIDERS.has(id)) {
808
+ return res.status(400).json({ error: `Invalid provider. Valid: ${[...MANAGEABLE_PROVIDERS].join(', ')}` });
809
+ }
810
+
811
+ clearInstallCache();
812
+ const providerObj = getProvider(id);
813
+ if (!providerObj) {
814
+ return res.json({ installed: false, authenticated: false, version: null, error: 'Unknown provider' });
815
+ }
816
+
817
+ const installed = providerObj.constructor.isInstalled();
818
+ let authenticated = false;
819
+ let version = null;
820
+ let error = null;
821
+
822
+ if (installed) {
823
+ const authStatus = providerObj.constructor.isAuthenticated?.();
824
+ authenticated = !!(authStatus?.authenticated);
825
+
826
+ const command = providerObj.constructor.command;
827
+ const customPath = getProviderPath(id);
828
+ const bin = customPath || command;
829
+
830
+ try {
831
+ version = execFileSync(bin, ['--version'], {
832
+ encoding: 'utf8',
833
+ timeout: 5000,
834
+ stdio: ['pipe', 'pipe', 'pipe'],
835
+ shell: true,
836
+ }).trim();
837
+ } catch (err) {
838
+ version = null;
839
+ error = `Version check failed: ${err.message?.slice(0, 200) || 'unknown error'}`;
840
+ }
841
+ } else {
842
+ error = 'Provider not installed';
843
+ }
844
+
845
+ daemon.broadcast({ type: 'provider:status-changed', provider: id });
846
+
847
+ res.json({ installed, authenticated, version, error });
848
+ });
849
+
579
850
  // --- Local Models (GGUF via HuggingFace) ---
580
851
 
581
852
  app.get('/api/models/installed', (req, res) => {
@@ -765,7 +1036,7 @@ export function createApi(app, daemon) {
765
1036
  }
766
1037
  try {
767
1038
  daemon.setProjectDir(dirPath);
768
- editorRootDir = daemon.projectDir;
1039
+ editorRootOverride = null;
769
1040
  res.json({ projectDir: daemon.projectDir, recentProjects: daemon.config.recentProjects || [] });
770
1041
  } catch (err) {
771
1042
  res.status(400).json({ error: err.message });
@@ -2392,12 +2663,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
2392
2663
  return LANG_MAP[ext] || 'text';
2393
2664
  }
2394
2665
 
2395
- const IGNORED_NAMES = new Set(['.git', 'node_modules', '.DS_Store', '.groove', '__pycache__', '.next', '.cache', 'dist', 'coverage']);
2666
+ const IGNORED_NAMES = new Set(['.DS_Store', '__pycache__']);
2396
2667
 
2397
- // Editor root directory — defaults to projectDir but can be changed at runtime
2398
- let editorRootDir = daemon.projectDir;
2668
+ // Editor root directory — always tracks daemon.projectDir unless explicitly
2669
+ // overridden via POST /api/files/root. Reset on project-dir change.
2670
+ let editorRootOverride = null;
2399
2671
 
2400
- function getEditorRoot() { return editorRootDir; }
2672
+ function getEditorRoot() { return editorRootOverride || daemon.projectDir; }
2401
2673
 
2402
2674
  function validateFilePath(relPath, projectDir) {
2403
2675
  if (!relPath || typeof relPath !== 'string') return { error: 'path is required' };
@@ -2421,13 +2693,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
2421
2693
 
2422
2694
  // Get/set the editor working directory
2423
2695
  app.get('/api/files/root', (req, res) => {
2424
- res.json({ root: editorRootDir });
2696
+ res.json({ root: getEditorRoot() });
2425
2697
  });
2426
2698
 
2427
2699
  app.post('/api/files/root', (req, res) => {
2428
2700
  const { root } = req.body || {};
2429
2701
  if (!root || typeof root !== 'string') return res.status(400).json({ error: 'root path is required' });
2430
- // Must be absolute and exist
2431
2702
  if (!root.startsWith('/')) return res.status(400).json({ error: 'root must be an absolute path' });
2432
2703
  if (root.includes('\0') || root.includes('..')) return res.status(400).json({ error: 'Invalid path' });
2433
2704
  if (!existsSync(root)) return res.status(404).json({ error: 'Directory not found' });
@@ -2435,9 +2706,9 @@ Keep responses concise. Help them think, don't lecture them about the system the
2435
2706
  const stat = statSync(root);
2436
2707
  if (!stat.isDirectory()) return res.status(400).json({ error: 'Path is not a directory' });
2437
2708
  } catch { return res.status(400).json({ error: 'Cannot access directory' }); }
2438
- editorRootDir = root;
2709
+ editorRootOverride = root;
2439
2710
  daemon.audit.log('editor.root.set', { root });
2440
- res.json({ ok: true, root: editorRootDir });
2711
+ res.json({ ok: true, root: getEditorRoot() });
2441
2712
  });
2442
2713
 
2443
2714
  // File tree — returns dirs + files for a given path
@@ -2462,18 +2733,22 @@ Keep responses concise. Help them think, don't lecture them about the system the
2462
2733
  const raw = readdirSync(fullPath, { withFileTypes: true });
2463
2734
  const entries = [];
2464
2735
 
2465
- // Dirs first (sorted), then files (sorted)
2466
- const isDir = (e) => {
2736
+ const dirs = raw.filter((e) => {
2737
+ if (e.name === '.DS_Store') return false;
2467
2738
  if (e.isDirectory()) return true;
2468
- if (e.isSymbolicLink()) { try { return statSync(resolve(fullPath, e.name)).isDirectory(); } catch { return false; } }
2739
+ if (e.isSymbolicLink()) {
2740
+ try { return statSync(resolve(fullPath, e.name)).isDirectory(); }
2741
+ catch { return true; }
2742
+ }
2469
2743
  return false;
2470
- };
2471
- const dirs = raw.filter((e) => isDir(e) && !IGNORED_NAMES.has(e.name) && !e.name.startsWith('.'))
2472
- .sort((a, b) => a.name.localeCompare(b.name));
2744
+ }).sort((a, b) => a.name.localeCompare(b.name));
2473
2745
  const files = raw.filter((e) => {
2474
- if (e.name.startsWith('.')) return false;
2746
+ if (e.name === '.DS_Store') return false;
2475
2747
  if (e.isFile()) return true;
2476
- if (e.isSymbolicLink()) { try { return statSync(resolve(fullPath, e.name)).isFile(); } catch { return false; } }
2748
+ if (e.isSymbolicLink()) {
2749
+ try { return statSync(resolve(fullPath, e.name)).isFile(); }
2750
+ catch { return false; }
2751
+ }
2477
2752
  return false;
2478
2753
  }).sort((a, b) => a.name.localeCompare(b.name));
2479
2754
 
@@ -2483,7 +2758,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
2483
2758
  let hasChildren = false;
2484
2759
  try {
2485
2760
  const children = readdirSync(childFull, { withFileTypes: true });
2486
- hasChildren = children.some((c) => !c.name.startsWith('.') && !IGNORED_NAMES.has(c.name));
2761
+ hasChildren = children.some((c) => c.name !== '.DS_Store');
2487
2762
  } catch { /* unreadable */ }
2488
2763
  entries.push({ name: d.name, type: 'dir', path: childPath, hasChildren });
2489
2764
  }
@@ -3809,6 +4084,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3809
4084
 
3810
4085
  const proc = spawn('npm', ['install', '-g', pkg], {
3811
4086
  stdio: ['ignore', 'pipe', 'pipe'],
4087
+ shell: true,
3812
4088
  env: { ...process.env, NODE_ENV: undefined },
3813
4089
  });
3814
4090
 
@@ -3831,9 +4107,11 @@ Keep responses concise. Help them think, don't lecture them about the system the
3831
4107
  const installed = providerObj ? providerObj.constructor.isInstalled() : false;
3832
4108
 
3833
4109
  if (code === 0 && installed) {
4110
+ clearInstallCache();
3834
4111
  write({ status: 'complete', output: `${pkg} installed successfully`, progress: 100, installed: true });
3835
4112
  daemon.audit.log('onboarding.installProvider', { provider, pkg, success: true });
3836
4113
  daemon.broadcast({ type: 'onboarding:provider-installed', provider });
4114
+ daemon.broadcast({ type: 'provider:status-changed', provider });
3837
4115
  } else {
3838
4116
  const reason = code !== 0
3839
4117
  ? (errOutput || output).slice(-500)
@@ -45,6 +45,7 @@ import { ConversationManager } from './conversations.js';
45
45
  import { Toys } from './toys.js';
46
46
  import { isFirstRun, runFirstTimeSetup, loadConfig, saveConfig, printWelcome } from './firstrun.js';
47
47
  import { bindDaemon as bindGrooveNetworkDaemon } from './providers/groove-network.js';
48
+ import { setProviderPaths } from './providers/index.js';
48
49
 
49
50
  const DEFAULT_PORT = 31415;
50
51
  const DEFAULT_HOST = '127.0.0.1';
@@ -113,6 +114,8 @@ export class Daemon {
113
114
  this.config = loadConfig(this.grooveDir);
114
115
  }
115
116
 
117
+ if (this.config.providerPaths) setProviderPaths(this.config.providerPaths);
118
+
116
119
  // Initialize core components
117
120
  this.state = new StateManager(this.grooveDir);
118
121
  this.registry = new Registry(this.state);
@@ -36,4 +36,12 @@ export class Provider {
36
36
  streamChat(messages, model, apiKey, onChunk, onDone, onError) {
37
37
  return null;
38
38
  }
39
+
40
+ static setupGuide() {
41
+ return { installSteps: [], authMethods: [], authInstructions: {} };
42
+ }
43
+
44
+ static authMethods() {
45
+ return [];
46
+ }
39
47
  }
@@ -336,8 +336,62 @@ export class ClaudeCodeProvider extends Provider {
336
336
  const child = cpSpawn('claude', ['auth', 'login', '--claudeai'], {
337
337
  detached: true,
338
338
  stdio: 'ignore',
339
+ shell: true,
339
340
  });
340
341
  child.unref();
341
342
  return { pid: child.pid };
342
343
  }
344
+
345
+ static setupGuide() {
346
+ return {
347
+ installSteps: ['Installing Claude Code...', 'This may take a minute'],
348
+ authMethods: ['subscription', 'api-key'],
349
+ authInstructions: {
350
+ subscriptionLoginHelp: 'Sign in with your Anthropic account',
351
+ },
352
+ };
353
+ }
354
+
355
+ static authMethods() {
356
+ return ['subscription', 'api-key'];
357
+ }
358
+
359
+ static async startLogin() {
360
+ return new Promise((resolve) => {
361
+ const child = cpSpawn('claude', ['auth', 'login', '--claudeai'], {
362
+ stdio: ['ignore', 'pipe', 'pipe'],
363
+ shell: true,
364
+ });
365
+ let stdout = '';
366
+ let stderr = '';
367
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
368
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
369
+
370
+ const timeout = setTimeout(() => {
371
+ const urlMatch = (stdout + stderr).match(/https:\/\/\S+/);
372
+ if (urlMatch) {
373
+ resolve({ status: 'pending', url: urlMatch[0], pid: child.pid });
374
+ } else {
375
+ resolve({ status: 'pending', message: 'Login started — check your browser', pid: child.pid });
376
+ }
377
+ }, 3000);
378
+
379
+ child.on('close', (code) => {
380
+ clearTimeout(timeout);
381
+ if (code === 0) {
382
+ resolve({ status: 'authenticated' });
383
+ } else {
384
+ const urlMatch = (stdout + stderr).match(/https:\/\/\S+/);
385
+ resolve(urlMatch
386
+ ? { status: 'pending', url: urlMatch[0], pid: child.pid }
387
+ : { status: 'error', error: stderr.slice(-200) || `Login failed (exit ${code})` });
388
+ }
389
+ });
390
+
391
+ child.on('error', (err) => {
392
+ clearTimeout(timeout);
393
+ resolve({ status: 'error', error: err.message });
394
+ });
395
+ });
396
+ }
343
397
  }
@@ -72,6 +72,7 @@ export class CodexProvider extends Provider {
72
72
  return new Promise((res) => {
73
73
  const proc = spawn('codex', ['login', '--with-api-key'], {
74
74
  stdio: ['pipe', 'pipe', 'pipe'],
75
+ shell: true,
75
76
  timeout: 15000,
76
77
  });
77
78
  let stderr = '';
@@ -323,4 +324,19 @@ export class CodexProvider extends Provider {
323
324
  return null;
324
325
  }
325
326
  }
327
+
328
+ static setupGuide() {
329
+ return {
330
+ installSteps: ['Installing Codex CLI...', 'This may take a minute'],
331
+ authMethods: ['api-key', 'chatgpt-plus'],
332
+ authInstructions: {
333
+ apiKeyHelp: 'Get your API key from platform.openai.com/api-keys',
334
+ chatgptPlusHelp: 'Sign in with your ChatGPT Plus account',
335
+ },
336
+ };
337
+ }
338
+
339
+ static authMethods() {
340
+ return ['api-key', 'chatgpt-plus'];
341
+ }
326
342
  }
@@ -219,4 +219,18 @@ export class GeminiProvider extends Provider {
219
219
  return null;
220
220
  }
221
221
  }
222
+
223
+ static setupGuide() {
224
+ return {
225
+ installSteps: ['Installing Gemini CLI...', 'This may take a minute'],
226
+ authMethods: ['api-key'],
227
+ authInstructions: {
228
+ keyInstructions: 'Get your API key from aistudio.google.com',
229
+ },
230
+ };
231
+ }
232
+
233
+ static authMethods() {
234
+ return ['api-key'];
235
+ }
222
236
  }
@@ -2,6 +2,7 @@
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
4
  import { execSync } from 'child_process';
5
+ import { dirname as pathDirname, delimiter as pathDelimiter } from 'path';
5
6
  import { ClaudeCodeProvider } from './claude-code.js';
6
7
  import { CodexProvider } from './codex.js';
7
8
  import { GeminiProvider } from './gemini.js';
@@ -11,6 +12,30 @@ import { GrooveNetworkProvider } from './groove-network.js';
11
12
 
12
13
  // Electron forks may not inherit the full shell PATH, causing `which` to miss
13
14
  // globally-installed CLI tools. Augment PATH with common npm global bin dirs.
15
+ // Custom provider paths from config are prepended when setProviderPaths() is called.
16
+ let _providerPaths = {};
17
+
18
+ export function setProviderPaths(paths) {
19
+ _providerPaths = paths || {};
20
+ _augmentPathWithCustomPaths();
21
+ }
22
+
23
+ function _augmentPathWithCustomPaths() {
24
+ const cur = process.env.PATH || '';
25
+ const dirs = [];
26
+ for (const p of Object.values(_providerPaths)) {
27
+ if (p && typeof p === 'string') {
28
+ const dir = pathDirname(p);
29
+ if (dir && !cur.split(pathDelimiter).includes(dir)) dirs.push(dir);
30
+ }
31
+ }
32
+ if (dirs.length) process.env.PATH = [...dirs, cur].join(pathDelimiter);
33
+ }
34
+
35
+ export function getProviderPath(id) {
36
+ return (_providerPaths && _providerPaths[id]) || null;
37
+ }
38
+
14
39
  (function augmentPath() {
15
40
  const extra = ['/usr/local/bin', '/opt/homebrew/bin'];
16
41
  try {
@@ -78,3 +103,14 @@ export function listProviders() {
78
103
  export function getInstalledProviders() {
79
104
  return listProviders().filter((p) => p.installed);
80
105
  }
106
+
107
+ export function getProviderMetadata(id) {
108
+ const p = providers[id];
109
+ if (!p) return null;
110
+ return {
111
+ id,
112
+ setupGuide: p.constructor.setupGuide?.() || { installSteps: [], authMethods: [], authInstructions: {} },
113
+ authMethods: p.constructor.authMethods?.() || [],
114
+ installCommand: p.constructor.installCommand(),
115
+ };
116
+ }