hedgequantx 2.7.22 → 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.22",
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
  },
@@ -60,14 +60,40 @@ const draw2ColTable = (title, titleColor, items, backText, W) => {
60
60
  * @param {Array} providers - List of AI providers
61
61
  * @param {Object} config - Current config
62
62
  * @param {number} boxWidth - Box width
63
+ * @param {string} cliproxyUrl - Current CLIProxy URL (optional)
63
64
  */
64
- const drawProvidersTable = (providers, config, boxWidth) => {
65
+ const drawProvidersTable = (providers, config, boxWidth, cliproxyUrl = null) => {
65
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
+
66
80
  const items = providers.map((p, i) => {
67
81
  const status = config.providers[p.id]?.active ? chalk.green(' ●') : '';
68
82
  return chalk.cyan(`[${i + 1}]`) + ' ' + chalk[p.color](p.name) + status;
69
83
  });
70
- draw2ColTable('AI AGENTS CONFIGURATION', chalk.yellow.bold, items, '[B] Back to Menu', W);
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('[S] CLIProxy Status', 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) + '╝'));
71
97
  };
72
98
 
73
99
  /**
@@ -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 } = 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,31 +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 spinner = ora({ text: 'Checking CLIProxy status...', color: 'yellow' }).start();
105
- const proxyStatus = await isCliProxyRunning();
106
104
 
107
- if (!proxyStatus.running) {
108
- spinner.fail('CLIProxy is not running');
109
- console.log(chalk.yellow('\n CLIProxy must be running on localhost:8317'));
110
- console.log(chalk.gray(' Install: https://help.router-for.me\n'));
111
- await prompts.waitForEnter();
112
- return false;
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();
109
+
110
+ const installResult = await cliproxy.install((msg, percent) => {
111
+ spinner.text = `${msg} ${percent}%`;
112
+ });
113
+
114
+ if (!installResult.success) {
115
+ spinner.fail(`Installation failed: ${installResult.error}`);
116
+ await prompts.waitForEnter();
117
+ return false;
118
+ }
119
+ spinner.succeed('CLIProxyAPI installed');
113
120
  }
114
121
 
115
- spinner.succeed('CLIProxy is running');
116
- const oauthResult = await getOAuthUrl(provider.id);
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();
127
+
128
+ if (!startResult.success) {
129
+ spinner.fail(`Failed to start: ${startResult.error}`);
130
+ await prompts.waitForEnter();
131
+ return false;
132
+ }
133
+ spinner.succeed('CLIProxyAPI started');
134
+ } else {
135
+ console.log(chalk.green(' ✓ CLIProxyAPI is running'));
136
+ }
117
137
 
118
- if (!oauthResult.success) {
119
- // OAuth not supported - try direct model fetch
120
- console.log(chalk.gray(` OAuth not available for ${provider.name}, checking models...`));
121
- 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);
122
144
 
123
145
  if (!modelsResult.success || modelsResult.models.length === 0) {
124
- console.log(chalk.red(` No models available via CLIProxy for ${provider.name}`));
125
- 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.'));
126
148
  await prompts.waitForEnter();
127
149
  return false;
128
150
  }
@@ -144,42 +166,25 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
144
166
  return true;
145
167
  }
146
168
 
147
- // OAuth flow
148
- console.log(chalk.cyan('\n Open this URL in your browser to authenticate:\n'));
149
- console.log(chalk.yellow(` ${oauthResult.url}\n`));
150
- console.log(chalk.gray(' Waiting for authentication... (Press Enter to cancel)'));
151
-
152
- let authenticated = false;
153
- const maxWait = 120000, pollInterval = 3000;
154
- let waited = 0;
155
-
156
- const pollPromise = (async () => {
157
- while (waited < maxWait) {
158
- await new Promise(r => setTimeout(r, pollInterval));
159
- waited += pollInterval;
160
- if (oauthResult.state) {
161
- const statusResult = await checkOAuthStatus(oauthResult.state);
162
- if (statusResult.success && statusResult.status === 'ok') { authenticated = true; return true; }
163
- if (statusResult.status === 'error') {
164
- console.log(chalk.red(`\n Authentication error: ${statusResult.error || 'Unknown'}`));
165
- return false;
166
- }
167
- }
168
- }
169
- return false;
170
- })();
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);
171
172
 
172
- await Promise.race([pollPromise, prompts.waitForEnter()]);
173
-
174
- if (!authenticated) {
175
- console.log(chalk.yellow(' Authentication cancelled or timed out.'));
173
+ if (!loginResult.success) {
174
+ console.log(chalk.red(` OAuth error: ${loginResult.error}`));
176
175
  await prompts.waitForEnter();
177
176
  return false;
178
177
  }
179
178
 
180
- 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);
181
187
 
182
- const modelsResult = await fetchModelsFromCliProxy();
183
188
  if (modelsResult.success && modelsResult.models.length > 0) {
184
189
  const selectedModel = await selectModelFromList(provider, modelsResult.models, boxWidth);
185
190
  if (selectedModel) {
@@ -189,17 +194,20 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
189
194
  modelName: selectedModel.name
190
195
  });
191
196
  if (saveConfig(config)) {
192
- console.log(chalk.green(`\n ✓ ${provider.name} connected via CLIProxy.`));
197
+ console.log(chalk.green(`\n ✓ ${provider.name} connected via Paid Plan.`));
193
198
  console.log(chalk.cyan(` Model: ${selectedModel.name}`));
194
199
  }
195
200
  }
196
201
  } else {
202
+ // No models but auth might have worked
197
203
  activateProvider(config, provider.id, {
198
204
  connectionType: 'cliproxy',
199
205
  modelId: null,
200
- modelName: 'Default'
206
+ modelName: 'Auto'
201
207
  });
202
- 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
+ }
203
211
  }
204
212
 
205
213
  await prompts.waitForEnter();
@@ -303,6 +311,26 @@ const getActiveProvider = () => {
303
311
  /** Count active AI agents */
304
312
  const getActiveAgentCount = () => getActiveProvider() ? 1 : 0;
305
313
 
314
+ /** Show CLIProxy status */
315
+ const showCliProxyStatus = async () => {
316
+ console.clear();
317
+ console.log(chalk.yellow('\n CLIProxyAPI Status\n'));
318
+
319
+ const installed = cliproxy.isInstalled();
320
+ console.log(chalk.gray(' Installed: ') + (installed ? chalk.green('Yes') : chalk.red('No')));
321
+
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));
328
+ }
329
+
330
+ console.log();
331
+ await prompts.waitForEnter();
332
+ };
333
+
306
334
  /** Main AI Agents menu */
307
335
  const aiAgentsMenu = async () => {
308
336
  let config = loadConfig();
@@ -310,13 +338,20 @@ const aiAgentsMenu = async () => {
310
338
 
311
339
  while (true) {
312
340
  console.clear();
313
- drawProvidersTable(AI_PROVIDERS, config, boxWidth);
341
+ const status = await cliproxy.isRunning();
342
+ const statusText = status.running ? `localhost:${cliproxy.DEFAULT_PORT}` : 'Not running';
343
+ drawProvidersTable(AI_PROVIDERS, config, boxWidth, statusText);
314
344
 
315
- const input = await prompts.textInput(chalk.cyan('Select provider: '));
345
+ const input = await prompts.textInput(chalk.cyan('Select (1-8/S/B): '));
316
346
  const choice = (input || '').toLowerCase().trim();
317
347
 
318
348
  if (choice === 'b' || choice === '') break;
319
349
 
350
+ if (choice === 's') {
351
+ await showCliProxyStatus();
352
+ continue;
353
+ }
354
+
320
355
  const num = parseInt(choice);
321
356
  if (!isNaN(num) && num >= 1 && num <= AI_PROVIDERS.length) {
322
357
  config = await handleProviderConfig(AI_PROVIDERS[num - 1], config);
@@ -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,199 +0,0 @@
1
- /**
2
- * CLIProxy Service
3
- *
4
- * Connects to CLIProxyAPI (localhost:8317) for AI provider access
5
- * via paid plans (Claude Pro, ChatGPT Plus, etc.)
6
- *
7
- * Docs: https://help.router-for.me
8
- */
9
-
10
- const http = require('http');
11
-
12
- // CLIProxy default endpoint
13
- const CLIPROXY_BASE = 'http://localhost:8317';
14
-
15
- /**
16
- * Make HTTP request to CLIProxy
17
- * @param {string} path - API path
18
- * @param {string} method - HTTP method
19
- * @param {Object} headers - Request headers
20
- * @param {number} timeout - Timeout in ms (default 60000 per RULES.md #15)
21
- * @returns {Promise<Object>} { success, data, error }
22
- */
23
- const fetchCliProxy = (path, method = 'GET', headers = {}, timeout = 60000) => {
24
- return new Promise((resolve) => {
25
- const url = new URL(path, CLIPROXY_BASE);
26
- const options = {
27
- hostname: url.hostname,
28
- port: url.port || 8317,
29
- path: url.pathname + url.search,
30
- method,
31
- headers: {
32
- 'Content-Type': 'application/json',
33
- ...headers
34
- },
35
- timeout
36
- };
37
-
38
- const req = http.request(options, (res) => {
39
- let data = '';
40
- res.on('data', chunk => data += chunk);
41
- res.on('end', () => {
42
- try {
43
- if (res.statusCode >= 200 && res.statusCode < 300) {
44
- resolve({ success: true, data: JSON.parse(data) });
45
- } else {
46
- resolve({ success: false, error: `HTTP ${res.statusCode}`, data: null });
47
- }
48
- } catch (error) {
49
- resolve({ success: false, error: 'Invalid JSON response', data: null });
50
- }
51
- });
52
- });
53
-
54
- req.on('error', (error) => {
55
- if (error.code === 'ECONNREFUSED') {
56
- resolve({ success: false, error: 'CLIProxy not running', data: null });
57
- } else {
58
- resolve({ success: false, error: error.message, data: null });
59
- }
60
- });
61
-
62
- req.on('timeout', () => {
63
- req.destroy();
64
- resolve({ success: false, error: 'Request timeout', data: null });
65
- });
66
-
67
- req.end();
68
- });
69
- };
70
-
71
- /**
72
- * Check if CLIProxy is running
73
- * @returns {Promise<Object>} { running, error }
74
- */
75
- const isCliProxyRunning = async () => {
76
- const result = await fetchCliProxy('/v1/models', 'GET', {}, 5000);
77
- return {
78
- running: result.success,
79
- error: result.success ? null : result.error
80
- };
81
- };
82
-
83
- /**
84
- * Fetch available models from CLIProxy
85
- * @returns {Promise<Object>} { success, models, error }
86
- */
87
- const fetchModelsFromCliProxy = async () => {
88
- const result = await fetchCliProxy('/v1/models');
89
-
90
- if (!result.success) {
91
- return { success: false, models: [], error: result.error };
92
- }
93
-
94
- // Parse OpenAI-compatible format: { data: [{ id, ... }] }
95
- const data = result.data;
96
- if (!data || !data.data || !Array.isArray(data.data)) {
97
- return { success: false, models: [], error: 'Invalid response format' };
98
- }
99
-
100
- const models = data.data
101
- .filter(m => m.id)
102
- .map(m => ({
103
- id: m.id,
104
- name: m.id
105
- }));
106
-
107
- if (models.length === 0) {
108
- return { success: false, models: [], error: 'No models available' };
109
- }
110
-
111
- return { success: true, models, error: null };
112
- };
113
-
114
- /**
115
- * Get OAuth URL for a provider
116
- * @param {string} providerId - Provider ID (anthropic, openai, google, etc.)
117
- * @returns {Promise<Object>} { success, url, state, error }
118
- */
119
- const getOAuthUrl = async (providerId) => {
120
- // Map HQX provider IDs to CLIProxy endpoints
121
- const oauthEndpoints = {
122
- anthropic: '/v0/management/anthropic-auth-url',
123
- openai: '/v0/management/codex-auth-url',
124
- google: '/v0/management/gemini-cli-auth-url',
125
- // Others may not have OAuth support in CLIProxy
126
- };
127
-
128
- const endpoint = oauthEndpoints[providerId];
129
- if (!endpoint) {
130
- return { success: false, url: null, state: null, error: 'OAuth not supported for this provider' };
131
- }
132
-
133
- const result = await fetchCliProxy(endpoint);
134
-
135
- if (!result.success) {
136
- return { success: false, url: null, state: null, error: result.error };
137
- }
138
-
139
- const data = result.data;
140
- if (!data || !data.url) {
141
- return { success: false, url: null, state: null, error: 'Invalid OAuth response' };
142
- }
143
-
144
- return {
145
- success: true,
146
- url: data.url,
147
- state: data.state || null,
148
- error: null
149
- };
150
- };
151
-
152
- /**
153
- * Check OAuth status
154
- * @param {string} state - OAuth state from getOAuthUrl
155
- * @returns {Promise<Object>} { success, status, error }
156
- */
157
- const checkOAuthStatus = async (state) => {
158
- const result = await fetchCliProxy(`/v0/management/get-auth-status?state=${encodeURIComponent(state)}`);
159
-
160
- if (!result.success) {
161
- return { success: false, status: null, error: result.error };
162
- }
163
-
164
- const data = result.data;
165
- // status can be: "wait", "ok", "error"
166
- return {
167
- success: true,
168
- status: data.status || 'unknown',
169
- error: data.error || null
170
- };
171
- };
172
-
173
- /**
174
- * Get CLIProxy auth files (connected accounts)
175
- * @returns {Promise<Object>} { success, files, error }
176
- */
177
- const getAuthFiles = async () => {
178
- const result = await fetchCliProxy('/v0/management/auth-files');
179
-
180
- if (!result.success) {
181
- return { success: false, files: [], error: result.error };
182
- }
183
-
184
- return {
185
- success: true,
186
- files: result.data?.files || [],
187
- error: null
188
- };
189
- };
190
-
191
- module.exports = {
192
- CLIPROXY_BASE,
193
- isCliProxyRunning,
194
- fetchModelsFromCliProxy,
195
- getOAuthUrl,
196
- checkOAuthStatus,
197
- getAuthFiles,
198
- fetchCliProxy
199
- };