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.
@@ -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
+ }