hedgequantx 2.7.23 → 2.7.24

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.23",
3
+ "version": "2.7.24",
4
4
  "description": "HedgeQuantX - Prop Futures Trading CLI",
5
5
  "main": "src/app.js",
6
6
  "bin": {
@@ -64,6 +64,7 @@
64
64
  "inquirer": "^8.2.6",
65
65
  "ora": "^5.4.1",
66
66
  "protobufjs": "^8.0.0",
67
+ "tar": "^7.5.2",
67
68
  "uuid": "^9.0.1",
68
69
  "ws": "^8.18.3"
69
70
  },
@@ -90,7 +90,7 @@ const drawProvidersTable = (providers, config, boxWidth, cliproxyUrl = null) =>
90
90
  }
91
91
 
92
92
  console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
93
- console.log(chalk.cyan('║') + chalk.gray(centerText('[C] Configure CLIProxy URL', W)) + chalk.cyan('║'));
93
+ console.log(chalk.cyan('║') + chalk.gray(centerText('[S] CLIProxy Status', W)) + chalk.cyan('║'));
94
94
  console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
95
95
  console.log(chalk.cyan('║') + chalk.red(centerText('[B] Back to Menu', W)) + chalk.cyan('║'));
96
96
  console.log(chalk.cyan('╚' + '═'.repeat(W) + '╝'));
@@ -15,7 +15,7 @@ const { getLogoWidth } = require('../ui');
15
15
  const { prompts } = require('../utils');
16
16
  const { fetchModelsFromApi } = require('./ai-models');
17
17
  const { drawProvidersTable, drawModelsTable, drawProviderWindow } = require('./ai-agents-ui');
18
- const { isCliProxyRunning, fetchModelsFromCliProxy, getOAuthUrl, checkOAuthStatus, getCliProxyUrl, setCliProxyUrl, DEFAULT_CLIPROXY_URL } = require('../services/cliproxy');
18
+ const cliproxy = require('../services/cliproxy');
19
19
 
20
20
  // Config file path
21
21
  const CONFIG_DIR = path.join(os.homedir(), '.hqx');
@@ -98,76 +98,53 @@ const activateProvider = (config, providerId, data) => {
98
98
  Object.assign(config.providers[providerId], data, { active: true, configuredAt: new Date().toISOString() });
99
99
  };
100
100
 
101
- /** Handle CLIProxy connection */
101
+ /** Handle CLIProxy connection (with auto-install) */
102
102
  const handleCliProxyConnection = async (provider, config, boxWidth) => {
103
103
  console.log();
104
- const currentUrl = getCliProxyUrl();
105
- const spinner = ora({ text: `Checking CLIProxy at ${currentUrl}...`, color: 'yellow' }).start();
106
- let proxyStatus = await isCliProxyRunning();
107
104
 
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();
105
+ // Check if CLIProxyAPI is installed
106
+ if (!cliproxy.isInstalled()) {
107
+ console.log(chalk.yellow(' CLIProxyAPI not installed. Installing...'));
108
+ const spinner = ora({ text: 'Downloading CLIProxyAPI...', color: 'yellow' }).start();
116
109
 
117
- const urlChoice = await prompts.textInput(chalk.cyan(' Select option: '));
110
+ const installResult = await cliproxy.install((msg, percent) => {
111
+ spinner.text = `${msg} ${percent}%`;
112
+ });
118
113
 
119
- if (!urlChoice || urlChoice.toLowerCase() === 'b') {
114
+ if (!installResult.success) {
115
+ spinner.fail(`Installation failed: ${installResult.error}`);
116
+ await prompts.waitForEnter();
120
117
  return false;
121
118
  }
119
+ spinner.succeed('CLIProxyAPI installed');
120
+ }
121
+
122
+ // Check if running, start if not
123
+ let status = await cliproxy.isRunning();
124
+ if (!status.running) {
125
+ const spinner = ora({ text: 'Starting CLIProxyAPI...', color: 'yellow' }).start();
126
+ const startResult = await cliproxy.start();
122
127
 
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
- }
139
- }
140
-
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 {
128
+ if (!startResult.success) {
129
+ spinner.fail(`Failed to start: ${startResult.error}`);
130
+ await prompts.waitForEnter();
156
131
  return false;
157
132
  }
133
+ spinner.succeed('CLIProxyAPI started');
158
134
  } else {
159
- spinner.succeed(`CLIProxy connected at ${currentUrl}`);
135
+ console.log(chalk.green(' ✓ CLIProxyAPI is running'));
160
136
  }
161
- const oauthResult = await getOAuthUrl(provider.id);
162
137
 
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();
138
+ // Check if provider supports OAuth
139
+ const oauthProviders = ['anthropic', 'openai', 'google', 'qwen'];
140
+ if (!oauthProviders.includes(provider.id)) {
141
+ // Try to fetch models directly
142
+ console.log(chalk.gray(` Checking available models for ${provider.name}...`));
143
+ const modelsResult = await cliproxy.fetchProviderModels(provider.id);
167
144
 
168
145
  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'}`));
146
+ console.log(chalk.red(` No models available for ${provider.name}`));
147
+ console.log(chalk.gray(' This provider may require API key connection.'));
171
148
  await prompts.waitForEnter();
172
149
  return false;
173
150
  }
@@ -189,42 +166,25 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
189
166
  return true;
190
167
  }
191
168
 
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)'));
196
-
197
- let authenticated = false;
198
- const maxWait = 120000, pollInterval = 3000;
199
- let waited = 0;
200
-
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
- })();
216
-
217
- await Promise.race([pollPromise, prompts.waitForEnter()]);
169
+ // OAuth flow - get login URL
170
+ console.log(chalk.cyan(`\n Starting OAuth login for ${provider.name}...`));
171
+ const loginResult = await cliproxy.getLoginUrl(provider.id);
218
172
 
219
- if (!authenticated) {
220
- console.log(chalk.yellow(' Authentication cancelled or timed out.'));
173
+ if (!loginResult.success) {
174
+ console.log(chalk.red(` OAuth error: ${loginResult.error}`));
221
175
  await prompts.waitForEnter();
222
176
  return false;
223
177
  }
224
178
 
225
- console.log(chalk.green(' Authentication successful!'));
179
+ console.log(chalk.cyan('\n Open this URL in your browser to authenticate:\n'));
180
+ console.log(chalk.yellow(` ${loginResult.url}\n`));
181
+ console.log(chalk.gray(' After authenticating, press Enter to continue...'));
182
+
183
+ await prompts.waitForEnter();
184
+
185
+ // Try to fetch models after auth
186
+ const modelsResult = await cliproxy.fetchProviderModels(provider.id);
226
187
 
227
- const modelsResult = await fetchModelsFromCliProxy();
228
188
  if (modelsResult.success && modelsResult.models.length > 0) {
229
189
  const selectedModel = await selectModelFromList(provider, modelsResult.models, boxWidth);
230
190
  if (selectedModel) {
@@ -234,17 +194,20 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
234
194
  modelName: selectedModel.name
235
195
  });
236
196
  if (saveConfig(config)) {
237
- console.log(chalk.green(`\n ✓ ${provider.name} connected via CLIProxy.`));
197
+ console.log(chalk.green(`\n ✓ ${provider.name} connected via Paid Plan.`));
238
198
  console.log(chalk.cyan(` Model: ${selectedModel.name}`));
239
199
  }
240
200
  }
241
201
  } else {
202
+ // No models but auth might have worked
242
203
  activateProvider(config, provider.id, {
243
204
  connectionType: 'cliproxy',
244
205
  modelId: null,
245
- modelName: 'Default'
206
+ modelName: 'Auto'
246
207
  });
247
- if (saveConfig(config)) console.log(chalk.green(`\n ✓ ${provider.name} connected via CLIProxy.`));
208
+ if (saveConfig(config)) {
209
+ console.log(chalk.green(`\n ✓ ${provider.name} connected via Paid Plan.`));
210
+ }
248
211
  }
249
212
 
250
213
  await prompts.waitForEnter();
@@ -348,64 +311,24 @@ const getActiveProvider = () => {
348
311
  /** Count active AI agents */
349
312
  const getActiveAgentCount = () => getActiveProvider() ? 1 : 0;
350
313
 
351
- /** Configure CLIProxy URL */
352
- const configureCliProxyUrl = async () => {
353
- const currentUrl = getCliProxyUrl();
314
+ /** Show CLIProxy status */
315
+ const showCliProxyStatus = async () => {
354
316
  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();
317
+ console.log(chalk.yellow('\n CLIProxyAPI Status\n'));
362
318
 
363
- const choice = await prompts.textInput(chalk.cyan(' Select option: '));
319
+ const installed = cliproxy.isInstalled();
320
+ console.log(chalk.gray(' Installed: ') + (installed ? chalk.green('Yes') : chalk.red('No')));
364
321
 
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;
322
+ if (installed) {
323
+ const status = await cliproxy.isRunning();
324
+ console.log(chalk.gray(' Running: ') + (status.running ? chalk.green('Yes') : chalk.red('No')));
325
+ console.log(chalk.gray(' Version: ') + chalk.cyan(cliproxy.CLIPROXY_VERSION));
326
+ console.log(chalk.gray(' Port: ') + chalk.cyan(cliproxy.DEFAULT_PORT));
327
+ console.log(chalk.gray(' Install dir: ') + chalk.cyan(cliproxy.INSTALL_DIR));
372
328
  }
373
329
 
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
- }
330
+ console.log();
331
+ await prompts.waitForEnter();
409
332
  };
410
333
 
411
334
  /** Main AI Agents menu */
@@ -415,16 +338,17 @@ const aiAgentsMenu = async () => {
415
338
 
416
339
  while (true) {
417
340
  console.clear();
418
- const cliproxyUrl = getCliProxyUrl();
419
- drawProvidersTable(AI_PROVIDERS, config, boxWidth, cliproxyUrl);
341
+ const status = await cliproxy.isRunning();
342
+ const statusText = status.running ? `localhost:${cliproxy.DEFAULT_PORT}` : 'Not running';
343
+ drawProvidersTable(AI_PROVIDERS, config, boxWidth, statusText);
420
344
 
421
- const input = await prompts.textInput(chalk.cyan('Select (1-8/C/B): '));
345
+ const input = await prompts.textInput(chalk.cyan('Select (1-8/S/B): '));
422
346
  const choice = (input || '').toLowerCase().trim();
423
347
 
424
348
  if (choice === 'b' || choice === '') break;
425
349
 
426
- if (choice === 'c') {
427
- await configureCliProxyUrl();
350
+ if (choice === 's') {
351
+ await showCliProxyStatus();
428
352
  continue;
429
353
  }
430
354
 
@@ -0,0 +1,184 @@
1
+ /**
2
+ * CLIProxy Service
3
+ *
4
+ * Provides OAuth connections to paid AI plans (Claude Pro, ChatGPT Plus, etc.)
5
+ * via the embedded CLIProxyAPI binary.
6
+ */
7
+
8
+ const http = require('http');
9
+ const manager = require('./manager');
10
+
11
+ // Re-export manager functions
12
+ const {
13
+ CLIPROXY_VERSION,
14
+ INSTALL_DIR,
15
+ AUTH_DIR,
16
+ DEFAULT_PORT,
17
+ isInstalled,
18
+ install,
19
+ isRunning,
20
+ start,
21
+ stop,
22
+ ensureRunning,
23
+ getLoginUrl
24
+ } = manager;
25
+
26
+ /**
27
+ * Make HTTP request to local CLIProxyAPI
28
+ * @param {string} path - API path
29
+ * @param {string} method - HTTP method
30
+ * @param {Object} body - Request body (optional)
31
+ * @param {number} timeout - Timeout in ms (default 60000 per RULES.md #15)
32
+ * @returns {Promise<Object>} { success, data, error }
33
+ */
34
+ const fetchLocal = (path, method = 'GET', body = null, timeout = 60000) => {
35
+ return new Promise((resolve) => {
36
+ const options = {
37
+ hostname: 'localhost',
38
+ port: DEFAULT_PORT,
39
+ path,
40
+ method,
41
+ headers: { 'Content-Type': 'application/json' },
42
+ timeout
43
+ };
44
+
45
+ const req = http.request(options, (res) => {
46
+ let data = '';
47
+ res.on('data', chunk => data += chunk);
48
+ res.on('end', () => {
49
+ try {
50
+ if (res.statusCode >= 200 && res.statusCode < 300) {
51
+ const parsed = data ? JSON.parse(data) : {};
52
+ resolve({ success: true, data: parsed, error: null });
53
+ } else {
54
+ resolve({ success: false, error: `HTTP ${res.statusCode}`, data: null });
55
+ }
56
+ } catch (error) {
57
+ resolve({ success: false, error: 'Invalid JSON response', data: null });
58
+ }
59
+ });
60
+ });
61
+
62
+ req.on('error', (error) => {
63
+ if (error.code === 'ECONNREFUSED') {
64
+ resolve({ success: false, error: 'CLIProxyAPI not running', data: null });
65
+ } else {
66
+ resolve({ success: false, error: error.message, data: null });
67
+ }
68
+ });
69
+
70
+ req.on('timeout', () => {
71
+ req.destroy();
72
+ resolve({ success: false, error: 'Request timeout', data: null });
73
+ });
74
+
75
+ if (body) {
76
+ req.write(JSON.stringify(body));
77
+ }
78
+
79
+ req.end();
80
+ });
81
+ };
82
+
83
+ /**
84
+ * Fetch available models from CLIProxyAPI
85
+ * @returns {Promise<Object>} { success, models, error }
86
+ */
87
+ const fetchModels = async () => {
88
+ const result = await fetchLocal('/v1/models');
89
+
90
+ if (!result.success) {
91
+ return { success: false, models: [], error: result.error };
92
+ }
93
+
94
+ const data = result.data;
95
+ if (!data || !data.data || !Array.isArray(data.data)) {
96
+ return { success: false, models: [], error: 'Invalid response format' };
97
+ }
98
+
99
+ const models = data.data
100
+ .filter(m => m.id)
101
+ .map(m => ({ id: m.id, name: m.id }));
102
+
103
+ if (models.length === 0) {
104
+ return { success: false, models: [], error: 'No models available' };
105
+ }
106
+
107
+ return { success: true, models, error: null };
108
+ };
109
+
110
+ /**
111
+ * Get provider-specific models
112
+ * @param {string} providerId - Provider ID
113
+ * @returns {Promise<Object>} { success, models, error }
114
+ */
115
+ const fetchProviderModels = async (providerId) => {
116
+ const result = await fetchModels();
117
+ if (!result.success) return result;
118
+
119
+ // Filter by provider prefix
120
+ const prefixMap = {
121
+ anthropic: 'claude',
122
+ openai: 'gpt',
123
+ google: 'gemini',
124
+ qwen: 'qwen'
125
+ };
126
+
127
+ const prefix = prefixMap[providerId];
128
+ if (!prefix) return result;
129
+
130
+ const filtered = result.models.filter(m =>
131
+ m.id.toLowerCase().includes(prefix)
132
+ );
133
+
134
+ return {
135
+ success: true,
136
+ models: filtered.length > 0 ? filtered : result.models,
137
+ error: null
138
+ };
139
+ };
140
+
141
+ /**
142
+ * Chat completion request
143
+ * @param {string} model - Model ID
144
+ * @param {Array} messages - Chat messages
145
+ * @param {Object} options - Additional options
146
+ * @returns {Promise<Object>} { success, response, error }
147
+ */
148
+ const chatCompletion = async (model, messages, options = {}) => {
149
+ const body = {
150
+ model,
151
+ messages,
152
+ stream: false,
153
+ ...options
154
+ };
155
+
156
+ const result = await fetchLocal('/v1/chat/completions', 'POST', body);
157
+
158
+ if (!result.success) {
159
+ return { success: false, response: null, error: result.error };
160
+ }
161
+
162
+ return { success: true, response: result.data, error: null };
163
+ };
164
+
165
+ module.exports = {
166
+ // Manager
167
+ CLIPROXY_VERSION,
168
+ INSTALL_DIR,
169
+ AUTH_DIR,
170
+ DEFAULT_PORT,
171
+ isInstalled,
172
+ install,
173
+ isRunning,
174
+ start,
175
+ stop,
176
+ ensureRunning,
177
+ getLoginUrl,
178
+
179
+ // API
180
+ fetchLocal,
181
+ fetchModels,
182
+ fetchProviderModels,
183
+ chatCompletion
184
+ };
@@ -0,0 +1,417 @@
1
+ /**
2
+ * CLIProxyAPI Manager
3
+ *
4
+ * Downloads, installs and manages CLIProxyAPI binary for OAuth connections
5
+ * to paid AI plans (Claude Pro, ChatGPT Plus, Gemini, etc.)
6
+ */
7
+
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const fs = require('fs');
11
+ const https = require('https');
12
+ const http = require('http');
13
+ const { spawn } = require('child_process');
14
+ const { createGunzip } = require('zlib');
15
+ const tar = require('tar');
16
+
17
+ // CLIProxyAPI version and download URLs
18
+ const CLIPROXY_VERSION = '6.6.88';
19
+ const GITHUB_RELEASE_BASE = 'https://github.com/router-for-me/CLIProxyAPI/releases/download';
20
+
21
+ // Installation directory
22
+ const INSTALL_DIR = path.join(os.homedir(), '.hqx', 'cliproxy');
23
+ const BINARY_NAME = process.platform === 'win32' ? 'cli-proxy-api.exe' : 'cli-proxy-api';
24
+ const BINARY_PATH = path.join(INSTALL_DIR, BINARY_NAME);
25
+ const PID_FILE = path.join(INSTALL_DIR, 'cliproxy.pid');
26
+ const AUTH_DIR = path.join(INSTALL_DIR, 'auths');
27
+
28
+ // Default port
29
+ const DEFAULT_PORT = 8317;
30
+
31
+ /**
32
+ * Get download URL for current platform
33
+ * @returns {Object} { url, filename } or null if unsupported
34
+ */
35
+ const getDownloadUrl = () => {
36
+ const platform = process.platform;
37
+ const arch = process.arch;
38
+
39
+ let osName, archName, ext;
40
+
41
+ if (platform === 'darwin') {
42
+ osName = 'darwin';
43
+ ext = 'tar.gz';
44
+ } else if (platform === 'linux') {
45
+ osName = 'linux';
46
+ ext = 'tar.gz';
47
+ } else if (platform === 'win32') {
48
+ osName = 'windows';
49
+ ext = 'zip';
50
+ } else {
51
+ return null;
52
+ }
53
+
54
+ if (arch === 'x64' || arch === 'amd64') {
55
+ archName = 'amd64';
56
+ } else if (arch === 'arm64') {
57
+ archName = 'arm64';
58
+ } else {
59
+ return null;
60
+ }
61
+
62
+ const filename = `CLIProxyAPI_${CLIPROXY_VERSION}_${osName}_${archName}.${ext}`;
63
+ const url = `${GITHUB_RELEASE_BASE}/v${CLIPROXY_VERSION}/${filename}`;
64
+
65
+ return { url, filename, ext };
66
+ };
67
+
68
+ /**
69
+ * Check if CLIProxyAPI is installed
70
+ * @returns {boolean}
71
+ */
72
+ const isInstalled = () => {
73
+ return fs.existsSync(BINARY_PATH);
74
+ };
75
+
76
+ /**
77
+ * Download file from URL
78
+ * @param {string} url - URL to download
79
+ * @param {string} destPath - Destination path
80
+ * @param {Function} onProgress - Progress callback (percent)
81
+ * @returns {Promise<boolean>}
82
+ */
83
+ const downloadFile = (url, destPath, onProgress = null) => {
84
+ return new Promise((resolve, reject) => {
85
+ const file = fs.createWriteStream(destPath);
86
+
87
+ const request = (url.startsWith('https') ? https : http).get(url, (response) => {
88
+ // Handle redirects
89
+ if (response.statusCode === 302 || response.statusCode === 301) {
90
+ file.close();
91
+ fs.unlinkSync(destPath);
92
+ return downloadFile(response.headers.location, destPath, onProgress)
93
+ .then(resolve)
94
+ .catch(reject);
95
+ }
96
+
97
+ if (response.statusCode !== 200) {
98
+ file.close();
99
+ fs.unlinkSync(destPath);
100
+ return reject(new Error(`HTTP ${response.statusCode}`));
101
+ }
102
+
103
+ const totalSize = parseInt(response.headers['content-length'], 10);
104
+ let downloadedSize = 0;
105
+
106
+ response.on('data', (chunk) => {
107
+ downloadedSize += chunk.length;
108
+ if (onProgress && totalSize) {
109
+ onProgress(Math.round((downloadedSize / totalSize) * 100));
110
+ }
111
+ });
112
+
113
+ response.pipe(file);
114
+
115
+ file.on('finish', () => {
116
+ file.close();
117
+ resolve(true);
118
+ });
119
+ });
120
+
121
+ request.on('error', (err) => {
122
+ file.close();
123
+ if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
124
+ reject(err);
125
+ });
126
+
127
+ request.setTimeout(120000, () => {
128
+ request.destroy();
129
+ reject(new Error('Download timeout'));
130
+ });
131
+ });
132
+ };
133
+
134
+ /**
135
+ * Extract tar.gz file
136
+ * @param {string} archivePath - Path to archive
137
+ * @param {string} destDir - Destination directory
138
+ * @returns {Promise<boolean>}
139
+ */
140
+ const extractTarGz = (archivePath, destDir) => {
141
+ return new Promise((resolve, reject) => {
142
+ fs.createReadStream(archivePath)
143
+ .pipe(createGunzip())
144
+ .pipe(tar.extract({ cwd: destDir }))
145
+ .on('finish', () => resolve(true))
146
+ .on('error', reject);
147
+ });
148
+ };
149
+
150
+ /**
151
+ * Extract zip file (Windows)
152
+ * @param {string} archivePath - Path to archive
153
+ * @param {string} destDir - Destination directory
154
+ * @returns {Promise<boolean>}
155
+ */
156
+ const extractZip = async (archivePath, destDir) => {
157
+ const { execSync } = require('child_process');
158
+
159
+ if (process.platform === 'win32') {
160
+ // Use PowerShell on Windows
161
+ execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force"`, {
162
+ stdio: 'ignore'
163
+ });
164
+ } else {
165
+ // Use unzip on Unix
166
+ execSync(`unzip -o "${archivePath}" -d "${destDir}"`, { stdio: 'ignore' });
167
+ }
168
+
169
+ return true;
170
+ };
171
+
172
+ /**
173
+ * Install CLIProxyAPI
174
+ * @param {Function} onProgress - Progress callback (message, percent)
175
+ * @returns {Promise<Object>} { success, error }
176
+ */
177
+ const install = async (onProgress = null) => {
178
+ try {
179
+ const download = getDownloadUrl();
180
+ if (!download) {
181
+ return { success: false, error: 'Unsupported platform' };
182
+ }
183
+
184
+ // Create install directory
185
+ if (!fs.existsSync(INSTALL_DIR)) {
186
+ fs.mkdirSync(INSTALL_DIR, { recursive: true });
187
+ }
188
+ if (!fs.existsSync(AUTH_DIR)) {
189
+ fs.mkdirSync(AUTH_DIR, { recursive: true });
190
+ }
191
+
192
+ const archivePath = path.join(INSTALL_DIR, download.filename);
193
+
194
+ // Download
195
+ if (onProgress) onProgress('Downloading CLIProxyAPI...', 0);
196
+ await downloadFile(download.url, archivePath, (percent) => {
197
+ if (onProgress) onProgress('Downloading CLIProxyAPI...', percent);
198
+ });
199
+
200
+ // Extract
201
+ if (onProgress) onProgress('Extracting...', 100);
202
+ if (download.ext === 'tar.gz') {
203
+ await extractTarGz(archivePath, INSTALL_DIR);
204
+ } else {
205
+ await extractZip(archivePath, INSTALL_DIR);
206
+ }
207
+
208
+ // Clean up archive
209
+ if (fs.existsSync(archivePath)) {
210
+ fs.unlinkSync(archivePath);
211
+ }
212
+
213
+ // Make executable on Unix
214
+ if (process.platform !== 'win32' && fs.existsSync(BINARY_PATH)) {
215
+ fs.chmodSync(BINARY_PATH, '755');
216
+ }
217
+
218
+ if (!fs.existsSync(BINARY_PATH)) {
219
+ return { success: false, error: 'Binary not found after extraction' };
220
+ }
221
+
222
+ return { success: true, error: null };
223
+ } catch (error) {
224
+ return { success: false, error: error.message };
225
+ }
226
+ };
227
+
228
+ /**
229
+ * Check if CLIProxyAPI is running
230
+ * @returns {Promise<Object>} { running, pid }
231
+ */
232
+ const isRunning = async () => {
233
+ // Check PID file
234
+ if (fs.existsSync(PID_FILE)) {
235
+ try {
236
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
237
+ // Check if process exists
238
+ process.kill(pid, 0);
239
+ return { running: true, pid };
240
+ } catch (e) {
241
+ // Process doesn't exist, clean up PID file
242
+ fs.unlinkSync(PID_FILE);
243
+ }
244
+ }
245
+
246
+ // Also check by trying to connect
247
+ return new Promise((resolve) => {
248
+ const req = http.get(`http://localhost:${DEFAULT_PORT}/v1/models`, (res) => {
249
+ resolve({ running: res.statusCode === 200, pid: null });
250
+ });
251
+ req.on('error', () => resolve({ running: false, pid: null }));
252
+ req.setTimeout(2000, () => {
253
+ req.destroy();
254
+ resolve({ running: false, pid: null });
255
+ });
256
+ });
257
+ };
258
+
259
+ /**
260
+ * Start CLIProxyAPI
261
+ * @returns {Promise<Object>} { success, error, pid }
262
+ */
263
+ const start = async () => {
264
+ if (!isInstalled()) {
265
+ return { success: false, error: 'CLIProxyAPI not installed', pid: null };
266
+ }
267
+
268
+ const status = await isRunning();
269
+ if (status.running) {
270
+ return { success: true, error: null, pid: status.pid };
271
+ }
272
+
273
+ try {
274
+ const args = [
275
+ '--port', String(DEFAULT_PORT),
276
+ '--auth-dir', AUTH_DIR
277
+ ];
278
+
279
+ const child = spawn(BINARY_PATH, args, {
280
+ detached: true,
281
+ stdio: 'ignore',
282
+ cwd: INSTALL_DIR
283
+ });
284
+
285
+ child.unref();
286
+
287
+ // Save PID
288
+ fs.writeFileSync(PID_FILE, String(child.pid));
289
+
290
+ // Wait for startup
291
+ await new Promise(r => setTimeout(r, 2000));
292
+
293
+ const runStatus = await isRunning();
294
+ if (runStatus.running) {
295
+ return { success: true, error: null, pid: child.pid };
296
+ } else {
297
+ return { success: false, error: 'Failed to start CLIProxyAPI', pid: null };
298
+ }
299
+ } catch (error) {
300
+ return { success: false, error: error.message, pid: null };
301
+ }
302
+ };
303
+
304
+ /**
305
+ * Stop CLIProxyAPI
306
+ * @returns {Promise<Object>} { success, error }
307
+ */
308
+ const stop = async () => {
309
+ const status = await isRunning();
310
+ if (!status.running) {
311
+ return { success: true, error: null };
312
+ }
313
+
314
+ try {
315
+ if (status.pid) {
316
+ process.kill(status.pid, 'SIGTERM');
317
+ }
318
+
319
+ if (fs.existsSync(PID_FILE)) {
320
+ fs.unlinkSync(PID_FILE);
321
+ }
322
+
323
+ return { success: true, error: null };
324
+ } catch (error) {
325
+ return { success: false, error: error.message };
326
+ }
327
+ };
328
+
329
+ /**
330
+ * Ensure CLIProxyAPI is installed and running
331
+ * @param {Function} onProgress - Progress callback
332
+ * @returns {Promise<Object>} { success, error }
333
+ */
334
+ const ensureRunning = async (onProgress = null) => {
335
+ // Check if installed
336
+ if (!isInstalled()) {
337
+ if (onProgress) onProgress('Installing CLIProxyAPI...', 0);
338
+ const installResult = await install(onProgress);
339
+ if (!installResult.success) {
340
+ return installResult;
341
+ }
342
+ }
343
+
344
+ // Check if running
345
+ const status = await isRunning();
346
+ if (status.running) {
347
+ return { success: true, error: null };
348
+ }
349
+
350
+ // Start
351
+ if (onProgress) onProgress('Starting CLIProxyAPI...', 100);
352
+ return start();
353
+ };
354
+
355
+ /**
356
+ * Get OAuth login URL for a provider
357
+ * @param {string} provider - Provider ID (anthropic, openai, google, etc.)
358
+ * @returns {Promise<Object>} { success, url, error }
359
+ */
360
+ const getLoginUrl = async (provider) => {
361
+ const providerFlags = {
362
+ anthropic: '--claude-login',
363
+ openai: '--codex-login',
364
+ google: '--gemini-login',
365
+ qwen: '--qwen-login'
366
+ };
367
+
368
+ const flag = providerFlags[provider];
369
+ if (!flag) {
370
+ return { success: false, url: null, error: 'Provider not supported for OAuth' };
371
+ }
372
+
373
+ // For headless/VPS, use --no-browser flag
374
+ return new Promise((resolve) => {
375
+ const args = [flag, '--no-browser'];
376
+ const child = spawn(BINARY_PATH, args, {
377
+ cwd: INSTALL_DIR,
378
+ env: { ...process.env, AUTH_DIR: AUTH_DIR }
379
+ });
380
+
381
+ let output = '';
382
+
383
+ child.stdout.on('data', (data) => {
384
+ output += data.toString();
385
+ });
386
+
387
+ child.stderr.on('data', (data) => {
388
+ output += data.toString();
389
+ });
390
+
391
+ // Look for URL in output
392
+ setTimeout(() => {
393
+ const urlMatch = output.match(/https?:\/\/[^\s]+/);
394
+ if (urlMatch) {
395
+ resolve({ success: true, url: urlMatch[0], error: null });
396
+ } else {
397
+ resolve({ success: false, url: null, error: 'Could not get login URL' });
398
+ }
399
+ }, 3000);
400
+ });
401
+ };
402
+
403
+ module.exports = {
404
+ CLIPROXY_VERSION,
405
+ INSTALL_DIR,
406
+ BINARY_PATH,
407
+ AUTH_DIR,
408
+ DEFAULT_PORT,
409
+ getDownloadUrl,
410
+ isInstalled,
411
+ install,
412
+ isRunning,
413
+ start,
414
+ stop,
415
+ ensureRunning,
416
+ getLoginUrl
417
+ };
@@ -1,255 +0,0 @@
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
- };