@jsonstudio/rcc 0.89.1136 → 0.89.1205

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/dist/build-info.js +2 -2
  2. package/dist/cli/commands/clean.d.ts +16 -0
  3. package/dist/cli/commands/clean.js +58 -0
  4. package/dist/cli/commands/clean.js.map +1 -0
  5. package/dist/cli/commands/code.d.ts +55 -0
  6. package/dist/cli/commands/code.js +376 -0
  7. package/dist/cli/commands/code.js.map +1 -0
  8. package/dist/cli/commands/config.d.ts +31 -0
  9. package/dist/cli/commands/config.js +168 -0
  10. package/dist/cli/commands/config.js.map +1 -0
  11. package/dist/cli/commands/env.d.ts +20 -0
  12. package/dist/cli/commands/env.js +73 -0
  13. package/dist/cli/commands/env.js.map +1 -0
  14. package/dist/cli/commands/examples.d.ts +5 -0
  15. package/dist/cli/commands/examples.js +66 -0
  16. package/dist/cli/commands/examples.js.map +1 -0
  17. package/dist/cli/commands/port.d.ts +24 -0
  18. package/dist/cli/commands/port.js +85 -0
  19. package/dist/cli/commands/port.js.map +1 -0
  20. package/dist/cli/commands/restart.d.ts +50 -0
  21. package/dist/cli/commands/restart.js +176 -0
  22. package/dist/cli/commands/restart.js.map +1 -0
  23. package/dist/cli/commands/start.d.ts +68 -0
  24. package/dist/cli/commands/start.js +295 -0
  25. package/dist/cli/commands/start.js.map +1 -0
  26. package/dist/cli/commands/status.d.ts +16 -0
  27. package/dist/cli/commands/status.js +104 -0
  28. package/dist/cli/commands/status.js.map +1 -0
  29. package/dist/cli/commands/stop.d.ts +35 -0
  30. package/dist/cli/commands/stop.js +95 -0
  31. package/dist/cli/commands/stop.js.map +1 -0
  32. package/dist/cli/logger.d.ts +8 -0
  33. package/dist/cli/logger.js +9 -0
  34. package/dist/cli/logger.js.map +1 -0
  35. package/dist/cli/main.d.ts +6 -0
  36. package/dist/cli/main.js +16 -0
  37. package/dist/cli/main.js.map +1 -0
  38. package/dist/cli/program.d.ts +8 -0
  39. package/dist/cli/program.js +16 -0
  40. package/dist/cli/program.js.map +1 -0
  41. package/dist/cli/register/basic-commands.d.ts +30 -0
  42. package/dist/cli/register/basic-commands.js +11 -0
  43. package/dist/cli/register/basic-commands.js.map +1 -0
  44. package/dist/cli/register/code-command.d.ts +3 -0
  45. package/dist/cli/register/code-command.js +5 -0
  46. package/dist/cli/register/code-command.js.map +1 -0
  47. package/dist/cli/register/restart-command.d.ts +3 -0
  48. package/dist/cli/register/restart-command.js +5 -0
  49. package/dist/cli/register/restart-command.js.map +1 -0
  50. package/dist/cli/register/start-command.d.ts +3 -0
  51. package/dist/cli/register/start-command.js +5 -0
  52. package/dist/cli/register/start-command.js.map +1 -0
  53. package/dist/cli/register/status-config-commands.d.ts +16 -0
  54. package/dist/cli/register/status-config-commands.js +7 -0
  55. package/dist/cli/register/status-config-commands.js.map +1 -0
  56. package/dist/cli/register/stop-command.d.ts +3 -0
  57. package/dist/cli/register/stop-command.js +5 -0
  58. package/dist/cli/register/stop-command.js.map +1 -0
  59. package/dist/cli/runtime.d.ts +5 -0
  60. package/dist/cli/runtime.js +11 -0
  61. package/dist/cli/runtime.js.map +1 -0
  62. package/dist/cli/server/port-utils.d.ts +52 -0
  63. package/dist/cli/server/port-utils.js +193 -0
  64. package/dist/cli/server/port-utils.js.map +1 -0
  65. package/dist/cli/spinner.d.ts +10 -0
  66. package/dist/cli/spinner.js +59 -0
  67. package/dist/cli/spinner.js.map +1 -0
  68. package/dist/cli/utils/normalize.d.ts +2 -0
  69. package/dist/cli/utils/normalize.js +22 -0
  70. package/dist/cli/utils/normalize.js.map +1 -0
  71. package/dist/cli/utils/safe-read-json.d.ts +1 -0
  72. package/dist/cli/utils/safe-read-json.js +11 -0
  73. package/dist/cli/utils/safe-read-json.js.map +1 -0
  74. package/dist/cli.js +148 -1775
  75. package/dist/cli.js.map +1 -1
  76. package/dist/client/anthropic/anthropic-protocol-client.js +4 -3
  77. package/dist/client/anthropic/anthropic-protocol-client.js.map +1 -1
  78. package/dist/client/gemini-cli/gemini-cli-protocol-client.d.ts +1 -1
  79. package/dist/client/gemini-cli/gemini-cli-protocol-client.js +10 -3
  80. package/dist/client/gemini-cli/gemini-cli-protocol-client.js.map +1 -1
  81. package/dist/commands/quota-daemon.js +2 -2
  82. package/dist/commands/quota-daemon.js.map +1 -1
  83. package/dist/config/provider-v2-loader.js +4 -2
  84. package/dist/config/provider-v2-loader.js.map +1 -1
  85. package/dist/manager/modules/quota/index.js +21 -4
  86. package/dist/manager/modules/quota/index.js.map +1 -1
  87. package/dist/manager/modules/routing/index.js.map +1 -1
  88. package/dist/manager/storage/file-store.js +1 -1
  89. package/dist/manager/storage/file-store.js.map +1 -1
  90. package/dist/modules/llmswitch/bridge.js +45 -1
  91. package/dist/modules/llmswitch/bridge.js.map +1 -1
  92. package/dist/providers/auth/oauth-lifecycle.js +2 -2
  93. package/dist/providers/auth/oauth-lifecycle.js.map +1 -1
  94. package/dist/providers/core/api/provider-config.d.ts +2 -0
  95. package/dist/providers/core/api/provider-types.d.ts +2 -0
  96. package/dist/providers/core/runtime/base-provider.js +21 -27
  97. package/dist/providers/core/runtime/base-provider.js.map +1 -1
  98. package/dist/providers/core/runtime/gemini-cli-http-provider.d.ts +1 -0
  99. package/dist/providers/core/runtime/gemini-cli-http-provider.js +37 -5
  100. package/dist/providers/core/runtime/gemini-cli-http-provider.js.map +1 -1
  101. package/dist/providers/core/runtime/http-request-executor.js +23 -29
  102. package/dist/providers/core/runtime/http-request-executor.js.map +1 -1
  103. package/dist/providers/core/runtime/http-transport-provider.js +20 -0
  104. package/dist/providers/core/runtime/http-transport-provider.js.map +1 -1
  105. package/dist/providers/core/utils/http-client.d.ts +9 -0
  106. package/dist/providers/core/utils/http-client.js +9 -11
  107. package/dist/providers/core/utils/http-client.js.map +1 -1
  108. package/dist/providers/core/utils/provider-error-reporter.js +2 -6
  109. package/dist/providers/core/utils/provider-error-reporter.js.map +1 -1
  110. package/dist/providers/mock/mock-provider-runtime.js +19 -5
  111. package/dist/providers/mock/mock-provider-runtime.js.map +1 -1
  112. package/dist/server/runtime/http-server/hub-shadow-compare.d.ts +18 -0
  113. package/dist/server/runtime/http-server/hub-shadow-compare.js +180 -0
  114. package/dist/server/runtime/http-server/hub-shadow-compare.js.map +1 -0
  115. package/dist/server/runtime/http-server/index.d.ts +4 -0
  116. package/dist/server/runtime/http-server/index.js +202 -11
  117. package/dist/server/runtime/http-server/index.js.map +1 -1
  118. package/dist/server/runtime/http-server/request-executor.js +9 -1
  119. package/dist/server/runtime/http-server/request-executor.js.map +1 -1
  120. package/dist/server/runtime/http-server/routes.js +8 -4
  121. package/dist/server/runtime/http-server/routes.js.map +1 -1
  122. package/dist/server/runtime/http-server/stats-manager.js +9 -3
  123. package/dist/server/runtime/http-server/stats-manager.js.map +1 -1
  124. package/dist/utils/errorsamples.d.ts +5 -0
  125. package/dist/utils/errorsamples.js +27 -0
  126. package/dist/utils/errorsamples.js.map +1 -0
  127. package/dist/utils/runtime-versions.d.ts +1 -0
  128. package/dist/utils/runtime-versions.js +38 -0
  129. package/dist/utils/runtime-versions.js.map +1 -0
  130. package/package.json +10 -4
  131. package/scripts/anthropic-compare-modes.mjs +40 -3
  132. package/scripts/antigravity-smoke.mjs +180 -0
  133. package/scripts/backfill-apply-patch-exec-errorsamples.mjs +225 -0
  134. package/scripts/compare-codex-rccx.mjs +59 -1
  135. package/scripts/compare-responses-request.mjs +50 -4
  136. package/scripts/lib/errorsamples.mjs +23 -0
  137. package/scripts/mock-provider/run-regressions.mjs +12 -2
  138. package/scripts/policy-violations-report.mjs +257 -0
  139. package/scripts/publish-rcc.mjs +16 -2
  140. package/scripts/tests/unified-hub-responses-enforce-safe.mjs +37 -0
  141. package/scripts/tests/unified-hub-shadow-regression.mjs +55 -0
  142. package/scripts/unified-hub-shadow-compare.mjs +359 -0
  143. package/scripts/verify-e2e-gemini-followup-sample.mjs +269 -0
  144. package/scripts/virtual-router-shadow-v2-real.mjs +71 -1
  145. package/scripts/virtual-router-shadow-v2.mjs +41 -0
package/dist/cli.js CHANGED
@@ -4,71 +4,28 @@
4
4
  * Multi-provider OpenAI proxy server command line interface
5
5
  */
6
6
  import { Command } from 'commander';
7
- import chalk from 'chalk';
8
7
  import fs from 'fs';
9
8
  import path from 'path';
10
9
  import { homedir, tmpdir } from 'os';
11
- import { spawnSync } from 'child_process';
10
+ import { spawn, spawnSync } from 'child_process';
12
11
  import { fileURLToPath } from 'url';
13
12
  import { LOCAL_HOSTS, HTTP_PROTOCOLS, API_PATHS, DEFAULT_CONFIG, API_ENDPOINTS } from './constants/index.js';
14
13
  import { buildInfo } from './build-info.js';
15
14
  import { ensureLocalTokenPortalEnv } from './token-portal/local-token-portal.js';
16
15
  import { parseNetstatListeningPids } from './utils/windows-netstat.js';
17
- async function createSpinner(text) {
18
- const mod = await dynamicImport('ora');
19
- const oraFactory = typeof mod?.default === 'function' ? mod.default : undefined;
20
- if (oraFactory) {
21
- const instance = oraFactory(text);
22
- if (typeof instance.start === 'function') {
23
- instance.start(text);
24
- return instance;
25
- }
26
- }
27
- let currentText = text;
28
- const log = (prefix, msg) => {
29
- const message = msg ?? currentText;
30
- if (!message) {
31
- return;
32
- }
33
- console.log(`${prefix} ${message}`);
34
- };
35
- const stub = {
36
- start(msg) {
37
- if (msg) {
38
- currentText = msg;
39
- }
40
- log('...', msg);
41
- return stub;
42
- },
43
- succeed(msg) { log('✓', msg); },
44
- fail(msg) { log('✗', msg); },
45
- warn(msg) { log('⚠', msg); },
46
- info(msg) { log('ℹ', msg); },
47
- stop() { },
48
- get text() { return currentText; },
49
- set text(value) { currentText = value; }
50
- };
51
- return stub;
52
- }
16
+ import { ensurePortAvailableImpl, findListeningPidsImpl, isServerHealthyQuickImpl, killPidBestEffortImpl } from './cli/server/port-utils.js';
17
+ import { registerBasicCommands } from './cli/register/basic-commands.js';
18
+ import { loadRouteCodexConfig } from './config/routecodex-config-loader.js';
19
+ import { createSpinner } from './cli/spinner.js';
20
+ import { logger } from './cli/logger.js';
21
+ import { registerStatusConfigCommands } from './cli/register/status-config-commands.js';
22
+ import { registerRestartCommand } from './cli/register/restart-command.js';
23
+ import { registerStopCommand } from './cli/register/stop-command.js';
24
+ import { registerStartCommand } from './cli/register/start-command.js';
25
+ import { registerCodeCommand } from './cli/register/code-command.js';
53
26
  const __filename = fileURLToPath(import.meta.url);
54
27
  const __dirname = path.dirname(__filename);
55
- // Simple logger
56
- const logger = {
57
- info: (msg) => console.log(`${chalk.blue('ℹ')} ${msg}`),
58
- success: (msg) => console.log(`${chalk.green('✓')} ${msg}`),
59
- warning: (msg) => console.log(`${chalk.yellow('⚠')} ${msg}`),
60
- error: (msg) => console.log(`${chalk.red('✗')} ${msg}`),
61
- debug: (msg) => console.log(`${chalk.gray('◉')} ${msg}`)
62
- };
63
- // Ensure llmswitch-core is resolvable(dev/worktree 场景下由 pipeline 加载 vendor)
64
- async function dynamicImport(specifier) {
65
- try {
66
- return (await import(specifier));
67
- }
68
- catch {
69
- return undefined;
70
- }
71
- }
28
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
72
29
  async function ensureCoreOrFail() {
73
30
  // 在当前 worktree/dev 场景下:
74
31
  // - llmswitch-core 直接通过 sharedmodule/llmswitch-core/dist 引用;
@@ -130,28 +87,12 @@ async function ensureTokenDaemonAutoStart() {
130
87
  }
131
88
  }
132
89
  function killPidBestEffort(pid, opts) {
133
- if (!Number.isFinite(pid) || pid <= 0) {
134
- return;
135
- }
136
- if (IS_WINDOWS) {
137
- const args = ['/PID', String(pid), '/T'];
138
- if (opts.force) {
139
- args.push('/F');
140
- }
141
- try {
142
- spawnSync('taskkill', args, { stdio: 'ignore', encoding: 'utf8' });
143
- }
144
- catch {
145
- // best-effort
146
- }
147
- return;
148
- }
149
- try {
150
- process.kill(pid, opts.force ? 'SIGKILL' : 'SIGTERM');
151
- }
152
- catch {
153
- // best-effort
154
- }
90
+ return killPidBestEffortImpl({
91
+ pid,
92
+ force: Boolean(opts?.force),
93
+ isWindows: IS_WINDOWS,
94
+ spawnSyncImpl: spawnSync
95
+ });
155
96
  }
156
97
  async function stopTokenDaemonIfRunning() {
157
98
  try {
@@ -253,1417 +194,126 @@ try {
253
194
  }
254
195
  catch { /* optional */ }
255
196
  // Code command - Launch Claude Code interface
256
- program
257
- .command('code')
258
- .description('Launch Claude Code interface with RouteCodex as proxy (args after this command are passed to Claude by default)')
259
- .option('-p, --port <port>', 'RouteCodex server port (overrides config file)')
260
- // Default to IPv4 localhost to avoid environments where localhost resolves to ::1
261
- .option('-h, --host <host>', 'RouteCodex server host', LOCAL_HOSTS.IPV4)
262
- .option('--url <url>', 'RouteCodex base URL (overrides host/port), e.g. https://code.codewhisper.cc')
263
- .option('-c, --config <config>', 'RouteCodex configuration file path')
264
- .option('--apikey <apikey>', 'RouteCodex server apikey (defaults to httpserver.apikey in config when present)')
265
- .option('--claude-path <path>', 'Path to Claude Code executable', 'claude')
266
- .option('--cwd <dir>', 'Working directory for Claude Code (defaults to current shell cwd)')
267
- .option('--model <model>', 'Model to use with Claude Code')
268
- .option('--profile <profile>', 'Claude Code profile to use')
269
- .option('--ensure-server', 'Ensure RouteCodex server is running before launching Claude')
270
- .argument('[extraArgs...]', 'Additional args to pass through to Claude')
271
- .allowUnknownOption(true)
272
- .allowExcessArguments(true)
273
- .action(async (extraArgs = [], options) => {
274
- const extraArgsFromCommander = Array.isArray(extraArgs) ? extraArgs : [];
275
- const spinner = await createSpinner('Preparing Claude Code with RouteCodex...');
276
- try {
277
- const parseServerUrl = (raw) => {
278
- const trimmed = String(raw || '').trim();
279
- if (!trimmed) {
280
- throw new Error('--url is empty');
281
- }
282
- let parsed;
283
- try {
284
- parsed = new URL(trimmed);
285
- }
286
- catch {
287
- parsed = new URL(`http://${trimmed}`);
288
- }
289
- const protocol = parsed.protocol === 'https:' ? 'https' : 'http';
290
- const host = parsed.hostname;
291
- const hasExplicitPort = Boolean(parsed.port && parsed.port.trim());
292
- const port = hasExplicitPort ? Number(parsed.port) : null;
293
- const rawPath = typeof parsed.pathname === 'string' ? parsed.pathname : '';
294
- const basePath = rawPath && rawPath !== '/' ? rawPath.replace(/\/+$/, '') : '';
295
- return { protocol, host, port: Number.isFinite(port) ? port : null, basePath };
296
- };
297
- const readConfigApiKey = (configPath) => {
298
- try {
299
- if (!configPath || !fs.existsSync(configPath)) {
300
- return null;
301
- }
302
- const txt = fs.readFileSync(configPath, 'utf8');
303
- const cfg = JSON.parse(txt);
304
- const direct = cfg?.httpserver?.apikey ?? cfg?.modules?.httpserver?.config?.apikey ?? cfg?.server?.apikey;
305
- const value = typeof direct === 'string' ? direct.trim() : '';
306
- return value ? value : null;
307
- }
308
- catch {
309
- return null;
310
- }
311
- };
312
- // Resolve configuration and determine port
313
- let configPath = options.config;
314
- if (!configPath) {
315
- configPath = path.join(homedir(), '.routecodex', 'config.json');
316
- }
317
- let actualProtocol = 'http';
318
- let actualPort = options.port ? parseInt(options.port, 10) : null;
319
- let actualHost = options.host;
320
- let actualBasePath = '';
321
- if (options.url && String(options.url).trim()) {
322
- const parsed = parseServerUrl(options.url);
323
- actualProtocol = parsed.protocol;
324
- actualHost = parsed.host || actualHost;
325
- actualPort = parsed.port ?? actualPort;
326
- actualBasePath = parsed.basePath;
327
- }
328
- // Determine effective port for code command:
329
- // - dev package (routecodex): env override, otherwise固定 5555,不读取配置端口
330
- // - release package (rcc): 按配置/参数解析端口
331
- if (IS_DEV_PACKAGE) {
332
- if (!actualPort) {
333
- const envPort = Number(process.env.ROUTECODEX_PORT || process.env.RCC_PORT || NaN);
334
- actualPort = Number.isFinite(envPort) && envPort > 0 ? envPort : DEFAULT_DEV_PORT;
335
- logger.info(`Using dev default port ${actualPort} for routecodex code (config ports ignored)`);
336
- }
337
- }
338
- else {
339
- // 非 dev 包:若未显式指定端口,则从配置文件解析
340
- if (!actualPort && fs.existsSync(configPath) && !(options.url && String(options.url).trim())) {
341
- try {
342
- const configContent = fs.readFileSync(configPath, 'utf8');
343
- const config = JSON.parse(configContent);
344
- actualPort = (config?.httpserver?.port ?? config?.server?.port ?? config?.port) || null;
345
- actualHost = (config?.httpserver?.host || config?.server?.host || config?.host || actualHost);
346
- }
347
- catch (error) {
348
- spinner.warn('Failed to read configuration file, using defaults');
349
- }
350
- }
351
- }
352
- // Require explicit port if not resolved (except when --url is used; default ports are implicit).
353
- if (!(options.url && String(options.url).trim()) && !actualPort) {
354
- spinner.fail('Invalid or missing port configuration for RouteCodex server');
355
- logger.error('Please set httpserver.port in your configuration (e.g., ~/.routecodex/config.json) or use --port');
356
- process.exit(1);
357
- }
358
- const configuredApiKey = (typeof options.apikey === 'string' && options.apikey.trim()
359
- ? options.apikey.trim()
360
- : null)
361
- ?? (typeof process.env.ROUTECODEX_APIKEY === 'string' && process.env.ROUTECODEX_APIKEY.trim()
362
- ? process.env.ROUTECODEX_APIKEY.trim()
363
- : null)
364
- ?? (typeof process.env.RCC_APIKEY === 'string' && process.env.RCC_APIKEY.trim()
365
- ? process.env.RCC_APIKEY.trim()
366
- : null)
367
- ?? readConfigApiKey(configPath);
368
- // Check if RouteCodex server needs to be started
369
- if (options.ensureServer) {
370
- spinner.text = 'Checking RouteCodex server status...';
371
- const normalizeConnectHost = (h) => {
372
- const v = String(h || '').toLowerCase();
373
- if (v === '0.0.0.0') {
374
- return LOCAL_HOSTS.IPV4;
375
- }
376
- if (v === '::' || v === '::1' || v === 'localhost') {
377
- return LOCAL_HOSTS.IPV4;
378
- }
379
- return h || LOCAL_HOSTS.IPV4;
380
- };
381
- const connectHost = normalizeConnectHost(actualHost);
382
- const portPart = actualPort ? `:${actualPort}` : '';
383
- const serverUrl = `${actualProtocol}://${connectHost}${portPart}${actualBasePath}`;
384
- try {
385
- const controller = new AbortController();
386
- const timeoutId = setTimeout(() => controller.abort(), 3000);
387
- const headers = configuredApiKey ? { 'x-api-key': configuredApiKey } : undefined;
388
- const response = await fetch(`${serverUrl}/ready`, { signal: controller.signal, method: 'GET', headers });
389
- clearTimeout(timeoutId);
390
- if (!response.ok) {
391
- throw new Error('Server not ready');
392
- }
393
- const j = await response.json().catch(() => ({}));
394
- if (j?.status !== 'ready') {
395
- throw new Error('Server reported not_ready');
396
- }
397
- spinner.succeed('RouteCodex server is ready');
398
- }
399
- catch (error) {
400
- if (options.url && String(options.url).trim()) {
401
- spinner.fail('RouteCodex server is not reachable (ensure-server with --url cannot auto-start)');
402
- logger.error(error instanceof Error ? error.message : String(error));
403
- process.exit(1);
404
- }
405
- spinner.info('RouteCodex server is not running, starting it...');
406
- // Start RouteCodex server in background
407
- const { spawn } = await import('child_process');
408
- const modulesConfigPath = path.resolve(__dirname, '../config/modules.json');
409
- const serverEntry = path.resolve(__dirname, 'index.js');
410
- const serverProcess = spawn(process.execPath, [serverEntry, modulesConfigPath], {
411
- stdio: 'pipe',
412
- env: { ...process.env },
413
- detached: true
414
- });
415
- serverProcess.unref();
416
- // Wait for server to become ready (up to ~30s)
417
- spinner.text = 'Waiting for RouteCodex server to become ready...';
418
- let ready = false;
419
- for (let i = 0; i < 30; i++) {
420
- await sleep(1000);
421
- try {
422
- const headers = configuredApiKey ? { 'x-api-key': configuredApiKey } : undefined;
423
- const res = await fetch(`${serverUrl}/ready`, { method: 'GET', headers });
424
- if (res.ok) {
425
- const jr = await res.json().catch(() => ({}));
426
- if (jr?.status === 'ready') {
427
- ready = true;
428
- break;
429
- }
430
- }
431
- }
432
- catch { /* ignore */ }
433
- }
434
- if (ready) {
435
- spinner.succeed('RouteCodex server is ready');
436
- }
437
- else {
438
- spinner.warn('RouteCodex server may not be fully ready, continuing...');
439
- }
440
- }
441
- }
442
- spinner.text = 'Launching Claude Code...';
443
- // Prepare environment variables for Claude Code
444
- const resolvedBaseHost = String((() => {
445
- const v = String(actualHost || '').toLowerCase();
446
- if (v === '0.0.0.0') {
447
- return LOCAL_HOSTS.IPV4;
448
- }
449
- if (v === '::' || v === '::1' || v === 'localhost') {
450
- return LOCAL_HOSTS.IPV4;
451
- }
452
- return actualHost || LOCAL_HOSTS.IPV4;
453
- })());
454
- const portPart = actualPort ? `:${actualPort}` : '';
455
- const anthropicBase = `${actualProtocol}://${resolvedBaseHost}${portPart}${actualBasePath}`;
456
- const currentCwd = (() => {
457
- try {
458
- const d = options.cwd ? String(options.cwd) : process.cwd();
459
- const resolved = path.resolve(d);
460
- if (fs.existsSync(resolved)) {
461
- return resolved;
462
- }
463
- }
464
- catch {
465
- return process.cwd();
466
- }
467
- return process.cwd();
468
- })();
469
- const claudeEnv = {
470
- ...process.env,
471
- // Normalize working directory context for downstream tools
472
- PWD: currentCwd,
473
- RCC_WORKDIR: currentCwd,
474
- ROUTECODEX_WORKDIR: currentCwd,
475
- CLAUDE_WORKDIR: currentCwd,
476
- // Cover both common env var names used by Anthropic SDK / tools
477
- ANTHROPIC_BASE_URL: anthropicBase,
478
- ANTHROPIC_API_URL: anthropicBase,
479
- ANTHROPIC_API_KEY: configuredApiKey || 'rcc-proxy-key'
480
- };
481
- // Avoid auth conflict: prefer API key routed via RouteCodex; remove shell tokens
482
- try {
483
- delete claudeEnv['ANTHROPIC_AUTH_TOKEN'];
484
- }
485
- catch { /* ignore */ }
486
- try {
487
- delete claudeEnv['ANTHROPIC_TOKEN'];
488
- }
489
- catch { /* ignore */ }
490
- logger.info('Unset ANTHROPIC_AUTH_TOKEN/ANTHROPIC_TOKEN for Claude process to avoid conflicts');
491
- logger.info(`Setting Anthropic base URL to: ${anthropicBase}`);
492
- // Prepare Claude Code command arguments(将 rcc code 后面的原始参数默认透传给 Claude)
493
- const claudeArgs = [];
494
- if (options.model) {
495
- claudeArgs.push('--model', options.model);
496
- }
497
- if (options.profile) {
498
- claudeArgs.push('--profile', options.profile);
499
- }
500
- // 透传用户紧随 `rcc code` 之后的参数(默认行为)
501
- try {
502
- const rawArgv = process.argv.slice(2); // drop node/bin and script
503
- const idxCode = rawArgv.findIndex(a => a === 'code');
504
- const afterCode = idxCode >= 0 ? rawArgv.slice(idxCode + 1) : [];
505
- // 支持显式分隔符 -- :其后的所有参数原样传给 Claude
506
- const sepIndex = afterCode.indexOf('--');
507
- const tail = sepIndex >= 0 ? afterCode.slice(sepIndex + 1) : afterCode;
508
- // 过滤本命令自身已识别的选项,剩余的作为透传参数
509
- const knownOpts = new Set([
510
- '-p',
511
- '--port',
512
- '-h',
513
- '--host',
514
- '--url',
515
- '-c',
516
- '--config',
517
- '--apikey',
518
- '--claude-path',
519
- '--model',
520
- '--profile',
521
- '--ensure-server'
522
- ]);
523
- const requireValue = new Set([
524
- '-p',
525
- '--port',
526
- '-h',
527
- '--host',
528
- '--url',
529
- '-c',
530
- '--config',
531
- '--apikey',
532
- '--claude-path',
533
- '--model',
534
- '--profile'
535
- ]);
536
- const passThrough = [];
537
- for (let i = 0; i < tail.length; i++) {
538
- const tok = tail[i];
539
- if (knownOpts.has(tok)) {
540
- if (requireValue.has(tok)) {
541
- i++;
542
- }
543
- continue;
544
- }
545
- // 若是组合形式 --opt=value 且 opt 为已识别的,跳过
546
- if (tok.startsWith('--')) {
547
- const eq = tok.indexOf('=');
548
- if (eq > 2) {
549
- const optName = tok.slice(0, eq);
550
- if (knownOpts.has(optName)) {
551
- continue;
552
- }
553
- }
554
- }
555
- passThrough.push(tok);
556
- }
557
- // 合并 Commander 捕获到的额外参数(多数为位置参数),与我们手动解析的尾参数,去重保序
558
- const merged = [];
559
- const seen = new Set();
560
- const pushUnique = (arr) => { for (const t of arr) {
561
- if (!seen.has(t)) {
562
- seen.add(t);
563
- merged.push(t);
564
- }
565
- } };
566
- pushUnique(extraArgsFromCommander);
567
- pushUnique(passThrough);
568
- if (merged.length) {
569
- claudeArgs.push(...merged);
570
- }
571
- }
572
- catch {
573
- // ignore passthrough errors
574
- void 0;
575
- }
576
- // Launch Claude Code
577
- const { spawn } = await import('child_process');
578
- const claudeBin = (() => {
579
- try {
580
- const v = String(options?.claudePath || '').trim();
581
- if (v) {
582
- return v;
583
- }
584
- }
585
- catch {
586
- // ignore
587
- }
588
- const envPath = String(process.env.CLAUDE_PATH || '').trim();
589
- return envPath || 'claude';
590
- })();
591
- // Windows: Node spawn does not resolve .cmd shims unless using a shell. Prefer shell for bare commands.
592
- const shouldUseShell = IS_WINDOWS &&
593
- !path.extname(claudeBin) &&
594
- !claudeBin.includes('/') &&
595
- !claudeBin.includes('\\');
596
- const claudeProcess = spawn(claudeBin, claudeArgs, {
597
- stdio: 'inherit',
598
- env: claudeEnv,
599
- cwd: currentCwd,
600
- shell: shouldUseShell
601
- });
602
- spinner.succeed('Claude Code launched with RouteCodex proxy');
603
- // Log normalized IPv4 host to avoid confusion (do not print ::/localhost)
604
- logger.info(`Using RouteCodex server at: http://${resolvedBaseHost}:${actualPort}`);
605
- logger.info(`Claude binary: ${claudeBin}`);
606
- logger.info(`Working directory for Claude: ${currentCwd}`);
607
- logger.info('Press Ctrl+C to exit Claude Code');
608
- // Handle graceful shutdown
609
- const shutdown = async (sig) => {
610
- try {
611
- claudeProcess.kill(sig);
612
- }
613
- catch { /* ignore */ }
614
- try {
615
- process.exit(0);
616
- }
617
- catch { /* ignore */ }
618
- };
619
- process.on('SIGINT', () => { void shutdown('SIGINT'); });
620
- process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
621
- claudeProcess.on('error', (err) => {
622
- try {
623
- logger.error(`Failed to launch Claude Code (${claudeBin}): ${err instanceof Error ? err.message : String(err)}`);
624
- if (IS_WINDOWS && shouldUseShell) {
625
- logger.error('Tip: If Claude is installed via npm, ensure the shim is in PATH (e.g. claude.cmd).');
626
- }
627
- }
628
- catch { /* ignore */ }
629
- process.exit(1);
630
- });
631
- claudeProcess.on('exit', (code, signal) => {
632
- if (signal) {
633
- process.exit(0);
634
- }
635
- else {
636
- process.exit(code ?? 0);
637
- }
638
- });
639
- // Keep process alive
640
- await new Promise(() => {
641
- // Keep process alive until interrupted
642
- return;
643
- });
644
- }
645
- catch (error) {
646
- spinner.fail('Failed to launch Claude Code');
647
- logger.error(error instanceof Error ? error.message : String(error));
648
- process.exit(1);
649
- }
197
+ registerCodeCommand(program, {
198
+ isDevPackage: IS_DEV_PACKAGE,
199
+ isWindows: IS_WINDOWS,
200
+ defaultDevPort: DEFAULT_DEV_PORT,
201
+ nodeBin: process.execPath,
202
+ createSpinner,
203
+ logger,
204
+ env: process.env,
205
+ rawArgv: process.argv.slice(2),
206
+ homedir,
207
+ cwd: () => process.cwd(),
208
+ sleep,
209
+ fetch,
210
+ spawn: (cmd, args, opts) => spawn(cmd, args, opts),
211
+ getModulesConfigPath,
212
+ resolveServerEntryPath: () => path.resolve(__dirname, 'index.js'),
213
+ waitForever: () => new Promise(() => {
214
+ return;
215
+ }),
216
+ onSignal: (sig, cb) => process.on(sig, cb),
217
+ exit: (code) => process.exit(code)
650
218
  });
651
- // Env command - Print env exports for Anthropic proxy
652
- program
653
- .command('env')
654
- .description('Print environment exports for Anthropic tools to use RouteCodex proxy')
655
- .option('-p, --port <port>', 'RouteCodex server port (overrides config file)')
656
- .option('-h, --host <host>', 'RouteCodex server host')
657
- .option('-c, --config <config>', 'RouteCodex configuration file path')
658
- .option('--json', 'Output JSON instead of shell exports')
659
- .action(async (options) => {
660
- try {
661
- const configPath = options.config
662
- ? options.config
663
- : path.join(homedir(), '.routecodex', 'config.json');
664
- let host = options.host;
665
- let port = normalizePort(options.port);
666
- if (IS_DEV_PACKAGE) {
667
- if (!Number.isFinite(port)) {
668
- const envPort = Number(process.env.ROUTECODEX_PORT || process.env.RCC_PORT || NaN);
669
- port = Number.isFinite(envPort) && envPort > 0 ? envPort : DEFAULT_DEV_PORT;
670
- }
671
- }
672
- else {
673
- if (!Number.isFinite(port) && fs.existsSync(configPath)) {
674
- const cfg = safeReadJson(configPath);
675
- port = normalizePort(cfg?.httpserver?.port ?? cfg?.server?.port ?? cfg?.port);
676
- host = typeof cfg?.httpserver?.host === 'string'
677
- ? cfg.httpserver.host
678
- : typeof cfg?.server?.host === 'string'
679
- ? cfg.server.host
680
- : cfg?.host ?? host;
681
- }
682
- }
683
- if (!Number.isFinite(port) || !port || port <= 0) {
684
- throw new Error('Missing port. Set via --port, env or config file');
685
- }
686
- const norm = (h) => {
687
- const v = String(h || '').toLowerCase();
688
- if (v === '0.0.0.0' || v === '::' || v === '::1' || v === 'localhost') {
689
- return LOCAL_HOSTS.IPV4;
690
- }
691
- return h || LOCAL_HOSTS.IPV4;
692
- };
693
- const resolvedHost = norm(host);
694
- const base = `http://${resolvedHost}:${port}`;
695
- if (options.json) {
696
- const out = {
697
- ANTHROPIC_BASE_URL: base,
698
- ANTHROPIC_API_URL: base,
699
- ANTHROPIC_API_KEY: 'rcc-proxy-key',
700
- UNSET: ['ANTHROPIC_TOKEN', 'ANTHROPIC_AUTH_TOKEN']
701
- };
702
- console.log(JSON.stringify(out, null, 2));
703
- }
704
- else {
705
- console.log(`export ANTHROPIC_BASE_URL=${base}`);
706
- console.log(`export ANTHROPIC_API_URL=${base}`);
707
- console.log(`export ANTHROPIC_API_KEY=rcc-proxy-key`);
708
- // Ensure conflicting tokens are not picked up by client tools
709
- console.log('unset ANTHROPIC_TOKEN');
710
- console.log('unset ANTHROPIC_AUTH_TOKEN');
711
- }
712
- }
713
- catch (error) {
714
- logger.error(error instanceof Error ? error.message : String(error));
715
- process.exit(1);
219
+ registerBasicCommands(program, {
220
+ env: {
221
+ isDevPackage: IS_DEV_PACKAGE,
222
+ defaultDevPort: DEFAULT_DEV_PORT,
223
+ log: (line) => console.log(line),
224
+ error: (line) => logger.error(line),
225
+ exit: (code) => process.exit(code)
226
+ },
227
+ clean: { logger },
228
+ examples: { log: (line) => console.log(line) },
229
+ port: {
230
+ defaultPort: DEFAULT_CONFIG.PORT,
231
+ createSpinner,
232
+ findListeningPids,
233
+ killPidBestEffort,
234
+ sleep,
235
+ log: (line) => console.log(line),
236
+ error: (line) => console.error(line),
237
+ exit: (code) => process.exit(code)
716
238
  }
717
239
  });
718
240
  // Start command
719
- program
720
- .command('start')
721
- .description('Start the RouteCodex server')
722
- .option('-c, --config <config>', 'Configuration file path')
723
- .option('-p, --port <port>', 'RouteCodex server port (dev package only; overrides env/config)')
724
- .option('--quota-routing <mode>', 'Quota routing admission control (on|off). off => do not remove providers from pool based on quota')
725
- .option('--log-level <level>', 'Log level (debug, info, warn, error)', 'info')
726
- .option('--codex', 'Use Codex system prompt (tools unchanged)')
727
- .option('--claude', 'Use Claude system prompt (tools unchanged)')
728
- .option('--ua <mode>', 'Upstream User-Agent override mode (e.g., codex)')
729
- .option('--snap', 'Force-enable snapshot capture')
730
- .option('--snap-off', 'Disable snapshot capture')
731
- .option('--verbose-errors', 'Print verbose error stacks in console output')
732
- .option('--quiet-errors', 'Silence detailed error stacks')
733
- .option('--restart', 'Restart if an instance is already running')
734
- .option('--exclusive', 'Always take over the port (kill existing listeners)')
735
- .action(async (options) => {
736
- const spinner = await createSpinner('Starting RouteCodex server...');
737
- try {
738
- // Validate system prompt replacement flags
739
- try {
740
- if (options.codex && options.claude) {
741
- spinner.fail('Flags --codex and --claude are mutually exclusive');
742
- process.exit(1);
743
- }
744
- const promptFlag = options.codex ? 'codex' : (options.claude ? 'claude' : null);
745
- if (promptFlag) {
746
- process.env.ROUTECODEX_SYSTEM_PROMPT_SOURCE = promptFlag;
747
- process.env.ROUTECODEX_SYSTEM_PROMPT_ENABLE = '1';
748
- }
749
- const uaFromFlag = typeof options.ua === 'string' && options.ua.trim()
750
- ? options.ua.trim()
751
- : null;
752
- const uaMode = uaFromFlag || (options.codex ? 'codex' : null);
753
- if (uaMode) {
754
- process.env.ROUTECODEX_UA_MODE = uaMode;
755
- }
756
- if (options.snap && options.snapOff) {
757
- spinner.fail('Flags --snap and --snap-off are mutually exclusive');
758
- process.exit(1);
759
- }
760
- if (options.snap) {
761
- process.env.ROUTECODEX_SNAPSHOT = '1';
762
- }
763
- else if (options.snapOff) {
764
- process.env.ROUTECODEX_SNAPSHOT = '0';
765
- }
766
- if (options.verboseErrors && options.quietErrors) {
767
- spinner.fail('Flags --verbose-errors and --quiet-errors are mutually exclusive');
768
- process.exit(1);
769
- }
770
- if (options.verboseErrors) {
771
- process.env.ROUTECODEX_VERBOSE_ERRORS = '1';
772
- }
773
- else if (options.quietErrors) {
774
- process.env.ROUTECODEX_VERBOSE_ERRORS = '0';
775
- }
776
- }
777
- catch { /* ignore */ }
778
- // Resolve config path
779
- let configPath = options.config;
780
- if (!configPath) {
781
- configPath = path.join(homedir(), '.routecodex', 'config.json');
782
- }
783
- // Ensure provided config path is a file (not a directory)
784
- if (fs.existsSync(configPath)) {
785
- const stats = fs.statSync(configPath);
786
- if (stats.isDirectory()) {
787
- spinner.fail(`Configuration path must be a file, received directory: ${configPath}`);
788
- process.exit(1);
789
- }
790
- }
791
- // Check if config exists; do NOT create defaults
792
- if (!fs.existsSync(configPath)) {
793
- spinner.fail(`Configuration file not found: ${configPath}`);
794
- logger.error('Please create a RouteCodex user config first (e.g., ~/.routecodex/config.json).');
795
- logger.error('Or initialize via CLI:');
796
- logger.error(' rcc config init');
797
- logger.error('Or specify a custom configuration file:');
798
- logger.error(' rcc start --config ./my-config.json');
799
- process.exit(1);
800
- }
801
- // Load and validate configuration (non-dev packages rely on config port)
802
- let config;
803
- try {
804
- const configContent = fs.readFileSync(configPath, 'utf8');
805
- config = JSON.parse(configContent);
806
- }
807
- catch (error) {
808
- spinner.fail('Failed to parse configuration file');
809
- logger.error(`Invalid JSON in configuration file: ${configPath}`);
810
- process.exit(1);
811
- }
812
- const parseBoolish = (value) => {
813
- if (typeof value !== 'string') {
814
- return undefined;
815
- }
816
- const normalized = value.trim().toLowerCase();
817
- if (!normalized) {
818
- return undefined;
819
- }
820
- if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on' || normalized === 'enable' || normalized === 'enabled') {
821
- return true;
822
- }
823
- if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off' || normalized === 'disable' || normalized === 'disabled') {
824
- return false;
825
- }
826
- return undefined;
827
- };
828
- const quotaRoutingOverride = parseBoolish(options.quotaRouting);
829
- if (options.quotaRouting !== undefined && quotaRoutingOverride === undefined) {
830
- spinner.fail('Invalid --quota-routing value. Use on|off');
831
- process.exit(1);
832
- }
833
- if (typeof quotaRoutingOverride === 'boolean') {
834
- const carrier = config && typeof config === 'object' ? config : {};
835
- const httpserver = carrier.httpserver && typeof carrier.httpserver === 'object' && carrier.httpserver !== null
836
- ? carrier.httpserver
837
- : {};
838
- carrier.httpserver = {
839
- ...httpserver,
840
- quotaRoutingEnabled: quotaRoutingOverride
841
- };
842
- config = carrier;
843
- const dir = fs.mkdtempSync(path.join(tmpdir(), 'routecodex-config-'));
844
- const patchedPath = path.join(dir, 'config.json');
845
- fs.writeFileSync(patchedPath, JSON.stringify(config, null, 2), 'utf8');
846
- configPath = patchedPath;
847
- spinner.info(`quota routing override: ${quotaRoutingOverride ? 'on' : 'off'} (temp config)`);
848
- }
849
- // Determine effective port:
850
- // - dev package (`routecodex`): env override, otherwise固定端口 5555(完全忽略配置中的端口)
851
- // - release package (`rcc`): 严格按配置文件端口启动
852
- let resolvedPort;
853
- if (IS_DEV_PACKAGE) {
854
- const flagPort = typeof options.port === 'string' ? Number(options.port) : NaN;
855
- if (!Number.isNaN(flagPort) && flagPort > 0) {
856
- logger.info(`Using port ${flagPort} from --port flag [dev package: routecodex]`);
857
- resolvedPort = flagPort;
858
- }
859
- else {
860
- const envPort = Number(process.env.ROUTECODEX_PORT || process.env.RCC_PORT || NaN);
861
- if (!Number.isNaN(envPort) && envPort > 0) {
862
- logger.info(`Using port ${envPort} from environment (ROUTECODEX_PORT/RCC_PORT) [dev package: routecodex]`);
863
- resolvedPort = envPort;
864
- }
865
- else {
866
- resolvedPort = DEFAULT_DEV_PORT;
867
- logger.info(`Using dev default port ${resolvedPort} (routecodex dev package)`);
868
- }
869
- }
870
- }
871
- else {
872
- const port = (config?.httpserver?.port ?? config?.server?.port ?? config?.port);
873
- if (!port || typeof port !== 'number' || port <= 0) {
874
- spinner.fail('Invalid or missing port configuration');
875
- logger.error('Please set a valid port (httpserver.port or top-level port) in your configuration');
876
- process.exit(1);
877
- }
878
- resolvedPort = port;
879
- }
880
- // Ensure port state aligns with requested behavior (always take over to avoid duplicates)
881
- await ensurePortAvailable(resolvedPort, spinner, { restart: true });
882
- const resolveServerHost = () => {
883
- if (typeof config?.httpserver?.host === 'string' && config.httpserver.host.trim()) {
884
- return config.httpserver.host;
885
- }
886
- if (typeof config?.server?.host === 'string' && config.server.host.trim()) {
887
- return config.server.host;
888
- }
889
- if (typeof config?.host === 'string' && config.host.trim()) {
890
- return config.host;
891
- }
892
- return LOCAL_HOSTS.LOCALHOST;
893
- };
894
- const serverHost = resolveServerHost();
895
- process.env.ROUTECODEX_PORT = String(resolvedPort);
896
- process.env.RCC_PORT = String(resolvedPort);
897
- process.env.ROUTECODEX_HTTP_HOST = serverHost;
898
- process.env.ROUTECODEX_HTTP_PORT = String(resolvedPort);
899
- await ensureLocalTokenPortalEnv();
900
- // Best-effort auto-start of token daemon (can be disabled via env)
901
- await ensureTokenDaemonAutoStart();
902
- // simple-log application removed
903
- // Resolve modules config path
904
- const modulesConfigPath = getModulesConfigPath();
905
- if (!fs.existsSync(modulesConfigPath)) {
906
- spinner.fail(`Modules configuration file not found: ${modulesConfigPath}`);
907
- process.exit(1);
908
- }
909
- // resolvedPort already determined above
910
- // Spawn child Node process to run the server entry; forward signals
911
- const nodeBin = process.execPath; // current Node
912
- const serverEntry = path.resolve(__dirname, 'index.js');
913
- // Use spawn (not spawnSync); import child_process at top already
914
- const { spawn } = await import('child_process');
915
- const env = { ...process.env };
916
- // Ensure server process picks the intended user config path
917
- env.ROUTECODEX_CONFIG = configPath;
918
- env.ROUTECODEX_CONFIG_PATH = configPath;
919
- // 对 dev 包(routecodex),强制通过环境变量传递端口,确保服务器与 CLI 使用同一个 5555/自定义端口
920
- if (IS_DEV_PACKAGE) {
921
- env.ROUTECODEX_PORT = String(resolvedPort);
922
- }
923
- const args = [serverEntry, modulesConfigPath];
924
- const childProc = spawn(nodeBin, args, { stdio: 'inherit', env });
925
- // Persist child pid for out-of-band stop diagnostics
926
- try {
927
- const pidFile = path.join(homedir(), '.routecodex', 'server.cli.pid');
928
- fs.writeFileSync(pidFile, String(childProc.pid ?? ''), 'utf8');
929
- }
930
- catch (error) { /* ignore */ }
931
- const host = serverHost;
932
- spinner.succeed(`RouteCodex server starting on ${host}:${resolvedPort}`);
933
- logger.info(`Configuration loaded from: ${configPath}`);
934
- logger.info(`Server will run on port: ${resolvedPort}`);
935
- logger.info('Press Ctrl+C to stop the server');
936
- // Forward signals to child
937
- const shutdown = async (sig) => {
938
- // 1) Ask server to shutdown over HTTP
939
- try {
940
- await fetch(`${HTTP_PROTOCOLS.HTTP}${LOCAL_HOSTS.IPV4}:${resolvedPort}${API_PATHS.SHUTDOWN}`, { method: 'POST' }).catch(() => { });
941
- }
942
- catch (error) { /* ignore */ }
943
- // 2) Forward signal to child
944
- try {
945
- childProc.kill(sig);
946
- }
947
- catch (error) { /* ignore */ }
948
- if (!IS_WINDOWS) {
949
- try {
950
- if (childProc.pid) {
951
- process.kill(-childProc.pid, sig);
952
- }
953
- }
954
- catch (error) { /* ignore */ }
955
- }
956
- // 3) Wait briefly; if still listening, try SIGTERM/SIGKILL by port
957
- const deadline = Date.now() + 3500;
958
- while (Date.now() < deadline) {
959
- if (findListeningPids(resolvedPort).length === 0) {
960
- break;
961
- }
962
- await sleep(120);
963
- }
964
- const remain = findListeningPids(resolvedPort);
965
- if (remain.length) {
966
- for (const pid of remain) {
967
- killPidBestEffort(pid, { force: false });
968
- }
969
- const killDeadline = Date.now() + 1500;
970
- while (Date.now() < killDeadline) {
971
- if (findListeningPids(resolvedPort).length === 0) {
972
- break;
973
- }
974
- await sleep(100);
975
- }
976
- }
977
- const still = findListeningPids(resolvedPort);
978
- if (still.length) {
979
- for (const pid of still) {
980
- killPidBestEffort(pid, { force: true });
981
- }
982
- }
983
- if (IS_DEV_PACKAGE) {
984
- await stopTokenDaemonIfRunning();
985
- }
986
- // Ensure parent exits even if child fails to exit
987
- try {
988
- process.exit(0);
989
- }
990
- catch { /* ignore */ }
991
- };
992
- process.on('SIGINT', () => { void shutdown('SIGINT'); });
993
- process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
994
- // Fallback keypress handler: capture Ctrl+C / q when some environments swallow SIGINT
995
- const cleanupKeypress = setupKeypress(() => { void shutdown('SIGINT'); });
996
- childProc.on('exit', (code, signal) => {
997
- // Propagate exit code
998
- try {
999
- cleanupKeypress();
1000
- }
1001
- catch { /* ignore */ }
1002
- if (signal) {
1003
- process.exit(0);
1004
- }
1005
- else {
1006
- process.exit(code ?? 0);
1007
- }
1008
- });
1009
- // Do not exit parent; keep process alive to relay signals
1010
- await new Promise(() => {
1011
- // Keep supervisor alive until shutdown completes
1012
- return;
1013
- });
1014
- }
1015
- catch (error) {
1016
- spinner.fail('Failed to start server');
1017
- logger.error(error instanceof Error ? error.message : String(error));
1018
- process.exit(1);
1019
- }
241
+ registerStartCommand(program, {
242
+ isDevPackage: IS_DEV_PACKAGE,
243
+ isWindows: IS_WINDOWS,
244
+ defaultDevPort: DEFAULT_DEV_PORT,
245
+ nodeBin: process.execPath,
246
+ createSpinner,
247
+ logger,
248
+ env: process.env,
249
+ fsImpl: fs,
250
+ pathImpl: path,
251
+ homedir,
252
+ tmpdir,
253
+ sleep,
254
+ ensureLocalTokenPortalEnv,
255
+ ensureTokenDaemonAutoStart,
256
+ stopTokenDaemonIfRunning,
257
+ ensurePortAvailable,
258
+ findListeningPids,
259
+ killPidBestEffort,
260
+ getModulesConfigPath,
261
+ resolveServerEntryPath: () => path.resolve(__dirname, 'index.js'),
262
+ spawn: (cmd, args, opts) => spawn(cmd, args, opts),
263
+ fetch,
264
+ setupKeypress,
265
+ waitForever: () => new Promise(() => {
266
+ return;
267
+ }),
268
+ onSignal: (sig, cb) => process.on(sig, cb),
269
+ exit: (code) => process.exit(code)
1020
270
  });
1021
271
  // Config command
1022
- program
1023
- .command('config')
1024
- .description('Configuration management')
1025
- .argument('<action>', 'Action to perform (show, edit, validate, init)')
1026
- .option('-c, --config <config>', 'Configuration file path')
1027
- .option('-t, --template <template>', 'Configuration template (default, lmstudio, oauth)')
1028
- .option('-f, --force', 'Force overwrite existing configuration')
1029
- .action(async (action, options) => {
1030
- try {
1031
- const configPath = options.config || path.join(homedir(), '.routecodex', 'config.json');
1032
- switch (action) {
1033
- case 'init':
1034
- await initializeConfig(configPath, options.template, options.force);
1035
- break;
1036
- case 'show':
1037
- if (fs.existsSync(configPath)) {
1038
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1039
- console.log(JSON.stringify(config, null, 2));
1040
- }
1041
- else {
1042
- logger.error('Configuration file not found');
1043
- }
1044
- break;
1045
- case 'edit': {
1046
- const editor = process.env.EDITOR || 'nano';
1047
- const { spawn } = await import('child_process');
1048
- spawn(editor, [configPath], { stdio: 'inherit' });
1049
- break;
1050
- }
1051
- case 'validate': {
1052
- if (fs.existsSync(configPath)) {
1053
- try {
1054
- JSON.parse(fs.readFileSync(configPath, 'utf8'));
1055
- logger.success('Configuration is valid');
1056
- }
1057
- catch (error) {
1058
- logger.error(`Configuration is invalid: ${error instanceof Error ? error.message : String(error)}`);
1059
- }
1060
- }
1061
- else {
1062
- logger.error('Configuration file not found');
1063
- }
1064
- break;
1065
- }
1066
- default:
1067
- logger.error('Unknown action. Use: show, edit, validate, init');
1068
- }
1069
- }
1070
- catch (error) {
1071
- logger.error(`Config command failed: ${error instanceof Error ? error.message : String(error)}`);
272
+ registerStatusConfigCommands(program, {
273
+ config: { logger, createSpinner },
274
+ status: {
275
+ logger,
276
+ log: (line) => console.log(line),
277
+ loadConfig: () => loadRouteCodexConfig(),
278
+ fetch
1072
279
  }
1073
280
  });
1074
- // Initialize configuration helper function
1075
- async function initializeConfig(configPath, template, force = false) {
1076
- const spinner = await createSpinner('Initializing configuration...');
1077
- try {
1078
- // Create config directory if it doesn't exist
1079
- const configDir = path.dirname(configPath);
1080
- if (!fs.existsSync(configDir)) {
1081
- fs.mkdirSync(configDir, { recursive: true });
1082
- }
1083
- // Check if config already exists
1084
- if (fs.existsSync(configPath) && !force) {
1085
- spinner.warn('Configuration file already exists');
1086
- spinner.info('Use --force flag to overwrite or choose a different path');
1087
- return;
1088
- }
1089
- // Load template
1090
- let templateConfig;
1091
- switch (template) {
1092
- case 'lmstudio':
1093
- templateConfig = {
1094
- server: {
1095
- port: DEFAULT_CONFIG.PORT,
1096
- host: LOCAL_HOSTS.LOCALHOST
1097
- },
1098
- logging: {
1099
- level: "info"
1100
- },
1101
- providers: {
1102
- lmstudio: {
1103
- type: "lmstudio",
1104
- baseUrl: `${HTTP_PROTOCOLS.HTTP}${LOCAL_HOSTS.LOCALHOST}:${DEFAULT_CONFIG.LM_STUDIO_PORT}`,
1105
- apiKey: "${LM_STUDIO_API_KEY:-}",
1106
- models: {
1107
- "gpt-oss-20b-mlx": {
1108
- maxTokens: 8192,
1109
- temperature: 0.7,
1110
- supportsStreaming: true,
1111
- supportsTools: true
1112
- },
1113
- "qwen2.5-7b-instruct": {
1114
- maxTokens: 32768,
1115
- temperature: 0.7,
1116
- supportsStreaming: true,
1117
- supportsTools: true
1118
- }
1119
- },
1120
- timeout: 60000,
1121
- retryAttempts: 3
1122
- }
1123
- },
1124
- routing: {
1125
- default: "lmstudio",
1126
- models: {
1127
- "gpt-4": "gpt-oss-20b-mlx",
1128
- "gpt-4-turbo": "gpt-oss-20b-mlx",
1129
- "gpt-3.5-turbo": "gpt-oss-20b-mlx",
1130
- "claude-3-haiku": "qwen2.5-7b-instruct",
1131
- "claude-3-sonnet": "gpt-oss-20b-mlx"
1132
- }
1133
- },
1134
- features: {
1135
- tools: {
1136
- enabled: true,
1137
- maxTools: 10
1138
- },
1139
- streaming: {
1140
- enabled: true,
1141
- chunkSize: 1024
1142
- },
1143
- oauth: {
1144
- enabled: true,
1145
- providers: ["qwen", "iflow"]
1146
- }
1147
- }
1148
- };
1149
- break;
1150
- case 'oauth':
1151
- templateConfig = {
1152
- server: {
1153
- port: DEFAULT_CONFIG.PORT,
1154
- host: LOCAL_HOSTS.LOCALHOST
1155
- },
1156
- logging: {
1157
- level: "info"
1158
- },
1159
- providers: {
1160
- qwen: {
1161
- type: "qwen-provider",
1162
- baseUrl: "https://chat.qwen.ai",
1163
- oauth: {
1164
- clientId: "f0304373b74a44d2b584a3fb70ca9e56",
1165
- deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code",
1166
- tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token",
1167
- scopes: ["openid", "profile", "email", "model.completion"]
1168
- },
1169
- models: {
1170
- "qwen3-coder-plus": {
1171
- maxTokens: 32768,
1172
- temperature: 0.7,
1173
- supportsStreaming: true,
1174
- supportsTools: true
1175
- }
1176
- }
1177
- }
1178
- },
1179
- routing: {
1180
- default: "qwen",
1181
- models: {
1182
- "gpt-4": "qwen3-coder-plus",
1183
- "gpt-3.5-turbo": "qwen3-coder-plus"
1184
- }
1185
- },
1186
- features: {
1187
- tools: {
1188
- enabled: true,
1189
- maxTools: 10
1190
- },
1191
- streaming: {
1192
- enabled: true,
1193
- chunkSize: 1024
1194
- },
1195
- oauth: {
1196
- enabled: true,
1197
- autoRefresh: true,
1198
- sharedCredentials: true
1199
- }
1200
- }
1201
- };
1202
- break;
1203
- default:
1204
- templateConfig = {
1205
- server: {
1206
- port: DEFAULT_CONFIG.PORT,
1207
- host: LOCAL_HOSTS.LOCALHOST
1208
- },
1209
- logging: {
1210
- level: "info"
1211
- },
1212
- providers: {
1213
- openai: {
1214
- type: "openai",
1215
- apiKey: "${OPENAI_API_KEY}",
1216
- baseUrl: API_ENDPOINTS.OPENAI,
1217
- models: {
1218
- "gpt-4": {
1219
- maxTokens: 8192,
1220
- temperature: 0.7
1221
- },
1222
- "gpt-3.5-turbo": {
1223
- maxTokens: 4096,
1224
- temperature: 0.7
1225
- }
1226
- }
1227
- }
1228
- },
1229
- routing: {
1230
- default: "openai"
1231
- },
1232
- features: {
1233
- tools: {
1234
- enabled: true,
1235
- maxTools: 10
1236
- },
1237
- streaming: {
1238
- enabled: true,
1239
- chunkSize: 1024
1240
- }
1241
- }
1242
- };
1243
- }
1244
- // Write configuration file
1245
- fs.writeFileSync(configPath, JSON.stringify(templateConfig, null, 2));
1246
- spinner.succeed(`Configuration initialized: ${configPath}`);
1247
- logger.info(`Template used: ${template || 'default'}`);
1248
- logger.info('You can now start the server with: rcc start');
1249
- }
1250
- catch (error) {
1251
- spinner.fail('Failed to initialize configuration');
1252
- logger.error(`Initialization failed: ${error instanceof Error ? error.message : String(error)}`);
1253
- }
1254
- }
1255
281
  // Stop command
1256
- program
1257
- .command('stop')
1258
- .description('Stop the RouteCodex server')
1259
- .action(async () => {
1260
- const spinner = await createSpinner('Stopping RouteCodex server...');
1261
- try {
1262
- let resolvedPort;
1263
- if (IS_DEV_PACKAGE) {
1264
- const envPort = Number(process.env.ROUTECODEX_PORT || process.env.RCC_PORT || NaN);
1265
- if (!Number.isNaN(envPort) && envPort > 0) {
1266
- logger.info(`Using port ${envPort} from environment (ROUTECODEX_PORT/RCC_PORT) [dev package: routecodex]`);
1267
- resolvedPort = envPort;
1268
- }
1269
- else {
1270
- resolvedPort = DEFAULT_DEV_PORT;
1271
- logger.info(`Using dev default port ${resolvedPort} (routecodex dev package)`);
1272
- }
1273
- }
1274
- else {
1275
- // Resolve config path and port
1276
- const configPath = path.join(homedir(), '.routecodex', 'config.json');
1277
- // Check if config exists
1278
- if (!fs.existsSync(configPath)) {
1279
- spinner.fail(`Configuration file not found: ${configPath}`);
1280
- logger.error('Cannot determine server port without configuration file');
1281
- logger.info('Please create a configuration file first:');
1282
- logger.info(' rcc config init');
1283
- process.exit(1);
1284
- }
1285
- // Load configuration to get port
1286
- let config;
1287
- try {
1288
- const configContent = fs.readFileSync(configPath, 'utf8');
1289
- config = JSON.parse(configContent);
1290
- }
1291
- catch (error) {
1292
- spinner.fail('Failed to parse configuration file');
1293
- logger.error(`Invalid JSON in configuration file: ${configPath}`);
1294
- process.exit(1);
1295
- }
1296
- const port = (config?.httpserver?.port ?? config?.server?.port ?? config?.port);
1297
- if (!port || typeof port !== 'number' || port <= 0) {
1298
- spinner.fail('Invalid or missing port configuration');
1299
- logger.error('Configuration file must specify a valid port number');
1300
- process.exit(1);
1301
- }
1302
- resolvedPort = port;
1303
- }
1304
- const pids = findListeningPids(resolvedPort);
1305
- if (!pids.length) {
1306
- spinner.succeed(`No server listening on ${resolvedPort}.`);
1307
- if (IS_DEV_PACKAGE) {
1308
- await stopTokenDaemonIfRunning();
1309
- }
1310
- return;
1311
- }
1312
- for (const pid of pids) {
1313
- killPidBestEffort(pid, { force: false });
1314
- }
1315
- const deadline = Date.now() + 3000;
1316
- while (Date.now() < deadline) {
1317
- if (findListeningPids(resolvedPort).length === 0) {
1318
- spinner.succeed(`Stopped server on ${resolvedPort}.`);
1319
- if (IS_DEV_PACKAGE) {
1320
- await stopTokenDaemonIfRunning();
1321
- }
1322
- return;
1323
- }
1324
- await sleep(100);
1325
- }
1326
- const remain = findListeningPids(resolvedPort);
1327
- if (remain.length) {
1328
- for (const pid of remain) {
1329
- killPidBestEffort(pid, { force: true });
1330
- }
1331
- }
1332
- spinner.succeed(`Force stopped server on ${resolvedPort}.`);
1333
- if (IS_DEV_PACKAGE) {
1334
- await stopTokenDaemonIfRunning();
1335
- }
1336
- }
1337
- catch (e) {
1338
- spinner.fail(`Failed to stop: ${e.message}`);
1339
- process.exit(1);
1340
- }
282
+ registerStopCommand(program, {
283
+ isDevPackage: IS_DEV_PACKAGE,
284
+ defaultDevPort: DEFAULT_DEV_PORT,
285
+ createSpinner,
286
+ logger,
287
+ findListeningPids,
288
+ killPidBestEffort,
289
+ sleep,
290
+ stopTokenDaemonIfRunning,
291
+ env: process.env,
292
+ exit: (code) => process.exit(code)
1341
293
  });
1342
294
  // Restart command (stop + start with same environment)
1343
- program
1344
- .command('restart')
1345
- .description('Restart the RouteCodex server')
1346
- .option('-c, --config <config>', 'Configuration file path')
1347
- .option('--log-level <level>', 'Log level (debug, info, warn, error)', 'info')
1348
- .option('--codex', 'Use Codex system prompt (tools unchanged)')
1349
- .option('--claude', 'Use Claude system prompt (tools unchanged)')
1350
- .action(async (options) => {
1351
- const spinner = await createSpinner('Restarting RouteCodex server...');
1352
- try {
1353
- let resolvedPort;
1354
- let resolvedHost = LOCAL_HOSTS.LOCALHOST;
1355
- if (IS_DEV_PACKAGE) {
1356
- const envPort = Number(process.env.ROUTECODEX_PORT || process.env.RCC_PORT || NaN);
1357
- if (!Number.isNaN(envPort) && envPort > 0) {
1358
- logger.info(`Using port ${envPort} from environment (ROUTECODEX_PORT/RCC_PORT) [dev package: routecodex]`);
1359
- resolvedPort = envPort;
1360
- }
1361
- else {
1362
- resolvedPort = DEFAULT_DEV_PORT;
1363
- logger.info(`Using dev default port ${resolvedPort} (routecodex dev package)`);
1364
- }
1365
- }
1366
- else {
1367
- // Resolve config path
1368
- const configPath = options.config || path.join(homedir(), '.routecodex', 'config.json');
1369
- // Check if config exists
1370
- if (!fs.existsSync(configPath)) {
1371
- spinner.fail(`Configuration file not found: ${configPath}`);
1372
- logger.error('Cannot determine server port without configuration file');
1373
- logger.info('Please create a configuration file first:');
1374
- logger.info(' rcc config init');
1375
- process.exit(1);
1376
- }
1377
- // Load configuration to get port
1378
- let config;
1379
- try {
1380
- const configContent = fs.readFileSync(configPath, 'utf8');
1381
- config = JSON.parse(configContent);
1382
- }
1383
- catch (error) {
1384
- spinner.fail('Failed to parse configuration file');
1385
- logger.error(`Invalid JSON in configuration file: ${configPath}`);
1386
- process.exit(1);
1387
- }
1388
- const port = (config?.httpserver?.port ?? config?.server?.port ?? config?.port);
1389
- if (!port || typeof port !== 'number' || port <= 0) {
1390
- spinner.fail('Invalid or missing port configuration');
1391
- logger.error('Configuration file must specify a valid port number');
1392
- process.exit(1);
1393
- }
1394
- resolvedPort = port;
1395
- resolvedHost =
1396
- (config?.httpserver?.host || config?.server?.host || config?.host || LOCAL_HOSTS.LOCALHOST);
1397
- }
1398
- // Stop current instance (if any)
1399
- const pids = findListeningPids(resolvedPort);
1400
- if (pids.length) {
1401
- for (const pid of pids) {
1402
- killPidBestEffort(pid, { force: false });
1403
- }
1404
- const deadline = Date.now() + 3500;
1405
- while (Date.now() < deadline) {
1406
- if (findListeningPids(resolvedPort).length === 0) {
1407
- break;
1408
- }
1409
- await sleep(120);
1410
- }
1411
- const remain = findListeningPids(resolvedPort);
1412
- for (const pid of remain) {
1413
- killPidBestEffort(pid, { force: true });
1414
- }
1415
- }
1416
- spinner.text = 'Starting RouteCodex server...';
1417
- // Delegate to start command behavior with --restart semantics
1418
- const nodeBin = process.execPath;
1419
- const serverEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'index.js');
1420
- const { spawn } = await import('child_process');
1421
- // Prompt source flags
1422
- if (options.codex && options.claude) {
1423
- spinner.fail('Flags --codex and --claude are mutually exclusive');
1424
- process.exit(1);
1425
- }
1426
- const restartPrompt = options.codex ? 'codex' : (options.claude ? 'claude' : null);
1427
- if (restartPrompt) {
1428
- process.env.ROUTECODEX_SYSTEM_PROMPT_SOURCE = restartPrompt;
1429
- process.env.ROUTECODEX_SYSTEM_PROMPT_ENABLE = '1';
1430
- }
1431
- const modulesConfigPath = getModulesConfigPath();
1432
- const env = { ...process.env };
1433
- const args = [serverEntry, modulesConfigPath];
1434
- const child = spawn(nodeBin, args, { stdio: 'inherit', env });
1435
- try {
1436
- fs.writeFileSync(path.join(homedir(), '.routecodex', 'server.cli.pid'), String(child.pid ?? ''), 'utf8');
1437
- }
1438
- catch (error) { /* ignore */ }
1439
- spinner.succeed(`RouteCodex server restarting on ${resolvedHost}:${resolvedPort}`);
1440
- logger.info(`Server will run on port: ${resolvedPort}`);
1441
- logger.info('Press Ctrl+C to stop the server');
1442
- const shutdown = async (sig) => {
1443
- try {
1444
- await fetch(`${HTTP_PROTOCOLS.HTTP}${LOCAL_HOSTS.IPV4}:${resolvedPort}${API_PATHS.SHUTDOWN}`, { method: 'POST' }).catch(() => { });
1445
- }
1446
- catch (error) { /* ignore */ }
1447
- try {
1448
- child.kill(sig);
1449
- }
1450
- catch (error) { /* ignore */ }
1451
- if (!IS_WINDOWS) {
1452
- try {
1453
- if (child.pid) {
1454
- process.kill(-child.pid, sig);
1455
- }
1456
- }
1457
- catch (error) { /* ignore */ }
1458
- }
1459
- const deadline = Date.now() + 3500;
1460
- while (Date.now() < deadline) {
1461
- if (findListeningPids(resolvedPort).length === 0) {
1462
- break;
1463
- }
1464
- await sleep(120);
1465
- }
1466
- const remain = findListeningPids(resolvedPort);
1467
- for (const pid of remain) {
1468
- killPidBestEffort(pid, { force: false });
1469
- }
1470
- const killDeadline = Date.now() + 1500;
1471
- while (Date.now() < killDeadline) {
1472
- if (findListeningPids(resolvedPort).length === 0) {
1473
- break;
1474
- }
1475
- await sleep(100);
1476
- }
1477
- const still = findListeningPids(resolvedPort);
1478
- for (const pid of still) {
1479
- killPidBestEffort(pid, { force: true });
1480
- }
1481
- // Ensure parent exits in any case
1482
- try {
1483
- process.exit(0);
1484
- }
1485
- catch (error) { /* ignore */ }
1486
- };
1487
- process.on('SIGINT', () => { void shutdown('SIGINT'); });
1488
- process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
1489
- // Fallback keypress handler for restart mode as well
1490
- const cleanupKeypress2 = setupKeypress(() => { void shutdown('SIGINT'); });
1491
- child.on('exit', (code, signal) => {
1492
- try {
1493
- cleanupKeypress2();
1494
- }
1495
- catch { /* ignore */ }
1496
- if (signal) {
1497
- process.exit(0);
1498
- }
1499
- else {
1500
- process.exit(code ?? 0);
1501
- }
1502
- });
1503
- await new Promise(() => {
1504
- // Keep CLI alive (never resolves)
1505
- return;
1506
- });
1507
- }
1508
- catch (e) {
1509
- spinner.fail(`Failed to restart: ${e.message}`);
1510
- process.exit(1);
1511
- }
1512
- });
1513
- // Status command
1514
- program
1515
- .command('status')
1516
- .description('Show server status')
1517
- .option('-j, --json', 'Output in JSON format')
1518
- .action(async (options) => {
1519
- try {
1520
- // Resolve config path and get configuration
1521
- const configPath = path.join(homedir(), '.routecodex', 'config.json');
1522
- // Check if config exists
1523
- if (!fs.existsSync(configPath)) {
1524
- logger.error('Configuration file not found');
1525
- logger.info('Please create a configuration file first:');
1526
- logger.info(' rcc config init');
1527
- if (options.json) {
1528
- console.log(JSON.stringify({ error: 'Configuration file not found' }, null, 2));
1529
- }
1530
- return;
1531
- }
1532
- let port;
1533
- let host;
1534
- // Load configuration to get port and host
1535
- try {
1536
- const configContent = fs.readFileSync(configPath, 'utf8');
1537
- const config = JSON.parse(configContent);
1538
- port = config?.port || config?.server?.port;
1539
- host = config?.server?.host || config?.host || LOCAL_HOSTS.LOCALHOST;
1540
- if (!port || typeof port !== 'number' || port <= 0) {
1541
- const errorMsg = 'Invalid or missing port configuration in configuration file';
1542
- logger.error(errorMsg);
1543
- if (options.json) {
1544
- console.log(JSON.stringify({ error: errorMsg }, null, 2));
1545
- }
1546
- return;
1547
- }
1548
- }
1549
- catch (error) {
1550
- const errorMsg = `Failed to parse configuration file: ${configPath}`;
1551
- logger.error(errorMsg);
1552
- if (options.json) {
1553
- console.log(JSON.stringify({ error: errorMsg }, null, 2));
1554
- }
1555
- return;
1556
- }
1557
- // Check if server is running by trying to connect (HTTP)
1558
- const { get } = await import('http');
1559
- const checkServer = (port, host) => {
1560
- return new Promise((resolve) => {
1561
- const req = get({
1562
- hostname: host,
1563
- port: port,
1564
- path: '/health',
1565
- method: 'GET',
1566
- timeout: 5000
1567
- }, (res) => {
1568
- let data = '';
1569
- res.on('data', chunk => data += chunk);
1570
- res.on('end', () => {
1571
- try {
1572
- const health = JSON.parse(data);
1573
- // Ensure required fields in case health payload differs
1574
- resolve({
1575
- status: health?.status || 'unknown',
1576
- port,
1577
- host
1578
- });
1579
- }
1580
- catch {
1581
- resolve({ status: 'unknown', port, host });
1582
- }
1583
- });
1584
- });
1585
- req.on('error', () => {
1586
- resolve({ status: 'stopped', port, host });
1587
- });
1588
- req.on('timeout', () => {
1589
- req.destroy();
1590
- resolve({ status: 'timeout', port, host });
1591
- });
1592
- req.end();
1593
- });
1594
- };
1595
- const status = await checkServer(port, host);
1596
- if (options.json) {
1597
- console.log(JSON.stringify(status, null, 2));
1598
- }
1599
- else {
1600
- switch (status.status) {
1601
- case 'running':
1602
- logger.success(`Server is running on ${host}:${port}`);
1603
- break;
1604
- case 'stopped':
1605
- logger.error('Server is not running');
1606
- break;
1607
- case 'error':
1608
- logger.error('Server is in error state');
1609
- break;
1610
- default:
1611
- logger.warning('Server status unknown');
1612
- }
1613
- }
1614
- }
1615
- catch (error) {
1616
- logger.error(`Status check failed: ${error instanceof Error ? error.message : String(error)}`);
1617
- }
1618
- });
1619
- // Clean command: purge local capture and debug data for fresh runs
1620
- program
1621
- .command('clean')
1622
- .description('Clean captured data and debug logs')
1623
- .option('-y, --yes', 'Confirm deletion without prompt')
1624
- .option('--what <targets>', 'Targets to clean: captures,logs,all', 'all')
1625
- .action(async (options) => {
1626
- const confirm = Boolean(options.yes);
1627
- const what = String(options.what || 'all');
1628
- if (!confirm) {
1629
- logger.warning("Add --yes to confirm deletion.");
1630
- logger.info("Example: rcc clean --yes --what all");
295
+ registerRestartCommand(program, {
296
+ isDevPackage: IS_DEV_PACKAGE,
297
+ isWindows: IS_WINDOWS,
298
+ defaultDevPort: DEFAULT_DEV_PORT,
299
+ createSpinner,
300
+ logger,
301
+ findListeningPids,
302
+ killPidBestEffort,
303
+ sleep,
304
+ getModulesConfigPath,
305
+ resolveServerEntryPath: () => path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'index.js'),
306
+ nodeBin: process.execPath,
307
+ spawn: (cmd, args, opts) => spawn(cmd, args, opts),
308
+ fetch,
309
+ setupKeypress,
310
+ waitForever: () => new Promise(() => {
1631
311
  return;
1632
- }
1633
- const home = homedir();
1634
- const targets = [];
1635
- if (what === 'captures' || what === 'all') {
1636
- targets.push({ path: path.join(home, '.routecodex', 'codex-samples'), label: 'captures' });
1637
- }
1638
- if (what === 'logs' || what === 'all') {
1639
- targets.push({ path: path.join(process.cwd(), 'debug-logs'), label: 'debug-logs' });
1640
- targets.push({ path: path.join(home, '.routecodex', 'logs'), label: 'user-logs' });
1641
- }
1642
- let removedAny = false;
1643
- for (const t of targets) {
1644
- try {
1645
- if (fs.existsSync(t.path)) {
1646
- const entries = fs.readdirSync(t.path);
1647
- for (const name of entries) {
1648
- const p = path.join(t.path, name);
1649
- try {
1650
- // Recursively remove files/folders
1651
- fs.rmSync(p, { recursive: true, force: true });
1652
- removedAny = true;
1653
- }
1654
- catch (e) {
1655
- logger.warning(`Failed to remove ${p}: ${e.message}`);
1656
- }
1657
- }
1658
- logger.success(`Cleared ${t.label} at ${t.path}`);
1659
- }
1660
- }
1661
- catch (e) {
1662
- logger.warning(`Unable to access ${t.label} at ${t.path}: ${e.message}`);
1663
- }
1664
- }
1665
- if (!removedAny) {
1666
- logger.info('Nothing to clean.');
312
+ }),
313
+ env: process.env,
314
+ exit: (code) => process.exit(code),
315
+ onSignal: (sig, cb) => {
316
+ process.on(sig, cb);
1667
317
  }
1668
318
  });
1669
319
  // Import commands at top level
@@ -1673,215 +323,30 @@ program
1673
323
  // dry-run commands removed
1674
324
  // offline-log command disabled
1675
325
  // simple-log command removed
1676
- // Examples command
1677
- program
1678
- .command('examples')
1679
- .description('Show usage examples')
1680
- .action(() => {
1681
- console.log(chalk.cyan('RouteCodex Usage Examples'));
1682
- console.log('='.repeat(40));
1683
- console.log('');
1684
- console.log(chalk.yellow('1. Initialize Configuration:'));
1685
- console.log(' # Create default configuration');
1686
- console.log(' rcc config init');
1687
- console.log('');
1688
- console.log(' # Create LMStudio configuration');
1689
- console.log(' rcc config init --template lmstudio');
1690
- console.log('');
1691
- console.log(' # Create OAuth configuration');
1692
- console.log(' rcc config init --template oauth');
1693
- console.log('');
1694
- console.log(chalk.yellow('2. Start Server:'));
1695
- console.log(' # Start with default config');
1696
- console.log(' rcc start');
1697
- console.log('');
1698
- console.log(' # Start with custom config');
1699
- console.log(' rcc start --config ./config/lmstudio-config.json');
1700
- console.log('');
1701
- console.log(' # Note: Port must be specified in configuration file');
1702
- console.log(' # Server will not start without valid port configuration');
1703
- console.log('');
1704
- console.log(chalk.yellow('3. Launch Claude Code:'));
1705
- console.log(' # Launch Claude Code with automatic server start');
1706
- console.log(' rcc code --ensure-server');
1707
- console.log('');
1708
- console.log(' # Launch Claude Code with specific model');
1709
- console.log(' rcc code --model claude-3-haiku');
1710
- console.log('');
1711
- console.log(' # Launch Claude Code with custom profile');
1712
- console.log(' rcc code --profile my-profile');
1713
- console.log('');
1714
- console.log(chalk.yellow('4. Configuration Management:'));
1715
- console.log(' # Show current configuration');
1716
- console.log(' rcc config show');
1717
- console.log('');
1718
- console.log(' # Edit configuration');
1719
- console.log(' rcc config edit');
1720
- console.log('');
1721
- console.log(' # Validate configuration');
1722
- console.log(' rcc config validate');
1723
- console.log('');
1724
- // Dry-Run examples removed
1725
- console.log(chalk.yellow('6. Environment Variables:'));
1726
- console.log(' # Set LM Studio API Key');
1727
- console.log(' export LM_STUDIO_API_KEY="your-api-key"');
1728
- console.log('');
1729
- console.log(' # Set OpenAI API Key');
1730
- console.log(' export OPENAI_API_KEY="your-api-key"');
1731
- console.log('');
1732
- console.log(chalk.yellow('7. Testing:'));
1733
- console.log(' # Test with curl');
1734
- console.log(' curl -X POST http://localhost:5506/v1/chat/completions \\');
1735
- console.log(' -H "Content-Type: application/json" \\');
1736
- console.log(' -H "Authorization: Bearer test-key" \\');
1737
- console.log(' -d \'{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello!"}]}\'');
1738
- console.log('');
1739
- });
1740
326
  async function ensurePortAvailable(port, parentSpinner, opts = {}) {
1741
- if (!port || Number.isNaN(port)) {
1742
- return;
1743
- }
1744
- // Best-effort HTTP shutdown on common loopback hosts to cover IPv4/IPv6
1745
- try {
1746
- const candidates = [LOCAL_HOSTS.IPV4, LOCAL_HOSTS.LOCALHOST];
1747
- for (const h of candidates) {
1748
- try {
1749
- const controller = new AbortController();
1750
- const t = setTimeout(() => { try {
1751
- controller.abort();
1752
- }
1753
- catch (error) { /* ignore */ } }, 700);
1754
- await fetch(`http://${h}:${port}/shutdown`, { method: 'POST', signal: controller.signal }).catch(() => { });
1755
- clearTimeout(t);
1756
- }
1757
- catch (error) { /* ignore */ }
1758
- }
1759
- await sleep(300);
1760
- }
1761
- catch { /* ignore */ }
1762
- const initialPids = findListeningPids(port);
1763
- if (initialPids.length === 0) {
1764
- return;
1765
- }
1766
- // If a healthy server is already running and no restart requested, report and exit gracefully
1767
- const healthy = await isServerHealthyQuick(port);
1768
- if (healthy && !opts.restart) {
1769
- parentSpinner.stop();
1770
- logger.success(`RouteCodex is already running on port ${port}.`);
1771
- logger.info(`Use 'rcc stop' or 'rcc start --restart' to restart.`);
1772
- process.exit(0);
1773
- }
1774
- parentSpinner.stop();
1775
- logger.warning(`Port ${port} is in use by PID(s): ${initialPids.join(', ')}`);
1776
- const stopSpinner = await createSpinner(`Port ${port} is in use on 0.0.0.0. Attempting graceful stop...`);
1777
- const gracefulTimeout = Number(process.env.ROUTECODEX_STOP_TIMEOUT_MS ?? 5000);
1778
- const killTimeout = Number(process.env.ROUTECODEX_KILL_TIMEOUT_MS ?? 3000);
1779
- const pollInterval = 150;
1780
- for (const pid of initialPids) {
1781
- try {
1782
- killPidBestEffort(pid, { force: false });
1783
- }
1784
- catch (error) {
1785
- stopSpinner.warn(`Failed to send SIGTERM to PID ${pid}: ${error.message}`);
1786
- }
1787
- }
1788
- const gracefulDeadline = Date.now() + gracefulTimeout;
1789
- while (Date.now() < gracefulDeadline) {
1790
- if (findListeningPids(port).length === 0) {
1791
- stopSpinner.succeed(`Port ${port} freed after graceful stop.`);
1792
- logger.success(`Port ${port} freed after graceful stop.`);
1793
- parentSpinner.start('Starting RouteCodex server...');
1794
- return;
1795
- }
1796
- await sleep(pollInterval);
1797
- }
1798
- let remaining = findListeningPids(port);
1799
- if (remaining.length) {
1800
- stopSpinner.warn(`Graceful stop timed out, sending SIGKILL to PID(s): ${remaining.join(', ')}`);
1801
- logger.warning(`Graceful stop timed out. Forcing SIGKILL to PID(s): ${remaining.join(', ')}`);
1802
- for (const pid of remaining) {
1803
- try {
1804
- killPidBestEffort(pid, { force: true });
1805
- }
1806
- catch (error) {
1807
- const message = error.message;
1808
- stopSpinner.warn(`Failed to send SIGKILL to PID ${pid}: ${message}`);
1809
- logger.error(`Failed to SIGKILL PID ${pid}: ${message}`);
1810
- }
1811
- }
1812
- const killDeadline = Date.now() + killTimeout;
1813
- while (Date.now() < killDeadline) {
1814
- if (findListeningPids(port).length === 0) {
1815
- stopSpinner.succeed(`Port ${port} freed after SIGKILL.`);
1816
- logger.success(`Port ${port} freed after SIGKILL.`);
1817
- parentSpinner.start('Starting RouteCodex server...');
1818
- return;
1819
- }
1820
- await sleep(pollInterval);
1821
- }
1822
- }
1823
- remaining = findListeningPids(port);
1824
- if (remaining.length) {
1825
- stopSpinner.fail(`Failed to free port ${port}. Still held by PID(s): ${remaining.join(', ')}`);
1826
- logger.error(`Failed to free port ${port}. Still held by PID(s): ${remaining.join(', ')}`);
1827
- throw new Error(`Failed to free port ${port}`);
1828
- }
1829
- stopSpinner.succeed(`Port ${port} freed.`);
1830
- logger.success(`Port ${port} freed.`);
1831
- parentSpinner.start('Starting RouteCodex server...');
327
+ return ensurePortAvailableImpl({
328
+ port,
329
+ parentSpinner,
330
+ opts,
331
+ fetchImpl: fetch,
332
+ sleep,
333
+ env: process.env,
334
+ logger,
335
+ createSpinner,
336
+ findListeningPids,
337
+ killPidBestEffort,
338
+ isServerHealthyQuick,
339
+ exit: (code) => process.exit(code)
340
+ });
1832
341
  }
1833
342
  function findListeningPids(port) {
1834
- try {
1835
- if (IS_WINDOWS) {
1836
- const result = spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' });
1837
- if (result.error) {
1838
- logger.warning(`netstat not available to inspect port usage: ${result.error.message}`);
1839
- return [];
1840
- }
1841
- return parseNetstatListeningPids(result.stdout || '', port);
1842
- }
1843
- // macOS/BSD lsof expects either "-i TCP:port" or "-tiTCP:port" as a single argument.
1844
- // Use the compact form to avoid treating ":port" as a filename.
1845
- const result = spawnSync('lsof', [`-tiTCP:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8' });
1846
- if (result.error) {
1847
- logger.warning(`lsof not available to inspect port usage: ${result.error.message}`);
1848
- return [];
1849
- }
1850
- const stdout = (result.stdout || '').trim();
1851
- if (!stdout) {
1852
- return [];
1853
- }
1854
- return stdout
1855
- .split(/\s+/)
1856
- .map((value) => parseInt(value, 10))
1857
- .filter((pid) => !Number.isNaN(pid));
1858
- }
1859
- catch (error) {
1860
- logger.warning(`Failed to inspect port ${port}: ${error.message}`);
1861
- return [];
1862
- }
1863
- }
1864
- const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
1865
- function normalizePort(value) {
1866
- if (typeof value === 'number' && Number.isFinite(value)) {
1867
- return value;
1868
- }
1869
- if (typeof value === 'string' && value.trim()) {
1870
- const parsed = Number(value);
1871
- if (Number.isFinite(parsed)) {
1872
- return parsed;
1873
- }
1874
- }
1875
- return NaN;
1876
- }
1877
- function safeReadJson(filePath) {
1878
- try {
1879
- const content = fs.readFileSync(filePath, 'utf8');
1880
- return JSON.parse(content);
1881
- }
1882
- catch {
1883
- return null;
1884
- }
343
+ return findListeningPidsImpl({
344
+ port,
345
+ isWindows: IS_WINDOWS,
346
+ spawnSyncImpl: spawnSync,
347
+ logger,
348
+ parseNetstatListeningPids
349
+ });
1885
350
  }
1886
351
  // Fallback keypress setup: capture Ctrl+C and 'q' to trigger shutdown when SIGINT is not delivered
1887
352
  function setupKeypress(onInterrupt) {
@@ -1935,103 +400,11 @@ function setupKeypress(onInterrupt) {
1935
400
  };
1936
401
  }
1937
402
  async function isServerHealthyQuick(port) {
1938
- try {
1939
- const controller = new AbortController();
1940
- const t = setTimeout(() => { try {
1941
- controller.abort();
1942
- }
1943
- catch { /* ignore */ } }, 800);
1944
- const res = await fetch(`${HTTP_PROTOCOLS.HTTP}${LOCAL_HOSTS.IPV4}:${port}${API_PATHS.HEALTH}`, { method: 'GET', signal: controller.signal });
1945
- clearTimeout(t);
1946
- if (!res.ok) {
1947
- return false;
1948
- }
1949
- const data = await res.json().catch(() => null);
1950
- return !!data && (data.status === 'healthy' || data.status === 'ready');
1951
- }
1952
- catch (error) {
1953
- return false;
1954
- }
403
+ return isServerHealthyQuickImpl({ port, fetchImpl: fetch });
1955
404
  }
1956
405
  function getModulesConfigPath() {
1957
406
  return path.resolve(__dirname, '../config/modules.json');
1958
407
  }
1959
- // Port utilities: doctor
1960
- program
1961
- .command('port')
1962
- .description('Port utilities (doctor)')
1963
- .argument('<sub>', 'Subcommand: doctor')
1964
- .argument('[port]', 'Port number (e.g., ${DEFAULT_CONFIG.PORT})')
1965
- .option('--kill', 'Kill all listeners on the port')
1966
- .action(async (sub, portArg, opts) => {
1967
- if ((sub || '').toLowerCase() !== 'doctor') {
1968
- console.error(chalk.red("Unknown subcommand. Use: rcc port doctor [port] [--kill]"));
1969
- process.exit(2);
1970
- }
1971
- const spinner = await createSpinner('Inspecting port...');
1972
- try {
1973
- let port = Number(portArg || 0);
1974
- if (!Number.isFinite(port) || port <= 0) {
1975
- // fallback to user config
1976
- const cfgPath = path.join(homedir(), '.routecodex', 'config.json');
1977
- if (fs.existsSync(cfgPath)) {
1978
- try {
1979
- const raw = fs.readFileSync(cfgPath, 'utf8');
1980
- const cfg = JSON.parse(raw);
1981
- port = (cfg?.httpserver?.port ?? cfg?.server?.port ?? cfg?.port) || port;
1982
- }
1983
- catch { /* ignore */ }
1984
- }
1985
- }
1986
- if (!Number.isFinite(port) || port <= 0) {
1987
- spinner.fail('Missing port. Provide an explicit port or set it in ~/.routecodex/config.json');
1988
- process.exit(1);
1989
- }
1990
- const pids = findListeningPids(port);
1991
- spinner.stop();
1992
- console.log(chalk.cyan(`Port ${port} listeners:`));
1993
- if (!pids.length) {
1994
- console.log(' (none)');
1995
- }
1996
- else {
1997
- for (const pid of pids) {
1998
- let cmd = '';
1999
- try {
2000
- cmd = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' }).stdout.trim();
2001
- }
2002
- catch {
2003
- cmd = '';
2004
- }
2005
- const origin = /node\s+.*routecodex-worktree/.test(cmd) ? 'local-dev' : (/node\s+.*lib\/node_modules\/routecodex/.test(cmd) ? 'global' : 'unknown');
2006
- console.log(` PID ${pid} [${origin}] ${cmd}`);
2007
- }
2008
- }
2009
- if (opts.kill && pids.length) {
2010
- const ksp = await createSpinner(`Killing ${pids.length} listener(s) on ${port}...`);
2011
- for (const pid of pids) {
2012
- try {
2013
- killPidBestEffort(pid, { force: true });
2014
- }
2015
- catch (e) {
2016
- ksp.warn(`Failed to kill ${pid}: ${e.message}`);
2017
- }
2018
- }
2019
- // brief wait
2020
- await sleep(300);
2021
- const remain = findListeningPids(port);
2022
- if (remain.length) {
2023
- ksp.fail(`Some listeners remain: ${remain.join(', ')}`);
2024
- process.exit(1);
2025
- }
2026
- ksp.succeed(`Port ${port} is now free.`);
2027
- }
2028
- }
2029
- catch (e) {
2030
- spinner.fail('Port inspection failed');
2031
- console.error(e instanceof Error ? e.message : String(e));
2032
- process.exit(1);
2033
- }
2034
- });
2035
408
  // Parse command line arguments (must be last)
2036
409
  program.parse();
2037
410
  //# sourceMappingURL=cli.js.map