hedgequantx 2.7.21 → 2.7.23

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": "hedgequantx",
3
- "version": "2.7.21",
3
+ "version": "2.7.23",
4
4
  "description": "HedgeQuantX - Prop Futures Trading CLI",
5
5
  "main": "src/app.js",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -126,7 +126,16 @@ const banner = async () => {
126
126
 
127
127
  const tagline = isMobile ? `HQX v${version}` : `Prop Futures Algo Trading v${version}`;
128
128
  console.log(chalk.cyan('║') + chalk.white(centerText(tagline, innerWidth)) + chalk.cyan('║'));
129
- console.log(chalk.cyan('╚' + '═'.repeat(innerWidth) + '╝'));
129
+ };
130
+
131
+ /**
132
+ * Display banner with closed bottom (standalone)
133
+ */
134
+ const bannerClosed = async () => {
135
+ await banner();
136
+ const termWidth = process.stdout.columns || 100;
137
+ const boxWidth = termWidth < 60 ? Math.max(termWidth - 2, 40) : Math.max(getLogoWidth(), 98);
138
+ console.log(chalk.cyan('╚' + '═'.repeat(boxWidth - 2) + '╝'));
130
139
  };
131
140
 
132
141
  const getFullLogo = () => [
@@ -187,7 +196,8 @@ const run = async () => {
187
196
  const totalContentWidth = numCols * colWidth;
188
197
  const leftMargin = Math.max(2, Math.floor((innerWidth - totalContentWidth) / 2));
189
198
 
190
- console.log(chalk.cyan('╔' + '═'.repeat(innerWidth) + '╗'));
199
+ // Continue from banner (connected rectangle)
200
+ console.log(chalk.cyan('╠' + '═'.repeat(innerWidth) + '╣'));
191
201
  console.log(chalk.cyan('║') + chalk.white.bold(centerText('SELECT PROPFIRM', innerWidth)) + chalk.cyan('║'));
192
202
  console.log(chalk.cyan('╠' + '═'.repeat(innerWidth) + '╣'));
193
203
 
@@ -30,8 +30,8 @@ const dashboardMenu = async (service) => {
30
30
  return chalk.cyan('║') + content + ' '.repeat(Math.max(0, padding)) + chalk.cyan('║');
31
31
  };
32
32
 
33
- // New box for dashboard menu
34
- console.log(chalk.cyan('' + '═'.repeat(W) + ''));
33
+ // Continue from banner (connected rectangle)
34
+ console.log(chalk.cyan('' + '═'.repeat(W) + ''));
35
35
  console.log(makeLine(chalk.yellow.bold('Welcome, HQX Trader!'), 'center'));
36
36
  console.log(chalk.cyan('╠' + '═'.repeat(W) + '╣'));
37
37
 
@@ -0,0 +1,211 @@
1
+ /**
2
+ * AI Agents UI Components
3
+ *
4
+ * UI drawing functions for the AI Agents configuration page.
5
+ */
6
+
7
+ const chalk = require('chalk');
8
+ const { centerText, visibleLength } = require('../ui');
9
+
10
+ /**
11
+ * Draw a 2-column row
12
+ * @param {string} leftText - Left column text
13
+ * @param {string} rightText - Right column text
14
+ * @param {number} W - Inner width
15
+ */
16
+ const draw2ColRow = (leftText, rightText, W) => {
17
+ const col1Width = Math.floor(W / 2);
18
+ const col2Width = W - col1Width;
19
+ const leftLen = visibleLength(leftText);
20
+ const leftPad = col1Width - leftLen;
21
+ const leftPadL = Math.floor(leftPad / 2);
22
+ const rightLen = visibleLength(rightText || '');
23
+ const rightPad = col2Width - rightLen;
24
+ const rightPadL = Math.floor(rightPad / 2);
25
+ console.log(
26
+ chalk.cyan('║') +
27
+ ' '.repeat(leftPadL) + leftText + ' '.repeat(leftPad - leftPadL) +
28
+ ' '.repeat(rightPadL) + (rightText || '') + ' '.repeat(rightPad - rightPadL) +
29
+ chalk.cyan('║')
30
+ );
31
+ };
32
+
33
+ /**
34
+ * Draw 2-column table with title and back option
35
+ * @param {string} title - Table title
36
+ * @param {Function} titleColor - Chalk color function
37
+ * @param {Array} items - Items to display
38
+ * @param {string} backText - Back button text
39
+ * @param {number} W - Inner width
40
+ */
41
+ const draw2ColTable = (title, titleColor, items, backText, W) => {
42
+ console.log(chalk.cyan('╔' + '═'.repeat(W) + '╗'));
43
+ console.log(chalk.cyan('║') + titleColor(centerText(title, W)) + chalk.cyan('║'));
44
+ console.log(chalk.cyan('╠' + '═'.repeat(W) + '╣'));
45
+
46
+ const rows = Math.ceil(items.length / 2);
47
+ for (let row = 0; row < rows; row++) {
48
+ const left = items[row];
49
+ const right = items[row + rows];
50
+ draw2ColRow(left || '', right || '', W);
51
+ }
52
+
53
+ console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
54
+ console.log(chalk.cyan('║') + chalk.red(centerText(backText, W)) + chalk.cyan('║'));
55
+ console.log(chalk.cyan('╚' + '═'.repeat(W) + '╝'));
56
+ };
57
+
58
+ /**
59
+ * Draw providers table
60
+ * @param {Array} providers - List of AI providers
61
+ * @param {Object} config - Current config
62
+ * @param {number} boxWidth - Box width
63
+ * @param {string} cliproxyUrl - Current CLIProxy URL (optional)
64
+ */
65
+ const drawProvidersTable = (providers, config, boxWidth, cliproxyUrl = null) => {
66
+ const W = boxWidth - 2;
67
+
68
+ console.log(chalk.cyan('╔' + '═'.repeat(W) + '╗'));
69
+ console.log(chalk.cyan('║') + chalk.yellow.bold(centerText('AI AGENTS CONFIGURATION', W)) + chalk.cyan('║'));
70
+
71
+ // Show CLIProxy URL if provided
72
+ if (cliproxyUrl) {
73
+ console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
74
+ const proxyText = chalk.gray('CLIProxy: ') + chalk.cyan(cliproxyUrl);
75
+ console.log(chalk.cyan('║') + centerText(proxyText, W) + chalk.cyan('║'));
76
+ }
77
+
78
+ console.log(chalk.cyan('╠' + '═'.repeat(W) + '╣'));
79
+
80
+ const items = providers.map((p, i) => {
81
+ const status = config.providers[p.id]?.active ? chalk.green(' ●') : '';
82
+ return chalk.cyan(`[${i + 1}]`) + ' ' + chalk[p.color](p.name) + status;
83
+ });
84
+
85
+ const rows = Math.ceil(items.length / 2);
86
+ for (let row = 0; row < rows; row++) {
87
+ const left = items[row];
88
+ const right = items[row + rows];
89
+ draw2ColRow(left || '', right || '', W);
90
+ }
91
+
92
+ console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
93
+ console.log(chalk.cyan('║') + chalk.gray(centerText('[C] Configure CLIProxy URL', W)) + chalk.cyan('║'));
94
+ console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
95
+ console.log(chalk.cyan('║') + chalk.red(centerText('[B] Back to Menu', W)) + chalk.cyan('║'));
96
+ console.log(chalk.cyan('╚' + '═'.repeat(W) + '╝'));
97
+ };
98
+
99
+ /**
100
+ * Draw models table
101
+ * @param {Object} provider - Provider object
102
+ * @param {Array} models - List of models
103
+ * @param {number} boxWidth - Box width
104
+ */
105
+ const drawModelsTable = (provider, models, boxWidth) => {
106
+ const W = boxWidth - 2;
107
+ const items = models.map((m, i) => chalk.cyan(`[${i + 1}]`) + ' ' + chalk.white(m.name));
108
+ draw2ColTable(`${provider.name.toUpperCase()} - MODELS`, chalk[provider.color].bold, items, '[B] Back', W);
109
+ };
110
+
111
+ /**
112
+ * Draw provider configuration window
113
+ * @param {Object} provider - Provider object
114
+ * @param {Object} config - Current config
115
+ * @param {number} boxWidth - Box width
116
+ */
117
+ const drawProviderWindow = (provider, config, boxWidth) => {
118
+ const W = boxWidth - 2;
119
+ const col1Width = Math.floor(W / 2);
120
+ const col2Width = W - col1Width;
121
+ const providerConfig = config.providers[provider.id] || {};
122
+
123
+ // Header
124
+ console.log(chalk.cyan('╔' + '═'.repeat(W) + '╗'));
125
+ console.log(chalk.cyan('║') + chalk[provider.color].bold(centerText(provider.name.toUpperCase(), W)) + chalk.cyan('║'));
126
+ console.log(chalk.cyan('╠' + '═'.repeat(W) + '╣'));
127
+
128
+ // Empty line
129
+ console.log(chalk.cyan('║') + ' '.repeat(W) + chalk.cyan('║'));
130
+
131
+ // Options in 2 columns
132
+ const opt1Title = '[1] Connect via Paid Plan';
133
+ const opt1Desc = 'Uses CLIProxy - No API key needed';
134
+ const opt2Title = '[2] Connect via API Key';
135
+ const opt2Desc = 'Enter your own API key';
136
+
137
+ // Row 1: Titles
138
+ const left1 = chalk.green(opt1Title);
139
+ const right1 = chalk.yellow(opt2Title);
140
+ const left1Len = visibleLength(left1);
141
+ const right1Len = visibleLength(right1);
142
+ const left1PadTotal = col1Width - left1Len;
143
+ const left1PadL = Math.floor(left1PadTotal / 2);
144
+ const left1PadR = left1PadTotal - left1PadL;
145
+ const right1PadTotal = col2Width - right1Len;
146
+ const right1PadL = Math.floor(right1PadTotal / 2);
147
+ const right1PadR = right1PadTotal - right1PadL;
148
+
149
+ console.log(
150
+ chalk.cyan('║') +
151
+ ' '.repeat(left1PadL) + left1 + ' '.repeat(left1PadR) +
152
+ ' '.repeat(right1PadL) + right1 + ' '.repeat(right1PadR) +
153
+ chalk.cyan('║')
154
+ );
155
+
156
+ // Row 2: Descriptions
157
+ const left2 = chalk.gray(opt1Desc);
158
+ const right2 = chalk.gray(opt2Desc);
159
+ const left2Len = visibleLength(left2);
160
+ const right2Len = visibleLength(right2);
161
+ const left2PadTotal = col1Width - left2Len;
162
+ const left2PadL = Math.floor(left2PadTotal / 2);
163
+ const left2PadR = left2PadTotal - left2PadL;
164
+ const right2PadTotal = col2Width - right2Len;
165
+ const right2PadL = Math.floor(right2PadTotal / 2);
166
+ const right2PadR = right2PadTotal - right2PadL;
167
+
168
+ console.log(
169
+ chalk.cyan('║') +
170
+ ' '.repeat(left2PadL) + left2 + ' '.repeat(left2PadR) +
171
+ ' '.repeat(right2PadL) + right2 + ' '.repeat(right2PadR) +
172
+ chalk.cyan('║')
173
+ );
174
+
175
+ // Empty line
176
+ console.log(chalk.cyan('║') + ' '.repeat(W) + chalk.cyan('║'));
177
+
178
+ // Status bar
179
+ console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
180
+
181
+ let statusText = '';
182
+ if (providerConfig.active) {
183
+ const connType = providerConfig.connectionType === 'cliproxy' ? 'CLIProxy' : 'API Key';
184
+ const modelName = providerConfig.modelName || 'N/A';
185
+ statusText = chalk.green('● ACTIVE') + chalk.gray(' Model: ') + chalk.yellow(modelName) + chalk.gray(' via ') + chalk.cyan(connType);
186
+ } else if (providerConfig.apiKey || providerConfig.connectionType) {
187
+ statusText = chalk.yellow('● CONFIGURED') + chalk.gray(' (not active)');
188
+ } else {
189
+ statusText = chalk.gray('○ NOT CONFIGURED');
190
+ }
191
+ console.log(chalk.cyan('║') + centerText(statusText, W) + chalk.cyan('║'));
192
+
193
+ // Disconnect option if active
194
+ if (providerConfig.active) {
195
+ console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
196
+ console.log(chalk.cyan('║') + chalk.red(centerText('[D] Disconnect', W)) + chalk.cyan('║'));
197
+ }
198
+
199
+ // Back
200
+ console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
201
+ console.log(chalk.cyan('║') + chalk.red(centerText('[B] Back', W)) + chalk.cyan('║'));
202
+ console.log(chalk.cyan('╚' + '═'.repeat(W) + '╝'));
203
+ };
204
+
205
+ module.exports = {
206
+ draw2ColRow,
207
+ draw2ColTable,
208
+ drawProvidersTable,
209
+ drawModelsTable,
210
+ drawProviderWindow
211
+ };
@@ -9,11 +9,13 @@ const chalk = require('chalk');
9
9
  const os = require('os');
10
10
  const path = require('path');
11
11
  const fs = require('fs');
12
-
13
12
  const ora = require('ora');
14
- const { getLogoWidth, centerText, visibleLength } = require('../ui');
13
+
14
+ const { getLogoWidth } = require('../ui');
15
15
  const { prompts } = require('../utils');
16
16
  const { fetchModelsFromApi } = require('./ai-models');
17
+ const { drawProvidersTable, drawModelsTable, drawProviderWindow } = require('./ai-agents-ui');
18
+ const { isCliProxyRunning, fetchModelsFromCliProxy, getOAuthUrl, checkOAuthStatus, getCliProxyUrl, setCliProxyUrl, DEFAULT_CLIPROXY_URL } = require('../services/cliproxy');
17
19
 
18
20
  // Config file path
19
21
  const CONFIG_DIR = path.join(os.homedir(), '.hqx');
@@ -31,32 +33,20 @@ const AI_PROVIDERS = [
31
33
  { id: 'openrouter', name: 'OpenRouter', color: 'gray' },
32
34
  ];
33
35
 
34
- /**
35
- * Load AI config from file
36
- * @returns {Object} Config object with provider settings
37
- */
36
+ /** Load AI config from file */
38
37
  const loadConfig = () => {
39
38
  try {
40
39
  if (fs.existsSync(CONFIG_FILE)) {
41
- const data = fs.readFileSync(CONFIG_FILE, 'utf8');
42
- return JSON.parse(data);
40
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
43
41
  }
44
- } catch (error) {
45
- // Config file doesn't exist or is invalid
46
- }
42
+ } catch (error) { /* ignore */ }
47
43
  return { providers: {} };
48
44
  };
49
45
 
50
- /**
51
- * Save AI config to file
52
- * @param {Object} config - Config object to save
53
- * @returns {boolean} Success status
54
- */
46
+ /** Save AI config to file */
55
47
  const saveConfig = (config) => {
56
48
  try {
57
- if (!fs.existsSync(CONFIG_DIR)) {
58
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
59
- }
49
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
60
50
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
61
51
  return true;
62
52
  } catch (error) {
@@ -64,87 +54,28 @@ const saveConfig = (config) => {
64
54
  }
65
55
  };
66
56
 
67
- /**
68
- * Mask API key for display
69
- * @param {string} key - API key
70
- * @returns {string} Masked key
71
- */
72
- const maskKey = (key) => {
73
- if (!key || key.length < 16) return '****';
74
- return key.substring(0, 8) + '...' + key.substring(key.length - 4);
75
- };
76
-
77
- /**
78
- * Draw a 2-column row
79
- */
80
- const draw2ColRow = (leftText, rightText, W) => {
81
- const col1Width = Math.floor(W / 2);
82
- const col2Width = W - col1Width;
83
- const leftLen = visibleLength(leftText);
84
- const leftPad = col1Width - leftLen;
85
- const leftPadL = Math.floor(leftPad / 2);
86
- const rightLen = visibleLength(rightText || '');
87
- const rightPad = col2Width - rightLen;
88
- const rightPadL = Math.floor(rightPad / 2);
89
- console.log(
90
- chalk.cyan('║') +
91
- ' '.repeat(leftPadL) + leftText + ' '.repeat(leftPad - leftPadL) +
92
- ' '.repeat(rightPadL) + (rightText || '') + ' '.repeat(rightPad - rightPadL) +
93
- chalk.cyan('║')
94
- );
95
- };
96
-
97
- /**
98
- * Draw 2-column table
99
- */
100
- const draw2ColTable = (title, titleColor, items, backText, W) => {
101
- console.log(chalk.cyan('╔' + '═'.repeat(W) + '╗'));
102
- console.log(chalk.cyan('║') + titleColor(centerText(title, W)) + chalk.cyan('║'));
103
- console.log(chalk.cyan('╠' + '═'.repeat(W) + '╣'));
104
-
105
- const rows = Math.ceil(items.length / 2);
106
- for (let row = 0; row < rows; row++) {
107
- const left = items[row];
108
- const right = items[row + rows];
109
- draw2ColRow(left || '', right || '', W);
57
+ /** Select a model from a pre-fetched list */
58
+ const selectModelFromList = async (provider, models, boxWidth) => {
59
+ while (true) {
60
+ console.clear();
61
+ drawModelsTable(provider, models, boxWidth);
62
+
63
+ const input = await prompts.textInput(chalk.cyan('Select model: '));
64
+ const choice = (input || '').toLowerCase().trim();
65
+
66
+ if (choice === 'b' || choice === '') return null;
67
+
68
+ const num = parseInt(choice);
69
+ if (!isNaN(num) && num >= 1 && num <= models.length) return models[num - 1];
70
+
71
+ console.log(chalk.red(' Invalid option.'));
72
+ await new Promise(r => setTimeout(r, 1000));
110
73
  }
111
-
112
- console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
113
- console.log(chalk.cyan('║') + chalk.red(centerText(backText, W)) + chalk.cyan('║'));
114
- console.log(chalk.cyan('╚' + '═'.repeat(W) + '╝'));
115
- };
116
-
117
- /**
118
- * Draw providers table
119
- */
120
- const drawProvidersTable = (config, boxWidth) => {
121
- const W = boxWidth - 2;
122
- const items = AI_PROVIDERS.map((p, i) => {
123
- const status = config.providers[p.id]?.active ? chalk.green(' ●') : '';
124
- return chalk.cyan(`[${i + 1}]`) + ' ' + chalk[p.color](p.name) + status;
125
- });
126
- draw2ColTable('AI AGENTS CONFIGURATION', chalk.yellow.bold, items, '[B] Back to Menu', W);
127
- };
128
-
129
- /**
130
- * Draw models table
131
- */
132
- const drawModelsTable = (provider, models, boxWidth) => {
133
- const W = boxWidth - 2;
134
- const items = models.map((m, i) => chalk.cyan(`[${i + 1}]`) + ' ' + chalk.white(m.name));
135
- draw2ColTable(`${provider.name.toUpperCase()} - MODELS`, chalk[provider.color].bold, items, '[B] Back', W);
136
74
  };
137
75
 
138
- /**
139
- * Select a model for a provider (fetches from API)
140
- * @param {Object} provider - Provider object
141
- * @param {string} apiKey - API key for fetching models
142
- * @returns {Object|null} Selected model or null if cancelled/failed
143
- */
76
+ /** Select a model for a provider (fetches from API) */
144
77
  const selectModel = async (provider, apiKey) => {
145
78
  const boxWidth = getLogoWidth();
146
-
147
- // Fetch models from API
148
79
  const spinner = ora({ text: 'Fetching models from API...', color: 'yellow' }).start();
149
80
  const result = await fetchModelsFromApi(provider.id, apiKey);
150
81
 
@@ -155,129 +86,212 @@ const selectModel = async (provider, apiKey) => {
155
86
  }
156
87
 
157
88
  spinner.succeed(`Found ${result.models.length} models`);
158
- const models = result.models;
89
+ return selectModelFromList(provider, result.models, boxWidth);
90
+ };
91
+
92
+ /** Deactivate all providers and activate one */
93
+ const activateProvider = (config, providerId, data) => {
94
+ Object.keys(config.providers).forEach(id => {
95
+ if (config.providers[id]) config.providers[id].active = false;
96
+ });
97
+ if (!config.providers[providerId]) config.providers[providerId] = {};
98
+ Object.assign(config.providers[providerId], data, { active: true, configuredAt: new Date().toISOString() });
99
+ };
100
+
101
+ /** Handle CLIProxy connection */
102
+ const handleCliProxyConnection = async (provider, config, boxWidth) => {
103
+ console.log();
104
+ const currentUrl = getCliProxyUrl();
105
+ const spinner = ora({ text: `Checking CLIProxy at ${currentUrl}...`, color: 'yellow' }).start();
106
+ let proxyStatus = await isCliProxyRunning();
159
107
 
160
- while (true) {
161
- console.clear();
162
- drawModelsTable(provider, models, boxWidth);
108
+ if (!proxyStatus.running) {
109
+ spinner.fail(`CLIProxy not reachable at ${currentUrl}`);
110
+ console.log();
111
+ console.log(chalk.yellow(' CLIProxy Options:'));
112
+ console.log(chalk.gray(' [1] Local - localhost:8317 (default)'));
113
+ console.log(chalk.gray(' [2] Remote - Enter custom URL (e.g., http://your-pc-ip:8317)'));
114
+ console.log(chalk.gray(' [B] Back'));
115
+ console.log();
163
116
 
164
- const input = await prompts.textInput(chalk.cyan('Select model: '));
165
- const choice = (input || '').toLowerCase().trim();
117
+ const urlChoice = await prompts.textInput(chalk.cyan(' Select option: '));
166
118
 
167
- if (choice === 'b' || choice === '') {
168
- return null;
119
+ if (!urlChoice || urlChoice.toLowerCase() === 'b') {
120
+ return false;
169
121
  }
170
122
 
171
- const num = parseInt(choice);
172
- if (!isNaN(num) && num >= 1 && num <= models.length) {
173
- return models[num - 1];
123
+ let newUrl = null;
124
+ if (urlChoice === '1') {
125
+ newUrl = DEFAULT_CLIPROXY_URL;
126
+ } else if (urlChoice === '2') {
127
+ console.log(chalk.gray('\n Enter CLIProxy URL (e.g., http://192.168.1.100:8317):'));
128
+ const customUrl = await prompts.textInput(chalk.cyan(' URL: '));
129
+ if (!customUrl || customUrl.trim() === '') {
130
+ console.log(chalk.gray(' Cancelled.'));
131
+ await prompts.waitForEnter();
132
+ return false;
133
+ }
134
+ newUrl = customUrl.trim();
135
+ // Add http:// if missing
136
+ if (!newUrl.startsWith('http://') && !newUrl.startsWith('https://')) {
137
+ newUrl = 'http://' + newUrl;
138
+ }
174
139
  }
175
140
 
176
- console.log(chalk.red(' Invalid option.'));
177
- await new Promise(r => setTimeout(r, 1000));
141
+ if (newUrl) {
142
+ const testSpinner = ora({ text: `Testing connection to ${newUrl}...`, color: 'yellow' }).start();
143
+ proxyStatus = await isCliProxyRunning(newUrl);
144
+
145
+ if (!proxyStatus.running) {
146
+ testSpinner.fail(`Cannot connect to ${newUrl}`);
147
+ console.log(chalk.gray(` Error: ${proxyStatus.error || 'Connection failed'}`));
148
+ console.log(chalk.yellow('\n Make sure CLIProxy is running and accessible.'));
149
+ await prompts.waitForEnter();
150
+ return false;
151
+ }
152
+
153
+ testSpinner.succeed(`Connected to CLIProxy at ${newUrl}`);
154
+ setCliProxyUrl(newUrl);
155
+ } else {
156
+ return false;
157
+ }
158
+ } else {
159
+ spinner.succeed(`CLIProxy connected at ${currentUrl}`);
160
+ }
161
+ const oauthResult = await getOAuthUrl(provider.id);
162
+
163
+ if (!oauthResult.success) {
164
+ // OAuth not supported - try direct model fetch
165
+ console.log(chalk.gray(` OAuth not available for ${provider.name}, checking models...`));
166
+ const modelsResult = await fetchModelsFromCliProxy();
167
+
168
+ if (!modelsResult.success || modelsResult.models.length === 0) {
169
+ console.log(chalk.red(` No models available via CLIProxy for ${provider.name}`));
170
+ console.log(chalk.gray(` Error: ${modelsResult.error || 'Unknown'}`));
171
+ await prompts.waitForEnter();
172
+ return false;
173
+ }
174
+
175
+ const selectedModel = await selectModelFromList(provider, modelsResult.models, boxWidth);
176
+ if (!selectedModel) return false;
177
+
178
+ activateProvider(config, provider.id, {
179
+ connectionType: 'cliproxy',
180
+ modelId: selectedModel.id,
181
+ modelName: selectedModel.name
182
+ });
183
+
184
+ if (saveConfig(config)) {
185
+ console.log(chalk.green(`\n ✓ ${provider.name} connected via CLIProxy.`));
186
+ console.log(chalk.cyan(` Model: ${selectedModel.name}`));
187
+ }
188
+ await prompts.waitForEnter();
189
+ return true;
178
190
  }
179
- };
180
-
181
- /**
182
- * Draw provider configuration window
183
- * @param {Object} provider - Provider object
184
- * @param {Object} config - Current config
185
- * @param {number} boxWidth - Box width
186
- */
187
- const drawProviderWindow = (provider, config, boxWidth) => {
188
- const W = boxWidth - 2;
189
- const col1Width = Math.floor(W / 2);
190
- const col2Width = W - col1Width;
191
- const providerConfig = config.providers[provider.id] || {};
192
191
 
193
- // Header
194
- console.log(chalk.cyan('╔' + '═'.repeat(W) + '╗'));
195
- console.log(chalk.cyan('║') + chalk[provider.color].bold(centerText(provider.name.toUpperCase(), W)) + chalk.cyan('║'));
196
- console.log(chalk.cyan('╠' + '═'.repeat(W) + '╣'));
192
+ // OAuth flow
193
+ console.log(chalk.cyan('\n Open this URL in your browser to authenticate:\n'));
194
+ console.log(chalk.yellow(` ${oauthResult.url}\n`));
195
+ console.log(chalk.gray(' Waiting for authentication... (Press Enter to cancel)'));
197
196
 
198
- // Empty line
199
- console.log(chalk.cyan('║') + ' '.repeat(W) + chalk.cyan('║'));
197
+ let authenticated = false;
198
+ const maxWait = 120000, pollInterval = 3000;
199
+ let waited = 0;
200
200
 
201
- // Options in 2 columns
202
- const opt1Title = '[1] Connect via Paid Plan';
203
- const opt1Desc = 'Uses CLIProxy - No API key needed';
204
- const opt2Title = '[2] Connect via API Key';
205
- const opt2Desc = 'Enter your own API key';
201
+ const pollPromise = (async () => {
202
+ while (waited < maxWait) {
203
+ await new Promise(r => setTimeout(r, pollInterval));
204
+ waited += pollInterval;
205
+ if (oauthResult.state) {
206
+ const statusResult = await checkOAuthStatus(oauthResult.state);
207
+ if (statusResult.success && statusResult.status === 'ok') { authenticated = true; return true; }
208
+ if (statusResult.status === 'error') {
209
+ console.log(chalk.red(`\n Authentication error: ${statusResult.error || 'Unknown'}`));
210
+ return false;
211
+ }
212
+ }
213
+ }
214
+ return false;
215
+ })();
206
216
 
207
- // Row 1: Titles
208
- const left1 = chalk.green(opt1Title);
209
- const right1 = chalk.yellow(opt2Title);
210
- const left1Len = visibleLength(left1);
211
- const right1Len = visibleLength(right1);
212
- const left1PadTotal = col1Width - left1Len;
213
- const left1PadL = Math.floor(left1PadTotal / 2);
214
- const left1PadR = left1PadTotal - left1PadL;
215
- const right1PadTotal = col2Width - right1Len;
216
- const right1PadL = Math.floor(right1PadTotal / 2);
217
- const right1PadR = right1PadTotal - right1PadL;
217
+ await Promise.race([pollPromise, prompts.waitForEnter()]);
218
218
 
219
- console.log(
220
- chalk.cyan('') +
221
- ' '.repeat(left1PadL) + left1 + ' '.repeat(left1PadR) +
222
- ' '.repeat(right1PadL) + right1 + ' '.repeat(right1PadR) +
223
- chalk.cyan('║')
224
- );
219
+ if (!authenticated) {
220
+ console.log(chalk.yellow(' Authentication cancelled or timed out.'));
221
+ await prompts.waitForEnter();
222
+ return false;
223
+ }
225
224
 
226
- // Row 2: Descriptions
227
- const left2 = chalk.gray(opt1Desc);
228
- const right2 = chalk.gray(opt2Desc);
229
- const left2Len = visibleLength(left2);
230
- const right2Len = visibleLength(right2);
231
- const left2PadTotal = col1Width - left2Len;
232
- const left2PadL = Math.floor(left2PadTotal / 2);
233
- const left2PadR = left2PadTotal - left2PadL;
234
- const right2PadTotal = col2Width - right2Len;
235
- const right2PadL = Math.floor(right2PadTotal / 2);
236
- const right2PadR = right2PadTotal - right2PadL;
225
+ console.log(chalk.green(' ✓ Authentication successful!'));
237
226
 
238
- console.log(
239
- chalk.cyan('║') +
240
- ' '.repeat(left2PadL) + left2 + ' '.repeat(left2PadR) +
241
- ' '.repeat(right2PadL) + right2 + ' '.repeat(right2PadR) +
242
- chalk.cyan('║')
243
- );
227
+ const modelsResult = await fetchModelsFromCliProxy();
228
+ if (modelsResult.success && modelsResult.models.length > 0) {
229
+ const selectedModel = await selectModelFromList(provider, modelsResult.models, boxWidth);
230
+ if (selectedModel) {
231
+ activateProvider(config, provider.id, {
232
+ connectionType: 'cliproxy',
233
+ modelId: selectedModel.id,
234
+ modelName: selectedModel.name
235
+ });
236
+ if (saveConfig(config)) {
237
+ console.log(chalk.green(`\n ✓ ${provider.name} connected via CLIProxy.`));
238
+ console.log(chalk.cyan(` Model: ${selectedModel.name}`));
239
+ }
240
+ }
241
+ } else {
242
+ activateProvider(config, provider.id, {
243
+ connectionType: 'cliproxy',
244
+ modelId: null,
245
+ modelName: 'Default'
246
+ });
247
+ if (saveConfig(config)) console.log(chalk.green(`\n ✓ ${provider.name} connected via CLIProxy.`));
248
+ }
244
249
 
245
- // Empty line
246
- console.log(chalk.cyan('║') + ' '.repeat(W) + chalk.cyan('║'));
250
+ await prompts.waitForEnter();
251
+ return true;
252
+ };
253
+
254
+ /** Handle API Key connection */
255
+ const handleApiKeyConnection = async (provider, config) => {
256
+ console.clear();
257
+ console.log(chalk.yellow(`\n Enter your ${provider.name} API key:`));
258
+ console.log(chalk.gray(' (Press Enter to cancel)\n'));
247
259
 
248
- // Status bar
249
- console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
260
+ const apiKey = await prompts.textInput(chalk.cyan(' API Key: '), true);
250
261
 
251
- let statusText = '';
252
- if (providerConfig.active) {
253
- const connType = providerConfig.connectionType === 'cliproxy' ? 'CLIProxy' : 'API Key';
254
- const modelName = providerConfig.modelName || 'N/A';
255
- statusText = chalk.green('● ACTIVE') + chalk.gray(' Model: ') + chalk.yellow(modelName) + chalk.gray(' via ') + chalk.cyan(connType);
256
- } else if (providerConfig.apiKey || providerConfig.connectionType) {
257
- statusText = chalk.yellow('● CONFIGURED') + chalk.gray(' (not active)');
258
- } else {
259
- statusText = chalk.gray('○ NOT CONFIGURED');
262
+ if (!apiKey || apiKey.trim() === '') {
263
+ console.log(chalk.gray(' Cancelled.'));
264
+ await prompts.waitForEnter();
265
+ return false;
260
266
  }
261
- console.log(chalk.cyan('║') + centerText(statusText, W) + chalk.cyan('║'));
262
267
 
263
- // Disconnect option if active
264
- if (providerConfig.active) {
265
- console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
266
- console.log(chalk.cyan('║') + chalk.red(centerText('[D] Disconnect', W)) + chalk.cyan('║'));
268
+ if (apiKey.length < 20) {
269
+ console.log(chalk.red(' Invalid API key format (too short).'));
270
+ await prompts.waitForEnter();
271
+ return false;
267
272
  }
268
273
 
269
- // Back
270
- console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
271
- console.log(chalk.cyan('║') + chalk.red(centerText('[B] Back', W)) + chalk.cyan('║'));
272
- console.log(chalk.cyan('╚' + '═'.repeat(W) + '╝'));
274
+ const selectedModel = await selectModel(provider, apiKey.trim());
275
+ if (!selectedModel) return false;
276
+
277
+ activateProvider(config, provider.id, {
278
+ connectionType: 'apikey',
279
+ apiKey: apiKey.trim(),
280
+ modelId: selectedModel.id,
281
+ modelName: selectedModel.name
282
+ });
283
+
284
+ if (saveConfig(config)) {
285
+ console.log(chalk.green(`\n ✓ ${provider.name} connected via API Key.`));
286
+ console.log(chalk.cyan(` Model: ${selectedModel.name}`));
287
+ } else {
288
+ console.log(chalk.red('\n Failed to save config.'));
289
+ }
290
+ await prompts.waitForEnter();
291
+ return true;
273
292
  };
274
293
 
275
- /**
276
- * Handle provider configuration
277
- * @param {Object} provider - Provider to configure
278
- * @param {Object} config - Current config
279
- * @returns {Object} Updated config
280
- */
294
+ /** Handle provider configuration */
281
295
  const handleProviderConfig = async (provider, config) => {
282
296
  const boxWidth = getLogoWidth();
283
297
 
@@ -288,94 +302,23 @@ const handleProviderConfig = async (provider, config) => {
288
302
  const input = await prompts.textInput(chalk.cyan('Select option: '));
289
303
  const choice = (input || '').toLowerCase().trim();
290
304
 
291
- if (choice === 'b' || choice === '') {
292
- break;
293
- }
305
+ if (choice === 'b' || choice === '') break;
294
306
 
295
- if (choice === 'd') {
296
- // Disconnect
297
- if (config.providers[provider.id]) {
298
- config.providers[provider.id].active = false;
299
- saveConfig(config);
300
- console.log(chalk.yellow(`\n ${provider.name} disconnected.`));
301
- await prompts.waitForEnter();
302
- }
307
+ if (choice === 'd' && config.providers[provider.id]) {
308
+ config.providers[provider.id].active = false;
309
+ saveConfig(config);
310
+ console.log(chalk.yellow(`\n ${provider.name} disconnected.`));
311
+ await prompts.waitForEnter();
303
312
  continue;
304
313
  }
305
314
 
306
315
  if (choice === '1') {
307
- // CLIProxy connection - models will be fetched via proxy
308
- console.log();
309
- console.log(chalk.cyan(' CLIProxy uses your paid plan subscription.'));
310
- console.log(chalk.gray(' Model selection will be available after connecting.'));
311
- console.log();
312
-
313
- // Deactivate all other providers
314
- Object.keys(config.providers).forEach(id => {
315
- if (config.providers[id]) config.providers[id].active = false;
316
- });
317
-
318
- if (!config.providers[provider.id]) config.providers[provider.id] = {};
319
- config.providers[provider.id].connectionType = 'cliproxy';
320
- config.providers[provider.id].modelId = null;
321
- config.providers[provider.id].modelName = 'N/A';
322
- config.providers[provider.id].active = true;
323
- config.providers[provider.id].configuredAt = new Date().toISOString();
324
-
325
- if (saveConfig(config)) {
326
- console.log(chalk.green(` ✓ ${provider.name} connected via CLIProxy.`));
327
- } else {
328
- console.log(chalk.red(' Failed to save config.'));
329
- }
330
- await prompts.waitForEnter();
316
+ await handleCliProxyConnection(provider, config, boxWidth);
331
317
  continue;
332
318
  }
333
319
 
334
320
  if (choice === '2') {
335
- // API Key connection - get key first, then fetch models
336
- console.clear();
337
- console.log(chalk.yellow(`\n Enter your ${provider.name} API key:`));
338
- console.log(chalk.gray(' (Press Enter to cancel)'));
339
- console.log();
340
-
341
- const apiKey = await prompts.textInput(chalk.cyan(' API Key: '), true);
342
-
343
- if (!apiKey || apiKey.trim() === '') {
344
- console.log(chalk.gray(' Cancelled.'));
345
- await prompts.waitForEnter();
346
- continue;
347
- }
348
-
349
- if (apiKey.length < 20) {
350
- console.log(chalk.red(' Invalid API key format (too short).'));
351
- await prompts.waitForEnter();
352
- continue;
353
- }
354
-
355
- // Fetch models from API with the provided key
356
- const selectedModel = await selectModel(provider, apiKey.trim());
357
- if (!selectedModel) continue;
358
-
359
- // Deactivate all other providers
360
- Object.keys(config.providers).forEach(id => {
361
- if (config.providers[id]) config.providers[id].active = false;
362
- });
363
-
364
- if (!config.providers[provider.id]) config.providers[provider.id] = {};
365
- config.providers[provider.id].connectionType = 'apikey';
366
- config.providers[provider.id].apiKey = apiKey.trim();
367
- config.providers[provider.id].modelId = selectedModel.id;
368
- config.providers[provider.id].modelName = selectedModel.name;
369
- config.providers[provider.id].active = true;
370
- config.providers[provider.id].configuredAt = new Date().toISOString();
371
-
372
- if (saveConfig(config)) {
373
- console.log(chalk.green(`\n ✓ ${provider.name} connected via API Key.`));
374
- console.log(chalk.cyan(` Model: ${selectedModel.name}`));
375
- } else {
376
- console.log(chalk.red('\n Failed to save config.'));
377
- }
378
- await prompts.waitForEnter();
321
+ await handleApiKeyConnection(provider, config);
379
322
  continue;
380
323
  }
381
324
  }
@@ -383,53 +326,106 @@ const handleProviderConfig = async (provider, config) => {
383
326
  return config;
384
327
  };
385
328
 
386
- /**
387
- * Get active AI provider config
388
- * @returns {Object|null} Active provider config or null
389
- */
329
+ /** Get active AI provider config */
390
330
  const getActiveProvider = () => {
391
331
  const config = loadConfig();
392
332
  for (const provider of AI_PROVIDERS) {
393
- const providerConfig = config.providers[provider.id];
394
- if (providerConfig && providerConfig.active) {
333
+ const pc = config.providers[provider.id];
334
+ if (pc && pc.active) {
395
335
  return {
396
336
  id: provider.id,
397
337
  name: provider.name,
398
- connectionType: providerConfig.connectionType,
399
- apiKey: providerConfig.apiKey || null,
400
- modelId: providerConfig.modelId || null,
401
- modelName: providerConfig.modelName || null
338
+ connectionType: pc.connectionType,
339
+ apiKey: pc.apiKey || null,
340
+ modelId: pc.modelId || null,
341
+ modelName: pc.modelName || null
402
342
  };
403
343
  }
404
344
  }
405
345
  return null;
406
346
  };
407
347
 
408
- /**
409
- * Count active AI agents
410
- * @returns {number} Number of active agents (0 or 1)
411
- */
412
- const getActiveAgentCount = () => {
413
- const active = getActiveProvider();
414
- return active ? 1 : 0;
348
+ /** Count active AI agents */
349
+ const getActiveAgentCount = () => getActiveProvider() ? 1 : 0;
350
+
351
+ /** Configure CLIProxy URL */
352
+ const configureCliProxyUrl = async () => {
353
+ const currentUrl = getCliProxyUrl();
354
+ console.clear();
355
+ console.log(chalk.yellow('\n Configure CLIProxy URL\n'));
356
+ console.log(chalk.gray(` Current: ${currentUrl}`));
357
+ console.log();
358
+ console.log(chalk.white(' [1] Local - localhost:8317 (default)'));
359
+ console.log(chalk.white(' [2] Remote - Enter custom URL'));
360
+ console.log(chalk.white(' [B] Back'));
361
+ console.log();
362
+
363
+ const choice = await prompts.textInput(chalk.cyan(' Select option: '));
364
+
365
+ if (!choice || choice.toLowerCase() === 'b') return;
366
+
367
+ if (choice === '1') {
368
+ setCliProxyUrl(DEFAULT_CLIPROXY_URL);
369
+ console.log(chalk.green(`\n ✓ CLIProxy URL set to ${DEFAULT_CLIPROXY_URL}`));
370
+ await prompts.waitForEnter();
371
+ return;
372
+ }
373
+
374
+ if (choice === '2') {
375
+ console.log(chalk.gray('\n Enter CLIProxy URL (e.g., http://192.168.1.100:8317):'));
376
+ const customUrl = await prompts.textInput(chalk.cyan(' URL: '));
377
+
378
+ if (!customUrl || customUrl.trim() === '') {
379
+ console.log(chalk.gray(' Cancelled.'));
380
+ await prompts.waitForEnter();
381
+ return;
382
+ }
383
+
384
+ let newUrl = customUrl.trim();
385
+ if (!newUrl.startsWith('http://') && !newUrl.startsWith('https://')) {
386
+ newUrl = 'http://' + newUrl;
387
+ }
388
+
389
+ // Test connection
390
+ const spinner = ora({ text: `Testing connection to ${newUrl}...`, color: 'yellow' }).start();
391
+ const status = await isCliProxyRunning(newUrl);
392
+
393
+ if (status.running) {
394
+ spinner.succeed(`Connected to ${newUrl}`);
395
+ setCliProxyUrl(newUrl);
396
+ console.log(chalk.green(`\n ✓ CLIProxy URL saved.`));
397
+ } else {
398
+ spinner.warn(`Cannot connect to ${newUrl}`);
399
+ console.log(chalk.gray(` Error: ${status.error || 'Connection failed'}`));
400
+ console.log(chalk.yellow('\n Save anyway? (URL will be used when CLIProxy is available)'));
401
+ const save = await prompts.textInput(chalk.cyan(' Save? (y/N): '));
402
+ if (save && save.toLowerCase() === 'y') {
403
+ setCliProxyUrl(newUrl);
404
+ console.log(chalk.green(' ✓ URL saved.'));
405
+ }
406
+ }
407
+ await prompts.waitForEnter();
408
+ }
415
409
  };
416
410
 
417
- /**
418
- * Main AI Agents menu
419
- */
411
+ /** Main AI Agents menu */
420
412
  const aiAgentsMenu = async () => {
421
413
  let config = loadConfig();
422
414
  const boxWidth = getLogoWidth();
423
415
 
424
416
  while (true) {
425
417
  console.clear();
426
- drawProvidersTable(config, boxWidth);
418
+ const cliproxyUrl = getCliProxyUrl();
419
+ drawProvidersTable(AI_PROVIDERS, config, boxWidth, cliproxyUrl);
427
420
 
428
- const input = await prompts.textInput(chalk.cyan('Select provider: '));
421
+ const input = await prompts.textInput(chalk.cyan('Select (1-8/C/B): '));
429
422
  const choice = (input || '').toLowerCase().trim();
430
423
 
431
- if (choice === 'b' || choice === '') {
432
- break;
424
+ if (choice === 'b' || choice === '') break;
425
+
426
+ if (choice === 'c') {
427
+ await configureCliProxyUrl();
428
+ continue;
433
429
  }
434
430
 
435
431
  const num = parseInt(choice);
@@ -0,0 +1,255 @@
1
+ /**
2
+ * CLIProxy Service
3
+ *
4
+ * Connects to CLIProxyAPI for AI provider access
5
+ * via paid plans (Claude Pro, ChatGPT Plus, etc.)
6
+ *
7
+ * Supports both local (localhost:8317) and remote connections.
8
+ * Docs: https://help.router-for.me
9
+ */
10
+
11
+ const http = require('http');
12
+ const https = require('https');
13
+ const os = require('os');
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+
17
+ // Config file path (same as ai-agents)
18
+ const CONFIG_DIR = path.join(os.homedir(), '.hqx');
19
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'ai-config.json');
20
+
21
+ // Default CLIProxy endpoint
22
+ const DEFAULT_CLIPROXY_URL = 'http://localhost:8317';
23
+
24
+ /**
25
+ * Get CLIProxy URL from config or default
26
+ * @returns {string} CLIProxy base URL
27
+ */
28
+ const getCliProxyUrl = () => {
29
+ try {
30
+ if (fs.existsSync(CONFIG_FILE)) {
31
+ const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
32
+ if (config.cliproxyUrl && config.cliproxyUrl.trim()) {
33
+ return config.cliproxyUrl.trim();
34
+ }
35
+ }
36
+ } catch (error) { /* ignore */ }
37
+ return DEFAULT_CLIPROXY_URL;
38
+ };
39
+
40
+ /**
41
+ * Set CLIProxy URL in config
42
+ * @param {string} url - CLIProxy URL
43
+ * @returns {boolean} Success status
44
+ */
45
+ const setCliProxyUrl = (url) => {
46
+ try {
47
+ let config = { providers: {} };
48
+ if (fs.existsSync(CONFIG_FILE)) {
49
+ config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
50
+ }
51
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
52
+ config.cliproxyUrl = url;
53
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
54
+ return true;
55
+ } catch (error) {
56
+ return false;
57
+ }
58
+ };
59
+
60
+ /**
61
+ * Make HTTP request to CLIProxy
62
+ * @param {string} path - API path
63
+ * @param {string} method - HTTP method
64
+ * @param {Object} headers - Request headers
65
+ * @param {number} timeout - Timeout in ms (default 60000 per RULES.md #15)
66
+ * @param {string} baseUrl - Optional base URL override
67
+ * @returns {Promise<Object>} { success, data, error }
68
+ */
69
+ const fetchCliProxy = (path, method = 'GET', headers = {}, timeout = 60000, baseUrl = null) => {
70
+ return new Promise((resolve) => {
71
+ const base = baseUrl || getCliProxyUrl();
72
+ const url = new URL(path, base);
73
+ const isHttps = url.protocol === 'https:';
74
+ const httpModule = isHttps ? https : http;
75
+
76
+ const options = {
77
+ hostname: url.hostname,
78
+ port: url.port || (isHttps ? 443 : 8317),
79
+ path: url.pathname + url.search,
80
+ method,
81
+ headers: {
82
+ 'Content-Type': 'application/json',
83
+ ...headers
84
+ },
85
+ timeout,
86
+ rejectUnauthorized: false // Allow self-signed certs for remote
87
+ };
88
+
89
+ const req = httpModule.request(options, (res) => {
90
+ let data = '';
91
+ res.on('data', chunk => data += chunk);
92
+ res.on('end', () => {
93
+ try {
94
+ if (res.statusCode >= 200 && res.statusCode < 300) {
95
+ resolve({ success: true, data: JSON.parse(data) });
96
+ } else {
97
+ resolve({ success: false, error: `HTTP ${res.statusCode}`, data: null });
98
+ }
99
+ } catch (error) {
100
+ resolve({ success: false, error: 'Invalid JSON response', data: null });
101
+ }
102
+ });
103
+ });
104
+
105
+ req.on('error', (error) => {
106
+ if (error.code === 'ECONNREFUSED') {
107
+ resolve({ success: false, error: 'CLIProxy not reachable', data: null });
108
+ } else {
109
+ resolve({ success: false, error: error.message, data: null });
110
+ }
111
+ });
112
+
113
+ req.on('timeout', () => {
114
+ req.destroy();
115
+ resolve({ success: false, error: 'Request timeout', data: null });
116
+ });
117
+
118
+ req.end();
119
+ });
120
+ };
121
+
122
+ /**
123
+ * Check if CLIProxy is running/reachable
124
+ * @param {string} url - Optional URL to test (uses config if not provided)
125
+ * @returns {Promise<Object>} { running, error, url }
126
+ */
127
+ const isCliProxyRunning = async (url = null) => {
128
+ const testUrl = url || getCliProxyUrl();
129
+ const result = await fetchCliProxy('/v1/models', 'GET', {}, 5000, testUrl);
130
+ return {
131
+ running: result.success,
132
+ error: result.success ? null : result.error,
133
+ url: testUrl
134
+ };
135
+ };
136
+
137
+ /**
138
+ * Fetch available models from CLIProxy
139
+ * @returns {Promise<Object>} { success, models, error }
140
+ */
141
+ const fetchModelsFromCliProxy = async () => {
142
+ const result = await fetchCliProxy('/v1/models');
143
+
144
+ if (!result.success) {
145
+ return { success: false, models: [], error: result.error };
146
+ }
147
+
148
+ // Parse OpenAI-compatible format: { data: [{ id, ... }] }
149
+ const data = result.data;
150
+ if (!data || !data.data || !Array.isArray(data.data)) {
151
+ return { success: false, models: [], error: 'Invalid response format' };
152
+ }
153
+
154
+ const models = data.data
155
+ .filter(m => m.id)
156
+ .map(m => ({
157
+ id: m.id,
158
+ name: m.id
159
+ }));
160
+
161
+ if (models.length === 0) {
162
+ return { success: false, models: [], error: 'No models available' };
163
+ }
164
+
165
+ return { success: true, models, error: null };
166
+ };
167
+
168
+ /**
169
+ * Get OAuth URL for a provider
170
+ * @param {string} providerId - Provider ID (anthropic, openai, google, etc.)
171
+ * @returns {Promise<Object>} { success, url, state, error }
172
+ */
173
+ const getOAuthUrl = async (providerId) => {
174
+ // Map HQX provider IDs to CLIProxy endpoints
175
+ const oauthEndpoints = {
176
+ anthropic: '/v0/management/anthropic-auth-url',
177
+ openai: '/v0/management/codex-auth-url',
178
+ google: '/v0/management/gemini-cli-auth-url',
179
+ // Others may not have OAuth support in CLIProxy
180
+ };
181
+
182
+ const endpoint = oauthEndpoints[providerId];
183
+ if (!endpoint) {
184
+ return { success: false, url: null, state: null, error: 'OAuth not supported for this provider' };
185
+ }
186
+
187
+ const result = await fetchCliProxy(endpoint);
188
+
189
+ if (!result.success) {
190
+ return { success: false, url: null, state: null, error: result.error };
191
+ }
192
+
193
+ const data = result.data;
194
+ if (!data || !data.url) {
195
+ return { success: false, url: null, state: null, error: 'Invalid OAuth response' };
196
+ }
197
+
198
+ return {
199
+ success: true,
200
+ url: data.url,
201
+ state: data.state || null,
202
+ error: null
203
+ };
204
+ };
205
+
206
+ /**
207
+ * Check OAuth status
208
+ * @param {string} state - OAuth state from getOAuthUrl
209
+ * @returns {Promise<Object>} { success, status, error }
210
+ */
211
+ const checkOAuthStatus = async (state) => {
212
+ const result = await fetchCliProxy(`/v0/management/get-auth-status?state=${encodeURIComponent(state)}`);
213
+
214
+ if (!result.success) {
215
+ return { success: false, status: null, error: result.error };
216
+ }
217
+
218
+ const data = result.data;
219
+ // status can be: "wait", "ok", "error"
220
+ return {
221
+ success: true,
222
+ status: data.status || 'unknown',
223
+ error: data.error || null
224
+ };
225
+ };
226
+
227
+ /**
228
+ * Get CLIProxy auth files (connected accounts)
229
+ * @returns {Promise<Object>} { success, files, error }
230
+ */
231
+ const getAuthFiles = async () => {
232
+ const result = await fetchCliProxy('/v0/management/auth-files');
233
+
234
+ if (!result.success) {
235
+ return { success: false, files: [], error: result.error };
236
+ }
237
+
238
+ return {
239
+ success: true,
240
+ files: result.data?.files || [],
241
+ error: null
242
+ };
243
+ };
244
+
245
+ module.exports = {
246
+ DEFAULT_CLIPROXY_URL,
247
+ getCliProxyUrl,
248
+ setCliProxyUrl,
249
+ isCliProxyRunning,
250
+ fetchModelsFromCliProxy,
251
+ getOAuthUrl,
252
+ checkOAuthStatus,
253
+ getAuthFiles,
254
+ fetchCliProxy
255
+ };