mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-01-29 22:02:05 +00:00
move to monorepo
This commit is contained in:
37
package.json
37
package.json
@@ -2,12 +2,16 @@
|
||||
"name": "@musistudio/claude-code-router",
|
||||
"version": "1.0.73",
|
||||
"description": "Use Claude Code without an Anthropics account and route it to another LLM provider",
|
||||
"bin": {
|
||||
"ccr": "dist/cli.js"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node scripts/build.js",
|
||||
"release": "npm run build && npm publish"
|
||||
"build:cli": "pnpm --filter @musistudio/claude-code-router-cli build",
|
||||
"build:server": "pnpm --filter @musistudio/claude-code-router-server build",
|
||||
"build:ui": "pnpm --filter @musistudio/claude-code-router-ui build",
|
||||
"release": "pnpm build && pnpm publish -r",
|
||||
"dev:cli": "pnpm --filter @musistudio/claude-code-router-cli dev",
|
||||
"dev:server": "pnpm --filter @musistudio/claude-code-router-server dev",
|
||||
"dev:ui": "pnpm --filter @musistudio/claude-code-router-ui dev"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
@@ -18,33 +22,14 @@
|
||||
],
|
||||
"author": "musistudio",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@musistudio/llms": "^1.0.51",
|
||||
"@inquirer/prompts": "^5.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"find-process": "^2.0.0",
|
||||
"json5": "^2.2.3",
|
||||
"lru-cache": "^11.2.2",
|
||||
"minimist": "^1.2.8",
|
||||
"openurl": "^1.1.1",
|
||||
"rotating-file-stream": "^3.2.7",
|
||||
"shell-quote": "^1.8.3",
|
||||
"tiktoken": "^1.0.21",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.15",
|
||||
"esbuild": "^0.25.1",
|
||||
"fastify": "^5.4.0",
|
||||
"shx": "^0.4.0",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"ignore": [
|
||||
"!build/",
|
||||
"src/",
|
||||
"screenshots/"
|
||||
]
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"pnpm": ">=8.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
34
packages/cli/package.json
Normal file
34
packages/cli/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@musistudio/claude-code-router-cli",
|
||||
"version": "1.0.73",
|
||||
"description": "CLI for Claude Code Router",
|
||||
"bin": {
|
||||
"ccr": "dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build-cli.js",
|
||||
"dev": "ts-node src/cli.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
"code",
|
||||
"router",
|
||||
"cli"
|
||||
],
|
||||
"author": "musistudio",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@musistudio/claude-code-router-shared": "workspace:*",
|
||||
"@inquirer/prompts": "^5.0.0",
|
||||
"@musistudio/claude-code-router-server": "workspace:*",
|
||||
"find-process": "^2.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"openurl": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.15",
|
||||
"esbuild": "^0.25.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
import { run } from "./index";
|
||||
// @ts-ignore - server package is built separately
|
||||
import { run } from "@musistudio/claude-code-router-server";
|
||||
// @ts-ignore - server package is built separately
|
||||
import { parseStatusLineData, type StatusLineInput } from "@musistudio/claude-code-router-server";
|
||||
import { showStatus } from "./utils/status";
|
||||
import { executeCodeCommand } from "./utils/codeCommand";
|
||||
import { parseStatusLineData, type StatusLineInput } from "./utils/statusline";
|
||||
import {
|
||||
cleanupPidFile,
|
||||
isServiceRunning,
|
||||
@@ -12,7 +14,7 @@ import { runModelSelector } from "./utils/modelSelector"; // ADD THIS LINE
|
||||
import { activateCommand } from "./utils/activateCommand";
|
||||
import { version } from "../package.json";
|
||||
import { spawn, exec } from "child_process";
|
||||
import { PID_FILE, REFERENCE_COUNT_FILE } from "./constants";
|
||||
import { PID_FILE, REFERENCE_COUNT_FILE } from "@musistudio/claude-code-router-shared";
|
||||
import fs, { existsSync, readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
82
packages/cli/src/types.d.ts
vendored
Normal file
82
packages/cli/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
declare module 'shell-quote' {
|
||||
export function quote(args: string[]): string;
|
||||
export function parse(cmd: string): string[];
|
||||
}
|
||||
|
||||
declare module 'minimist' {
|
||||
interface Options {
|
||||
string?: string[];
|
||||
boolean?: string | string[];
|
||||
alias?: Record<string, string | string[]>;
|
||||
default?: Record<string, any>;
|
||||
stopEarly?: boolean;
|
||||
'--'?: boolean;
|
||||
unknown?: (arg: string) => boolean;
|
||||
}
|
||||
|
||||
interface ParsedArgs {
|
||||
_: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
function minimist(args?: string[], opts?: Options): ParsedArgs;
|
||||
export = minimist;
|
||||
}
|
||||
|
||||
declare module '@inquirer/prompts' {
|
||||
export function select<T>(config: {
|
||||
message: string;
|
||||
choices: Array<{ name: string; value: T; description?: string }>;
|
||||
pageSize?: number;
|
||||
}): Promise<T>;
|
||||
export function input(config: {
|
||||
message: string;
|
||||
default?: string;
|
||||
validate?: (value: string) => boolean | string | Promise<boolean | string>;
|
||||
}): Promise<string>;
|
||||
export function confirm(config: {
|
||||
message: string;
|
||||
default?: boolean;
|
||||
}): Promise<boolean>;
|
||||
}
|
||||
|
||||
declare module 'find-process' {
|
||||
export default function find(
|
||||
type: 'pid' | 'name' | 'port',
|
||||
value: string | number
|
||||
): Promise<Array<{ pid: number; name: string; ppid?: number; cmd?: string }>>;
|
||||
}
|
||||
|
||||
declare module 'json5' {
|
||||
export function parse(text: string): any;
|
||||
export function stringify(value: any, replacer?: any, space?: string | number): string;
|
||||
}
|
||||
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
CI?: string;
|
||||
FORCE_COLOR?: string;
|
||||
NODE_NO_READLINE?: string;
|
||||
TERM?: string;
|
||||
ANTHROPIC_SMALL_FAST_MODEL?: string;
|
||||
}
|
||||
}
|
||||
|
||||
interface ClaudeSettingsFlag {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN?: any;
|
||||
ANTHROPIC_API_KEY: string;
|
||||
ANTHROPIC_BASE_URL: string;
|
||||
NO_PROXY: string;
|
||||
DISABLE_TELEMETRY: string;
|
||||
DISABLE_COST_WARNINGS: string;
|
||||
API_TIMEOUT_MS: string;
|
||||
CLAUDE_CODE_USE_BEDROCK?: undefined;
|
||||
[key: string]: any;
|
||||
};
|
||||
statusLine?: {
|
||||
type: string;
|
||||
command: string;
|
||||
padding: number;
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawn, type StdioOptions } from "child_process";
|
||||
import { readConfigFile } from ".";
|
||||
import { closeService } from "./close";
|
||||
// @ts-ignore - server package is built separately
|
||||
import { closeService } from "@musistudio/claude-code-router-server";
|
||||
import {
|
||||
decrementReferenceCount,
|
||||
incrementReferenceCount,
|
||||
@@ -14,8 +15,8 @@ export async function executeCodeCommand(args: string[] = []) {
|
||||
// Set environment variables using shared function
|
||||
const config = await readConfigFile();
|
||||
const env = await createEnvVariables();
|
||||
const settingsFlag = {
|
||||
env
|
||||
const settingsFlag: ClaudeSettingsFlag = {
|
||||
env: env as ClaudeSettingsFlag['env']
|
||||
};
|
||||
if (config?.StatusLine?.enabled) {
|
||||
settingsFlag.statusLine = {
|
||||
@@ -4,7 +4,7 @@ import { readConfigFile } from ".";
|
||||
* Get environment variables for Agent SDK/Claude Code integration
|
||||
* This function is shared between `ccr env` and `ccr code` commands
|
||||
*/
|
||||
export const createEnvVariables = async () => {
|
||||
export const createEnvVariables = async (): Promise<Record<string, string | undefined>> => {
|
||||
const config = await readConfigFile();
|
||||
const port = config.PORT || 3456;
|
||||
const apiKey = config.APIKEY || "test";
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
DEFAULT_CONFIG,
|
||||
HOME_DIR,
|
||||
PLUGINS_DIR,
|
||||
} from "../constants";
|
||||
import { cleanupLogFiles } from "./logCleanup";
|
||||
} from "@musistudio/claude-code-router-shared";
|
||||
// @ts-ignore - server package is built separately
|
||||
import { cleanupLogFiles } from "@musistudio/claude-code-router-server";
|
||||
|
||||
// Function to interpolate environment variables in config values
|
||||
const interpolateEnvVars = (obj: any): any => {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { PID_FILE, REFERENCE_COUNT_FILE } from '../constants';
|
||||
import { PID_FILE, REFERENCE_COUNT_FILE } from '@musistudio/claude-code-router-shared';
|
||||
import { readConfigFile } from '.';
|
||||
import find from 'find-process';
|
||||
import { execSync } from 'child_process'; // 引入 execSync 来执行命令行
|
||||
813
packages/cli/src/utils/statusline.ts
Normal file
813
packages/cli/src/utils/statusline.ts
Normal file
@@ -0,0 +1,813 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { execSync } from "child_process";
|
||||
import { CONFIG_FILE } from "@musistudio/claude-code-router-shared";
|
||||
import JSON5 from "json5";
|
||||
|
||||
export interface StatusLineModuleConfig {
|
||||
type: string;
|
||||
icon?: string;
|
||||
text: string;
|
||||
color?: string;
|
||||
background?: string;
|
||||
scriptPath?: string; // 用于script类型的模块,指定要执行的Node.js脚本文件路径
|
||||
}
|
||||
|
||||
export interface StatusLineThemeConfig {
|
||||
modules: StatusLineModuleConfig[];
|
||||
}
|
||||
|
||||
export interface StatusLineInput {
|
||||
hook_event_name: string;
|
||||
session_id: string;
|
||||
transcript_path: string;
|
||||
cwd: string;
|
||||
model: {
|
||||
id: string;
|
||||
display_name: string;
|
||||
};
|
||||
workspace: {
|
||||
current_dir: string;
|
||||
project_dir: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AssistantMessage {
|
||||
type: "assistant";
|
||||
message: {
|
||||
model: string;
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ANSIColor代码
|
||||
const COLORS: Record<string, string> = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
// 标准颜色
|
||||
black: "\x1b[30m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
white: "\x1b[37m",
|
||||
// 亮色
|
||||
bright_black: "\x1b[90m",
|
||||
bright_red: "\x1b[91m",
|
||||
bright_green: "\x1b[92m",
|
||||
bright_yellow: "\x1b[93m",
|
||||
bright_blue: "\x1b[94m",
|
||||
bright_magenta: "\x1b[95m",
|
||||
bright_cyan: "\x1b[96m",
|
||||
bright_white: "\x1b[97m",
|
||||
// 背景颜色
|
||||
bg_black: "\x1b[40m",
|
||||
bg_red: "\x1b[41m",
|
||||
bg_green: "\x1b[42m",
|
||||
bg_yellow: "\x1b[43m",
|
||||
bg_blue: "\x1b[44m",
|
||||
bg_magenta: "\x1b[45m",
|
||||
bg_cyan: "\x1b[46m",
|
||||
bg_white: "\x1b[47m",
|
||||
// 亮背景色
|
||||
bg_bright_black: "\x1b[100m",
|
||||
bg_bright_red: "\x1b[101m",
|
||||
bg_bright_green: "\x1b[102m",
|
||||
bg_bright_yellow: "\x1b[103m",
|
||||
bg_bright_blue: "\x1b[104m",
|
||||
bg_bright_magenta: "\x1b[105m",
|
||||
bg_bright_cyan: "\x1b[106m",
|
||||
bg_bright_white: "\x1b[107m",
|
||||
};
|
||||
|
||||
// 使用TrueColor(24位色)支持十六进制颜色
|
||||
const TRUE_COLOR_PREFIX = "\x1b[38;2;";
|
||||
const TRUE_COLOR_BG_PREFIX = "\x1b[48;2;";
|
||||
|
||||
// 将十六进制颜色转为RGB格式
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
// 移除#和空格
|
||||
hex = hex.replace(/^#/, '').trim();
|
||||
|
||||
// 处理简写形式 (#RGB -> #RRGGBB)
|
||||
if (hex.length === 3) {
|
||||
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||
}
|
||||
|
||||
if (hex.length !== 6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
// 验证RGB值是否有效
|
||||
if (isNaN(r) || isNaN(g) || isNaN(b) || r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
// 获取颜色代码
|
||||
function getColorCode(colorName: string): string {
|
||||
// 检查是否是十六进制颜色
|
||||
if (colorName.startsWith('#') || /^[0-9a-fA-F]{6}$/.test(colorName) || /^[0-9a-fA-F]{3}$/.test(colorName)) {
|
||||
const rgb = hexToRgb(colorName);
|
||||
if (rgb) {
|
||||
return `${TRUE_COLOR_PREFIX}${rgb.r};${rgb.g};${rgb.b}m`;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认返回空字符串
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
// 变量替换函数,支持{{var}}格式的变量替换
|
||||
function replaceVariables(text: string, variables: Record<string, string>): string {
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (_match, varName) => {
|
||||
return variables[varName] || "";
|
||||
});
|
||||
}
|
||||
|
||||
// 执行脚本并获取输出
|
||||
async function executeScript(scriptPath: string, variables: Record<string, string>): Promise<string> {
|
||||
try {
|
||||
// 检查文件是否存在
|
||||
await fs.access(scriptPath);
|
||||
|
||||
// 使用require动态加载脚本模块
|
||||
const scriptModule = require(scriptPath);
|
||||
|
||||
// 如果导出的是函数,则调用它并传入变量
|
||||
if (typeof scriptModule === 'function') {
|
||||
const result = scriptModule(variables);
|
||||
// 如果返回的是Promise,则等待它完成
|
||||
if (result instanceof Promise) {
|
||||
return await result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 如果导出的是default函数,则调用它
|
||||
if (scriptModule.default && typeof scriptModule.default === 'function') {
|
||||
const result = scriptModule.default(variables);
|
||||
// 如果返回的是Promise,则等待它完成
|
||||
if (result instanceof Promise) {
|
||||
return await result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 如果导出的是字符串,则直接返回
|
||||
if (typeof scriptModule === 'string') {
|
||||
return scriptModule;
|
||||
}
|
||||
|
||||
// 如果导出的是default字符串,则返回它
|
||||
if (scriptModule.default && typeof scriptModule.default === 'string') {
|
||||
return scriptModule.default;
|
||||
}
|
||||
|
||||
// 默认情况下返回空字符串
|
||||
return "";
|
||||
} catch (error) {
|
||||
console.error(`执行脚本 ${scriptPath} 时出错:`, error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// 默认主题配置 - 使用Nerd Fonts图标和美观配色
|
||||
const DEFAULT_THEME: StatusLineThemeConfig = {
|
||||
modules: [
|
||||
{
|
||||
type: "workDir",
|
||||
icon: "", // nf-md-folder_outline
|
||||
text: "{{workDirName}}",
|
||||
color: "bright_blue"
|
||||
},
|
||||
{
|
||||
type: "gitBranch",
|
||||
icon: "", // nf-dev-git_branch
|
||||
text: "{{gitBranch}}",
|
||||
color: "bright_magenta"
|
||||
},
|
||||
{
|
||||
type: "model",
|
||||
icon: "", // nf-md-robot_outline
|
||||
text: "{{model}}",
|
||||
color: "bright_cyan"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↑", // 上箭头
|
||||
text: "{{inputTokens}}",
|
||||
color: "bright_green"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↓", // 下箭头
|
||||
text: "{{outputTokens}}",
|
||||
color: "bright_yellow"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Powerline风格主题配置
|
||||
const POWERLINE_THEME: StatusLineThemeConfig = {
|
||||
modules: [
|
||||
{
|
||||
type: "workDir",
|
||||
icon: "", // nf-md-folder_outline
|
||||
text: "{{workDirName}}",
|
||||
color: "white",
|
||||
background: "bg_bright_blue"
|
||||
},
|
||||
{
|
||||
type: "gitBranch",
|
||||
icon: "", // nf-dev-git_branch
|
||||
text: "{{gitBranch}}",
|
||||
color: "white",
|
||||
background: "bg_bright_magenta"
|
||||
},
|
||||
{
|
||||
type: "model",
|
||||
icon: "", // nf-md-robot_outline
|
||||
text: "{{model}}",
|
||||
color: "white",
|
||||
background: "bg_bright_cyan"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↑", // 上箭头
|
||||
text: "{{inputTokens}}",
|
||||
color: "white",
|
||||
background: "bg_bright_green"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↓", // 下箭头
|
||||
text: "{{outputTokens}}",
|
||||
color: "white",
|
||||
background: "bg_bright_yellow"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 简单文本主题配置 - 用于图标无法显示时的fallback
|
||||
const SIMPLE_THEME: StatusLineThemeConfig = {
|
||||
modules: [
|
||||
{
|
||||
type: "workDir",
|
||||
icon: "",
|
||||
text: "{{workDirName}}",
|
||||
color: "bright_blue"
|
||||
},
|
||||
{
|
||||
type: "gitBranch",
|
||||
icon: "",
|
||||
text: "{{gitBranch}}",
|
||||
color: "bright_magenta"
|
||||
},
|
||||
{
|
||||
type: "model",
|
||||
icon: "",
|
||||
text: "{{model}}",
|
||||
color: "bright_cyan"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↑",
|
||||
text: "{{inputTokens}}",
|
||||
color: "bright_green"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↓",
|
||||
text: "{{outputTokens}}",
|
||||
color: "bright_yellow"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 格式化usage信息,如果大于1000则使用k单位
|
||||
function formatUsage(input_tokens: number, output_tokens: number): string {
|
||||
if (input_tokens > 1000 || output_tokens > 1000) {
|
||||
const inputFormatted = input_tokens > 1000 ? `${(input_tokens / 1000).toFixed(1)}k` : `${input_tokens}`;
|
||||
const outputFormatted = output_tokens > 1000 ? `${(output_tokens / 1000).toFixed(1)}k` : `${output_tokens}`;
|
||||
return `${inputFormatted} ${outputFormatted}`;
|
||||
}
|
||||
return `${input_tokens} ${output_tokens}`;
|
||||
}
|
||||
|
||||
// 读取用户主目录的主题配置
|
||||
async function getProjectThemeConfig(): Promise<{ theme: StatusLineThemeConfig | null, style: string }> {
|
||||
try {
|
||||
// 只使用主目录的固定配置文件
|
||||
const configPath = CONFIG_FILE;
|
||||
|
||||
// 检查配置文件是否存在
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
} catch {
|
||||
return { theme: null, style: 'default' };
|
||||
}
|
||||
|
||||
const configContent = await fs.readFile(configPath, "utf-8");
|
||||
const config = JSON5.parse(configContent);
|
||||
|
||||
// 检查是否有StatusLine配置
|
||||
if (config.StatusLine) {
|
||||
// 获取当前使用的风格,默认为default
|
||||
const currentStyle = config.StatusLine.currentStyle || 'default';
|
||||
|
||||
// 检查是否有对应风格的配置
|
||||
if (config.StatusLine[currentStyle] && config.StatusLine[currentStyle].modules) {
|
||||
return { theme: config.StatusLine[currentStyle], style: currentStyle };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果读取失败,返回null
|
||||
// console.error("Failed to read theme config:", error);
|
||||
}
|
||||
|
||||
return { theme: null, style: 'default' };
|
||||
}
|
||||
|
||||
// 检查是否应该使用简单主题(fallback方案)
|
||||
// 当环境变量 USE_SIMPLE_ICONS 被设置时,或者当检测到可能不支持Nerd Fonts的终端时
|
||||
function shouldUseSimpleTheme(): boolean {
|
||||
// 检查环境变量
|
||||
if (process.env.USE_SIMPLE_ICONS === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查终端类型(一些常见的不支持复杂图标的终端)
|
||||
const term = process.env.TERM || '';
|
||||
const unsupportedTerms = ['dumb', 'unknown'];
|
||||
if (unsupportedTerms.includes(term)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 默认情况下,假设终端支持Nerd Fonts
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查Nerd Fonts图标是否能正确显示
|
||||
// 通过检查终端字体信息或使用试探性方法
|
||||
function canDisplayNerdFonts(): boolean {
|
||||
// 如果环境变量明确指定使用简单图标,则不能显示Nerd Fonts
|
||||
if (process.env.USE_SIMPLE_ICONS === 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查一些常见的支持Nerd Fonts的终端环境变量
|
||||
const fontEnvVars = ['NERD_FONT', 'NERDFONT', 'FONT'];
|
||||
for (const envVar of fontEnvVars) {
|
||||
const value = process.env[envVar];
|
||||
if (value && (value.includes('Nerd') || value.includes('nerd'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查终端类型
|
||||
const termProgram = process.env.TERM_PROGRAM || '';
|
||||
const supportedTerminals = ['iTerm.app', 'vscode', 'Hyper', 'kitty', 'alacritty'];
|
||||
if (supportedTerminals.includes(termProgram)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查COLORTERM环境变量
|
||||
const colorTerm = process.env.COLORTERM || '';
|
||||
if (colorTerm.includes('truecolor') || colorTerm.includes('24bit')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 默认情况下,假设可以显示Nerd Fonts(但允许用户通过环境变量覆盖)
|
||||
return process.env.USE_SIMPLE_ICONS !== 'true';
|
||||
}
|
||||
|
||||
// 检查特定Unicode字符是否能正确显示
|
||||
// 这是一个简单的试探性检查
|
||||
function canDisplayUnicodeCharacter(char: string): boolean {
|
||||
// 对于Nerd Fonts图标,我们假设支持UTF-8的终端可以显示
|
||||
// 但实际上很难准确检测,所以我们依赖环境变量和终端类型检测
|
||||
try {
|
||||
// 检查终端是否支持UTF-8
|
||||
const lang = process.env.LANG || process.env.LC_ALL || process.env.LC_CTYPE || '';
|
||||
if (lang.includes('UTF-8') || lang.includes('utf8') || lang.includes('UTF8')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查LC_*环境变量
|
||||
const lcVars = ['LC_ALL', 'LC_CTYPE', 'LANG'];
|
||||
for (const lcVar of lcVars) {
|
||||
const value = process.env[lcVar];
|
||||
if (value && (value.includes('UTF-8') || value.includes('utf8'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果检查失败,默认返回true
|
||||
return true;
|
||||
}
|
||||
|
||||
// 默认情况下,假设可以显示
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function parseStatusLineData(input: StatusLineInput): Promise<string> {
|
||||
try {
|
||||
// 检查是否应该使用简单主题
|
||||
const useSimpleTheme = shouldUseSimpleTheme();
|
||||
|
||||
// 检查是否可以显示Nerd Fonts图标
|
||||
const canDisplayNerd = canDisplayNerdFonts();
|
||||
|
||||
// 确定使用的主题:如果用户强制使用简单主题或无法显示Nerd Fonts,则使用简单主题
|
||||
const effectiveTheme = useSimpleTheme || !canDisplayNerd ? SIMPLE_THEME : DEFAULT_THEME;
|
||||
|
||||
// 获取主目录的主题配置,如果没有则使用确定的默认配置
|
||||
const { theme: projectTheme, style: currentStyle } = await getProjectThemeConfig();
|
||||
const theme = projectTheme || effectiveTheme;
|
||||
|
||||
// 获取当前工作目录和Git分支
|
||||
const workDir = input.workspace.current_dir;
|
||||
let gitBranch = "";
|
||||
|
||||
try {
|
||||
// 尝试获取Git分支名
|
||||
gitBranch = execSync("git branch --show-current", {
|
||||
cwd: workDir,
|
||||
stdio: ["pipe", "pipe", "ignore"],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
} catch (error) {
|
||||
// 如果不是Git仓库或获取失败,则忽略错误
|
||||
}
|
||||
|
||||
// 从transcript_path文件中读取最后一条assistant消息
|
||||
const transcriptContent = await fs.readFile(input.transcript_path, "utf-8");
|
||||
const lines = transcriptContent.trim().split("\n");
|
||||
|
||||
// 反向遍历寻找最后一条assistant消息
|
||||
let model = "";
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const message: AssistantMessage = JSON.parse(lines[i]);
|
||||
if (message.type === "assistant" && message.message.model) {
|
||||
model = message.message.model;
|
||||
|
||||
if (message.message.usage) {
|
||||
inputTokens = message.message.usage.input_tokens;
|
||||
outputTokens = message.message.usage.output_tokens;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (parseError) {
|
||||
// 忽略解析错误,继续查找
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有从transcript中获取到模型名称,则尝试从配置文件中获取
|
||||
if (!model) {
|
||||
try {
|
||||
// 获取项目配置文件路径
|
||||
const projectConfigPath = path.join(workDir, ".claude-code-router", "config.json");
|
||||
let configPath = projectConfigPath;
|
||||
|
||||
// 检查项目配置文件是否存在,如果不存在则使用用户主目录的配置文件
|
||||
try {
|
||||
await fs.access(projectConfigPath);
|
||||
} catch {
|
||||
configPath = CONFIG_FILE;
|
||||
}
|
||||
|
||||
// 读取配置文件
|
||||
const configContent = await fs.readFile(configPath, "utf-8");
|
||||
const config = JSON5.parse(configContent);
|
||||
|
||||
// 从Router字段的default内容中获取模型名称
|
||||
if (config.Router && config.Router.default) {
|
||||
const [, defaultModel] = config.Router.default.split(",");
|
||||
if (defaultModel) {
|
||||
model = defaultModel.trim();
|
||||
}
|
||||
}
|
||||
} catch (configError) {
|
||||
// 如果配置文件读取失败,则忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
// 如果仍然没有获取到模型名称,则使用传入的JSON数据中的model字段的display_name
|
||||
if (!model) {
|
||||
model = input.model.display_name;
|
||||
}
|
||||
|
||||
// 获取工作目录名
|
||||
const workDirName = workDir.split("/").pop() || "";
|
||||
|
||||
// 格式化usage信息
|
||||
const usage = formatUsage(inputTokens, outputTokens);
|
||||
const [formattedInputTokens, formattedOutputTokens] = usage.split(" ");
|
||||
|
||||
// 定义变量替换映射
|
||||
const variables = {
|
||||
workDirName,
|
||||
gitBranch,
|
||||
model,
|
||||
inputTokens: formattedInputTokens,
|
||||
outputTokens: formattedOutputTokens
|
||||
};
|
||||
|
||||
// 确定使用的风格
|
||||
const isPowerline = currentStyle === 'powerline';
|
||||
|
||||
// 根据风格渲染状态行
|
||||
if (isPowerline) {
|
||||
return await renderPowerlineStyle(theme, variables);
|
||||
} else {
|
||||
return await renderDefaultStyle(theme, variables);
|
||||
}
|
||||
} catch (error) {
|
||||
// 发生错误时返回空字符串
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// 读取用户主目录的主题配置(指定风格)
|
||||
async function getProjectThemeConfigForStyle(style: string): Promise<StatusLineThemeConfig | null> {
|
||||
try {
|
||||
// 只使用主目录的固定配置文件
|
||||
const configPath = CONFIG_FILE;
|
||||
|
||||
// 检查配置文件是否存在
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configContent = await fs.readFile(configPath, "utf-8");
|
||||
const config = JSON5.parse(configContent);
|
||||
|
||||
// 检查是否有StatusLine配置
|
||||
if (config.StatusLine && config.StatusLine[style] && config.StatusLine[style].modules) {
|
||||
return config.StatusLine[style];
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果读取失败,返回null
|
||||
// console.error("Failed to read theme config:", error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 渲染默认风格的状态行
|
||||
async function renderDefaultStyle(
|
||||
theme: StatusLineThemeConfig,
|
||||
variables: Record<string, string>
|
||||
): Promise<string> {
|
||||
const modules = theme.modules || DEFAULT_THEME.modules;
|
||||
const parts: string[] = [];
|
||||
|
||||
// 遍历模块数组,渲染每个模块
|
||||
for (let i = 0; i < Math.min(modules.length, 5); i++) {
|
||||
const module = modules[i];
|
||||
const color = module.color ? getColorCode(module.color) : "";
|
||||
const background = module.background ? getColorCode(module.background) : "";
|
||||
const icon = module.icon || "";
|
||||
|
||||
// 如果是script类型,执行脚本获取文本
|
||||
let text = "";
|
||||
if (module.type === "script" && module.scriptPath) {
|
||||
text = await executeScript(module.scriptPath, variables);
|
||||
} else {
|
||||
text = replaceVariables(module.text, variables);
|
||||
}
|
||||
|
||||
// 构建显示文本
|
||||
let displayText = "";
|
||||
if (icon) {
|
||||
displayText += `${icon} `;
|
||||
}
|
||||
displayText += text;
|
||||
|
||||
// 如果displayText为空,或者只有图标没有实际文本,则跳过该模块
|
||||
if (!displayText || !text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 构建模块字符串
|
||||
let part = `${background}${color}`;
|
||||
part += `${displayText}${COLORS.reset}`;
|
||||
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
// 使用空格连接所有部分
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// Powerline符号
|
||||
const SEP_RIGHT = "\uE0B0"; //
|
||||
|
||||
// 颜色编号(256色表)
|
||||
const COLOR_MAP: Record<string, number> = {
|
||||
// 基础颜色映射到256色
|
||||
black: 0,
|
||||
red: 1,
|
||||
green: 2,
|
||||
yellow: 3,
|
||||
blue: 4,
|
||||
magenta: 5,
|
||||
cyan: 6,
|
||||
white: 7,
|
||||
bright_black: 8,
|
||||
bright_red: 9,
|
||||
bright_green: 10,
|
||||
bright_yellow: 11,
|
||||
bright_blue: 12,
|
||||
bright_magenta: 13,
|
||||
bright_cyan: 14,
|
||||
bright_white: 15,
|
||||
// 亮背景色映射
|
||||
bg_black: 0,
|
||||
bg_red: 1,
|
||||
bg_green: 2,
|
||||
bg_yellow: 3,
|
||||
bg_blue: 4,
|
||||
bg_magenta: 5,
|
||||
bg_cyan: 6,
|
||||
bg_white: 7,
|
||||
bg_bright_black: 8,
|
||||
bg_bright_red: 9,
|
||||
bg_bright_green: 10,
|
||||
bg_bright_yellow: 11,
|
||||
bg_bright_blue: 12,
|
||||
bg_bright_magenta: 13,
|
||||
bg_bright_cyan: 14,
|
||||
bg_bright_white: 15,
|
||||
// 自定义颜色映射
|
||||
bg_bright_orange: 202,
|
||||
bg_bright_purple: 129,
|
||||
};
|
||||
|
||||
// 获取TrueColor的RGB值
|
||||
function getTrueColorRgb(colorName: string): { r: number; g: number; b: number } | null {
|
||||
// 如果是预定义颜色,返回对应RGB
|
||||
if (COLOR_MAP[colorName] !== undefined) {
|
||||
const color256 = COLOR_MAP[colorName];
|
||||
return color256ToRgb(color256);
|
||||
}
|
||||
|
||||
// 处理十六进制颜色
|
||||
if (colorName.startsWith('#') || /^[0-9a-fA-F]{6}$/.test(colorName) || /^[0-9a-fA-F]{3}$/.test(colorName)) {
|
||||
return hexToRgb(colorName);
|
||||
}
|
||||
|
||||
// 处理背景色十六进制
|
||||
if (colorName.startsWith('bg_#')) {
|
||||
return hexToRgb(colorName.substring(3));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 将256色表索引转换为RGB值
|
||||
function color256ToRgb(index: number): { r: number; g: number; b: number } | null {
|
||||
if (index < 0 || index > 255) return null;
|
||||
|
||||
// ANSI 256色表转换
|
||||
if (index < 16) {
|
||||
// 基本颜色
|
||||
const basicColors = [
|
||||
[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
|
||||
[0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192],
|
||||
[128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0],
|
||||
[0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]
|
||||
];
|
||||
return { r: basicColors[index][0], g: basicColors[index][1], b: basicColors[index][2] };
|
||||
} else if (index < 232) {
|
||||
// 216色:6×6×6的颜色立方体
|
||||
const i = index - 16;
|
||||
const r = Math.floor(i / 36);
|
||||
const g = Math.floor((i % 36) / 6);
|
||||
const b = i % 6;
|
||||
const rgb = [0, 95, 135, 175, 215, 255];
|
||||
return { r: rgb[r], g: rgb[g], b: rgb[b] };
|
||||
} else {
|
||||
// 灰度色
|
||||
const gray = 8 + (index - 232) * 10;
|
||||
return { r: gray, g: gray, b: gray };
|
||||
}
|
||||
}
|
||||
|
||||
// 生成一个无缝拼接的段:文本在 bgN 上显示,分隔符从 bgN 过渡到 nextBgN
|
||||
function segment(text: string, textFg: string, bgColor: string, nextBgColor: string | null): string {
|
||||
const bgRgb = getTrueColorRgb(bgColor);
|
||||
if (!bgRgb) {
|
||||
// 如果无法获取RGB,使用默认蓝色背景
|
||||
const defaultBlueRgb = { r: 33, g: 150, b: 243 };
|
||||
const curBg = `\x1b[48;2;${defaultBlueRgb.r};${defaultBlueRgb.g};${defaultBlueRgb.b}m`;
|
||||
const fgColor = `\x1b[38;2;255;255;255m`;
|
||||
const body = `${curBg}${fgColor} ${text} \x1b[0m`;
|
||||
return body;
|
||||
}
|
||||
|
||||
const curBg = `\x1b[48;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||
|
||||
// 获取前景色RGB
|
||||
let fgRgb = { r: 255, g: 255, b: 255 }; // 默认前景色为白色
|
||||
const textFgRgb = getTrueColorRgb(textFg);
|
||||
if (textFgRgb) {
|
||||
fgRgb = textFgRgb;
|
||||
}
|
||||
|
||||
const fgColor = `\x1b[38;2;${fgRgb.r};${fgRgb.g};${fgRgb.b}m`;
|
||||
const body = `${curBg}${fgColor} ${text} \x1b[0m`;
|
||||
|
||||
if (nextBgColor != null) {
|
||||
const nextBgRgb = getTrueColorRgb(nextBgColor);
|
||||
if (nextBgRgb) {
|
||||
// 分隔符:前景色是当前段的背景色,背景色是下一段的背景色
|
||||
const sepCurFg = `\x1b[38;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||
const sepNextBg = `\x1b[48;2;${nextBgRgb.r};${nextBgRgb.g};${nextBgRgb.b}m`;
|
||||
const sep = `${sepCurFg}${sepNextBg}${SEP_RIGHT}\x1b[0m`;
|
||||
return body + sep;
|
||||
}
|
||||
// 如果没有下一个背景色,假设终端背景为黑色并渲染黑色箭头
|
||||
const sepCurFg = `\x1b[38;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||
const sepNextBg = `\x1b[48;2;0;0;0m`; // 黑色背景
|
||||
const sep = `${sepCurFg}${sepNextBg}${SEP_RIGHT}\x1b[0m`;
|
||||
return body + sep;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
// 渲染Powerline风格的状态行
|
||||
async function renderPowerlineStyle(
|
||||
theme: StatusLineThemeConfig,
|
||||
variables: Record<string, string>
|
||||
): Promise<string> {
|
||||
const modules = theme.modules || POWERLINE_THEME.modules;
|
||||
const segments: string[] = [];
|
||||
|
||||
// 遍历模块数组,渲染每个模块
|
||||
for (let i = 0; i < Math.min(modules.length, 5); i++) {
|
||||
const module = modules[i];
|
||||
const color = module.color || "white";
|
||||
const backgroundName = module.background || "";
|
||||
const icon = module.icon || "";
|
||||
|
||||
// 如果是script类型,执行脚本获取文本
|
||||
let text = "";
|
||||
if (module.type === "script" && module.scriptPath) {
|
||||
text = await executeScript(module.scriptPath, variables);
|
||||
} else {
|
||||
text = replaceVariables(module.text, variables);
|
||||
}
|
||||
|
||||
// 构建显示文本
|
||||
let displayText = "";
|
||||
if (icon) {
|
||||
displayText += `${icon} `;
|
||||
}
|
||||
displayText += text;
|
||||
|
||||
// 如果displayText为空,或者只有图标没有实际文本,则跳过该模块
|
||||
if (!displayText || !text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取下一个模块的背景色(用于分隔符)
|
||||
let nextBackground: string | null = null;
|
||||
if (i < modules.length - 1) {
|
||||
const nextModule = modules[i + 1];
|
||||
nextBackground = nextModule.background || null;
|
||||
}
|
||||
|
||||
// 使用模块定义的背景色,或者为Powerline风格提供默认背景色
|
||||
const actualBackground = backgroundName || "bg_bright_blue";
|
||||
|
||||
// 生成段,支持十六进制颜色
|
||||
const segmentStr = segment(displayText, color, actualBackground, nextBackground);
|
||||
segments.push(segmentStr);
|
||||
}
|
||||
|
||||
return segments.join("");
|
||||
}
|
||||
10
packages/cli/tsconfig.json
Normal file
10
packages/cli/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
12
packages/server/.dockerignore
Normal file
12
packages/server/.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
coverage
|
||||
.nyc_output
|
||||
69
packages/server/Dockerfile
Normal file
69
packages/server/Dockerfile
Normal file
@@ -0,0 +1,69 @@
|
||||
# ===========================
|
||||
# 构建阶段
|
||||
# ===========================
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装 pnpm
|
||||
RUN npm install -g pnpm@latest && \
|
||||
rm -rf /root/.npm
|
||||
|
||||
# 复制工作区配置文件
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
||||
COPY scripts ./scripts
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/server/package.json ./packages/server/
|
||||
|
||||
# 安装所有依赖(包括开发依赖)并清理
|
||||
RUN pnpm install --frozen-lockfile && \
|
||||
pnpm store prune
|
||||
|
||||
# 复制源代码并构建
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY packages/server ./packages/server
|
||||
|
||||
# 构建所有包
|
||||
WORKDIR /app/packages/shared
|
||||
RUN pnpm build
|
||||
|
||||
WORKDIR /app/packages/server
|
||||
RUN pnpm build && \
|
||||
rm -rf node_modules/.cache
|
||||
|
||||
# ===========================
|
||||
# 生产阶段(极简版 - 无 node_modules)
|
||||
# ===========================
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# 只安装 PM2、curl 和 pm2-logrotate,并删除不需要的 npm 和 corepack
|
||||
RUN apk add --no-cache curl && \
|
||||
npm install -g pm2 pm2-logrotate --no-scripts && \
|
||||
pm2 install pm2-logrotate && \
|
||||
pm2 set pm2-logrotate:max_size 100M && \
|
||||
pm2 set pm2-logrotate:retain 5 && \
|
||||
pm2 set pm2-logrotate:compress true && \
|
||||
pm2 set pm2-logrotate:rotateInterval '0 0 * * *'
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 从构建阶段复制 server bundle(shared 已被打包进 index.js,无需单独复制)
|
||||
COPY --from=builder /app/packages/server/dist ./packages/server/dist
|
||||
# 复制本地预先构建的 UI 产物到同一目录
|
||||
COPY packages/ui/dist/. ./packages/server/dist/
|
||||
|
||||
# 复制 PM2 配置文件
|
||||
COPY packages/server/ecosystem.config.cjs /app/
|
||||
|
||||
# 创建日志目录
|
||||
RUN mkdir -p /root/.claude-code-router/logs
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 3456
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://127.0.0.1:3456/health || exit 1
|
||||
|
||||
# 直接启动应用
|
||||
CMD ["pm2-runtime", "start", "/app/ecosystem.config.cjs"]
|
||||
23
packages/server/ecosystem.config.cjs
Normal file
23
packages/server/ecosystem.config.cjs
Normal file
@@ -0,0 +1,23 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'claude-code-router-server',
|
||||
script: '/app/packages/server/dist/index.js',
|
||||
cwd: '/app/packages/server',
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '1G',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
// 日志配置
|
||||
error_file: '/root/.claude-code-router/logs/error.log',
|
||||
out_file: '/root/.claude-code-router/logs/out.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
merge_logs: true,
|
||||
// 启用日志时间戳
|
||||
time: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
37
packages/server/package.json
Normal file
37
packages/server/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@musistudio/claude-code-router-server",
|
||||
"version": "1.0.73",
|
||||
"description": "Server for Claude Code Router",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build-server.js",
|
||||
"dev": "ts-node src/index.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
"code",
|
||||
"router",
|
||||
"server"
|
||||
],
|
||||
"author": "musistudio",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@musistudio/claude-code-router-shared": "workspace:*",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@musistudio/llms": "^1.0.51",
|
||||
"dotenv": "^16.4.7",
|
||||
"json5": "^2.2.3",
|
||||
"lru-cache": "^11.2.2",
|
||||
"rotating-file-stream": "^3.2.7",
|
||||
"shell-quote": "^1.8.3",
|
||||
"tiktoken": "^1.0.21",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.15",
|
||||
"esbuild": "^0.25.1",
|
||||
"fastify": "^5.4.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export class ImageAgent implements IAgent {
|
||||
)
|
||||
) {
|
||||
req.body.model = config.Router.image;
|
||||
const images = [];
|
||||
const images: any[] = [];
|
||||
lastMessage.content
|
||||
.filter((item: any) => item.type === "tool_result")
|
||||
.forEach((item: any) => {
|
||||
@@ -183,7 +183,7 @@ export class ImageAgent implements IAgent {
|
||||
context.req.body.messages[context.req.body.messages.length - 1];
|
||||
if (userMessage.role === "user" && Array.isArray(userMessage.content)) {
|
||||
const msgs = userMessage.content.filter(
|
||||
(item) =>
|
||||
(item: any) =>
|
||||
item.type === "text" &&
|
||||
!item.text.includes(
|
||||
"This is an image, if you need to view or analyze it, you need to extract the imageId"
|
||||
@@ -286,7 +286,7 @@ Your response should consistently follow this rule whenever image-related analys
|
||||
} else if (msg.type === "tool_result") {
|
||||
if (
|
||||
Array.isArray(msg.content) &&
|
||||
msg.content.some((ele) => ele.type === "image")
|
||||
msg.content.some((ele: any) => ele.type === "image")
|
||||
) {
|
||||
imageCache.storeImage(
|
||||
`${req.id}_Image#${imgId}`,
|
||||
@@ -1,19 +1,13 @@
|
||||
import { existsSync } from "fs";
|
||||
import { existsSync, writeFileSync, unlinkSync } from "fs";
|
||||
import { writeFile } from "fs/promises";
|
||||
import { homedir } from "os";
|
||||
import path, { join } from "path";
|
||||
import { initConfig, initDir, cleanupLogFiles } from "./utils";
|
||||
import { join } from "path";
|
||||
import { initConfig, initDir } from "./utils";
|
||||
import { createServer } from "./server";
|
||||
import { router } from "./utils/router";
|
||||
import { apiKeyAuth } from "./middleware/auth";
|
||||
import {
|
||||
cleanupPidFile,
|
||||
isServiceRunning,
|
||||
savePid,
|
||||
} from "./utils/processCheck";
|
||||
import { CONFIG_FILE } from "./constants";
|
||||
import { PID_FILE, CONFIG_FILE, HOME_DIR } from "@musistudio/claude-code-router-shared";
|
||||
import { createStream } from 'rotating-file-stream';
|
||||
import { HOME_DIR } from "./constants";
|
||||
import { sessionUsageCache } from "./utils/cache";
|
||||
import {SSEParserTransform} from "./utils/SSEParser.transform";
|
||||
import {SSESerializerTransform} from "./utils/SSESerializer.transform";
|
||||
@@ -47,11 +41,12 @@ async function initializeClaudeConfig() {
|
||||
|
||||
interface RunOptions {
|
||||
port?: number;
|
||||
logger?: any;
|
||||
}
|
||||
|
||||
async function run(options: RunOptions = {}) {
|
||||
// Check if service is already running
|
||||
const isRunning = await isServiceRunning()
|
||||
const isRunning = existsSync(PID_FILE);
|
||||
if (isRunning) {
|
||||
console.log("✅ Service is already running in the background.");
|
||||
return;
|
||||
@@ -59,33 +54,56 @@ async function run(options: RunOptions = {}) {
|
||||
|
||||
await initializeClaudeConfig();
|
||||
await initDir();
|
||||
// Clean up old log files, keeping only the 10 most recent ones
|
||||
await cleanupLogFiles();
|
||||
const config = await initConfig();
|
||||
|
||||
// Check if Providers is configured
|
||||
const providers = config.Providers || config.providers || [];
|
||||
const hasProviders = providers && providers.length > 0;
|
||||
|
||||
let HOST = config.HOST || "127.0.0.1";
|
||||
|
||||
if (config.HOST && !config.APIKEY) {
|
||||
HOST = "127.0.0.1";
|
||||
console.warn("⚠️ API key is not set. HOST is forced to 127.0.0.1.");
|
||||
if (hasProviders) {
|
||||
// When providers are configured, require both HOST and APIKEY
|
||||
if (!config.HOST || !config.APIKEY) {
|
||||
console.error("❌ Both HOST and APIKEY must be configured when Providers are set.");
|
||||
console.error(" Please add HOST and APIKEY to your config file.");
|
||||
process.exit(1);
|
||||
}
|
||||
HOST = config.HOST;
|
||||
} else {
|
||||
// When no providers are configured, listen on 0.0.0.0 without authentication
|
||||
HOST = "0.0.0.0";
|
||||
console.log("ℹ️ No providers configured. Listening on 0.0.0.0 without authentication.");
|
||||
}
|
||||
|
||||
const port = config.PORT || 3456;
|
||||
|
||||
// Save the PID of the background process
|
||||
savePid(process.pid);
|
||||
writeFileSync(PID_FILE, process.pid.toString());
|
||||
|
||||
// Handle SIGINT (Ctrl+C) to clean up PID file
|
||||
process.on("SIGINT", () => {
|
||||
console.log("Received SIGINT, cleaning up...");
|
||||
cleanupPidFile();
|
||||
if (existsSync(PID_FILE)) {
|
||||
try {
|
||||
unlinkSync(PID_FILE);
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Handle SIGTERM to clean up PID file
|
||||
process.on("SIGTERM", () => {
|
||||
cleanupPidFile();
|
||||
if (existsSync(PID_FILE)) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
fs.unlinkSync(PID_FILE);
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -94,33 +112,52 @@ async function run(options: RunOptions = {}) {
|
||||
? parseInt(process.env.SERVICE_PORT)
|
||||
: port;
|
||||
|
||||
// Configure logger based on config settings
|
||||
const pad = num => (num > 9 ? "" : "0") + num;
|
||||
const generator = (time, index) => {
|
||||
// Configure logger based on config settings or external options
|
||||
const pad = (num: number) => (num > 9 ? "" : "0") + num;
|
||||
const generator = (time: number | Date | undefined, index: number | undefined) => {
|
||||
let date: Date;
|
||||
if (!time) {
|
||||
time = new Date()
|
||||
date = new Date();
|
||||
} else if (typeof time === 'number') {
|
||||
date = new Date(time);
|
||||
} else {
|
||||
date = time;
|
||||
}
|
||||
|
||||
var month = time.getFullYear() + "" + pad(time.getMonth() + 1);
|
||||
var day = pad(time.getDate());
|
||||
var hour = pad(time.getHours());
|
||||
var minute = pad(time.getMinutes());
|
||||
const month = date.getFullYear() + "" + pad(date.getMonth() + 1);
|
||||
const day = pad(date.getDate());
|
||||
const hour = pad(date.getHours());
|
||||
const minute = pad(date.getMinutes());
|
||||
|
||||
return `./logs/ccr-${month}${day}${hour}${minute}${pad(time.getSeconds())}${index ? `_${index}` : ''}.log`;
|
||||
return `./logs/ccr-${month}${day}${hour}${minute}${pad(date.getSeconds())}${index ? `_${index}` : ''}.log`;
|
||||
};
|
||||
const loggerConfig =
|
||||
config.LOG !== false
|
||||
? {
|
||||
level: config.LOG_LEVEL || "debug",
|
||||
stream: createStream(generator, {
|
||||
path: HOME_DIR,
|
||||
maxFiles: 3,
|
||||
interval: "1d",
|
||||
compress: false,
|
||||
maxSize: "50M"
|
||||
}),
|
||||
}
|
||||
: false;
|
||||
|
||||
let loggerConfig: any;
|
||||
|
||||
// 如果外部传入了 logger 配置,使用外部的
|
||||
if (options.logger !== undefined) {
|
||||
loggerConfig = options.logger;
|
||||
} else {
|
||||
// 如果没有传入,并且 config.LOG !== false,则启用 logger
|
||||
if (config.LOG !== false) {
|
||||
// 将 config.LOG 设为 true(如果它还未设置)
|
||||
if (config.LOG === undefined) {
|
||||
config.LOG = true;
|
||||
}
|
||||
loggerConfig = {
|
||||
level: config.LOG_LEVEL || "debug",
|
||||
stream: createStream(generator, {
|
||||
path: HOME_DIR,
|
||||
maxFiles: 3,
|
||||
interval: "1d",
|
||||
compress: false,
|
||||
maxSize: "50M"
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
loggerConfig = false;
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer({
|
||||
jsonPath: CONFIG_FILE,
|
||||
@@ -147,8 +184,8 @@ async function run(options: RunOptions = {}) {
|
||||
server.logger.error("Unhandled rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
// Add async preHandler hook for authentication
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.addHook("preHandler", async (req: any, reply: any) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const done = (err?: Error) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
@@ -157,7 +194,7 @@ async function run(options: RunOptions = {}) {
|
||||
apiKeyAuth(config)(req, reply, done).catch(reject);
|
||||
});
|
||||
});
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
server.addHook("preHandler", async (req: any, reply: any) => {
|
||||
if (req.url.startsWith("/v1/messages") && !req.url.startsWith("/v1/messages/count_tokens")) {
|
||||
const useAgents = []
|
||||
|
||||
@@ -194,10 +231,10 @@ async function run(options: RunOptions = {}) {
|
||||
});
|
||||
}
|
||||
});
|
||||
server.addHook("onError", async (request, reply, error) => {
|
||||
server.addHook("onError", async (request: any, reply: any, error: any) => {
|
||||
event.emit('onError', request, reply, error);
|
||||
})
|
||||
server.addHook("onSend", (req, reply, payload, done) => {
|
||||
server.addHook("onSend", (req: any, reply: any, payload: any, done: any) => {
|
||||
if (req.sessionId && req.url.startsWith("/v1/messages") && !req.url.startsWith("/v1/messages/count_tokens")) {
|
||||
if (payload instanceof ReadableStream) {
|
||||
if (req.agents) {
|
||||
@@ -281,7 +318,7 @@ async function run(options: RunOptions = {}) {
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
const stream = response.body!.pipeThrough(new SSEParserTransform())
|
||||
const stream = response.body!.pipeThrough(new SSEParserTransform() as any)
|
||||
const reader = stream.getReader()
|
||||
while (true) {
|
||||
try {
|
||||
@@ -289,7 +326,8 @@ async function run(options: RunOptions = {}) {
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (['message_start', 'message_stop'].includes(value.event)) {
|
||||
const eventData = value as any;
|
||||
if (['message_start', 'message_stop'].includes(eventData.event)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -298,7 +336,7 @@ async function run(options: RunOptions = {}) {
|
||||
break;
|
||||
}
|
||||
|
||||
controller.enqueue(value)
|
||||
controller.enqueue(eventData)
|
||||
}catch (readError: any) {
|
||||
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||
abortController.abort(); // 中止所有相关操作
|
||||
@@ -371,7 +409,7 @@ async function run(options: RunOptions = {}) {
|
||||
}
|
||||
done(null, payload)
|
||||
});
|
||||
server.addHook("onSend", async (req, reply, payload) => {
|
||||
server.addHook("onSend", async (req: any, reply: any, payload: any) => {
|
||||
event.emit('onSend', req, reply, payload);
|
||||
return payload;
|
||||
})
|
||||
@@ -381,4 +419,11 @@ async function run(options: RunOptions = {}) {
|
||||
}
|
||||
|
||||
export { run };
|
||||
// run();
|
||||
|
||||
// 如果是直接运行此文件,则启动服务
|
||||
if (require.main === module) {
|
||||
run().catch((error) => {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,13 @@ export const apiKeyAuth =
|
||||
return done();
|
||||
}
|
||||
|
||||
// Check if Providers is empty or not configured
|
||||
const providers = config.Providers || config.providers || [];
|
||||
if (!providers || providers.length === 0) {
|
||||
// No providers configured, skip authentication
|
||||
return done();
|
||||
}
|
||||
|
||||
const apiKey = config.APIKEY;
|
||||
if (!apiKey) {
|
||||
// If no API key is set, enable CORS for local
|
||||
@@ -1,29 +1,28 @@
|
||||
import Server from "@musistudio/llms";
|
||||
import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils";
|
||||
import { checkForUpdates, performUpdate } from "./utils";
|
||||
import { join } from "path";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import {calculateTokenCount} from "./utils/router";
|
||||
|
||||
export const createServer = (config: any): Server => {
|
||||
export const createServer = (config: any): any => {
|
||||
const server = new Server(config);
|
||||
|
||||
server.app.post("/v1/messages/count_tokens", async (req, reply) => {
|
||||
server.app.post("/v1/messages/count_tokens", async (req: any, reply: any) => {
|
||||
const {messages, tools, system} = req.body;
|
||||
const tokenCount = calculateTokenCount(messages, system, tools);
|
||||
return { "input_tokens": tokenCount }
|
||||
});
|
||||
|
||||
// Add endpoint to read config.json with access control
|
||||
server.app.get("/api/config", async (req, reply) => {
|
||||
server.app.get("/api/config", async (req: any, reply: any) => {
|
||||
return await readConfigFile();
|
||||
});
|
||||
|
||||
server.app.get("/api/transformers", async () => {
|
||||
server.app.get("/api/transformers", async (req: any, reply: any) => {
|
||||
const transformers =
|
||||
server.app._server!.transformerService.getAllTransformers();
|
||||
(server.app as any)._server!.transformerService.getAllTransformers();
|
||||
const transformerList = Array.from(transformers.entries()).map(
|
||||
([name, transformer]: any) => ({
|
||||
name,
|
||||
@@ -34,7 +33,7 @@ export const createServer = (config: any): Server => {
|
||||
});
|
||||
|
||||
// Add endpoint to save config.json with access control
|
||||
server.app.post("/api/config", async (req, reply) => {
|
||||
server.app.post("/api/config", async (req: any, reply: any) => {
|
||||
const newConfig = req.body;
|
||||
|
||||
// Backup existing config file if it exists
|
||||
@@ -48,7 +47,7 @@ export const createServer = (config: any): Server => {
|
||||
});
|
||||
|
||||
// Add endpoint to restart the service with access control
|
||||
server.app.post("/api/restart", async (req, reply) => {
|
||||
server.app.post("/api/restart", async (req: any, reply: any) => {
|
||||
reply.send({ success: true, message: "Service restart initiated" });
|
||||
|
||||
// Restart the service after a short delay to allow response to be sent
|
||||
@@ -69,50 +68,12 @@ export const createServer = (config: any): Server => {
|
||||
});
|
||||
|
||||
// Redirect /ui to /ui/ for proper static file serving
|
||||
server.app.get("/ui", async (_, reply) => {
|
||||
server.app.get("/ui", async (_: any, reply: any) => {
|
||||
return reply.redirect("/ui/");
|
||||
});
|
||||
|
||||
// 版本检查端点
|
||||
server.app.get("/api/update/check", async (req, reply) => {
|
||||
try {
|
||||
// 获取当前版本
|
||||
const currentVersion = require("../package.json").version;
|
||||
const { hasUpdate, latestVersion, changelog } = await checkForUpdates(currentVersion);
|
||||
|
||||
return {
|
||||
hasUpdate,
|
||||
latestVersion: hasUpdate ? latestVersion : undefined,
|
||||
changelog: hasUpdate ? changelog : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to check for updates:", error);
|
||||
reply.status(500).send({ error: "Failed to check for updates" });
|
||||
}
|
||||
});
|
||||
|
||||
// 执行更新端点
|
||||
server.app.post("/api/update/perform", async (req, reply) => {
|
||||
try {
|
||||
// 只允许完全访问权限的用户执行更新
|
||||
const accessLevel = (req as any).accessLevel || "restricted";
|
||||
if (accessLevel !== "full") {
|
||||
reply.status(403).send("Full access required to perform updates");
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行更新逻辑
|
||||
const result = await performUpdate();
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to perform update:", error);
|
||||
reply.status(500).send({ error: "Failed to perform update" });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取日志文件列表端点
|
||||
server.app.get("/api/logs/files", async (req, reply) => {
|
||||
server.app.get("/api/logs/files", async (req: any, reply: any) => {
|
||||
try {
|
||||
const logDir = join(homedir(), ".claude-code-router", "logs");
|
||||
const logFiles: Array<{ name: string; path: string; size: number; lastModified: string }> = [];
|
||||
@@ -146,7 +107,7 @@ export const createServer = (config: any): Server => {
|
||||
});
|
||||
|
||||
// 获取日志内容端点
|
||||
server.app.get("/api/logs", async (req, reply) => {
|
||||
server.app.get("/api/logs", async (req: any, reply: any) => {
|
||||
try {
|
||||
const filePath = (req.query as any).file as string;
|
||||
let logFilePath: string;
|
||||
@@ -174,7 +135,7 @@ export const createServer = (config: any): Server => {
|
||||
});
|
||||
|
||||
// 清除日志内容端点
|
||||
server.app.delete("/api/logs", async (req, reply) => {
|
||||
server.app.delete("/api/logs", async (req: any, reply: any) => {
|
||||
try {
|
||||
const filePath = (req.query as any).file as string;
|
||||
let logFilePath: string;
|
||||
21
packages/server/src/types.d.ts
vendored
Normal file
21
packages/server/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
declare module "@musistudio/llms" {
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
export interface ServerConfig {
|
||||
jsonPath?: string;
|
||||
initialConfig?: any;
|
||||
logger?: any;
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
app: FastifyInstance;
|
||||
logger: any;
|
||||
start(): Promise<void>;
|
||||
}
|
||||
|
||||
const Server: {
|
||||
new (config: ServerConfig): Server;
|
||||
};
|
||||
|
||||
export default Server;
|
||||
}
|
||||
@@ -5,9 +5,7 @@ export class SSEParserTransform extends TransformStream<string, any> {
|
||||
constructor() {
|
||||
super({
|
||||
transform: (chunk: string, controller) => {
|
||||
const decoder = new TextDecoder();
|
||||
const text = decoder.decode(chunk);
|
||||
this.buffer += text;
|
||||
this.buffer += chunk;
|
||||
const lines = this.buffer.split('\n');
|
||||
|
||||
// 保留最后一行(可能不完整)
|
||||
173
packages/server/src/utils/index.ts
Normal file
173
packages/server/src/utils/index.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import fs from "node:fs/promises";
|
||||
import readline from "node:readline";
|
||||
import JSON5 from "json5";
|
||||
import path from "node:path";
|
||||
import {
|
||||
CONFIG_FILE,
|
||||
DEFAULT_CONFIG,
|
||||
HOME_DIR,
|
||||
PLUGINS_DIR,
|
||||
} from "@musistudio/claude-code-router-shared";
|
||||
|
||||
// Function to interpolate environment variables in config values
|
||||
const interpolateEnvVars = (obj: any): any => {
|
||||
if (typeof obj === "string") {
|
||||
// Replace $VAR_NAME or ${VAR_NAME} with environment variable values
|
||||
return obj.replace(/\$\{([^}]+)\}|\$([A-Z_][A-Z0-9_]*)/g, (match, braced, unbraced) => {
|
||||
const varName = braced || unbraced;
|
||||
return process.env[varName] || match; // Keep original if env var doesn't exist
|
||||
});
|
||||
} else if (Array.isArray(obj)) {
|
||||
return obj.map(interpolateEnvVars);
|
||||
} else if (obj !== null && typeof obj === "object") {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = interpolateEnvVars(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
const ensureDir = async (dir_path: string) => {
|
||||
try {
|
||||
await fs.access(dir_path);
|
||||
} catch {
|
||||
await fs.mkdir(dir_path, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
export const initDir = async () => {
|
||||
await ensureDir(HOME_DIR);
|
||||
await ensureDir(PLUGINS_DIR);
|
||||
await ensureDir(path.join(HOME_DIR, "logs"));
|
||||
};
|
||||
|
||||
const createReadline = () => {
|
||||
return readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
};
|
||||
|
||||
const question = (query: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
const rl = createReadline();
|
||||
rl.question(query, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const confirm = async (query: string): Promise<boolean> => {
|
||||
const answer = await question(query);
|
||||
return answer.toLowerCase() !== "n";
|
||||
};
|
||||
|
||||
export const readConfigFile = async () => {
|
||||
try {
|
||||
const config = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
try {
|
||||
// Try to parse with JSON5 first (which also supports standard JSON)
|
||||
const parsedConfig = JSON5.parse(config);
|
||||
// Interpolate environment variables in the parsed config
|
||||
return interpolateEnvVars(parsedConfig);
|
||||
} catch (parseError) {
|
||||
console.error(`Failed to parse config file at ${CONFIG_FILE}`);
|
||||
console.error("Error details:", (parseError as Error).message);
|
||||
console.error("Please check your config file syntax.");
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (readError: any) {
|
||||
if (readError.code === "ENOENT") {
|
||||
// Config file doesn't exist, prompt user for initial setup
|
||||
try {
|
||||
// Initialize directories
|
||||
await initDir();
|
||||
|
||||
// Backup existing config file if it exists
|
||||
const backupPath = await backupConfigFile();
|
||||
if (backupPath) {
|
||||
console.log(
|
||||
`Backed up existing configuration file to ${backupPath}`
|
||||
);
|
||||
}
|
||||
const config = {
|
||||
PORT: 3456,
|
||||
Providers: [],
|
||||
Router: {},
|
||||
}
|
||||
// Create a minimal default config file
|
||||
await writeConfigFile(config);
|
||||
console.log(
|
||||
"Created minimal default configuration file at ~/.claude-code-router/config.json"
|
||||
);
|
||||
console.log(
|
||||
"Please edit this file with your actual configuration."
|
||||
);
|
||||
return config
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"Failed to create default configuration:",
|
||||
error.message
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error(`Failed to read config file at ${CONFIG_FILE}`);
|
||||
console.error("Error details:", readError.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const backupConfigFile = async () => {
|
||||
try {
|
||||
if (await fs.access(CONFIG_FILE).then(() => true).catch(() => false)) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupPath = `${CONFIG_FILE}.${timestamp}.bak`;
|
||||
await fs.copyFile(CONFIG_FILE, backupPath);
|
||||
|
||||
// Clean up old backups, keeping only the 3 most recent
|
||||
try {
|
||||
const configDir = path.dirname(CONFIG_FILE);
|
||||
const configFileName = path.basename(CONFIG_FILE);
|
||||
const files = await fs.readdir(configDir);
|
||||
|
||||
// Find all backup files for this config
|
||||
const backupFiles = files
|
||||
.filter(file => file.startsWith(configFileName) && file.endsWith('.bak'))
|
||||
.sort()
|
||||
.reverse(); // Sort in descending order (newest first)
|
||||
|
||||
// Delete all but the 3 most recent backups
|
||||
if (backupFiles.length > 3) {
|
||||
for (let i = 3; i < backupFiles.length; i++) {
|
||||
const oldBackupPath = path.join(configDir, backupFiles[i]);
|
||||
await fs.unlink(oldBackupPath);
|
||||
}
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn("Failed to clean up old backups:", cleanupError);
|
||||
}
|
||||
|
||||
return backupPath;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to backup config file:", error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const writeConfigFile = async (config: any) => {
|
||||
await ensureDir(HOME_DIR);
|
||||
const configWithComment = `${JSON.stringify(config, null, 2)}`;
|
||||
await fs.writeFile(CONFIG_FILE, configWithComment);
|
||||
};
|
||||
|
||||
export const initConfig = async () => {
|
||||
const config = await readConfigFile();
|
||||
Object.assign(process.env, config);
|
||||
return config;
|
||||
};
|
||||
@@ -1,16 +1,35 @@
|
||||
import {
|
||||
MessageCreateParamsBase,
|
||||
MessageParam,
|
||||
Tool,
|
||||
} from "@anthropic-ai/sdk/resources/messages";
|
||||
import { get_encoding } from "tiktoken";
|
||||
import { sessionUsageCache, Usage } from "./cache";
|
||||
import { readFile, access } from "fs/promises";
|
||||
import { opendir, stat } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { CLAUDE_PROJECTS_DIR, HOME_DIR } from "../constants";
|
||||
import { CLAUDE_PROJECTS_DIR, HOME_DIR } from "@musistudio/claude-code-router-shared";
|
||||
import { LRUCache } from "lru-cache";
|
||||
|
||||
// Types from @anthropic-ai/sdk
|
||||
interface Tool {
|
||||
name: string;
|
||||
description?: string;
|
||||
input_schema: object;
|
||||
}
|
||||
|
||||
interface ContentBlockParam {
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface MessageParam {
|
||||
role: string;
|
||||
content: string | ContentBlockParam[];
|
||||
}
|
||||
|
||||
interface MessageCreateParamsBase {
|
||||
messages?: MessageParam[];
|
||||
system?: string | any[];
|
||||
tools?: Tool[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const enc = get_encoding("cl100k_base");
|
||||
|
||||
export const calculateTokenCount = (
|
||||
@@ -232,7 +251,7 @@ export const router = async (req: any, _res: any, context: any) => {
|
||||
// 内存缓存,存储sessionId到项目名称的映射
|
||||
// null值表示之前已查找过但未找到项目
|
||||
// 使用LRU缓存,限制最大1000个条目
|
||||
const sessionProjectCache = new LRUCache<string, string | null>({
|
||||
const sessionProjectCache = new LRUCache<string, string>({
|
||||
max: 1000,
|
||||
});
|
||||
|
||||
@@ -241,7 +260,11 @@ export const searchProjectBySession = async (
|
||||
): Promise<string | null> => {
|
||||
// 首先检查缓存
|
||||
if (sessionProjectCache.has(sessionId)) {
|
||||
return sessionProjectCache.get(sessionId)!;
|
||||
const result = sessionProjectCache.get(sessionId);
|
||||
if (!result || result === '') {
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -283,12 +306,12 @@ export const searchProjectBySession = async (
|
||||
}
|
||||
|
||||
// 缓存未找到的结果(null值表示之前已查找过但未找到项目)
|
||||
sessionProjectCache.set(sessionId, null);
|
||||
sessionProjectCache.set(sessionId, '');
|
||||
return null; // 没有找到匹配的项目
|
||||
} catch (error) {
|
||||
console.error("Error searching for project by session:", error);
|
||||
// 出错时也缓存null结果,避免重复出错
|
||||
sessionProjectCache.set(sessionId, null);
|
||||
sessionProjectCache.set(sessionId, '');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
10
packages/server/tsconfig.json
Normal file
10
packages/server/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
23
packages/shared/package.json
Normal file
23
packages/shared/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@musistudio/claude-code-router-shared",
|
||||
"version": "1.0.73",
|
||||
"description": "Shared utilities and constants for Claude Code Router",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build-shared.js"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
"code",
|
||||
"router",
|
||||
"shared"
|
||||
],
|
||||
"author": "musistudio",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.15",
|
||||
"esbuild": "^0.25.1",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
1
packages/shared/src/index.ts
Normal file
1
packages/shared/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./constants";
|
||||
9
packages/shared/tsconfig.json
Normal file
9
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "temp-project",
|
||||
"name": "@musistudio/claude-code-router-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.73",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
3632
pnpm-lock.yaml
generated
3632
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- 'packages/*'
|
||||
90
scripts/build-cli.js
Normal file
90
scripts/build-cli.js
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
console.log('Building CLI package...');
|
||||
|
||||
try {
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
const sharedDir = path.join(rootDir, 'packages/shared');
|
||||
const cliDir = path.join(rootDir, 'packages/cli');
|
||||
const serverDir = path.join(rootDir, 'packages/server');
|
||||
const uiDir = path.join(rootDir, 'packages/ui');
|
||||
|
||||
// Step 0: Ensure shared package is built first
|
||||
console.log('Ensuring shared package is built...');
|
||||
const sharedDistDir = path.join(sharedDir, 'dist');
|
||||
if (!fs.existsSync(sharedDistDir) || !fs.existsSync(path.join(sharedDistDir, 'index.js'))) {
|
||||
console.log('Shared package not found, building it first...');
|
||||
execSync('node scripts/build-shared.js', {
|
||||
stdio: 'inherit',
|
||||
cwd: rootDir
|
||||
});
|
||||
}
|
||||
|
||||
// Step 1: Build Server package first
|
||||
console.log('Building Server package...');
|
||||
execSync('node scripts/build-server.js', {
|
||||
stdio: 'inherit',
|
||||
cwd: rootDir
|
||||
});
|
||||
|
||||
// Step 2: Build UI package
|
||||
console.log('Building UI package...');
|
||||
execSync('pnpm build', {
|
||||
stdio: 'inherit',
|
||||
cwd: uiDir
|
||||
});
|
||||
|
||||
// Step 3: Create CLI dist directory
|
||||
const cliDistDir = path.join(cliDir, 'dist');
|
||||
if (!fs.existsSync(cliDistDir)) {
|
||||
fs.mkdirSync(cliDistDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Step 4: Build the CLI application
|
||||
console.log('Building CLI application...');
|
||||
execSync('esbuild src/cli.ts --bundle --platform=node --outfile=dist/cli.js', {
|
||||
stdio: 'inherit',
|
||||
cwd: cliDir
|
||||
});
|
||||
|
||||
// Step 5: Copy tiktoken WASM file from server dist to CLI dist
|
||||
console.log('Copying tiktoken_bg.wasm from server to CLI dist...');
|
||||
const tiktokenSource = path.join(serverDir, 'dist/tiktoken_bg.wasm');
|
||||
const tiktokenDest = path.join(cliDistDir, 'tiktoken_bg.wasm');
|
||||
|
||||
if (fs.existsSync(tiktokenSource)) {
|
||||
fs.copyFileSync(tiktokenSource, tiktokenDest);
|
||||
console.log('✓ tiktoken_bg.wasm copied successfully!');
|
||||
} else {
|
||||
console.warn('⚠ Warning: tiktoken_bg.wasm not found in server dist, skipping...');
|
||||
}
|
||||
|
||||
// Step 6: Copy UI index.html from UI dist to CLI dist
|
||||
console.log('Copying index.html from UI to CLI dist...');
|
||||
const uiSource = path.join(uiDir, 'dist/index.html');
|
||||
const uiDest = path.join(cliDistDir, 'index.html');
|
||||
|
||||
if (fs.existsSync(uiSource)) {
|
||||
fs.copyFileSync(uiSource, uiDest);
|
||||
console.log('✓ index.html copied successfully!');
|
||||
} else {
|
||||
console.warn('⚠ Warning: index.html not found in UI dist, skipping...');
|
||||
}
|
||||
|
||||
console.log('CLI build completed successfully!');
|
||||
console.log('\nCLI dist contents:');
|
||||
const files = fs.readdirSync(cliDistDir);
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(cliDistDir, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
const size = (stats.size / 1024 / 1024).toFixed(2);
|
||||
console.log(` - ${file} (${size} MB)`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('CLI build failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
40
scripts/build-server.js
Normal file
40
scripts/build-server.js
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
console.log('Building Server package...');
|
||||
|
||||
try {
|
||||
// Create dist directory
|
||||
const distDir = path.join(__dirname, '../packages/server/dist');
|
||||
if (!fs.existsSync(distDir)) {
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Build the server application
|
||||
console.log('Building server application...');
|
||||
// 使用 minify 和 tree-shaking 优化体积
|
||||
execSync('esbuild src/index.ts --bundle --platform=node --minify --tree-shaking=true --outfile=dist/index.js', {
|
||||
stdio: 'inherit',
|
||||
cwd: path.join(__dirname, '../packages/server')
|
||||
});
|
||||
|
||||
// Copy the tiktoken WASM file
|
||||
console.log('Copying tiktoken WASM file...');
|
||||
const tiktokenSource = path.join(__dirname, '../packages/server/node_modules/tiktoken/tiktoken_bg.wasm');
|
||||
const tiktokenDest = path.join(__dirname, '../packages/server/dist/tiktoken_bg.wasm');
|
||||
|
||||
if (fs.existsSync(tiktokenSource)) {
|
||||
fs.copyFileSync(tiktokenSource, tiktokenDest);
|
||||
console.log('Tiktoken WASM file copied successfully!');
|
||||
} else {
|
||||
console.warn('Warning: tiktoken_bg.wasm not found, skipping...');
|
||||
}
|
||||
|
||||
console.log('Server build completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('Server build failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
36
scripts/build-shared.js
Normal file
36
scripts/build-shared.js
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
console.log('Building Shared package...');
|
||||
|
||||
try {
|
||||
const sharedDir = path.join(__dirname, '../packages/shared');
|
||||
|
||||
// Create dist directory
|
||||
const distDir = path.join(sharedDir, 'dist');
|
||||
if (!fs.existsSync(distDir)) {
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate type declaration files
|
||||
console.log('Generating type declaration files...');
|
||||
execSync('tsc --emitDeclarationOnly', {
|
||||
stdio: 'inherit',
|
||||
cwd: sharedDir
|
||||
});
|
||||
|
||||
// Build the shared package
|
||||
console.log('Building shared package...');
|
||||
execSync('esbuild src/index.ts --bundle --platform=node --minify --tree-shaking=true --outfile=dist/index.js', {
|
||||
stdio: 'inherit',
|
||||
cwd: sharedDir
|
||||
});
|
||||
|
||||
console.log('Shared package build completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('Shared package build failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user