@jsonstudio/rcc 0.89.1121 → 0.89.1189

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 (194) 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 +149 -1738
  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/gemini-protocol-client.js +5 -0
  79. package/dist/client/gemini/gemini-protocol-client.js.map +1 -1
  80. package/dist/client/gemini-cli/gemini-cli-protocol-client.d.ts +1 -1
  81. package/dist/client/gemini-cli/gemini-cli-protocol-client.js +10 -3
  82. package/dist/client/gemini-cli/gemini-cli-protocol-client.js.map +1 -1
  83. package/dist/commands/provider-update.js +355 -5
  84. package/dist/commands/provider-update.js.map +1 -1
  85. package/dist/commands/quota-daemon.js +2 -2
  86. package/dist/commands/quota-daemon.js.map +1 -1
  87. package/dist/config/provider-v2-loader.js +4 -2
  88. package/dist/config/provider-v2-loader.js.map +1 -1
  89. package/dist/docs/daemon-admin-ui.html +583 -87
  90. package/dist/index.js +32 -1
  91. package/dist/index.js.map +1 -1
  92. package/dist/manager/modules/quota/index.d.ts +19 -1
  93. package/dist/manager/modules/quota/index.js +130 -5
  94. package/dist/manager/modules/quota/index.js.map +1 -1
  95. package/dist/manager/modules/routing/index.js.map +1 -1
  96. package/dist/manager/storage/file-store.js +1 -1
  97. package/dist/manager/storage/file-store.js.map +1 -1
  98. package/dist/manager/types.d.ts +5 -0
  99. package/dist/providers/auth/oauth-lifecycle.js +2 -2
  100. package/dist/providers/auth/oauth-lifecycle.js.map +1 -1
  101. package/dist/providers/core/api/provider-config.d.ts +2 -0
  102. package/dist/providers/core/api/provider-types.d.ts +2 -0
  103. package/dist/providers/core/config/service-profiles.js +1 -1
  104. package/dist/providers/core/config/service-profiles.js.map +1 -1
  105. package/dist/providers/core/runtime/base-provider.js +21 -27
  106. package/dist/providers/core/runtime/base-provider.js.map +1 -1
  107. package/dist/providers/core/runtime/gemini-cli-http-provider.d.ts +1 -0
  108. package/dist/providers/core/runtime/gemini-cli-http-provider.js +37 -6
  109. package/dist/providers/core/runtime/gemini-cli-http-provider.js.map +1 -1
  110. package/dist/providers/core/runtime/http-request-executor.js +23 -29
  111. package/dist/providers/core/runtime/http-request-executor.js.map +1 -1
  112. package/dist/providers/core/runtime/http-transport-provider.js +46 -38
  113. package/dist/providers/core/runtime/http-transport-provider.js.map +1 -1
  114. package/dist/providers/core/utils/http-client.d.ts +9 -0
  115. package/dist/providers/core/utils/http-client.js +9 -11
  116. package/dist/providers/core/utils/http-client.js.map +1 -1
  117. package/dist/providers/core/utils/provider-error-reporter.js +2 -6
  118. package/dist/providers/core/utils/provider-error-reporter.js.map +1 -1
  119. package/dist/providers/mock/mock-provider-runtime.js +19 -5
  120. package/dist/providers/mock/mock-provider-runtime.js.map +1 -1
  121. package/dist/server/handlers/handler-utils.d.ts +1 -1
  122. package/dist/server/handlers/handler-utils.js +4 -4
  123. package/dist/server/handlers/handler-utils.js.map +1 -1
  124. package/dist/server/handlers/responses-handler.js +2 -1
  125. package/dist/server/handlers/responses-handler.js.map +1 -1
  126. package/dist/server/handlers/sse-dispatcher.js +1 -4
  127. package/dist/server/handlers/sse-dispatcher.js.map +1 -1
  128. package/dist/server/runtime/http-server/colored-logger.d.ts +1 -1
  129. package/dist/server/runtime/http-server/colored-logger.js +22 -10
  130. package/dist/server/runtime/http-server/colored-logger.js.map +1 -1
  131. package/dist/server/runtime/http-server/daemon-admin/credentials-handler.js +12 -6
  132. package/dist/server/runtime/http-server/daemon-admin/credentials-handler.js.map +1 -1
  133. package/dist/server/runtime/http-server/daemon-admin/providers-handler.js +116 -98
  134. package/dist/server/runtime/http-server/daemon-admin/providers-handler.js.map +1 -1
  135. package/dist/server/runtime/http-server/daemon-admin/quota-handler.js +108 -15
  136. package/dist/server/runtime/http-server/daemon-admin/quota-handler.js.map +1 -1
  137. package/dist/server/runtime/http-server/daemon-admin/restart-handler.js +2 -1
  138. package/dist/server/runtime/http-server/daemon-admin/restart-handler.js.map +1 -1
  139. package/dist/server/runtime/http-server/daemon-admin/stats-handler.d.ts +3 -0
  140. package/dist/server/runtime/http-server/daemon-admin/stats-handler.js +56 -0
  141. package/dist/server/runtime/http-server/daemon-admin/stats-handler.js.map +1 -0
  142. package/dist/server/runtime/http-server/daemon-admin/status-handler.js +8 -4
  143. package/dist/server/runtime/http-server/daemon-admin/status-handler.js.map +1 -1
  144. package/dist/server/runtime/http-server/daemon-admin-routes.d.ts +9 -0
  145. package/dist/server/runtime/http-server/daemon-admin-routes.js +3 -0
  146. package/dist/server/runtime/http-server/daemon-admin-routes.js.map +1 -1
  147. package/dist/server/runtime/http-server/executor-provider.js +74 -0
  148. package/dist/server/runtime/http-server/executor-provider.js.map +1 -1
  149. package/dist/server/runtime/http-server/index.d.ts +2 -0
  150. package/dist/server/runtime/http-server/index.js +107 -17
  151. package/dist/server/runtime/http-server/index.js.map +1 -1
  152. package/dist/server/runtime/http-server/request-executor.js +18 -11
  153. package/dist/server/runtime/http-server/request-executor.js.map +1 -1
  154. package/dist/server/runtime/http-server/routes.d.ts +5 -0
  155. package/dist/server/runtime/http-server/routes.js +17 -4
  156. package/dist/server/runtime/http-server/routes.js.map +1 -1
  157. package/dist/server/runtime/http-server/stats-manager.d.ts +7 -0
  158. package/dist/server/runtime/http-server/stats-manager.js +31 -6
  159. package/dist/server/runtime/http-server/stats-manager.js.map +1 -1
  160. package/dist/server/runtime/http-server/types.d.ts +5 -0
  161. package/dist/server/utils/http-error-mapper.js +70 -9
  162. package/dist/server/utils/http-error-mapper.js.map +1 -1
  163. package/dist/server/utils/request-id-manager.js +9 -5
  164. package/dist/server/utils/request-id-manager.js.map +1 -1
  165. package/dist/server/utils/sse-request-parser.js +2 -1
  166. package/dist/server/utils/sse-request-parser.js.map +1 -1
  167. package/dist/server/utils/utf8-chunk-buffer.d.ts +15 -30
  168. package/dist/server/utils/utf8-chunk-buffer.js +78 -88
  169. package/dist/server/utils/utf8-chunk-buffer.js.map +1 -1
  170. package/dist/server/utils/warmup-storm-tracker.js +1 -1
  171. package/dist/server/utils/warmup-storm-tracker.js.map +1 -1
  172. package/dist/tools/provider-update/fetch-models.js +8 -5
  173. package/dist/tools/provider-update/fetch-models.js.map +1 -1
  174. package/dist/tools/provider-update/probe-context.d.ts +24 -0
  175. package/dist/tools/provider-update/probe-context.js +199 -0
  176. package/dist/tools/provider-update/probe-context.js.map +1 -0
  177. package/dist/tools/provider-update/types.d.ts +1 -0
  178. package/package.json +10 -4
  179. package/scripts/anthropic-compare-modes.mjs +40 -3
  180. package/scripts/antigravity-smoke.mjs +180 -0
  181. package/scripts/backfill-apply-patch-exec-errorsamples.mjs +225 -0
  182. package/scripts/compare-codex-rccx.mjs +59 -1
  183. package/scripts/compare-responses-request.mjs +50 -4
  184. package/scripts/lib/errorsamples.mjs +23 -0
  185. package/scripts/mock-provider/run-regressions.mjs +12 -2
  186. package/scripts/policy-violations-report.mjs +257 -0
  187. package/scripts/publish-rcc.mjs +16 -2
  188. package/scripts/scan-apply-patch-samples.mjs +148 -7
  189. package/scripts/tests/unified-hub-responses-enforce-safe.mjs +37 -0
  190. package/scripts/tests/unified-hub-shadow-regression.mjs +55 -0
  191. package/scripts/unified-hub-shadow-compare.mjs +359 -0
  192. package/scripts/verify-e2e-gemini-followup-sample.mjs +269 -0
  193. package/scripts/virtual-router-shadow-v2-real.mjs +71 -1
  194. 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
- import { homedir } from 'os';
11
- import { spawnSync } from 'child_process';
9
+ import { homedir, tmpdir } from 'os';
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,1379 +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('--log-level <level>', 'Log level (debug, info, warn, error)', 'info')
725
- .option('--codex', 'Use Codex system prompt (tools unchanged)')
726
- .option('--claude', 'Use Claude system prompt (tools unchanged)')
727
- .option('--ua <mode>', 'Upstream User-Agent override mode (e.g., codex)')
728
- .option('--snap', 'Force-enable snapshot capture')
729
- .option('--snap-off', 'Disable snapshot capture')
730
- .option('--verbose-errors', 'Print verbose error stacks in console output')
731
- .option('--quiet-errors', 'Silence detailed error stacks')
732
- .option('--restart', 'Restart if an instance is already running')
733
- .option('--exclusive', 'Always take over the port (kill existing listeners)')
734
- .action(async (options) => {
735
- const spinner = await createSpinner('Starting RouteCodex server...');
736
- try {
737
- // Validate system prompt replacement flags
738
- try {
739
- if (options.codex && options.claude) {
740
- spinner.fail('Flags --codex and --claude are mutually exclusive');
741
- process.exit(1);
742
- }
743
- const promptFlag = options.codex ? 'codex' : (options.claude ? 'claude' : null);
744
- if (promptFlag) {
745
- process.env.ROUTECODEX_SYSTEM_PROMPT_SOURCE = promptFlag;
746
- process.env.ROUTECODEX_SYSTEM_PROMPT_ENABLE = '1';
747
- }
748
- const uaFromFlag = typeof options.ua === 'string' && options.ua.trim()
749
- ? options.ua.trim()
750
- : null;
751
- const uaMode = uaFromFlag || (options.codex ? 'codex' : null);
752
- if (uaMode) {
753
- process.env.ROUTECODEX_UA_MODE = uaMode;
754
- }
755
- if (options.snap && options.snapOff) {
756
- spinner.fail('Flags --snap and --snap-off are mutually exclusive');
757
- process.exit(1);
758
- }
759
- if (options.snap) {
760
- process.env.ROUTECODEX_SNAPSHOT = '1';
761
- }
762
- else if (options.snapOff) {
763
- process.env.ROUTECODEX_SNAPSHOT = '0';
764
- }
765
- if (options.verboseErrors && options.quietErrors) {
766
- spinner.fail('Flags --verbose-errors and --quiet-errors are mutually exclusive');
767
- process.exit(1);
768
- }
769
- if (options.verboseErrors) {
770
- process.env.ROUTECODEX_VERBOSE_ERRORS = '1';
771
- }
772
- else if (options.quietErrors) {
773
- process.env.ROUTECODEX_VERBOSE_ERRORS = '0';
774
- }
775
- }
776
- catch { /* ignore */ }
777
- // Resolve config path
778
- let configPath = options.config;
779
- if (!configPath) {
780
- configPath = path.join(homedir(), '.routecodex', 'config.json');
781
- }
782
- // Ensure provided config path is a file (not a directory)
783
- if (fs.existsSync(configPath)) {
784
- const stats = fs.statSync(configPath);
785
- if (stats.isDirectory()) {
786
- spinner.fail(`Configuration path must be a file, received directory: ${configPath}`);
787
- process.exit(1);
788
- }
789
- }
790
- // Check if config exists; do NOT create defaults
791
- if (!fs.existsSync(configPath)) {
792
- spinner.fail(`Configuration file not found: ${configPath}`);
793
- logger.error('Please create a RouteCodex user config first (e.g., ~/.routecodex/config.json).');
794
- logger.error('Or initialize via CLI:');
795
- logger.error(' rcc config init');
796
- logger.error('Or specify a custom configuration file:');
797
- logger.error(' rcc start --config ./my-config.json');
798
- process.exit(1);
799
- }
800
- // Load and validate configuration (non-dev packages rely on config port)
801
- let config;
802
- try {
803
- const configContent = fs.readFileSync(configPath, 'utf8');
804
- config = JSON.parse(configContent);
805
- }
806
- catch (error) {
807
- spinner.fail('Failed to parse configuration file');
808
- logger.error(`Invalid JSON in configuration file: ${configPath}`);
809
- process.exit(1);
810
- }
811
- // Determine effective port:
812
- // - dev package (`routecodex`): env override, otherwise固定端口 5555(完全忽略配置中的端口)
813
- // - release package (`rcc`): 严格按配置文件端口启动
814
- let resolvedPort;
815
- if (IS_DEV_PACKAGE) {
816
- const flagPort = typeof options.port === 'string' ? Number(options.port) : NaN;
817
- if (!Number.isNaN(flagPort) && flagPort > 0) {
818
- logger.info(`Using port ${flagPort} from --port flag [dev package: routecodex]`);
819
- resolvedPort = flagPort;
820
- }
821
- else {
822
- const envPort = Number(process.env.ROUTECODEX_PORT || process.env.RCC_PORT || NaN);
823
- if (!Number.isNaN(envPort) && envPort > 0) {
824
- logger.info(`Using port ${envPort} from environment (ROUTECODEX_PORT/RCC_PORT) [dev package: routecodex]`);
825
- resolvedPort = envPort;
826
- }
827
- else {
828
- resolvedPort = DEFAULT_DEV_PORT;
829
- logger.info(`Using dev default port ${resolvedPort} (routecodex dev package)`);
830
- }
831
- }
832
- }
833
- else {
834
- const port = (config?.httpserver?.port ?? config?.server?.port ?? config?.port);
835
- if (!port || typeof port !== 'number' || port <= 0) {
836
- spinner.fail('Invalid or missing port configuration');
837
- logger.error('Please set a valid port (httpserver.port or top-level port) in your configuration');
838
- process.exit(1);
839
- }
840
- resolvedPort = port;
841
- }
842
- // Ensure port state aligns with requested behavior (always take over to avoid duplicates)
843
- await ensurePortAvailable(resolvedPort, spinner, { restart: true });
844
- const resolveServerHost = () => {
845
- if (typeof config?.httpserver?.host === 'string' && config.httpserver.host.trim()) {
846
- return config.httpserver.host;
847
- }
848
- if (typeof config?.server?.host === 'string' && config.server.host.trim()) {
849
- return config.server.host;
850
- }
851
- if (typeof config?.host === 'string' && config.host.trim()) {
852
- return config.host;
853
- }
854
- return LOCAL_HOSTS.LOCALHOST;
855
- };
856
- const serverHost = resolveServerHost();
857
- process.env.ROUTECODEX_PORT = String(resolvedPort);
858
- process.env.RCC_PORT = String(resolvedPort);
859
- process.env.ROUTECODEX_HTTP_HOST = serverHost;
860
- process.env.ROUTECODEX_HTTP_PORT = String(resolvedPort);
861
- await ensureLocalTokenPortalEnv();
862
- // Best-effort auto-start of token daemon (can be disabled via env)
863
- await ensureTokenDaemonAutoStart();
864
- // simple-log application removed
865
- // Resolve modules config path
866
- const modulesConfigPath = getModulesConfigPath();
867
- if (!fs.existsSync(modulesConfigPath)) {
868
- spinner.fail(`Modules configuration file not found: ${modulesConfigPath}`);
869
- process.exit(1);
870
- }
871
- // resolvedPort already determined above
872
- // Spawn child Node process to run the server entry; forward signals
873
- const nodeBin = process.execPath; // current Node
874
- const serverEntry = path.resolve(__dirname, 'index.js');
875
- // Use spawn (not spawnSync); import child_process at top already
876
- const { spawn } = await import('child_process');
877
- const env = { ...process.env };
878
- // Ensure server process picks the intended user config path
879
- env.ROUTECODEX_CONFIG = configPath;
880
- env.ROUTECODEX_CONFIG_PATH = configPath;
881
- // 对 dev 包(routecodex),强制通过环境变量传递端口,确保服务器与 CLI 使用同一个 5555/自定义端口
882
- if (IS_DEV_PACKAGE) {
883
- env.ROUTECODEX_PORT = String(resolvedPort);
884
- }
885
- const args = [serverEntry, modulesConfigPath];
886
- const childProc = spawn(nodeBin, args, { stdio: 'inherit', env });
887
- // Persist child pid for out-of-band stop diagnostics
888
- try {
889
- const pidFile = path.join(homedir(), '.routecodex', 'server.cli.pid');
890
- fs.writeFileSync(pidFile, String(childProc.pid ?? ''), 'utf8');
891
- }
892
- catch (error) { /* ignore */ }
893
- const host = serverHost;
894
- spinner.succeed(`RouteCodex server starting on ${host}:${resolvedPort}`);
895
- logger.info(`Configuration loaded from: ${configPath}`);
896
- logger.info(`Server will run on port: ${resolvedPort}`);
897
- logger.info('Press Ctrl+C to stop the server');
898
- // Forward signals to child
899
- const shutdown = async (sig) => {
900
- // 1) Ask server to shutdown over HTTP
901
- try {
902
- await fetch(`${HTTP_PROTOCOLS.HTTP}${LOCAL_HOSTS.IPV4}:${resolvedPort}${API_PATHS.SHUTDOWN}`, { method: 'POST' }).catch(() => { });
903
- }
904
- catch (error) { /* ignore */ }
905
- // 2) Forward signal to child
906
- try {
907
- childProc.kill(sig);
908
- }
909
- catch (error) { /* ignore */ }
910
- if (!IS_WINDOWS) {
911
- try {
912
- if (childProc.pid) {
913
- process.kill(-childProc.pid, sig);
914
- }
915
- }
916
- catch (error) { /* ignore */ }
917
- }
918
- // 3) Wait briefly; if still listening, try SIGTERM/SIGKILL by port
919
- const deadline = Date.now() + 3500;
920
- while (Date.now() < deadline) {
921
- if (findListeningPids(resolvedPort).length === 0) {
922
- break;
923
- }
924
- await sleep(120);
925
- }
926
- const remain = findListeningPids(resolvedPort);
927
- if (remain.length) {
928
- for (const pid of remain) {
929
- killPidBestEffort(pid, { force: false });
930
- }
931
- const killDeadline = Date.now() + 1500;
932
- while (Date.now() < killDeadline) {
933
- if (findListeningPids(resolvedPort).length === 0) {
934
- break;
935
- }
936
- await sleep(100);
937
- }
938
- }
939
- const still = findListeningPids(resolvedPort);
940
- if (still.length) {
941
- for (const pid of still) {
942
- killPidBestEffort(pid, { force: true });
943
- }
944
- }
945
- if (IS_DEV_PACKAGE) {
946
- await stopTokenDaemonIfRunning();
947
- }
948
- // Ensure parent exits even if child fails to exit
949
- try {
950
- process.exit(0);
951
- }
952
- catch { /* ignore */ }
953
- };
954
- process.on('SIGINT', () => { void shutdown('SIGINT'); });
955
- process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
956
- // Fallback keypress handler: capture Ctrl+C / q when some environments swallow SIGINT
957
- const cleanupKeypress = setupKeypress(() => { void shutdown('SIGINT'); });
958
- childProc.on('exit', (code, signal) => {
959
- // Propagate exit code
960
- try {
961
- cleanupKeypress();
962
- }
963
- catch { /* ignore */ }
964
- if (signal) {
965
- process.exit(0);
966
- }
967
- else {
968
- process.exit(code ?? 0);
969
- }
970
- });
971
- // Do not exit parent; keep process alive to relay signals
972
- await new Promise(() => {
973
- // Keep supervisor alive until shutdown completes
974
- return;
975
- });
976
- }
977
- catch (error) {
978
- spinner.fail('Failed to start server');
979
- logger.error(error instanceof Error ? error.message : String(error));
980
- process.exit(1);
981
- }
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)
982
270
  });
983
271
  // Config command
984
- program
985
- .command('config')
986
- .description('Configuration management')
987
- .argument('<action>', 'Action to perform (show, edit, validate, init)')
988
- .option('-c, --config <config>', 'Configuration file path')
989
- .option('-t, --template <template>', 'Configuration template (default, lmstudio, oauth)')
990
- .option('-f, --force', 'Force overwrite existing configuration')
991
- .action(async (action, options) => {
992
- try {
993
- const configPath = options.config || path.join(homedir(), '.routecodex', 'config.json');
994
- switch (action) {
995
- case 'init':
996
- await initializeConfig(configPath, options.template, options.force);
997
- break;
998
- case 'show':
999
- if (fs.existsSync(configPath)) {
1000
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1001
- console.log(JSON.stringify(config, null, 2));
1002
- }
1003
- else {
1004
- logger.error('Configuration file not found');
1005
- }
1006
- break;
1007
- case 'edit': {
1008
- const editor = process.env.EDITOR || 'nano';
1009
- const { spawn } = await import('child_process');
1010
- spawn(editor, [configPath], { stdio: 'inherit' });
1011
- break;
1012
- }
1013
- case 'validate': {
1014
- if (fs.existsSync(configPath)) {
1015
- try {
1016
- JSON.parse(fs.readFileSync(configPath, 'utf8'));
1017
- logger.success('Configuration is valid');
1018
- }
1019
- catch (error) {
1020
- logger.error(`Configuration is invalid: ${error instanceof Error ? error.message : String(error)}`);
1021
- }
1022
- }
1023
- else {
1024
- logger.error('Configuration file not found');
1025
- }
1026
- break;
1027
- }
1028
- default:
1029
- logger.error('Unknown action. Use: show, edit, validate, init');
1030
- }
1031
- }
1032
- catch (error) {
1033
- 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
1034
279
  }
1035
280
  });
1036
- // Initialize configuration helper function
1037
- async function initializeConfig(configPath, template, force = false) {
1038
- const spinner = await createSpinner('Initializing configuration...');
1039
- try {
1040
- // Create config directory if it doesn't exist
1041
- const configDir = path.dirname(configPath);
1042
- if (!fs.existsSync(configDir)) {
1043
- fs.mkdirSync(configDir, { recursive: true });
1044
- }
1045
- // Check if config already exists
1046
- if (fs.existsSync(configPath) && !force) {
1047
- spinner.warn('Configuration file already exists');
1048
- spinner.info('Use --force flag to overwrite or choose a different path');
1049
- return;
1050
- }
1051
- // Load template
1052
- let templateConfig;
1053
- switch (template) {
1054
- case 'lmstudio':
1055
- templateConfig = {
1056
- server: {
1057
- port: DEFAULT_CONFIG.PORT,
1058
- host: LOCAL_HOSTS.LOCALHOST
1059
- },
1060
- logging: {
1061
- level: "info"
1062
- },
1063
- providers: {
1064
- lmstudio: {
1065
- type: "lmstudio",
1066
- baseUrl: `${HTTP_PROTOCOLS.HTTP}${LOCAL_HOSTS.LOCALHOST}:${DEFAULT_CONFIG.LM_STUDIO_PORT}`,
1067
- apiKey: "${LM_STUDIO_API_KEY:-}",
1068
- models: {
1069
- "gpt-oss-20b-mlx": {
1070
- maxTokens: 8192,
1071
- temperature: 0.7,
1072
- supportsStreaming: true,
1073
- supportsTools: true
1074
- },
1075
- "qwen2.5-7b-instruct": {
1076
- maxTokens: 32768,
1077
- temperature: 0.7,
1078
- supportsStreaming: true,
1079
- supportsTools: true
1080
- }
1081
- },
1082
- timeout: 60000,
1083
- retryAttempts: 3
1084
- }
1085
- },
1086
- routing: {
1087
- default: "lmstudio",
1088
- models: {
1089
- "gpt-4": "gpt-oss-20b-mlx",
1090
- "gpt-4-turbo": "gpt-oss-20b-mlx",
1091
- "gpt-3.5-turbo": "gpt-oss-20b-mlx",
1092
- "claude-3-haiku": "qwen2.5-7b-instruct",
1093
- "claude-3-sonnet": "gpt-oss-20b-mlx"
1094
- }
1095
- },
1096
- features: {
1097
- tools: {
1098
- enabled: true,
1099
- maxTools: 10
1100
- },
1101
- streaming: {
1102
- enabled: true,
1103
- chunkSize: 1024
1104
- },
1105
- oauth: {
1106
- enabled: true,
1107
- providers: ["qwen", "iflow"]
1108
- }
1109
- }
1110
- };
1111
- break;
1112
- case 'oauth':
1113
- templateConfig = {
1114
- server: {
1115
- port: DEFAULT_CONFIG.PORT,
1116
- host: LOCAL_HOSTS.LOCALHOST
1117
- },
1118
- logging: {
1119
- level: "info"
1120
- },
1121
- providers: {
1122
- qwen: {
1123
- type: "qwen-provider",
1124
- baseUrl: "https://chat.qwen.ai",
1125
- oauth: {
1126
- clientId: "f0304373b74a44d2b584a3fb70ca9e56",
1127
- deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code",
1128
- tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token",
1129
- scopes: ["openid", "profile", "email", "model.completion"]
1130
- },
1131
- models: {
1132
- "qwen3-coder-plus": {
1133
- maxTokens: 32768,
1134
- temperature: 0.7,
1135
- supportsStreaming: true,
1136
- supportsTools: true
1137
- }
1138
- }
1139
- }
1140
- },
1141
- routing: {
1142
- default: "qwen",
1143
- models: {
1144
- "gpt-4": "qwen3-coder-plus",
1145
- "gpt-3.5-turbo": "qwen3-coder-plus"
1146
- }
1147
- },
1148
- features: {
1149
- tools: {
1150
- enabled: true,
1151
- maxTools: 10
1152
- },
1153
- streaming: {
1154
- enabled: true,
1155
- chunkSize: 1024
1156
- },
1157
- oauth: {
1158
- enabled: true,
1159
- autoRefresh: true,
1160
- sharedCredentials: true
1161
- }
1162
- }
1163
- };
1164
- break;
1165
- default:
1166
- templateConfig = {
1167
- server: {
1168
- port: DEFAULT_CONFIG.PORT,
1169
- host: LOCAL_HOSTS.LOCALHOST
1170
- },
1171
- logging: {
1172
- level: "info"
1173
- },
1174
- providers: {
1175
- openai: {
1176
- type: "openai",
1177
- apiKey: "${OPENAI_API_KEY}",
1178
- baseUrl: API_ENDPOINTS.OPENAI,
1179
- models: {
1180
- "gpt-4": {
1181
- maxTokens: 8192,
1182
- temperature: 0.7
1183
- },
1184
- "gpt-3.5-turbo": {
1185
- maxTokens: 4096,
1186
- temperature: 0.7
1187
- }
1188
- }
1189
- }
1190
- },
1191
- routing: {
1192
- default: "openai"
1193
- },
1194
- features: {
1195
- tools: {
1196
- enabled: true,
1197
- maxTools: 10
1198
- },
1199
- streaming: {
1200
- enabled: true,
1201
- chunkSize: 1024
1202
- }
1203
- }
1204
- };
1205
- }
1206
- // Write configuration file
1207
- fs.writeFileSync(configPath, JSON.stringify(templateConfig, null, 2));
1208
- spinner.succeed(`Configuration initialized: ${configPath}`);
1209
- logger.info(`Template used: ${template || 'default'}`);
1210
- logger.info('You can now start the server with: rcc start');
1211
- }
1212
- catch (error) {
1213
- spinner.fail('Failed to initialize configuration');
1214
- logger.error(`Initialization failed: ${error instanceof Error ? error.message : String(error)}`);
1215
- }
1216
- }
1217
281
  // Stop command
1218
- program
1219
- .command('stop')
1220
- .description('Stop the RouteCodex server')
1221
- .action(async () => {
1222
- const spinner = await createSpinner('Stopping RouteCodex server...');
1223
- try {
1224
- let resolvedPort;
1225
- if (IS_DEV_PACKAGE) {
1226
- const envPort = Number(process.env.ROUTECODEX_PORT || process.env.RCC_PORT || NaN);
1227
- if (!Number.isNaN(envPort) && envPort > 0) {
1228
- logger.info(`Using port ${envPort} from environment (ROUTECODEX_PORT/RCC_PORT) [dev package: routecodex]`);
1229
- resolvedPort = envPort;
1230
- }
1231
- else {
1232
- resolvedPort = DEFAULT_DEV_PORT;
1233
- logger.info(`Using dev default port ${resolvedPort} (routecodex dev package)`);
1234
- }
1235
- }
1236
- else {
1237
- // Resolve config path and port
1238
- const configPath = path.join(homedir(), '.routecodex', 'config.json');
1239
- // Check if config exists
1240
- if (!fs.existsSync(configPath)) {
1241
- spinner.fail(`Configuration file not found: ${configPath}`);
1242
- logger.error('Cannot determine server port without configuration file');
1243
- logger.info('Please create a configuration file first:');
1244
- logger.info(' rcc config init');
1245
- process.exit(1);
1246
- }
1247
- // Load configuration to get port
1248
- let config;
1249
- try {
1250
- const configContent = fs.readFileSync(configPath, 'utf8');
1251
- config = JSON.parse(configContent);
1252
- }
1253
- catch (error) {
1254
- spinner.fail('Failed to parse configuration file');
1255
- logger.error(`Invalid JSON in configuration file: ${configPath}`);
1256
- process.exit(1);
1257
- }
1258
- const port = (config?.httpserver?.port ?? config?.server?.port ?? config?.port);
1259
- if (!port || typeof port !== 'number' || port <= 0) {
1260
- spinner.fail('Invalid or missing port configuration');
1261
- logger.error('Configuration file must specify a valid port number');
1262
- process.exit(1);
1263
- }
1264
- resolvedPort = port;
1265
- }
1266
- const pids = findListeningPids(resolvedPort);
1267
- if (!pids.length) {
1268
- spinner.succeed(`No server listening on ${resolvedPort}.`);
1269
- if (IS_DEV_PACKAGE) {
1270
- await stopTokenDaemonIfRunning();
1271
- }
1272
- return;
1273
- }
1274
- for (const pid of pids) {
1275
- killPidBestEffort(pid, { force: false });
1276
- }
1277
- const deadline = Date.now() + 3000;
1278
- while (Date.now() < deadline) {
1279
- if (findListeningPids(resolvedPort).length === 0) {
1280
- spinner.succeed(`Stopped server on ${resolvedPort}.`);
1281
- if (IS_DEV_PACKAGE) {
1282
- await stopTokenDaemonIfRunning();
1283
- }
1284
- return;
1285
- }
1286
- await sleep(100);
1287
- }
1288
- const remain = findListeningPids(resolvedPort);
1289
- if (remain.length) {
1290
- for (const pid of remain) {
1291
- killPidBestEffort(pid, { force: true });
1292
- }
1293
- }
1294
- spinner.succeed(`Force stopped server on ${resolvedPort}.`);
1295
- if (IS_DEV_PACKAGE) {
1296
- await stopTokenDaemonIfRunning();
1297
- }
1298
- }
1299
- catch (e) {
1300
- spinner.fail(`Failed to stop: ${e.message}`);
1301
- process.exit(1);
1302
- }
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)
1303
293
  });
1304
294
  // Restart command (stop + start with same environment)
1305
- program
1306
- .command('restart')
1307
- .description('Restart the RouteCodex server')
1308
- .option('-c, --config <config>', 'Configuration file path')
1309
- .option('--log-level <level>', 'Log level (debug, info, warn, error)', 'info')
1310
- .option('--codex', 'Use Codex system prompt (tools unchanged)')
1311
- .option('--claude', 'Use Claude system prompt (tools unchanged)')
1312
- .action(async (options) => {
1313
- const spinner = await createSpinner('Restarting RouteCodex server...');
1314
- try {
1315
- let resolvedPort;
1316
- let resolvedHost = LOCAL_HOSTS.LOCALHOST;
1317
- if (IS_DEV_PACKAGE) {
1318
- const envPort = Number(process.env.ROUTECODEX_PORT || process.env.RCC_PORT || NaN);
1319
- if (!Number.isNaN(envPort) && envPort > 0) {
1320
- logger.info(`Using port ${envPort} from environment (ROUTECODEX_PORT/RCC_PORT) [dev package: routecodex]`);
1321
- resolvedPort = envPort;
1322
- }
1323
- else {
1324
- resolvedPort = DEFAULT_DEV_PORT;
1325
- logger.info(`Using dev default port ${resolvedPort} (routecodex dev package)`);
1326
- }
1327
- }
1328
- else {
1329
- // Resolve config path
1330
- const configPath = options.config || path.join(homedir(), '.routecodex', 'config.json');
1331
- // Check if config exists
1332
- if (!fs.existsSync(configPath)) {
1333
- spinner.fail(`Configuration file not found: ${configPath}`);
1334
- logger.error('Cannot determine server port without configuration file');
1335
- logger.info('Please create a configuration file first:');
1336
- logger.info(' rcc config init');
1337
- process.exit(1);
1338
- }
1339
- // Load configuration to get port
1340
- let config;
1341
- try {
1342
- const configContent = fs.readFileSync(configPath, 'utf8');
1343
- config = JSON.parse(configContent);
1344
- }
1345
- catch (error) {
1346
- spinner.fail('Failed to parse configuration file');
1347
- logger.error(`Invalid JSON in configuration file: ${configPath}`);
1348
- process.exit(1);
1349
- }
1350
- const port = (config?.httpserver?.port ?? config?.server?.port ?? config?.port);
1351
- if (!port || typeof port !== 'number' || port <= 0) {
1352
- spinner.fail('Invalid or missing port configuration');
1353
- logger.error('Configuration file must specify a valid port number');
1354
- process.exit(1);
1355
- }
1356
- resolvedPort = port;
1357
- resolvedHost =
1358
- (config?.httpserver?.host || config?.server?.host || config?.host || LOCAL_HOSTS.LOCALHOST);
1359
- }
1360
- // Stop current instance (if any)
1361
- const pids = findListeningPids(resolvedPort);
1362
- if (pids.length) {
1363
- for (const pid of pids) {
1364
- killPidBestEffort(pid, { force: false });
1365
- }
1366
- const deadline = Date.now() + 3500;
1367
- while (Date.now() < deadline) {
1368
- if (findListeningPids(resolvedPort).length === 0) {
1369
- break;
1370
- }
1371
- await sleep(120);
1372
- }
1373
- const remain = findListeningPids(resolvedPort);
1374
- for (const pid of remain) {
1375
- killPidBestEffort(pid, { force: true });
1376
- }
1377
- }
1378
- spinner.text = 'Starting RouteCodex server...';
1379
- // Delegate to start command behavior with --restart semantics
1380
- const nodeBin = process.execPath;
1381
- const serverEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'index.js');
1382
- const { spawn } = await import('child_process');
1383
- // Prompt source flags
1384
- if (options.codex && options.claude) {
1385
- spinner.fail('Flags --codex and --claude are mutually exclusive');
1386
- process.exit(1);
1387
- }
1388
- const restartPrompt = options.codex ? 'codex' : (options.claude ? 'claude' : null);
1389
- if (restartPrompt) {
1390
- process.env.ROUTECODEX_SYSTEM_PROMPT_SOURCE = restartPrompt;
1391
- process.env.ROUTECODEX_SYSTEM_PROMPT_ENABLE = '1';
1392
- }
1393
- const modulesConfigPath = getModulesConfigPath();
1394
- const env = { ...process.env };
1395
- const args = [serverEntry, modulesConfigPath];
1396
- const child = spawn(nodeBin, args, { stdio: 'inherit', env });
1397
- try {
1398
- fs.writeFileSync(path.join(homedir(), '.routecodex', 'server.cli.pid'), String(child.pid ?? ''), 'utf8');
1399
- }
1400
- catch (error) { /* ignore */ }
1401
- spinner.succeed(`RouteCodex server restarting on ${resolvedHost}:${resolvedPort}`);
1402
- logger.info(`Server will run on port: ${resolvedPort}`);
1403
- logger.info('Press Ctrl+C to stop the server');
1404
- const shutdown = async (sig) => {
1405
- try {
1406
- await fetch(`${HTTP_PROTOCOLS.HTTP}${LOCAL_HOSTS.IPV4}:${resolvedPort}${API_PATHS.SHUTDOWN}`, { method: 'POST' }).catch(() => { });
1407
- }
1408
- catch (error) { /* ignore */ }
1409
- try {
1410
- child.kill(sig);
1411
- }
1412
- catch (error) { /* ignore */ }
1413
- if (!IS_WINDOWS) {
1414
- try {
1415
- if (child.pid) {
1416
- process.kill(-child.pid, sig);
1417
- }
1418
- }
1419
- catch (error) { /* ignore */ }
1420
- }
1421
- const deadline = Date.now() + 3500;
1422
- while (Date.now() < deadline) {
1423
- if (findListeningPids(resolvedPort).length === 0) {
1424
- break;
1425
- }
1426
- await sleep(120);
1427
- }
1428
- const remain = findListeningPids(resolvedPort);
1429
- for (const pid of remain) {
1430
- killPidBestEffort(pid, { force: false });
1431
- }
1432
- const killDeadline = Date.now() + 1500;
1433
- while (Date.now() < killDeadline) {
1434
- if (findListeningPids(resolvedPort).length === 0) {
1435
- break;
1436
- }
1437
- await sleep(100);
1438
- }
1439
- const still = findListeningPids(resolvedPort);
1440
- for (const pid of still) {
1441
- killPidBestEffort(pid, { force: true });
1442
- }
1443
- // Ensure parent exits in any case
1444
- try {
1445
- process.exit(0);
1446
- }
1447
- catch (error) { /* ignore */ }
1448
- };
1449
- process.on('SIGINT', () => { void shutdown('SIGINT'); });
1450
- process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
1451
- // Fallback keypress handler for restart mode as well
1452
- const cleanupKeypress2 = setupKeypress(() => { void shutdown('SIGINT'); });
1453
- child.on('exit', (code, signal) => {
1454
- try {
1455
- cleanupKeypress2();
1456
- }
1457
- catch { /* ignore */ }
1458
- if (signal) {
1459
- process.exit(0);
1460
- }
1461
- else {
1462
- process.exit(code ?? 0);
1463
- }
1464
- });
1465
- await new Promise(() => {
1466
- // Keep CLI alive (never resolves)
1467
- return;
1468
- });
1469
- }
1470
- catch (e) {
1471
- spinner.fail(`Failed to restart: ${e.message}`);
1472
- process.exit(1);
1473
- }
1474
- });
1475
- // Status command
1476
- program
1477
- .command('status')
1478
- .description('Show server status')
1479
- .option('-j, --json', 'Output in JSON format')
1480
- .action(async (options) => {
1481
- try {
1482
- // Resolve config path and get configuration
1483
- const configPath = path.join(homedir(), '.routecodex', 'config.json');
1484
- // Check if config exists
1485
- if (!fs.existsSync(configPath)) {
1486
- logger.error('Configuration file not found');
1487
- logger.info('Please create a configuration file first:');
1488
- logger.info(' rcc config init');
1489
- if (options.json) {
1490
- console.log(JSON.stringify({ error: 'Configuration file not found' }, null, 2));
1491
- }
1492
- return;
1493
- }
1494
- let port;
1495
- let host;
1496
- // Load configuration to get port and host
1497
- try {
1498
- const configContent = fs.readFileSync(configPath, 'utf8');
1499
- const config = JSON.parse(configContent);
1500
- port = config?.port || config?.server?.port;
1501
- host = config?.server?.host || config?.host || LOCAL_HOSTS.LOCALHOST;
1502
- if (!port || typeof port !== 'number' || port <= 0) {
1503
- const errorMsg = 'Invalid or missing port configuration in configuration file';
1504
- logger.error(errorMsg);
1505
- if (options.json) {
1506
- console.log(JSON.stringify({ error: errorMsg }, null, 2));
1507
- }
1508
- return;
1509
- }
1510
- }
1511
- catch (error) {
1512
- const errorMsg = `Failed to parse configuration file: ${configPath}`;
1513
- logger.error(errorMsg);
1514
- if (options.json) {
1515
- console.log(JSON.stringify({ error: errorMsg }, null, 2));
1516
- }
1517
- return;
1518
- }
1519
- // Check if server is running by trying to connect (HTTP)
1520
- const { get } = await import('http');
1521
- const checkServer = (port, host) => {
1522
- return new Promise((resolve) => {
1523
- const req = get({
1524
- hostname: host,
1525
- port: port,
1526
- path: '/health',
1527
- method: 'GET',
1528
- timeout: 5000
1529
- }, (res) => {
1530
- let data = '';
1531
- res.on('data', chunk => data += chunk);
1532
- res.on('end', () => {
1533
- try {
1534
- const health = JSON.parse(data);
1535
- // Ensure required fields in case health payload differs
1536
- resolve({
1537
- status: health?.status || 'unknown',
1538
- port,
1539
- host
1540
- });
1541
- }
1542
- catch {
1543
- resolve({ status: 'unknown', port, host });
1544
- }
1545
- });
1546
- });
1547
- req.on('error', () => {
1548
- resolve({ status: 'stopped', port, host });
1549
- });
1550
- req.on('timeout', () => {
1551
- req.destroy();
1552
- resolve({ status: 'timeout', port, host });
1553
- });
1554
- req.end();
1555
- });
1556
- };
1557
- const status = await checkServer(port, host);
1558
- if (options.json) {
1559
- console.log(JSON.stringify(status, null, 2));
1560
- }
1561
- else {
1562
- switch (status.status) {
1563
- case 'running':
1564
- logger.success(`Server is running on ${host}:${port}`);
1565
- break;
1566
- case 'stopped':
1567
- logger.error('Server is not running');
1568
- break;
1569
- case 'error':
1570
- logger.error('Server is in error state');
1571
- break;
1572
- default:
1573
- logger.warning('Server status unknown');
1574
- }
1575
- }
1576
- }
1577
- catch (error) {
1578
- logger.error(`Status check failed: ${error instanceof Error ? error.message : String(error)}`);
1579
- }
1580
- });
1581
- // Clean command: purge local capture and debug data for fresh runs
1582
- program
1583
- .command('clean')
1584
- .description('Clean captured data and debug logs')
1585
- .option('-y, --yes', 'Confirm deletion without prompt')
1586
- .option('--what <targets>', 'Targets to clean: captures,logs,all', 'all')
1587
- .action(async (options) => {
1588
- const confirm = Boolean(options.yes);
1589
- const what = String(options.what || 'all');
1590
- if (!confirm) {
1591
- logger.warning("Add --yes to confirm deletion.");
1592
- 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(() => {
1593
311
  return;
1594
- }
1595
- const home = homedir();
1596
- const targets = [];
1597
- if (what === 'captures' || what === 'all') {
1598
- targets.push({ path: path.join(home, '.routecodex', 'codex-samples'), label: 'captures' });
1599
- }
1600
- if (what === 'logs' || what === 'all') {
1601
- targets.push({ path: path.join(process.cwd(), 'debug-logs'), label: 'debug-logs' });
1602
- targets.push({ path: path.join(home, '.routecodex', 'logs'), label: 'user-logs' });
1603
- }
1604
- let removedAny = false;
1605
- for (const t of targets) {
1606
- try {
1607
- if (fs.existsSync(t.path)) {
1608
- const entries = fs.readdirSync(t.path);
1609
- for (const name of entries) {
1610
- const p = path.join(t.path, name);
1611
- try {
1612
- // Recursively remove files/folders
1613
- fs.rmSync(p, { recursive: true, force: true });
1614
- removedAny = true;
1615
- }
1616
- catch (e) {
1617
- logger.warning(`Failed to remove ${p}: ${e.message}`);
1618
- }
1619
- }
1620
- logger.success(`Cleared ${t.label} at ${t.path}`);
1621
- }
1622
- }
1623
- catch (e) {
1624
- logger.warning(`Unable to access ${t.label} at ${t.path}: ${e.message}`);
1625
- }
1626
- }
1627
- if (!removedAny) {
1628
- 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);
1629
317
  }
1630
318
  });
1631
319
  // Import commands at top level
@@ -1635,215 +323,30 @@ program
1635
323
  // dry-run commands removed
1636
324
  // offline-log command disabled
1637
325
  // simple-log command removed
1638
- // Examples command
1639
- program
1640
- .command('examples')
1641
- .description('Show usage examples')
1642
- .action(() => {
1643
- console.log(chalk.cyan('RouteCodex Usage Examples'));
1644
- console.log('='.repeat(40));
1645
- console.log('');
1646
- console.log(chalk.yellow('1. Initialize Configuration:'));
1647
- console.log(' # Create default configuration');
1648
- console.log(' rcc config init');
1649
- console.log('');
1650
- console.log(' # Create LMStudio configuration');
1651
- console.log(' rcc config init --template lmstudio');
1652
- console.log('');
1653
- console.log(' # Create OAuth configuration');
1654
- console.log(' rcc config init --template oauth');
1655
- console.log('');
1656
- console.log(chalk.yellow('2. Start Server:'));
1657
- console.log(' # Start with default config');
1658
- console.log(' rcc start');
1659
- console.log('');
1660
- console.log(' # Start with custom config');
1661
- console.log(' rcc start --config ./config/lmstudio-config.json');
1662
- console.log('');
1663
- console.log(' # Note: Port must be specified in configuration file');
1664
- console.log(' # Server will not start without valid port configuration');
1665
- console.log('');
1666
- console.log(chalk.yellow('3. Launch Claude Code:'));
1667
- console.log(' # Launch Claude Code with automatic server start');
1668
- console.log(' rcc code --ensure-server');
1669
- console.log('');
1670
- console.log(' # Launch Claude Code with specific model');
1671
- console.log(' rcc code --model claude-3-haiku');
1672
- console.log('');
1673
- console.log(' # Launch Claude Code with custom profile');
1674
- console.log(' rcc code --profile my-profile');
1675
- console.log('');
1676
- console.log(chalk.yellow('4. Configuration Management:'));
1677
- console.log(' # Show current configuration');
1678
- console.log(' rcc config show');
1679
- console.log('');
1680
- console.log(' # Edit configuration');
1681
- console.log(' rcc config edit');
1682
- console.log('');
1683
- console.log(' # Validate configuration');
1684
- console.log(' rcc config validate');
1685
- console.log('');
1686
- // Dry-Run examples removed
1687
- console.log(chalk.yellow('6. Environment Variables:'));
1688
- console.log(' # Set LM Studio API Key');
1689
- console.log(' export LM_STUDIO_API_KEY="your-api-key"');
1690
- console.log('');
1691
- console.log(' # Set OpenAI API Key');
1692
- console.log(' export OPENAI_API_KEY="your-api-key"');
1693
- console.log('');
1694
- console.log(chalk.yellow('7. Testing:'));
1695
- console.log(' # Test with curl');
1696
- console.log(' curl -X POST http://localhost:5506/v1/chat/completions \\');
1697
- console.log(' -H "Content-Type: application/json" \\');
1698
- console.log(' -H "Authorization: Bearer test-key" \\');
1699
- console.log(' -d \'{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello!"}]}\'');
1700
- console.log('');
1701
- });
1702
326
  async function ensurePortAvailable(port, parentSpinner, opts = {}) {
1703
- if (!port || Number.isNaN(port)) {
1704
- return;
1705
- }
1706
- // Best-effort HTTP shutdown on common loopback hosts to cover IPv4/IPv6
1707
- try {
1708
- const candidates = [LOCAL_HOSTS.IPV4, LOCAL_HOSTS.LOCALHOST];
1709
- for (const h of candidates) {
1710
- try {
1711
- const controller = new AbortController();
1712
- const t = setTimeout(() => { try {
1713
- controller.abort();
1714
- }
1715
- catch (error) { /* ignore */ } }, 700);
1716
- await fetch(`http://${h}:${port}/shutdown`, { method: 'POST', signal: controller.signal }).catch(() => { });
1717
- clearTimeout(t);
1718
- }
1719
- catch (error) { /* ignore */ }
1720
- }
1721
- await sleep(300);
1722
- }
1723
- catch { /* ignore */ }
1724
- const initialPids = findListeningPids(port);
1725
- if (initialPids.length === 0) {
1726
- return;
1727
- }
1728
- // If a healthy server is already running and no restart requested, report and exit gracefully
1729
- const healthy = await isServerHealthyQuick(port);
1730
- if (healthy && !opts.restart) {
1731
- parentSpinner.stop();
1732
- logger.success(`RouteCodex is already running on port ${port}.`);
1733
- logger.info(`Use 'rcc stop' or 'rcc start --restart' to restart.`);
1734
- process.exit(0);
1735
- }
1736
- parentSpinner.stop();
1737
- logger.warning(`Port ${port} is in use by PID(s): ${initialPids.join(', ')}`);
1738
- const stopSpinner = await createSpinner(`Port ${port} is in use on 0.0.0.0. Attempting graceful stop...`);
1739
- const gracefulTimeout = Number(process.env.ROUTECODEX_STOP_TIMEOUT_MS ?? 5000);
1740
- const killTimeout = Number(process.env.ROUTECODEX_KILL_TIMEOUT_MS ?? 3000);
1741
- const pollInterval = 150;
1742
- for (const pid of initialPids) {
1743
- try {
1744
- killPidBestEffort(pid, { force: false });
1745
- }
1746
- catch (error) {
1747
- stopSpinner.warn(`Failed to send SIGTERM to PID ${pid}: ${error.message}`);
1748
- }
1749
- }
1750
- const gracefulDeadline = Date.now() + gracefulTimeout;
1751
- while (Date.now() < gracefulDeadline) {
1752
- if (findListeningPids(port).length === 0) {
1753
- stopSpinner.succeed(`Port ${port} freed after graceful stop.`);
1754
- logger.success(`Port ${port} freed after graceful stop.`);
1755
- parentSpinner.start('Starting RouteCodex server...');
1756
- return;
1757
- }
1758
- await sleep(pollInterval);
1759
- }
1760
- let remaining = findListeningPids(port);
1761
- if (remaining.length) {
1762
- stopSpinner.warn(`Graceful stop timed out, sending SIGKILL to PID(s): ${remaining.join(', ')}`);
1763
- logger.warning(`Graceful stop timed out. Forcing SIGKILL to PID(s): ${remaining.join(', ')}`);
1764
- for (const pid of remaining) {
1765
- try {
1766
- killPidBestEffort(pid, { force: true });
1767
- }
1768
- catch (error) {
1769
- const message = error.message;
1770
- stopSpinner.warn(`Failed to send SIGKILL to PID ${pid}: ${message}`);
1771
- logger.error(`Failed to SIGKILL PID ${pid}: ${message}`);
1772
- }
1773
- }
1774
- const killDeadline = Date.now() + killTimeout;
1775
- while (Date.now() < killDeadline) {
1776
- if (findListeningPids(port).length === 0) {
1777
- stopSpinner.succeed(`Port ${port} freed after SIGKILL.`);
1778
- logger.success(`Port ${port} freed after SIGKILL.`);
1779
- parentSpinner.start('Starting RouteCodex server...');
1780
- return;
1781
- }
1782
- await sleep(pollInterval);
1783
- }
1784
- }
1785
- remaining = findListeningPids(port);
1786
- if (remaining.length) {
1787
- stopSpinner.fail(`Failed to free port ${port}. Still held by PID(s): ${remaining.join(', ')}`);
1788
- logger.error(`Failed to free port ${port}. Still held by PID(s): ${remaining.join(', ')}`);
1789
- throw new Error(`Failed to free port ${port}`);
1790
- }
1791
- stopSpinner.succeed(`Port ${port} freed.`);
1792
- logger.success(`Port ${port} freed.`);
1793
- 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
+ });
1794
341
  }
1795
342
  function findListeningPids(port) {
1796
- try {
1797
- if (IS_WINDOWS) {
1798
- const result = spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' });
1799
- if (result.error) {
1800
- logger.warning(`netstat not available to inspect port usage: ${result.error.message}`);
1801
- return [];
1802
- }
1803
- return parseNetstatListeningPids(result.stdout || '', port);
1804
- }
1805
- // macOS/BSD lsof expects either "-i TCP:port" or "-tiTCP:port" as a single argument.
1806
- // Use the compact form to avoid treating ":port" as a filename.
1807
- const result = spawnSync('lsof', [`-tiTCP:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8' });
1808
- if (result.error) {
1809
- logger.warning(`lsof not available to inspect port usage: ${result.error.message}`);
1810
- return [];
1811
- }
1812
- const stdout = (result.stdout || '').trim();
1813
- if (!stdout) {
1814
- return [];
1815
- }
1816
- return stdout
1817
- .split(/\s+/)
1818
- .map((value) => parseInt(value, 10))
1819
- .filter((pid) => !Number.isNaN(pid));
1820
- }
1821
- catch (error) {
1822
- logger.warning(`Failed to inspect port ${port}: ${error.message}`);
1823
- return [];
1824
- }
1825
- }
1826
- const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
1827
- function normalizePort(value) {
1828
- if (typeof value === 'number' && Number.isFinite(value)) {
1829
- return value;
1830
- }
1831
- if (typeof value === 'string' && value.trim()) {
1832
- const parsed = Number(value);
1833
- if (Number.isFinite(parsed)) {
1834
- return parsed;
1835
- }
1836
- }
1837
- return NaN;
1838
- }
1839
- function safeReadJson(filePath) {
1840
- try {
1841
- const content = fs.readFileSync(filePath, 'utf8');
1842
- return JSON.parse(content);
1843
- }
1844
- catch {
1845
- return null;
1846
- }
343
+ return findListeningPidsImpl({
344
+ port,
345
+ isWindows: IS_WINDOWS,
346
+ spawnSyncImpl: spawnSync,
347
+ logger,
348
+ parseNetstatListeningPids
349
+ });
1847
350
  }
1848
351
  // Fallback keypress setup: capture Ctrl+C and 'q' to trigger shutdown when SIGINT is not delivered
1849
352
  function setupKeypress(onInterrupt) {
@@ -1897,103 +400,11 @@ function setupKeypress(onInterrupt) {
1897
400
  };
1898
401
  }
1899
402
  async function isServerHealthyQuick(port) {
1900
- try {
1901
- const controller = new AbortController();
1902
- const t = setTimeout(() => { try {
1903
- controller.abort();
1904
- }
1905
- catch { /* ignore */ } }, 800);
1906
- const res = await fetch(`${HTTP_PROTOCOLS.HTTP}${LOCAL_HOSTS.IPV4}:${port}${API_PATHS.HEALTH}`, { method: 'GET', signal: controller.signal });
1907
- clearTimeout(t);
1908
- if (!res.ok) {
1909
- return false;
1910
- }
1911
- const data = await res.json().catch(() => null);
1912
- return !!data && (data.status === 'healthy' || data.status === 'ready');
1913
- }
1914
- catch (error) {
1915
- return false;
1916
- }
403
+ return isServerHealthyQuickImpl({ port, fetchImpl: fetch });
1917
404
  }
1918
405
  function getModulesConfigPath() {
1919
406
  return path.resolve(__dirname, '../config/modules.json');
1920
407
  }
1921
- // Port utilities: doctor
1922
- program
1923
- .command('port')
1924
- .description('Port utilities (doctor)')
1925
- .argument('<sub>', 'Subcommand: doctor')
1926
- .argument('[port]', 'Port number (e.g., ${DEFAULT_CONFIG.PORT})')
1927
- .option('--kill', 'Kill all listeners on the port')
1928
- .action(async (sub, portArg, opts) => {
1929
- if ((sub || '').toLowerCase() !== 'doctor') {
1930
- console.error(chalk.red("Unknown subcommand. Use: rcc port doctor [port] [--kill]"));
1931
- process.exit(2);
1932
- }
1933
- const spinner = await createSpinner('Inspecting port...');
1934
- try {
1935
- let port = Number(portArg || 0);
1936
- if (!Number.isFinite(port) || port <= 0) {
1937
- // fallback to user config
1938
- const cfgPath = path.join(homedir(), '.routecodex', 'config.json');
1939
- if (fs.existsSync(cfgPath)) {
1940
- try {
1941
- const raw = fs.readFileSync(cfgPath, 'utf8');
1942
- const cfg = JSON.parse(raw);
1943
- port = (cfg?.httpserver?.port ?? cfg?.server?.port ?? cfg?.port) || port;
1944
- }
1945
- catch { /* ignore */ }
1946
- }
1947
- }
1948
- if (!Number.isFinite(port) || port <= 0) {
1949
- spinner.fail('Missing port. Provide an explicit port or set it in ~/.routecodex/config.json');
1950
- process.exit(1);
1951
- }
1952
- const pids = findListeningPids(port);
1953
- spinner.stop();
1954
- console.log(chalk.cyan(`Port ${port} listeners:`));
1955
- if (!pids.length) {
1956
- console.log(' (none)');
1957
- }
1958
- else {
1959
- for (const pid of pids) {
1960
- let cmd = '';
1961
- try {
1962
- cmd = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' }).stdout.trim();
1963
- }
1964
- catch {
1965
- cmd = '';
1966
- }
1967
- const origin = /node\s+.*routecodex-worktree/.test(cmd) ? 'local-dev' : (/node\s+.*lib\/node_modules\/routecodex/.test(cmd) ? 'global' : 'unknown');
1968
- console.log(` PID ${pid} [${origin}] ${cmd}`);
1969
- }
1970
- }
1971
- if (opts.kill && pids.length) {
1972
- const ksp = await createSpinner(`Killing ${pids.length} listener(s) on ${port}...`);
1973
- for (const pid of pids) {
1974
- try {
1975
- killPidBestEffort(pid, { force: true });
1976
- }
1977
- catch (e) {
1978
- ksp.warn(`Failed to kill ${pid}: ${e.message}`);
1979
- }
1980
- }
1981
- // brief wait
1982
- await sleep(300);
1983
- const remain = findListeningPids(port);
1984
- if (remain.length) {
1985
- ksp.fail(`Some listeners remain: ${remain.join(', ')}`);
1986
- process.exit(1);
1987
- }
1988
- ksp.succeed(`Port ${port} is now free.`);
1989
- }
1990
- }
1991
- catch (e) {
1992
- spinner.fail('Port inspection failed');
1993
- console.error(e instanceof Error ? e.message : String(e));
1994
- process.exit(1);
1995
- }
1996
- });
1997
408
  // Parse command line arguments (must be last)
1998
409
  program.parse();
1999
410
  //# sourceMappingURL=cli.js.map