ai-cc-router 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +32 -0
- package/LICENSE +21 -0
- package/README.md +395 -0
- package/accounts.example.json +16 -0
- package/dist/cli/cmd-accounts.js +142 -0
- package/dist/cli/cmd-configure.js +37 -0
- package/dist/cli/cmd-docker.js +140 -0
- package/dist/cli/cmd-service.js +193 -0
- package/dist/cli/cmd-setup.js +248 -0
- package/dist/cli/cmd-start.js +80 -0
- package/dist/cli/cmd-status.js +46 -0
- package/dist/cli/cmd-stop.js +128 -0
- package/dist/cli/index.js +38 -0
- package/dist/config/manager.js +56 -0
- package/dist/config/paths.js +11 -0
- package/dist/proxy/logger.js +34 -0
- package/dist/proxy/server.js +178 -0
- package/dist/proxy/stats.js +21 -0
- package/dist/proxy/token-pool.js +47 -0
- package/dist/proxy/token-refresher.js +114 -0
- package/dist/proxy/types.js +1 -0
- package/dist/ui/Dashboard.js +110 -0
- package/dist/utils/claude-config.js +76 -0
- package/dist/utils/platform.js +13 -0
- package/dist/utils/token-extractor.js +90 -0
- package/dist/utils/token-validator.js +24 -0
- package/docker-compose.yml +45 -0
- package/litellm-config.yaml +44 -0
- package/package.json +64 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { execFile, spawn } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { dirname } from "path";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { ACCOUNTS_PATH, LITELLM_PORT, PROXY_PORT } from "../config/paths.js";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const COMPOSE_FILE = join(dirname(__filename), "..", "..", "docker-compose.yml");
|
|
12
|
+
export function registerDocker(program) {
|
|
13
|
+
const docker = program
|
|
14
|
+
.command("docker")
|
|
15
|
+
.description("Manage the full Docker stack (cc-router + LiteLLM)");
|
|
16
|
+
docker
|
|
17
|
+
.command("up")
|
|
18
|
+
.description("Start cc-router + LiteLLM with Docker Compose")
|
|
19
|
+
.option("--build", "Rebuild the cc-router image before starting")
|
|
20
|
+
.action(async (opts) => {
|
|
21
|
+
await ensureDockerAvailable();
|
|
22
|
+
await ensureAccountsExist();
|
|
23
|
+
console.log(chalk.cyan("\nStarting cc-router + LiteLLM via Docker Compose...\n"));
|
|
24
|
+
const args = ["compose", "-f", COMPOSE_FILE, "up", "-d"];
|
|
25
|
+
if (opts.build)
|
|
26
|
+
args.push("--build");
|
|
27
|
+
try {
|
|
28
|
+
await spawnInherited("docker", args);
|
|
29
|
+
await waitForHealthy();
|
|
30
|
+
printDockerInfo();
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
console.error(chalk.red("\n✗ docker compose up failed:"), err.message);
|
|
34
|
+
console.error(chalk.gray(" Check logs with: cc-router docker logs"));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
docker
|
|
39
|
+
.command("down")
|
|
40
|
+
.description("Stop and remove Docker containers")
|
|
41
|
+
.option("-v, --volumes", "Also remove named volumes")
|
|
42
|
+
.action(async (opts) => {
|
|
43
|
+
const args = ["compose", "-f", COMPOSE_FILE, "down"];
|
|
44
|
+
if (opts.volumes)
|
|
45
|
+
args.push("-v");
|
|
46
|
+
await spawnInherited("docker", args);
|
|
47
|
+
console.log(chalk.green("\n✓ Containers stopped.\n"));
|
|
48
|
+
});
|
|
49
|
+
docker
|
|
50
|
+
.command("logs")
|
|
51
|
+
.description("Tail Docker Compose logs")
|
|
52
|
+
.option("-f, --follow", "Follow log output", true)
|
|
53
|
+
.option("--service <name>", "Show logs for a specific service (cc-router or litellm)")
|
|
54
|
+
.action(async (opts) => {
|
|
55
|
+
const args = ["compose", "-f", COMPOSE_FILE, "logs"];
|
|
56
|
+
if (opts.follow)
|
|
57
|
+
args.push("-f");
|
|
58
|
+
args.push("--tail=100");
|
|
59
|
+
if (opts.service)
|
|
60
|
+
args.push(opts.service);
|
|
61
|
+
await spawnInherited("docker", args);
|
|
62
|
+
});
|
|
63
|
+
docker
|
|
64
|
+
.command("ps")
|
|
65
|
+
.description("Show running container status")
|
|
66
|
+
.action(async () => {
|
|
67
|
+
await spawnInherited("docker", ["compose", "-f", COMPOSE_FILE, "ps"]);
|
|
68
|
+
});
|
|
69
|
+
docker
|
|
70
|
+
.command("restart")
|
|
71
|
+
.description("Restart a service without rebuilding")
|
|
72
|
+
.argument("[service]", "Service to restart (cc-router or litellm)", "cc-router")
|
|
73
|
+
.action(async (service) => {
|
|
74
|
+
await spawnInherited("docker", ["compose", "-f", COMPOSE_FILE, "restart", service]);
|
|
75
|
+
console.log(chalk.green(`\n✓ ${service} restarted.\n`));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
79
|
+
async function ensureDockerAvailable() {
|
|
80
|
+
try {
|
|
81
|
+
await execFileAsync("docker", ["info"]);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
console.error(chalk.red("✗ Docker is not running or not installed."));
|
|
85
|
+
console.error(chalk.gray(" Install Docker Desktop: https://docs.docker.com/get-docker/"));
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function ensureAccountsExist() {
|
|
90
|
+
if (!existsSync(ACCOUNTS_PATH)) {
|
|
91
|
+
console.error(chalk.red(`✗ accounts.json not found at ${ACCOUNTS_PATH}`));
|
|
92
|
+
console.error(chalk.yellow(" Run: cc-router setup"));
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Wait until the cc-router health endpoint responds OK (max 60s) */
|
|
97
|
+
async function waitForHealthy() {
|
|
98
|
+
process.stdout.write(chalk.gray(" Waiting for services to be healthy"));
|
|
99
|
+
const deadline = Date.now() + 60_000;
|
|
100
|
+
while (Date.now() < deadline) {
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(`http://localhost:${PROXY_PORT}/cc-router/health`, {
|
|
103
|
+
signal: AbortSignal.timeout(1_000),
|
|
104
|
+
});
|
|
105
|
+
if (res.ok) {
|
|
106
|
+
console.log(chalk.green(" ✓"));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// not ready yet
|
|
112
|
+
}
|
|
113
|
+
process.stdout.write(".");
|
|
114
|
+
await sleep(2_000);
|
|
115
|
+
}
|
|
116
|
+
console.log(chalk.yellow(" timed out"));
|
|
117
|
+
console.log(chalk.gray(" Services may still be starting. Check: cc-router docker ps"));
|
|
118
|
+
}
|
|
119
|
+
function printDockerInfo() {
|
|
120
|
+
console.log(chalk.bold("\n Stack is running:\n"));
|
|
121
|
+
console.log(` Proxy: ${chalk.cyan(`http://localhost:${PROXY_PORT}`)}`);
|
|
122
|
+
console.log(` LiteLLM UI: ${chalk.cyan(`http://localhost:${LITELLM_PORT}/ui`)}`);
|
|
123
|
+
console.log(` Health: ${chalk.cyan(`http://localhost:${PROXY_PORT}/cc-router/health`)}`);
|
|
124
|
+
console.log();
|
|
125
|
+
console.log(chalk.gray(" Logs: cc-router docker logs"));
|
|
126
|
+
console.log(chalk.gray(" Stop: cc-router docker down\n"));
|
|
127
|
+
}
|
|
128
|
+
/** Spawn a command with inherited stdio (user sees output in real time) */
|
|
129
|
+
function spawnInherited(cmd, args) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const child = spawn(cmd, args, { stdio: "inherit" });
|
|
132
|
+
child.on("error", reject);
|
|
133
|
+
child.on("close", code => {
|
|
134
|
+
code === 0 ? resolve() : reject(new Error(`exited with code ${code}`));
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function sleep(ms) {
|
|
139
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
140
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { join, dirname } from "path";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import { detectPlatform } from "../utils/platform.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
// Resolve the path to the compiled CLI entry point
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
const CLI_ENTRY = join(__dirname, "index.js");
|
|
12
|
+
export function registerService(program) {
|
|
13
|
+
const service = program
|
|
14
|
+
.command("service")
|
|
15
|
+
.description("Manage cc-router as a system service (auto-start on boot via PM2)");
|
|
16
|
+
service
|
|
17
|
+
.command("install")
|
|
18
|
+
.description("Register cc-router to start automatically on system boot")
|
|
19
|
+
.action(async () => {
|
|
20
|
+
console.log(chalk.cyan("\nInstalling cc-router as a system service...\n"));
|
|
21
|
+
// 1. Verify PM2 is installed
|
|
22
|
+
const pm2Version = await getPm2Version();
|
|
23
|
+
if (!pm2Version) {
|
|
24
|
+
console.log(chalk.yellow("PM2 not found. Installing globally..."));
|
|
25
|
+
try {
|
|
26
|
+
await execFileAsync("npm", ["install", "-g", "pm2"]);
|
|
27
|
+
console.log(chalk.green("✓ PM2 installed"));
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
console.error(chalk.red("✗ Failed to install PM2:"), err.message);
|
|
31
|
+
console.error(chalk.gray(" Try manually: npm install -g pm2"));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
console.log(chalk.green(`✓ PM2 ${pm2Version} found`));
|
|
37
|
+
}
|
|
38
|
+
// 2. Register cc-router in PM2
|
|
39
|
+
console.log(chalk.gray("\nRegistering cc-router in PM2..."));
|
|
40
|
+
try {
|
|
41
|
+
await execFileAsync("pm2", [
|
|
42
|
+
"start", CLI_ENTRY,
|
|
43
|
+
"--name", "cc-router",
|
|
44
|
+
"--interpreter", process.execPath,
|
|
45
|
+
"--max-memory-restart", "500M", // restart if memory exceeds 500MB
|
|
46
|
+
"--",
|
|
47
|
+
"start",
|
|
48
|
+
]);
|
|
49
|
+
console.log(chalk.green("✓ cc-router registered in PM2"));
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
const msg = err.message;
|
|
53
|
+
// PM2 may already have the process — try restart instead
|
|
54
|
+
if (msg.includes("already")) {
|
|
55
|
+
await execFileAsync("pm2", ["restart", "cc-router"]);
|
|
56
|
+
console.log(chalk.green("✓ cc-router restarted in PM2"));
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
console.error(chalk.red("✗ Failed to start in PM2:"), msg);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// 3. Save process list so it survives reboots
|
|
64
|
+
await execFileAsync("pm2", ["save"]);
|
|
65
|
+
console.log(chalk.green("✓ PM2 process list saved"));
|
|
66
|
+
// 4. Generate and apply startup hook
|
|
67
|
+
console.log(chalk.gray("\nConfiguring system startup hook..."));
|
|
68
|
+
console.log(chalk.gray("(may ask for your password on Linux/macOS)\n"));
|
|
69
|
+
try {
|
|
70
|
+
const { stdout, stderr } = await execFileAsync("pm2", ["startup"]);
|
|
71
|
+
const output = stdout + stderr;
|
|
72
|
+
// PM2 prints a sudo command to run if it can't apply it automatically
|
|
73
|
+
const sudoMatch = output.match(/sudo\s+.+/);
|
|
74
|
+
if (sudoMatch) {
|
|
75
|
+
console.log(chalk.yellow("Run this command to complete startup registration:"));
|
|
76
|
+
console.log(chalk.white(` ${sudoMatch[0]}`));
|
|
77
|
+
console.log(chalk.gray("\nThen run: pm2 save"));
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
console.log(chalk.green("✓ System startup hook configured"));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
const msg = err;
|
|
85
|
+
const combined = (msg.stdout ?? "") + (msg.stderr ?? "");
|
|
86
|
+
const sudoMatch = combined.match(/sudo\s+.+/);
|
|
87
|
+
if (sudoMatch) {
|
|
88
|
+
console.log(chalk.yellow("\nRun this command to complete startup registration:"));
|
|
89
|
+
console.log(chalk.white(` ${sudoMatch[0]}`));
|
|
90
|
+
console.log(chalk.gray("\nThen run: pm2 save"));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
console.log(chalk.yellow("⚠ Could not configure startup hook automatically."));
|
|
94
|
+
printManualStartupInstructions();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
printServiceInfo();
|
|
98
|
+
});
|
|
99
|
+
service
|
|
100
|
+
.command("uninstall")
|
|
101
|
+
.description("Remove cc-router from system startup")
|
|
102
|
+
.action(async () => {
|
|
103
|
+
let removed = false;
|
|
104
|
+
try {
|
|
105
|
+
await execFileAsync("pm2", ["stop", "cc-router"]);
|
|
106
|
+
await execFileAsync("pm2", ["delete", "cc-router"]);
|
|
107
|
+
await execFileAsync("pm2", ["save"]);
|
|
108
|
+
console.log(chalk.green("✓ cc-router removed from PM2"));
|
|
109
|
+
removed = true;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
console.log(chalk.gray("cc-router was not registered in PM2."));
|
|
113
|
+
}
|
|
114
|
+
// Remove Claude Code proxy config too
|
|
115
|
+
const { removeClaudeSettings } = await import("../utils/claude-config.js");
|
|
116
|
+
const { readClaudeProxySettings } = await import("../utils/claude-config.js");
|
|
117
|
+
if (readClaudeProxySettings().baseUrl) {
|
|
118
|
+
removeClaudeSettings();
|
|
119
|
+
console.log(chalk.green("✓ Removed proxy settings from ~/.claude/settings.json"));
|
|
120
|
+
removed = true;
|
|
121
|
+
}
|
|
122
|
+
if (removed) {
|
|
123
|
+
console.log(chalk.green("\n✓ Service uninstalled. Claude Code will use normal authentication.\n"));
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
console.log(chalk.gray("\nNothing to uninstall.\n"));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
service
|
|
130
|
+
.command("status")
|
|
131
|
+
.description("Show the service status in PM2")
|
|
132
|
+
.action(async () => {
|
|
133
|
+
try {
|
|
134
|
+
const { stdout } = await execFileAsync("pm2", ["info", "cc-router"]);
|
|
135
|
+
console.log(stdout);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
console.log(chalk.yellow("cc-router is not registered as a PM2 service."));
|
|
139
|
+
console.log(chalk.gray(" Install it with: cc-router service install"));
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
service
|
|
143
|
+
.command("logs")
|
|
144
|
+
.description("Tail the proxy logs from PM2")
|
|
145
|
+
.option("--lines <n>", "Number of lines to show", "50")
|
|
146
|
+
.action(async (opts) => {
|
|
147
|
+
try {
|
|
148
|
+
// pm2 logs streams continuously — spawn it directly so it inherits stdio
|
|
149
|
+
const { spawn } = await import("child_process");
|
|
150
|
+
const child = spawn("pm2", ["logs", "cc-router", "--lines", opts.lines], {
|
|
151
|
+
stdio: "inherit",
|
|
152
|
+
});
|
|
153
|
+
child.on("error", () => {
|
|
154
|
+
console.log(chalk.yellow("PM2 not found. Is cc-router installed as a service?"));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
console.error(chalk.red("Could not tail logs."));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
163
|
+
async function getPm2Version() {
|
|
164
|
+
try {
|
|
165
|
+
const { stdout } = await execFileAsync("pm2", ["--version"]);
|
|
166
|
+
return stdout.trim();
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function printServiceInfo() {
|
|
173
|
+
console.log(chalk.bold("\n━━━ Service installed ━━━━━━━━━━━━━━━━━━━━━━━━\n"));
|
|
174
|
+
console.log(` Check status: ${chalk.cyan("cc-router service status")}`);
|
|
175
|
+
console.log(` View logs: ${chalk.cyan("cc-router service logs")}`);
|
|
176
|
+
console.log(` Stop & remove: ${chalk.cyan("cc-router service uninstall")}`);
|
|
177
|
+
console.log(` Restart: ${chalk.cyan("pm2 restart cc-router")}`);
|
|
178
|
+
console.log();
|
|
179
|
+
}
|
|
180
|
+
function printManualStartupInstructions() {
|
|
181
|
+
const platform = detectPlatform();
|
|
182
|
+
console.log(chalk.gray("\n To configure auto-start manually:"));
|
|
183
|
+
if (platform === "macos") {
|
|
184
|
+
console.log(chalk.gray(" macOS (launchd): pm2 startup launchd && pm2 save"));
|
|
185
|
+
}
|
|
186
|
+
else if (platform === "linux") {
|
|
187
|
+
console.log(chalk.gray(" Linux (systemd): pm2 startup systemd && pm2 save"));
|
|
188
|
+
console.log(chalk.gray(" Then: sudo systemctl enable pm2-$(whoami)"));
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
console.log(chalk.gray(" Windows: see https://github.com/jessety/pm2-installer"));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { select, input, confirm, password } from "@inquirer/prompts";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { detectPlatform, isMacos } from "../utils/platform.js";
|
|
4
|
+
import { extractFromKeychain, extractFromCredentialsFile, formatExpiry, redactToken, } from "../utils/token-extractor.js";
|
|
5
|
+
import { validateToken } from "../utils/token-validator.js";
|
|
6
|
+
import { writeClaudeSettings } from "../utils/claude-config.js";
|
|
7
|
+
import { saveAccounts, } from "../proxy/token-refresher.js";
|
|
8
|
+
import { loadAccounts, accountsFileExists } from "../config/manager.js";
|
|
9
|
+
import { PROXY_PORT } from "../config/paths.js";
|
|
10
|
+
// ─── Public registration ──────────────────────────────────────────────────────
|
|
11
|
+
export function registerSetup(program) {
|
|
12
|
+
program
|
|
13
|
+
.command("setup")
|
|
14
|
+
.description("Interactive wizard: extract tokens and configure Claude Code automatically")
|
|
15
|
+
.option("--add", "Add a new account to an existing configuration (skip intro questions)")
|
|
16
|
+
.action(async (opts) => {
|
|
17
|
+
await runSetupWizard({ addMode: opts.add ?? false });
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
// ─── Shared single-account setup (also used by `accounts add`) ───────────────
|
|
21
|
+
export async function setupSingleAccount(index) {
|
|
22
|
+
const platform = detectPlatform();
|
|
23
|
+
const choices = [];
|
|
24
|
+
if (isMacos()) {
|
|
25
|
+
choices.push({ name: "Extract automatically from macOS Keychain (recommended)", value: "keychain" });
|
|
26
|
+
}
|
|
27
|
+
choices.push({ name: "Read from ~/.claude/.credentials.json", value: "credentials" });
|
|
28
|
+
choices.push({ name: "Paste tokens manually", value: "manual" });
|
|
29
|
+
const method = await select({
|
|
30
|
+
message: "How do you want to add the tokens?",
|
|
31
|
+
choices,
|
|
32
|
+
});
|
|
33
|
+
let tokens = null;
|
|
34
|
+
if (method === "keychain") {
|
|
35
|
+
process.stdout.write(chalk.gray(" Extracting from Keychain... "));
|
|
36
|
+
tokens = await extractFromKeychain();
|
|
37
|
+
if (tokens) {
|
|
38
|
+
console.log(chalk.green("✓"));
|
|
39
|
+
console.log(chalk.gray(` Token: ${redactToken(tokens.accessToken)}`));
|
|
40
|
+
console.log(chalk.gray(` Expiry: ${formatExpiry(tokens.expiresAt)}`));
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
console.log(chalk.red("✗"));
|
|
44
|
+
console.log(chalk.yellow(" Could not find credentials in Keychain."));
|
|
45
|
+
console.log(chalk.gray(" Make sure Claude Code is logged in: run `claude login` first."));
|
|
46
|
+
const retry = await confirm({ message: "Try another extraction method?", default: true });
|
|
47
|
+
if (!retry)
|
|
48
|
+
return null;
|
|
49
|
+
return setupSingleAccount(index);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (method === "credentials") {
|
|
53
|
+
tokens = extractFromCredentialsFile();
|
|
54
|
+
if (tokens) {
|
|
55
|
+
console.log(chalk.green(` ✓ Found credentials in ~/.claude/.credentials.json`));
|
|
56
|
+
console.log(chalk.gray(` Token: ${redactToken(tokens.accessToken)}`));
|
|
57
|
+
console.log(chalk.gray(` Expiry: ${formatExpiry(tokens.expiresAt)}`));
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
console.log(chalk.red(" ✗ ~/.claude/.credentials.json not found or unreadable."));
|
|
61
|
+
console.log(chalk.gray(" Make sure Claude Code is installed and you've run `claude login`."));
|
|
62
|
+
const retry = await confirm({ message: "Paste tokens manually instead?", default: true });
|
|
63
|
+
if (!retry)
|
|
64
|
+
return null;
|
|
65
|
+
tokens = await promptManualTokens();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (method === "manual") {
|
|
69
|
+
tokens = await promptManualTokens();
|
|
70
|
+
}
|
|
71
|
+
if (!tokens)
|
|
72
|
+
return null;
|
|
73
|
+
// Ask for account ID
|
|
74
|
+
const defaultId = `max-account-${index}`;
|
|
75
|
+
const accountId = await input({
|
|
76
|
+
message: "Account ID (press Enter to accept default):",
|
|
77
|
+
default: defaultId,
|
|
78
|
+
validate: (v) => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
|
|
79
|
+
});
|
|
80
|
+
// Validate tokens against Anthropic API
|
|
81
|
+
process.stdout.write(chalk.gray(" Validating tokens against Anthropic... "));
|
|
82
|
+
const validation = await validateToken(tokens.accessToken);
|
|
83
|
+
if (validation.valid) {
|
|
84
|
+
console.log(chalk.green("✓ Valid"));
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
console.log(chalk.red("✗ Invalid"));
|
|
88
|
+
console.log(chalk.yellow(` Reason: ${validation.reason}`));
|
|
89
|
+
console.log(chalk.gray(" The token will be saved but may not work until refreshed."));
|
|
90
|
+
const keepAnyway = await confirm({
|
|
91
|
+
message: "Save this account anyway?",
|
|
92
|
+
default: false,
|
|
93
|
+
});
|
|
94
|
+
if (!keepAnyway)
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
id: accountId,
|
|
99
|
+
tokens,
|
|
100
|
+
healthy: validation.valid,
|
|
101
|
+
busy: false,
|
|
102
|
+
requestCount: 0,
|
|
103
|
+
errorCount: 0,
|
|
104
|
+
lastUsed: 0,
|
|
105
|
+
lastRefresh: 0,
|
|
106
|
+
consecutiveErrors: 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// ─── Full wizard ──────────────────────────────────────────────────────────────
|
|
110
|
+
async function runSetupWizard({ addMode }) {
|
|
111
|
+
const platform = detectPlatform();
|
|
112
|
+
const hasExisting = accountsFileExists();
|
|
113
|
+
printBanner();
|
|
114
|
+
console.log(chalk.gray(`Platform: ${platform}\n`));
|
|
115
|
+
// If accounts already exist and we're not in add-mode, ask what to do
|
|
116
|
+
if (hasExisting && !addMode) {
|
|
117
|
+
const existing = loadAccounts();
|
|
118
|
+
console.log(chalk.yellow(` Found ${existing.length} existing account(s).\n`));
|
|
119
|
+
const action = await select({
|
|
120
|
+
message: "What do you want to do?",
|
|
121
|
+
choices: [
|
|
122
|
+
{ name: "Add more accounts to the existing configuration", value: "add" },
|
|
123
|
+
{ name: "Start fresh (replace all accounts)", value: "replace" },
|
|
124
|
+
{ name: "Cancel", value: "cancel" },
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
if (action === "cancel") {
|
|
128
|
+
console.log(chalk.gray("\nCancelled.\n"));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (action === "replace") {
|
|
132
|
+
const sure = await confirm({
|
|
133
|
+
message: chalk.red("This will delete all existing accounts. Are you sure?"),
|
|
134
|
+
default: false,
|
|
135
|
+
});
|
|
136
|
+
if (!sure) {
|
|
137
|
+
console.log(chalk.gray("\nCancelled.\n"));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// If 'add', we'll merge below
|
|
142
|
+
}
|
|
143
|
+
// Guide for multi-account setup
|
|
144
|
+
if (!addMode && isMacos()) {
|
|
145
|
+
console.log(chalk.cyan(" Tip: to add multiple accounts, you need to:"));
|
|
146
|
+
console.log(chalk.gray(" 1. Log in to Claude Code with account 1 (already done if you use CC normally)"));
|
|
147
|
+
console.log(chalk.gray(" 2. Extract tokens → log out → log in with account 2 → extract → repeat\n"));
|
|
148
|
+
}
|
|
149
|
+
let numAccounts = 1;
|
|
150
|
+
if (!addMode) {
|
|
151
|
+
const { number } = await import("@inquirer/prompts");
|
|
152
|
+
numAccounts = await number({
|
|
153
|
+
message: "How many accounts do you want to configure now?",
|
|
154
|
+
default: 1,
|
|
155
|
+
min: 1,
|
|
156
|
+
max: 20,
|
|
157
|
+
}) ?? 1;
|
|
158
|
+
}
|
|
159
|
+
const newAccounts = [];
|
|
160
|
+
for (let i = 0; i < numAccounts; i++) {
|
|
161
|
+
const label = numAccounts > 1 ? `${i + 1}/${numAccounts}` : "";
|
|
162
|
+
console.log(chalk.bold(`\n${"━".repeat(40)}\n Account ${label}\n${"━".repeat(40)}\n`));
|
|
163
|
+
// If on macOS and this isn't the first account, remind user to switch accounts
|
|
164
|
+
if (i > 0 && isMacos()) {
|
|
165
|
+
console.log(chalk.yellow(` Before extracting account ${i + 1}:\n` +
|
|
166
|
+
` 1. Run: ${chalk.white("claude logout")}\n` +
|
|
167
|
+
` 2. Run: ${chalk.white("claude login")} (log in with your next Max account)\n`));
|
|
168
|
+
await confirm({ message: "Ready?", default: true });
|
|
169
|
+
}
|
|
170
|
+
const account = await setupSingleAccount(i + 1 + (hasExisting ? loadAccounts().length : 0));
|
|
171
|
+
if (account) {
|
|
172
|
+
newAccounts.push(account);
|
|
173
|
+
console.log(chalk.green(`\n ✓ Account "${account.id}" ready.\n`));
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
console.log(chalk.yellow(` ↷ Skipped account ${i + 1}.\n`));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (newAccounts.length === 0) {
|
|
180
|
+
console.log(chalk.red("\n✗ No accounts configured. Run cc-router setup again.\n"));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// Merge with existing accounts (by ID — new entries win on conflict)
|
|
184
|
+
const existing = hasExisting && !addMode ? [] : (hasExisting ? loadAccounts() : []);
|
|
185
|
+
const existingIds = new Set(existing.map(a => a.id));
|
|
186
|
+
const merged = [
|
|
187
|
+
...existing.filter(a => !newAccounts.some(n => n.id === a.id)),
|
|
188
|
+
...newAccounts,
|
|
189
|
+
];
|
|
190
|
+
console.log(chalk.bold(`\n${"━".repeat(40)}\n Saving configuration\n${"━".repeat(40)}\n`));
|
|
191
|
+
// Save accounts.json (atomic write)
|
|
192
|
+
saveAccounts(merged);
|
|
193
|
+
console.log(chalk.green(` ✓ Saved ${merged.length} account(s) to ~/.cc-router/accounts.json`));
|
|
194
|
+
// Write ~/.claude/settings.json
|
|
195
|
+
writeClaudeSettings(PROXY_PORT);
|
|
196
|
+
console.log(chalk.green(` ✓ Updated ~/.claude/settings.json`));
|
|
197
|
+
console.log(chalk.gray(` ANTHROPIC_BASE_URL = http://localhost:${PROXY_PORT}`));
|
|
198
|
+
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
|
|
199
|
+
printNextSteps(merged.length);
|
|
200
|
+
}
|
|
201
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
202
|
+
async function promptManualTokens() {
|
|
203
|
+
console.log(chalk.gray("\n You can find your tokens by running:\n" +
|
|
204
|
+
" macOS: security find-generic-password -s 'Claude Code-credentials' -w\n" +
|
|
205
|
+
" Linux/Windows: cat ~/.claude/.credentials.json\n"));
|
|
206
|
+
const accessToken = await password({
|
|
207
|
+
message: "Paste accessToken (sk-ant-oat01-...):",
|
|
208
|
+
mask: "•",
|
|
209
|
+
validate: (v) => v.startsWith("sk-ant-oat01-") || v.startsWith("sk-ant-")
|
|
210
|
+
? true
|
|
211
|
+
: "Must start with sk-ant-oat01-",
|
|
212
|
+
});
|
|
213
|
+
const refreshToken = await password({
|
|
214
|
+
message: "Paste refreshToken (sk-ant-ort01-...):",
|
|
215
|
+
mask: "•",
|
|
216
|
+
validate: (v) => v.startsWith("sk-ant-ort01-") || v.startsWith("sk-ant-")
|
|
217
|
+
? true
|
|
218
|
+
: "Must start with sk-ant-ort01-",
|
|
219
|
+
});
|
|
220
|
+
// expiresAt is optional — default to 8h from now
|
|
221
|
+
const useDefaultExpiry = await confirm({
|
|
222
|
+
message: "Use default expiry (8 hours from now)?",
|
|
223
|
+
default: true,
|
|
224
|
+
});
|
|
225
|
+
const expiresAt = useDefaultExpiry
|
|
226
|
+
? Date.now() + 8 * 60 * 60 * 1000
|
|
227
|
+
: new Date(await input({
|
|
228
|
+
message: "Paste expiresAt (ISO date or ms timestamp):",
|
|
229
|
+
})).getTime();
|
|
230
|
+
return {
|
|
231
|
+
accessToken,
|
|
232
|
+
refreshToken,
|
|
233
|
+
expiresAt,
|
|
234
|
+
scopes: ["user:inference", "user:profile"],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function printBanner() {
|
|
238
|
+
console.log(chalk.cyan("\n╔══════════════════════════════════════════╗\n" +
|
|
239
|
+
"║ CC-Router — Setup ║\n" +
|
|
240
|
+
"╚══════════════════════════════════════════╝\n"));
|
|
241
|
+
}
|
|
242
|
+
function printNextSteps(accountCount) {
|
|
243
|
+
console.log(chalk.bold(`\n${"━".repeat(40)}\n Done — ${accountCount} account(s) configured\n${"━".repeat(40)}\n`));
|
|
244
|
+
console.log(` Start proxy: ${chalk.cyan("cc-router start")}`);
|
|
245
|
+
console.log(` Auto-start: ${chalk.cyan("cc-router service install")}`);
|
|
246
|
+
console.log(` Live dashboard: ${chalk.cyan("cc-router status")}`);
|
|
247
|
+
console.log(` Add more accounts: ${chalk.cyan("cc-router setup --add")}\n`);
|
|
248
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { PROXY_PORT, LITELLM_PORT, ACCOUNTS_PATH } from "../config/paths.js";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
export function registerStart(program) {
|
|
7
|
+
program
|
|
8
|
+
.command("start")
|
|
9
|
+
.description("Start the proxy server")
|
|
10
|
+
.option("--port <port>", "Port to listen on", String(PROXY_PORT))
|
|
11
|
+
.option("--daemon", "Run in background via PM2 (requires: cc-router service install)")
|
|
12
|
+
.option("--litellm [url]", "Forward to LiteLLM instead of Anthropic directly (default URL: http://localhost:4000)")
|
|
13
|
+
.option("--accounts <path>", "Path to accounts.json", ACCOUNTS_PATH)
|
|
14
|
+
.action(async (opts) => {
|
|
15
|
+
if (opts.daemon) {
|
|
16
|
+
await startDaemon();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const litellmUrl = opts.litellm
|
|
20
|
+
? (typeof opts.litellm === "string" ? opts.litellm : `http://localhost:${LITELLM_PORT}`)
|
|
21
|
+
: undefined;
|
|
22
|
+
// If --litellm is set and no URL is provided, try to start LiteLLM via Docker
|
|
23
|
+
if (opts.litellm && typeof opts.litellm !== "string") {
|
|
24
|
+
await ensureLiteLLMRunning();
|
|
25
|
+
}
|
|
26
|
+
const { startServer } = await import("../proxy/server.js");
|
|
27
|
+
await startServer({
|
|
28
|
+
port: parseInt(opts.port, 10),
|
|
29
|
+
litellmUrl,
|
|
30
|
+
accountsPath: opts.accounts !== ACCOUNTS_PATH ? opts.accounts : undefined,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function startDaemon() {
|
|
35
|
+
try {
|
|
36
|
+
await execFileAsync("pm2", ["restart", "cc-router"]);
|
|
37
|
+
console.log(chalk.green("✓ cc-router restarted via PM2"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
console.error(chalk.red("✗ cc-router is not registered as a PM2 service."));
|
|
41
|
+
console.error(chalk.gray(" Set it up first: cc-router service install"));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Start only the LiteLLM container if it's not already responding */
|
|
46
|
+
async function ensureLiteLLMRunning() {
|
|
47
|
+
const litellmUrl = `http://localhost:${LITELLM_PORT}`;
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`${litellmUrl}/health`, { signal: AbortSignal.timeout(1_000) });
|
|
50
|
+
if (res.ok) {
|
|
51
|
+
console.log(chalk.green(`✓ LiteLLM already running at ${litellmUrl}`));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Not running — start it
|
|
57
|
+
}
|
|
58
|
+
console.log(chalk.cyan("Starting LiteLLM via Docker..."));
|
|
59
|
+
try {
|
|
60
|
+
await execFileAsync("docker", ["info"]);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
console.error(chalk.red("✗ Docker is not running. Start Docker Desktop first."));
|
|
64
|
+
console.error(chalk.gray(" Or pass a custom LiteLLM URL: cc-router start --litellm http://your-host:4000"));
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const { spawn } = await import("child_process");
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
const child = spawn("docker", ["compose", "up", "-d", "litellm"], { stdio: "inherit" });
|
|
71
|
+
child.on("error", reject);
|
|
72
|
+
child.on("close", code => code === 0 ? resolve() : reject(new Error(`exit ${code}`)));
|
|
73
|
+
});
|
|
74
|
+
console.log(chalk.green(`✓ LiteLLM starting at ${litellmUrl}/ui`));
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
console.error(chalk.red("✗ Failed to start LiteLLM:"), err.message);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
}
|