From e04295a1a03a2319e8e388016f55700888f09bf2 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 04:42:04 -0500 Subject: [PATCH 01/26] chore(gitignore): remove logs directory from tracking --- .gitignore | 5 +- logs/main.log | 175 -------------------------------------------------- 2 files changed, 4 insertions(+), 176 deletions(-) delete mode 100644 logs/main.log diff --git a/.gitignore b/.gitignore index fcffb332..67400651 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,7 @@ yarn.lock pnpm-lock.yaml package-lock.json -.claude/active-plan \ No newline at end of file +.claude/active-plan + +# Logs directory +logs/ \ No newline at end of file diff --git a/logs/main.log b/logs/main.log deleted file mode 100644 index c340cadf..00000000 --- a/logs/main.log +++ /dev/null @@ -1,175 +0,0 @@ -[2025-12-12 02:50:06] [info] [main.go:413] CLIProxyAPI Version: 6.6.5, Commit: fc054db, BuiltAt: 2025-12-12T00:54:34Z -[2025-12-12 02:50:06] [info] [server.go:466] management routes registered after secret key configuration -[2025-12-12 02:50:06] [info] [watcher.go:1000] full client load complete - 5 clients (5 auth files + 0 Gemini API keys + 0 Vertex API keys + 0 Claude API keys + 0 Codex keys + 0 OpenAI-compat) -[2025-12-12 02:50:06] [info] [service.go:543] file watcher started for config and auth directory changes -[2025-12-12 02:50:06] [info] [service.go:549] core auth auto-refresh started (interval=15m0s) -[2025-12-12 02:50:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:18 | 200 | 0s | 127.0.0.1 | GET "/" -[2025-12-12 02:50:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:18 | 200 | 0s | 127.0.0.1 | GET "/v1/models" -[2025-12-12 02:50:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:19 | 200 | 0s | ::1 | GET "/" -[2025-12-12 02:50:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:19 | 200 | 0s | ::1 | GET "/" -[2025-12-12 02:50:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:19 | 200 | 49ms | ::1 | GET "/v0/management/debug" -[2025-12-12 02:50:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:19 | 200 | 50ms | ::1 | GET "/v0/management/config" -[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/usage" -[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 0s | ::1 | GET "/v1/models" -[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/config.yaml" -[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/auth-files" -[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/config" -[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models" -[2025-12-12 02:50:32] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:32 | 200 | 0s | 127.0.0.1 | GET "/" -[2025-12-12 02:50:32] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:32 | 200 | 0s | 127.0.0.1 | GET "/v1/models" -[2025-12-12 02:50:42] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:42 | 200 | 2.115s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:50:43] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:43 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" -[2025-12-12 02:50:43] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:43 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" -[2025-12-12 02:50:43] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:43 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" -[2025-12-12 02:50:43] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:43 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" -[2025-12-12 02:50:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:44 | 200 | 773ms | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:50:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:50:45] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:45 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:50:45] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:45 | 200 | 1.568s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:50:45] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:45 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" -[2025-12-12 02:50:45] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:45 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" -[2025-12-12 02:50:47] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:47 | 200 | 1.16s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:50:47] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:47 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:50:48] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:48 | 200 | 6.774s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:50:49] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:49 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:50:50] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:50 | 200 | 7.951s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:50:50] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:50 | 200 | 0s | ::1 | GET "/" -[2025-12-12 02:50:50] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:50 | 200 | 0s | ::1 | GET "/" -[2025-12-12 02:50:50] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:50 | 200 | 50ms | ::1 | GET "/v0/management/debug" -[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 49ms | ::1 | GET "/v0/management/config" -[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 49ms | ::1 | GET "/v0/management/usage" -[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 0s | ::1 | GET "/v1/models" -[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 53ms | ::1 | GET "/v0/management/auth-files" -[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 53ms | ::1 | GET "/v0/management/config.yaml" -[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 49ms | ::1 | GET "/v0/management/config" -[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models" -[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:50:52] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:52 | 200 | 2.247s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:50:55] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:55 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:50:55] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:55 | 200 | 13.83s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:50:55] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:55 | 200 | 3.023s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:50:55] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:55 | 200 | 10.921s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:00] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:00 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" -[2025-12-12 02:51:00] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:00 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" -[2025-12-12 02:51:00] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:00 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" -[2025-12-12 02:51:00] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:00 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" -[2025-12-12 02:51:02] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:02 | 200 | 1.16s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:02] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:02 | 200 | 1.254s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:02] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:02 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" -[2025-12-12 02:51:02] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:02 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:02] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:02 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" -[2025-12-12 02:51:03] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:03 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:05] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:05 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:06] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:06 | 200 | 3.412s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:06] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:06 | 200 | 8.706s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:07] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:07 | 200 | 6.799s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:08] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:08 | 200 | 10.614s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:09] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:09 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:10] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:10 | 200 | 1.661s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:11] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:11 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:13 | 200 | 3.155s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:16] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:16 | 200 | 8.675s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:17] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:17 | 200 | 1.132s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:17] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:17 | 200 | 1.16s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:17] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:17 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:18 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:18 | 200 | 2.018s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:18 | 200 | 2.137s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:18 | 200 | 2.219s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:19 | 200 | 2.993s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:20 | 200 | 955ms | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:20 | 200 | 1.468s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:21] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:21 | 200 | 23.465s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:21] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:21 | 200 | 2.506s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:22] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:22 | 200 | 3.102s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 50ms | ::1 | GET "/v0/management/config" -[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 50ms | ::1 | GET "/v0/management/usage" -[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 0s | ::1 | GET "/v1/models" -[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 50ms | ::1 | GET "/v0/management/config.yaml" -[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 50ms | ::1 | GET "/v0/management/auth-files" -[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 49ms | ::1 | GET "/v0/management/config" -[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models" -[2025-12-12 02:51:29] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:29 | 200 | 6.533s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:30] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:30 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:30] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:30 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:31] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:31 | 200 | 1.337s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:34 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:35] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:35 | 200 | 5.291s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:36] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:36 | 200 | 1.482s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:38] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:38 | 200 | 2.867s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:40] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:40 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:40] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:40 | 200 | 5.468s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:43 | 200 | 7.802s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:45] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:45 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:47] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:47 | 200 | 4.344s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:48] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:48 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:48] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:48 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:52] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:52 | 200 | 4.022s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:52] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:52 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:51:54] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:54 | 200 | 5.541s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:57] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:57 | 200 | 3.579s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:58 | 200 | 4.434s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:51:59] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:59 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:52:01] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:01 | 200 | 6.851s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:52:03] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:03 | 200 | 2.477s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:52:04] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:04 | 200 | 2.796s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:52:06] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:06 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:52:08] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:08 | 200 | 3.953s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:52:09] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:09 | 200 | 741ms | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:52:11] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:11 | 200 | 1.983s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:52:12] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:12 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:52:12] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:12 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:52:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:52:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:13 | 200 | 3.488s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:52:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:18 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/config" -[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/usage" -[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 0s | ::1 | GET "/v1/models" -[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/config.yaml" -[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/auth-files" -[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/config" -[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models" -[2025-12-12 02:52:42] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:42 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:52:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:52:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:53:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:53:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:53:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:53:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:18 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:53:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:43 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:53:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:53:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:54:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:54:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:54:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:54:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:43 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:54:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:54:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:55:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:14 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:55:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:55:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:55:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:44 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:55:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:55:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:56:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:56:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:14 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:56:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:56:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:56:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:56:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:56:57] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:57 | 200 | 49ms | ::1 | GET "/v0/management/config" -[2025-12-12 02:56:57] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:57 | 200 | 49ms | ::1 | GET "/v0/management/usage" -[2025-12-12 02:56:57] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:57 | 200 | 0s | ::1 | GET "/v1/models" -[2025-12-12 02:56:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:58 | 200 | 49ms | ::1 | GET "/v0/management/config.yaml" -[2025-12-12 02:56:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:58 | 200 | 49ms | ::1 | GET "/v0/management/auth-files" -[2025-12-12 02:56:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:58 | 200 | 49ms | ::1 | GET "/v0/management/config" -[2025-12-12 02:56:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:58 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models" -[2025-12-12 02:57:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:14 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:57:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:57:15] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:15 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:57:16] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:16 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:57:22] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:22 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" -[2025-12-12 02:57:22] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:22 | 200 | 5.549s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" -[2025-12-12 02:57:26] [error] [service.go:613] error stopping API server: failed to shutdown HTTP server: context deadline exceeded -[2025-12-12 02:57:26] [error] [service.go:425] service shutdown returned error: failed to shutdown HTTP server: context deadline exceeded From 0b23ec2c0afc972b1b2037afdc0a5ec740b1c538 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Dec 2025 09:45:50 +0000 Subject: [PATCH 02/26] chore(release): 5.16.0-dev.2 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index cb480c81..28356fec 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.16.0 +5.16.0-dev.2 diff --git a/package.json b/package.json index 9f66780e..b89e0194 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.16.0", + "version": "5.16.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 4005f1c01ca9fa921978664a0a1b929689513456 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 05:11:59 -0500 Subject: [PATCH 03/26] feat(cli): standardize UI output with ui.ts abstraction layer --- src/cliproxy/auth-handler.ts | 39 +++++----- src/cliproxy/binary-manager.ts | 11 ++- src/cliproxy/cliproxy-executor.ts | 29 ++++---- src/cliproxy/config-generator.ts | 5 +- src/commands/cliproxy-command.ts | 20 ++--- src/commands/install-command.ts | 16 ++-- src/commands/migrate-command.ts | 41 ++++++----- src/commands/shell-completion-command.ts | 19 ++--- src/commands/sync-command.ts | 9 ++- src/commands/update-command.ts | 93 +++++++++++------------- src/commands/version-command.ts | 37 +++++----- src/config/migration-manager.ts | 11 +-- src/delegation/delegation-handler.ts | 11 +-- src/delegation/headless-executor.ts | 32 ++++---- src/management/doctor.ts | 4 +- src/management/recovery-manager.ts | 5 +- src/management/shared-manager.ts | 37 +++++----- src/utils/claude-dir-installer.ts | 6 +- src/utils/claude-symlink-manager.ts | 6 +- src/utils/config-manager.ts | 6 +- src/utils/update-checker.ts | 12 ++- src/web-server/shutdown.ts | 7 +- src/web-server/usage-disk-cache.ts | 13 ++-- src/web-server/usage-routes.ts | 29 ++++---- src/web-server/websocket.ts | 13 ++-- 25 files changed, 262 insertions(+), 249 deletions(-) diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index 1f8b057f..32567662 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -16,6 +16,7 @@ import { execSync, spawn } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { ProgressIndicator } from '../utils/progress-indicator'; +import { ok, fail, info, warn } from '../utils/ui'; import { ensureCLIProxyBinary } from './binary-manager'; import { generateConfig, getProviderAuthDir } from './config-generator'; import { CLIProxyProvider } from './types'; @@ -460,7 +461,9 @@ export async function triggerOAuth( if (existingAccounts.length > 0 && !add) { console.log(''); console.log( - `[i] ${existingAccounts.length} account(s) already authenticated for ${oauthConfig.displayName}` + info( + `${existingAccounts.length} account(s) already authenticated for ${oauthConfig.displayName}` + ) ); // Import readline for confirm prompt @@ -478,7 +481,7 @@ export async function triggerOAuth( }); if (!confirmed) { - console.log('[i] Cancelled'); + console.log(info('Cancelled')); return null; } } @@ -487,12 +490,12 @@ export async function triggerOAuth( const preflight = await preflightOAuthCheck(provider); if (!preflight.ready) { console.log(''); - console.log('[!] OAuth pre-flight check failed:'); + console.log(warn('OAuth pre-flight check failed:')); for (const issue of preflight.issues) { console.log(` ${issue}`); } console.log(''); - console.log('[i] Resolve the port conflict and try again.'); + console.log(info('Resolve the port conflict and try again.')); return null; } @@ -501,7 +504,7 @@ export async function triggerOAuth( try { binaryPath = await ensureCLIProxyBinary(verbose); } catch (error) { - console.error('[X] Failed to prepare CLIProxy binary'); + console.error(fail('Failed to prepare CLIProxy binary')); throw error; } @@ -532,12 +535,12 @@ export async function triggerOAuth( // Show appropriate message console.log(''); if (headless) { - console.log(`[i] Headless mode detected - manual authentication required`); - console.log(`[i] ${oauthConfig.displayName} will display an OAuth URL below`); + console.log(info('Headless mode detected - manual authentication required')); + console.log(info(`${oauthConfig.displayName} will display an OAuth URL below`)); console.log(''); } else { - console.log(`[i] Opening browser for ${oauthConfig.displayName} authentication...`); - console.log('[i] Complete the login in your browser.'); + console.log(info(`Opening browser for ${oauthConfig.displayName} authentication...`)); + console.log(info('Complete the login in your browser.')); console.log(''); } @@ -572,7 +575,7 @@ export async function triggerOAuth( if (urlMatch && !urlDisplayed) { console.log(` ${urlMatch[0]}`); console.log(''); - console.log('[i] Waiting for authentication... (press Ctrl+C to cancel)'); + console.log(info('Waiting for authentication... (press Ctrl+C to cancel)')); urlDisplayed = true; } } @@ -590,7 +593,7 @@ export async function triggerOAuth( if (urlMatch) { console.log(` ${urlMatch[0]}`); console.log(''); - console.log('[i] Waiting for authentication... (press Ctrl+C to cancel)'); + console.log(info('Waiting for authentication... (press Ctrl+C to cancel)')); urlDisplayed = true; } } @@ -601,7 +604,7 @@ export async function triggerOAuth( const timeout = setTimeout(() => { if (!headless) spinner.fail('Authentication timeout'); authProcess.kill(); - console.error(`[X] OAuth timed out after ${headless ? 5 : 2} minutes`); + console.error(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`)); console.error(''); if (!headless) { console.error('Troubleshooting:'); @@ -618,14 +621,14 @@ export async function triggerOAuth( // Verify token was created BEFORE showing success if (isAuthenticated(provider)) { if (!headless) spinner.succeed(`Authenticated with ${oauthConfig.displayName}`); - console.log('[OK] Authentication successful'); + console.log(ok('Authentication successful')); // Register the account in accounts registry const account = registerAccountFromToken(provider, tokenDir, nickname); resolve(account); } else { if (!headless) spinner.fail('Authentication incomplete'); - console.error('[X] Token not found after authentication'); + console.error(fail('Token not found after authentication')); // Qwen uses Device Code Flow (polling), others use Authorization Code Flow (callback) if (provider === 'qwen') { console.error(' Qwen uses Device Code Flow - ensure you completed auth in browser'); @@ -639,14 +642,14 @@ export async function triggerOAuth( } } else { if (!headless) spinner.fail('Authentication failed'); - console.error(`[X] CLIProxyAPI auth exited with code ${code}`); + console.error(fail(`CLIProxyAPI auth exited with code ${code}`)); if (stderrData && !urlDisplayed) { console.error(` ${stderrData.trim().split('\n')[0]}`); } // Show headless hint if we detected headless environment if (headless && !urlDisplayed) { console.error(''); - console.error('[i] No OAuth URL was displayed. Try with --verbose for details.'); + console.error(info('No OAuth URL was displayed. Try with --verbose for details.')); } resolve(null); } @@ -655,7 +658,7 @@ export async function triggerOAuth( authProcess.on('error', (error) => { clearTimeout(timeout); if (!headless) spinner.fail('Authentication error'); - console.error(`[X] Failed to start auth process: ${error.message}`); + console.error(fail(`Failed to start auth process: ${error.message}`)); resolve(null); }); }); @@ -735,7 +738,7 @@ export async function ensureAuth( // Not authenticated - trigger OAuth const oauthConfig = getOAuthConfig(provider); - console.log(`[i] ${oauthConfig.displayName} authentication required`); + console.log(info(`${oauthConfig.displayName} authentication required`)); const account = await triggerOAuth(provider, options); return account !== null; diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 87ecbc28..5ebc8948 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -18,6 +18,7 @@ import * as http from 'http'; import * as crypto from 'crypto'; import * as zlib from 'zlib'; import { ProgressIndicator } from '../utils/progress-indicator'; +import { ok, info } from '../utils/ui'; import { getBinDir, getCliproxyDir } from './config-generator'; import { BinaryInfo, @@ -100,9 +101,11 @@ export class BinaryManager { const updateResult = await this.checkForUpdates(); if (updateResult.hasUpdate) { console.log( - `[i] CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}` + info( + `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}` + ) ); - console.log(`[i] Updating CLIProxyAPI...`); + console.log(info('Updating CLIProxyAPI...')); // Delete old binary and download new version this.deleteBinary(); @@ -435,7 +438,7 @@ export class BinaryManager { // Save installed version for future update checks this.saveInstalledVersion(this.config.version); - console.log(`[OK] CLIProxyAPI v${this.config.version} installed successfully`); + console.log(ok(`CLIProxyAPI v${this.config.version} installed successfully`)); } catch (error) { spinner.fail('Installation failed'); throw error; @@ -938,7 +941,7 @@ export async function installCliproxyVersion(version: string, verbose = false): if (manager.isBinaryInstalled()) { const currentVersion = getInstalledCliproxyVersion(); if (verbose) { - console.log(`[i] Removing existing CLIProxyAPI v${currentVersion}`); + console.log(info(`Removing existing CLIProxyAPI v${currentVersion}`)); } manager.deleteBinary(); } diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index e21d1a27..744a66a0 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -15,6 +15,7 @@ import { spawn, ChildProcess } from 'child_process'; import * as net from 'net'; import { ProgressIndicator } from '../utils/progress-indicator'; +import { ok, fail, info, warn } from '../utils/ui'; import { escapeShellArg } from '../utils/shell-executor'; import { ensureCLIProxyBinary } from './binary-manager'; import { @@ -152,7 +153,7 @@ export async function execClaudeWithCLIProxy( if (showAccounts) { const accounts = getProviderAccounts(provider); if (accounts.length === 0) { - console.log(`[i] No accounts registered for ${providerConfig.displayName}`); + console.log(info(`No accounts registered for ${providerConfig.displayName}`)); console.log(` Run "ccs ${provider} --auth" to add an account`); } else { console.log(`\n${providerConfig.displayName} Accounts:\n`); @@ -170,7 +171,7 @@ export async function execClaudeWithCLIProxy( if (useAccount) { const account = findAccountByQuery(provider, useAccount); if (!account) { - console.error(`[X] Account not found: "${useAccount}"`); + console.error(fail(`Account not found: "${useAccount}"`)); const accounts = getProviderAccounts(provider); if (accounts.length > 0) { console.error(` Available accounts:`); @@ -183,27 +184,27 @@ export async function execClaudeWithCLIProxy( // Set as default for this and future sessions setDefaultAccount(provider, account.id); touchAccount(provider, account.id); - console.log(`[OK] Switched to account: ${account.nickname || account.email || account.id}`); + console.log(ok(`Switched to account: ${account.nickname || account.email || account.id}`)); } // Handle --nickname: rename account and exit (unless used with --auth --add) if (setNickname && !addAccount) { const defaultAccount = getDefaultAccount(provider); if (!defaultAccount) { - console.error(`[X] No account found for ${providerConfig.displayName}`); + console.error(fail(`No account found for ${providerConfig.displayName}`)); console.error(` Run "ccs ${provider} --auth" to add an account first`); process.exit(1); } try { const success = renameAccount(provider, defaultAccount.id, setNickname); if (success) { - console.log(`[OK] Renamed account to: ${setNickname}`); + console.log(ok(`Renamed account to: ${setNickname}`)); } else { - console.error(`[X] Failed to rename account`); + console.error(fail('Failed to rename account')); process.exit(1); } } catch (err) { - console.error(`[X] ${err instanceof Error ? err.message : 'Failed to rename account'}`); + console.error(fail(err instanceof Error ? err.message : 'Failed to rename account')); process.exit(1); } process.exit(0); @@ -220,9 +221,9 @@ export async function execClaudeWithCLIProxy( if (forceLogout) { const { clearAuth } = await import('./auth-handler'); if (clearAuth(provider)) { - console.log(`[OK] Logged out from ${providerConfig.displayName}`); + console.log(ok(`Logged out from ${providerConfig.displayName}`)); } else { - console.log(`[i] No authentication found for ${providerConfig.displayName}`); + console.log(info(`No authentication found for ${providerConfig.displayName}`)); } process.exit(0); } @@ -265,9 +266,7 @@ export async function execClaudeWithCLIProxy( const modelEntry = findModel(provider, currentModel); const issueUrl = getModelIssueUrl(provider, currentModel); console.error(''); - console.error( - `[!] Warning: ${modelEntry?.name || currentModel} has known issues with Claude Code` - ); + console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.'); if (issueUrl) { console.error(` Tracking: ${issueUrl}`); @@ -306,7 +305,7 @@ export async function execClaudeWithCLIProxy( // Handle proxy errors proxy.on('error', (error) => { - console.error(`[X] CLIProxy spawn error: ${error.message}`); + console.error(fail(`CLIProxy spawn error: ${error.message}`)); }); // 5. Wait for proxy readiness via TCP polling @@ -322,7 +321,7 @@ export async function execClaudeWithCLIProxy( const err = error as Error; console.error(''); - console.error('[X] CLIProxy failed to start'); + console.error(fail('CLIProxy failed to start')); console.error(''); console.error('Possible causes:'); console.error(` 1. Port ${cfg.port} already in use`); @@ -398,7 +397,7 @@ export async function execClaudeWithCLIProxy( }); claude.on('error', (error) => { - console.error('[X] Claude CLI error:', error); + console.error(fail(`Claude CLI error: ${error}`)); proxy.kill('SIGTERM'); process.exit(1); }); diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 2bc103db..92e70e1e 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -11,6 +11,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir } from '../utils/config-manager'; +import { warn } from '../utils/ui'; import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types'; import { getModelMappingFromConfig, getEnvVarsFromConfig } from './base-config-loader'; @@ -373,10 +374,10 @@ export function getEffectiveEnvVars( } } catch { // Invalid JSON - fall through to provider defaults - console.warn(`[!] Warning: Invalid settings file: ${customSettingsPath}`); + console.warn(warn(`Invalid settings file: ${customSettingsPath}`)); } } else { - console.warn(`[!] Warning: Settings file not found: ${customSettingsPath}`); + console.warn(warn(`Settings file not found: ${customSettingsPath}`)); } } diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index ff4b65ba..a6fd1715 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -945,21 +945,21 @@ async function showStatus(verbose: boolean): Promise { async function installVersion(version: string, verbose: boolean): Promise { // Validate version format (basic semver check) if (!/^\d+\.\d+\.\d+$/.test(version)) { - console.error('[X] Invalid version format. Expected format: X.Y.Z (e.g., 6.5.53)'); + console.error(fail('Invalid version format. Expected format: X.Y.Z (e.g., 6.5.53)')); process.exit(1); } - console.log(`[i] Installing CLIProxyAPI v${version}...`); + console.log(info(`Installing CLIProxyAPI v${version}...`)); console.log(''); try { await installCliproxyVersion(version, verbose); console.log(''); - console.log(`[OK] CLIProxyAPI v${version} installed successfully`); + console.log(ok(`CLIProxyAPI v${version} installed successfully`)); } catch (error) { const err = error as Error; console.error(''); - console.error(`[X] Failed to install CLIProxyAPI v${version}`); + console.error(fail(`Failed to install CLIProxyAPI v${version}`)); console.error(` ${err.message}`); console.error(''); console.error('Possible causes:'); @@ -977,29 +977,29 @@ async function installVersion(version: string, verbose: boolean): Promise * Install latest version */ async function installLatest(verbose: boolean): Promise { - console.log('[i] Fetching latest CLIProxyAPI version...'); + console.log(info('Fetching latest CLIProxyAPI version...')); try { const latestVersion = await fetchLatestCliproxyVersion(); const currentVersion = getInstalledCliproxyVersion(); if (isCLIProxyInstalled() && latestVersion === currentVersion) { - console.log(`[OK] Already running latest version: v${latestVersion}`); + console.log(ok(`Already running latest version: v${latestVersion}`)); return; } - console.log(`[i] Latest version: v${latestVersion}`); + console.log(info(`Latest version: v${latestVersion}`)); if (isCLIProxyInstalled()) { - console.log(`[i] Current version: v${currentVersion}`); + console.log(info(`Current version: v${currentVersion}`)); } console.log(''); await installCliproxyVersion(latestVersion, verbose); console.log(''); - console.log(`[OK] CLIProxyAPI updated to v${latestVersion}`); + console.log(ok(`CLIProxyAPI updated to v${latestVersion}`)); } catch (error) { const err = error as Error; - console.error(`[X] Failed to install latest version: ${err.message}`); + console.error(fail(`Failed to install latest version: ${err.message}`)); process.exit(1); } } diff --git a/src/commands/install-command.ts b/src/commands/install-command.ts index 30a41136..5bdcfbf2 100644 --- a/src/commands/install-command.ts +++ b/src/commands/install-command.ts @@ -4,17 +4,20 @@ * Handle --install and --uninstall commands for CCS. */ +import { info, color, initUI } from '../utils/ui'; + /** * Handle install command */ -export function handleInstallCommand(): void { +export async function handleInstallCommand(): Promise { + await initUI(); console.log(''); - console.log('Feature not available'); + console.log(info('Feature not available')); console.log(''); console.log('The --install flag is currently under development.'); console.log('.claude/ integration testing is not complete.'); console.log(''); - console.log('For updates: https://github.com/kaitranntt/ccs/issues'); + console.log(`For updates: ${color('https://github.com/kaitranntt/ccs/issues', 'path')}`); console.log(''); process.exit(0); } @@ -22,14 +25,15 @@ export function handleInstallCommand(): void { /** * Handle uninstall command */ -export function handleUninstallCommand(): void { +export async function handleUninstallCommand(): Promise { + await initUI(); console.log(''); - console.log('Feature not available'); + console.log(info('Feature not available')); console.log(''); console.log('The --uninstall flag is currently under development.'); console.log('.claude/ integration testing is not complete.'); console.log(''); - console.log('For updates: https://github.com/kaitranntt/ccs/issues'); + console.log(`For updates: ${color('https://github.com/kaitranntt/ccs/issues', 'path')}`); console.log(''); process.exit(0); } diff --git a/src/commands/migrate-command.ts b/src/commands/migrate-command.ts index aac190fc..3d643689 100644 --- a/src/commands/migrate-command.ts +++ b/src/commands/migrate-command.ts @@ -17,8 +17,11 @@ import { getBackupDirectories, } from '../config/migration-manager'; import { hasUnifiedConfig } from '../config/unified-config-loader'; +import { initUI, ok, fail, info, warn, infoBox, dim } from '../utils/ui'; export async function handleMigrateCommand(args: string[]): Promise { + await initUI(); + // Handle --list-backups if (args.includes('--list-backups')) { listBackups(); @@ -31,9 +34,9 @@ export async function handleMigrateCommand(args: string[]): Promise { const backupPath = args[rollbackIndex + 1]; if (!backupPath) { - console.error('[X] Error: --rollback requires backup path'); - console.log('[i] Usage: ccs migrate --rollback '); - console.log('[i] Use --list-backups to see available backups'); + console.error(fail('Error: --rollback requires backup path')); + console.log(info('Usage: ccs migrate --rollback ')); + console.log(info('Use --list-backups to see available backups')); process.exit(1); } @@ -43,13 +46,13 @@ export async function handleMigrateCommand(args: string[]): Promise { // Check if already migrated if (hasUnifiedConfig() && !needsMigration()) { - console.log('[i] Already using unified config format (config.yaml)'); + console.log(info('Already using unified config format (config.yaml)')); return; } // Check if migration is needed if (!needsMigration()) { - console.log('[i] No migration needed - no legacy config found'); + console.log(info('No migration needed - no legacy config found')); return; } @@ -57,7 +60,7 @@ export async function handleMigrateCommand(args: string[]): Promise { const dryRun = args.includes('--dry-run'); if (dryRun) { - console.log('[i] Dry run - no changes will be made'); + console.log(info('Dry run - no changes will be made')); console.log(''); } @@ -65,13 +68,11 @@ export async function handleMigrateCommand(args: string[]): Promise { if (result.success) { console.log(''); - console.log('╭─────────────────────────────────────────────────────────╮'); if (dryRun) { - console.log('│ [i] Dry run - migration preview (no changes made) │'); + console.log(infoBox('Dry run - migration preview (no changes made)')); } else { - console.log('│ [OK] Migrated to unified config (config.yaml) │'); + console.log(infoBox('Migrated to unified config (config.yaml)', 'SUCCESS')); } - console.log('╰─────────────────────────────────────────────────────────╯'); if (result.backupPath && !dryRun) { console.log(` Backup: ${result.backupPath}`); @@ -80,18 +81,18 @@ export async function handleMigrateCommand(args: string[]): Promise { if (result.warnings.length > 0) { for (const warning of result.warnings) { - console.log(` [!] ${warning}`); + console.log(warn(warning)); } } if (dryRun) { - console.log(' Run without --dry-run to apply changes'); + console.log(dim(' Run without --dry-run to apply changes')); } else { console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`); } console.log(''); } else { - console.error(`[X] Migration failed: ${result.error}`); + console.error(fail(`Migration failed: ${result.error}`)); if (result.migratedFiles.length > 0) { console.log(''); @@ -104,16 +105,16 @@ export async function handleMigrateCommand(args: string[]): Promise { } async function handleRollback(backupPath: string): Promise { - console.log(`[i] Rolling back from: ${backupPath}`); + console.log(info(`Rolling back from: ${backupPath}`)); console.log(''); const success = await rollback(backupPath); if (success) { - console.log('[OK] Rollback complete'); - console.log('[i] Legacy config restored'); + console.log(ok('Rollback complete')); + console.log(info('Legacy config restored')); } else { - console.error('[X] Rollback failed'); + console.error(fail('Rollback failed')); process.exit(1); } } @@ -122,17 +123,17 @@ function listBackups(): void { const backups = getBackupDirectories(); if (backups.length === 0) { - console.log('[i] No backup directories found'); + console.log(info('No backup directories found')); return; } - console.log('[i] Available backups (most recent first):'); + console.log(info('Available backups (most recent first):')); console.log(''); backups.forEach((backup, index) => { console.log(` ${index + 1}. ${backup}`); }); console.log(''); - console.log('[i] To rollback: ccs migrate --rollback '); + console.log(info('To rollback: ccs migrate --rollback ')); } /** diff --git a/src/commands/shell-completion-command.ts b/src/commands/shell-completion-command.ts index 804c7f7f..f01de47c 100644 --- a/src/commands/shell-completion-command.ts +++ b/src/commands/shell-completion-command.ts @@ -4,15 +4,16 @@ * Handle --shell-completion command for CCS. */ -import { colored } from '../utils/helpers'; +import { initUI, header, ok, fail, color } from '../utils/ui'; /** * Handle shell completion command */ export async function handleShellCompletionCommand(args: string[]): Promise { + await initUI(); const { ShellCompletionInstaller } = await import('../utils/shell-completion'); - console.log(colored('Shell Completion Installer', 'bold')); + console.log(header('Shell Completion Installer')); console.log(''); // Parse flags @@ -30,28 +31,28 @@ export async function handleShellCompletionCommand(args: string[]): Promise # See available profiles'); console.log(' ccs auth # See auth subcommands'); console.log(''); } catch (error) { const err = error as Error; - console.error(colored('[X] Error:', 'red'), err.message); + console.error(fail(`Error: ${err.message}`)); console.error(''); - console.error(colored('Usage:', 'yellow')); + console.error(color('Usage:', 'warning')); console.error(' ccs --shell-completion # Auto-detect shell'); console.error(' ccs --shell-completion --bash # Install for bash'); console.error(' ccs --shell-completion --zsh # Install for zsh'); diff --git a/src/commands/sync-command.ts b/src/commands/sync-command.ts index bb2c60c0..2ceb5d16 100644 --- a/src/commands/sync-command.ts +++ b/src/commands/sync-command.ts @@ -4,14 +4,15 @@ * Handle sync command for CCS. */ -import { colored } from '../utils/helpers'; +import { initUI, header, ok } from '../utils/ui'; /** * Handle sync command */ export async function handleSyncCommand(): Promise { + await initUI(); console.log(''); - console.log(colored('Syncing CCS Components...', 'cyan')); + console.log(header('Syncing CCS Components...')); console.log(''); // First, copy .claude/ directory from package to ~/.ccs/.claude/ @@ -38,10 +39,10 @@ export async function handleSyncCommand(): Promise { const SharedManager = (await import('../management/shared-manager')).default; const sharedManager = new SharedManager(); sharedManager.ensureSharedDirectories(); - console.log(colored('[OK]', 'green') + ' Shared symlinks verified'); + console.log(ok('Shared symlinks verified')); console.log(''); - console.log(colored('[OK] Sync complete!', 'green')); + console.log(ok('Sync complete!')); console.log(''); process.exit(0); diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index c875b8bd..406dd9df 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -6,7 +6,7 @@ */ import { spawn } from 'child_process'; -import { colored } from '../utils/helpers'; +import { initUI, header, ok, fail, warn, info, color } from '../utils/ui'; import { detectInstallationMethod, detectPackageManager } from '../utils/package-manager-detector'; import { compareVersionsWithPrerelease } from '../utils/update-checker'; import { getVersion } from '../utils/version'; @@ -27,11 +27,12 @@ const CCS_VERSION = getVersion(); * Checks for updates and installs the latest version */ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise { + await initUI(); const { force = false, beta = false } = options; const targetTag = beta ? 'dev' : 'latest'; console.log(''); - console.log(colored('Checking for updates...', 'cyan')); + console.log(header('Checking for updates...')); console.log(''); const installMethod = detectInstallationMethod(); @@ -39,7 +40,7 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< // Force reinstall - skip update check if (force) { - console.log(colored(`[i] Force reinstall from @${targetTag} channel...`, 'cyan')); + console.log(info(`Force reinstall from @${targetTag} channel...`)); console.log(''); if (isNpmInstall) { @@ -70,9 +71,7 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< } // Update available - console.log( - colored(`[i] Update available: ${updateResult.current} -> ${updateResult.latest}`, 'yellow') - ); + console.log(warn(`Update available: ${updateResult.current} -> ${updateResult.latest}`)); console.log(''); // Check if this is a downgrade (e.g., stable to older dev) @@ -84,23 +83,22 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< // This happens when stable user requests @dev but @dev base is older if (isDowngrade && beta) { console.log( - colored( - '[!] WARNING: Downgrading from ' + + warn( + 'WARNING: Downgrading from ' + (updateResult.current || 'unknown') + ' to ' + - (updateResult.latest || 'unknown'), - 'yellow' + (updateResult.latest || 'unknown') ) ); - console.log(colored('[!] Dev channel may be behind stable.', 'yellow')); + console.log(warn('Dev channel may be behind stable.')); console.log(''); } // Show beta warning if (beta) { - console.log(colored('[!] Installing from @dev channel (unstable)', 'yellow')); - console.log(colored('[!] Not recommended for production use', 'yellow')); - console.log(colored('[!] Use `ccs update` (without --beta) to return to stable', 'cyan')); + console.log(warn('Installing from @dev channel (unstable)')); + console.log(warn('Not recommended for production use')); + console.log(info('Use `ccs update` (without --beta) to return to stable')); console.log(''); } @@ -119,9 +117,9 @@ function handleCheckFailed( isNpmInstall: boolean, targetTag: string = 'latest' ): void { - console.log(colored(`[X] ${message}`, 'red')); + console.log(fail(message)); console.log(''); - console.log(colored('[i] Possible causes:', 'yellow')); + console.log(warn('Possible causes:')); console.log(' - Network connection issues'); console.log(' - Firewall blocking requests'); console.log(' - GitHub/npm API temporarily unavailable'); @@ -149,13 +147,13 @@ function handleCheckFailed( manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; } - console.log(colored(` ${manualCommand}`, 'yellow')); + console.log(color(` ${manualCommand}`, 'command')); } else { const isWindows = process.platform === 'win32'; if (isWindows) { - console.log(colored(' irm ccs.kaitran.ca/install | iex', 'yellow')); + console.log(color(' irm ccs.kaitran.ca/install | iex', 'command')); } else { - console.log(colored(' curl -fsSL ccs.kaitran.ca/install | bash', 'yellow')); + console.log(color(' curl -fsSL ccs.kaitran.ca/install | bash', 'command')); } } console.log(''); @@ -173,14 +171,14 @@ function handleNoUpdate(reason: string | undefined): void { switch (reason) { case 'dismissed': message = `Update dismissed. You are on version ${version}`; - console.log(colored(`[i] ${message}`, 'yellow')); + console.log(warn(message)); break; case 'cached': message = `No updates available (cached result). You are on version ${version}`; - console.log(colored(`[i] ${message}`, 'cyan')); + console.log(info(message)); break; default: - console.log(colored(`[OK] ${message}`, 'green')); + console.log(ok(message)); } console.log(''); process.exit(0); @@ -231,9 +229,7 @@ async function performNpmUpdate( cacheArgs = ['cache', 'clean', '--force']; } - console.log( - colored(`${isReinstall ? 'Reinstalling' : 'Updating'} via ${packageManager}...`, 'cyan') - ); + console.log(info(`${isReinstall ? 'Reinstalling' : 'Updating'} via ${packageManager}...`)); console.log(''); const performUpdate = (): void => { @@ -244,16 +240,16 @@ async function performNpmUpdate( child.on('exit', (code) => { if (code === 0) { console.log(''); - console.log(colored(`[OK] ${isReinstall ? 'Reinstall' : 'Update'} successful!`, 'green')); + console.log(ok(`${isReinstall ? 'Reinstall' : 'Update'} successful!`)); console.log(''); - console.log(`Run ${colored('ccs --version', 'yellow')} to verify`); + console.log(`Run ${color('ccs --version', 'command')} to verify`); console.log(''); } else { console.log(''); - console.log(colored(`[X] ${isReinstall ? 'Reinstall' : 'Update'} failed`, 'red')); + console.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`)); console.log(''); console.log('Try manually:'); - console.log(colored(` ${updateCommand} ${updateArgs.join(' ')}`, 'yellow')); + console.log(color(` ${updateCommand} ${updateArgs.join(' ')}`, 'command')); console.log(''); } process.exit(code || 0); @@ -261,35 +257,30 @@ async function performNpmUpdate( child.on('error', () => { console.log(''); - console.log( - colored( - `[X] Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`, - 'red' - ) - ); + console.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`)); console.log(''); console.log('Try manually:'); - console.log(colored(` ${updateCommand} ${updateArgs.join(' ')}`, 'yellow')); + console.log(color(` ${updateCommand} ${updateArgs.join(' ')}`, 'command')); console.log(''); process.exit(1); }); }; if (cacheCommand && cacheArgs) { - console.log(colored('Clearing package cache...', 'cyan')); + console.log(info('Clearing package cache...')); const cacheChild = spawn(cacheCommand, cacheArgs, { stdio: 'inherit', }); cacheChild.on('exit', (code) => { if (code !== 0) { - console.log(colored('[!] Cache clearing failed, proceeding anyway...', 'yellow')); + console.log(warn('Cache clearing failed, proceeding anyway...')); } performUpdate(); }); cacheChild.on('error', () => { - console.log(colored('[!] Cache clearing failed, proceeding anyway...', 'yellow')); + console.log(warn('Cache clearing failed, proceeding anyway...')); performUpdate(); }); } else { @@ -301,13 +292,13 @@ async function performNpmUpdate( * Handle direct install beta not supported error */ function handleDirectBetaNotSupported(): void { - console.log(colored('[X] --beta flag requires npm installation', 'red')); + console.log(fail('--beta flag requires npm installation')); console.log(''); console.log('Current installation method: direct installer'); console.log('To use beta releases, install via npm:'); console.log(''); - console.log(colored(' npm install -g @kaitranntt/ccs', 'yellow')); - console.log(colored(' ccs update --beta', 'yellow')); + console.log(color(' npm install -g @kaitranntt/ccs', 'command')); + console.log(color(' ccs update --beta', 'command')); console.log(''); console.log('Or continue using stable releases via direct installer.'); console.log(''); @@ -318,7 +309,7 @@ function handleDirectBetaNotSupported(): void { * Perform update via direct installer (curl/irm) */ async function performDirectUpdate(): Promise { - console.log(colored('Updating via installer...', 'cyan')); + console.log(info('Updating via installer...')); console.log(''); const isWindows = process.platform === 'win32'; @@ -346,19 +337,19 @@ async function performDirectUpdate(): Promise { child.on('exit', (code) => { if (code === 0) { console.log(''); - console.log(colored('[OK] Update successful!', 'green')); + console.log(ok('Update successful!')); console.log(''); - console.log(`Run ${colored('ccs --version', 'yellow')} to verify`); + console.log(`Run ${color('ccs --version', 'command')} to verify`); console.log(''); } else { console.log(''); - console.log(colored('[X] Update failed', 'red')); + console.log(fail('Update failed')); console.log(''); console.log('Try manually:'); if (isWindows) { - console.log(colored(' irm ccs.kaitran.ca/install | iex', 'yellow')); + console.log(color(' irm ccs.kaitran.ca/install | iex', 'command')); } else { - console.log(colored(' curl -fsSL ccs.kaitran.ca/install | bash', 'yellow')); + console.log(color(' curl -fsSL ccs.kaitran.ca/install | bash', 'command')); } console.log(''); } @@ -367,13 +358,13 @@ async function performDirectUpdate(): Promise { child.on('error', () => { console.log(''); - console.log(colored('[X] Failed to run installer', 'red')); + console.log(fail('Failed to run installer')); console.log(''); console.log('Try manually:'); if (isWindows) { - console.log(colored(' irm ccs.kaitran.ca/install | iex', 'yellow')); + console.log(color(' irm ccs.kaitran.ca/install | iex', 'command')); } else { - console.log(colored(' curl -fsSL ccs.kaitran.ca/install | bash', 'yellow')); + console.log(color(' curl -fsSL ccs.kaitran.ca/install | bash', 'command')); } console.log(''); process.exit(1); diff --git a/src/commands/version-command.ts b/src/commands/version-command.ts index 50395af2..5c4ab2a0 100644 --- a/src/commands/version-command.ts +++ b/src/commands/version-command.ts @@ -7,29 +7,30 @@ import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; -import { colored } from '../utils/helpers'; +import { initUI, header, subheader, color, warn } from '../utils/ui'; import { getConfigPath } from '../utils/config-manager'; import { getVersion } from '../utils/version'; /** * Handle version command */ -export function handleVersionCommand(): void { - console.log(colored(`CCS (Claude Code Switch) v${getVersion()}`, 'bold')); +export async function handleVersionCommand(): Promise { + await initUI(); + console.log(header(`CCS (Claude Code Switch) v${getVersion()}`)); console.log(''); - console.log(colored('Installation:', 'cyan')); + console.log(subheader('Installation:')); const installLocation = process.argv[1] || '(not found)'; - console.log(` ${colored('Location:'.padEnd(17), 'cyan')} ${installLocation}`); + console.log(` ${color('Location:'.padEnd(17), 'info')} ${installLocation}`); const ccsDir = path.join(os.homedir(), '.ccs'); - console.log(` ${colored('CCS Directory:'.padEnd(17), 'cyan')} ${ccsDir}`); + console.log(` ${color('CCS Directory:'.padEnd(17), 'info')} ${ccsDir}`); const configPath = getConfigPath(); - console.log(` ${colored('Config:'.padEnd(17), 'cyan')} ${configPath}`); + console.log(` ${color('Config:'.padEnd(17), 'info')} ${configPath}`); const profilesJson = path.join(os.homedir(), '.ccs', 'profiles.json'); - console.log(` ${colored('Profiles:'.padEnd(17), 'cyan')} ${profilesJson}`); + console.log(` ${color('Profiles:'.padEnd(17), 'info')} ${profilesJson}`); // Delegation status const delegationSessionsPath = path.join(os.homedir(), '.ccs', 'delegation-sessions.json'); @@ -57,29 +58,31 @@ export function handleVersionCommand(): void { const delegationEnabled = delegationConfigured || hasValidApiKeys; if (delegationEnabled) { - console.log(` ${colored('Delegation:'.padEnd(17), 'cyan')} Enabled`); + console.log(` ${color('Delegation:'.padEnd(17), 'info')} Enabled`); } else { - console.log(` ${colored('Delegation:'.padEnd(17), 'cyan')} Not configured`); + console.log(` ${color('Delegation:'.padEnd(17), 'info')} Not configured`); } console.log(''); if (readyProfiles.length > 0) { - console.log(colored('Delegation Ready:', 'cyan')); + console.log(subheader('Delegation Ready:')); console.log( - ` ${colored('[OK]', 'yellow')} ${readyProfiles.join(', ')} profiles are ready for delegation` + ` ${color('[OK]', 'warning')} ${readyProfiles.join(', ')} profiles are ready for delegation` ); console.log(''); } else if (delegationEnabled) { - console.log(colored('Delegation Ready:', 'cyan')); - console.log(` ${colored('[!]', 'yellow')} Delegation configured but no valid API keys found`); + console.log(subheader('Delegation Ready:')); + console.log(warn('Delegation configured but no valid API keys found')); console.log(''); } - console.log(`${colored('Documentation:', 'cyan')} https://github.com/kaitranntt/ccs`); - console.log(`${colored('License:', 'cyan')} MIT`); + console.log( + `${subheader('Documentation:')} ${color('https://github.com/kaitranntt/ccs', 'path')}` + ); + console.log(`${subheader('License:')} MIT`); console.log(''); - console.log(colored("Run 'ccs --help' for usage information", 'yellow')); + console.log(color("Run 'ccs --help' for usage information", 'command')); process.exit(0); } diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 88344006..bd011c79 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -18,6 +18,7 @@ import { getCcsDir } from '../utils/config-manager'; import type { ProfileConfig, AccountConfig, CLIProxyVariantConfig } from './unified-config-types'; import { createEmptyUnifiedConfig } from './unified-config-types'; import { saveUnifiedConfig, hasUnifiedConfig } from './unified-config-loader'; +import { infoBox, warn } from '../utils/ui'; const BACKUP_DIR_PREFIX = 'backup-v1-'; @@ -343,23 +344,19 @@ export async function autoMigrate(): Promise { if (result.success) { console.log(''); - console.log('╭─────────────────────────────────────────────────────────╮'); - console.log('│ [OK] Migrated to unified config (config.yaml) │'); - console.log('╰─────────────────────────────────────────────────────────╯'); + console.log(infoBox('Migrated to unified config (config.yaml)', 'SUCCESS')); console.log(` Backup: ${result.backupPath}`); console.log(` Items: ${result.migratedFiles.length} migrated`); if (result.warnings.length > 0) { for (const warning of result.warnings) { - console.log(` [!] ${warning}`); + console.log(warn(warning)); } } console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`); console.log(''); } else { console.log(''); - console.log('╭─────────────────────────────────────────────────────────╮'); - console.log('│ [!] Migration failed - using legacy config │'); - console.log('╰─────────────────────────────────────────────────────────╯'); + console.log(infoBox('Migration failed - using legacy config', 'WARNING')); console.log(` Error: ${result.error}`); console.log(' Retry: ccs migrate'); console.log(''); diff --git a/src/delegation/delegation-handler.ts b/src/delegation/delegation-handler.ts index 868b39cf..bfe4aa61 100644 --- a/src/delegation/delegation-handler.ts +++ b/src/delegation/delegation-handler.ts @@ -5,6 +5,7 @@ import { SessionManager } from './session-manager'; import { ResultFormatter } from './result-formatter'; import { DelegationValidator } from '../utils/delegation-validator'; import { SettingsParser } from './settings-parser'; +import { fail } from '../utils/ui'; interface ParsedArgs { profile: string; @@ -52,7 +53,7 @@ export class DelegationHandler { // 6. Exit with proper code process.exit(result.exitCode || 0); } catch (error) { - console.error(`[X] Delegation error: ${(error as Error).message}`); + console.error(fail(`Delegation error: ${(error as Error).message}`)); if (process.env.CCS_DEBUG) { console.error((error as Error).stack); } @@ -72,7 +73,7 @@ export class DelegationHandler { const lastSession = sessionMgr.getLastSession(baseProfile); if (!lastSession) { - console.error(`[X] No previous session found for ${baseProfile}`); + console.error(fail(`No previous session found for ${baseProfile}`)); console.error(` Start a new session first with: ccs ${baseProfile} -p "task"`); process.exit(1); } @@ -148,7 +149,7 @@ export class DelegationHandler { const index = pIndex !== -1 ? pIndex : promptIndex; if (index === -1 || index === args.length - 1) { - console.error('[X] Missing prompt after -p flag'); + console.error(fail('Missing prompt after -p flag')); console.error(' Usage: ccs glm -p "task description"'); process.exit(1); } @@ -228,7 +229,7 @@ export class DelegationHandler { */ _validateProfile(profile: string): void { if (!profile) { - console.error('[X] No profile specified'); + console.error(fail('No profile specified')); console.error(' Usage: ccs -p "task"'); console.error(' Examples: ccs glm -p "task", ccs kimi -p "task"'); process.exit(1); @@ -237,7 +238,7 @@ export class DelegationHandler { // Use DelegationValidator to check profile const validation = DelegationValidator.validate(profile); if (!validation.valid) { - console.error(`[X] Profile '${profile}' is not configured for delegation`); + console.error(fail(`Profile '${profile}' is not configured for delegation`)); console.error(` ${validation.error}`); console.error(''); console.error(' Run: ccs doctor'); diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 0416f470..c0217593 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -6,7 +6,7 @@ import * as os from 'os'; import * as fs from 'fs'; import { SessionManager } from './session-manager'; import { SettingsParser } from './settings-parser'; -import { ui } from '../utils/ui'; +import { ui, warn, info } from '../utils/ui'; // Type definitions for delegation responses interface ClaudeMessage { @@ -157,9 +157,9 @@ export class HeadlessExecutor { args.push('--dangerously-skip-permissions'); // Warn about dangerous mode if (process.env.CCS_DEBUG) { - console.warn('[!] WARNING: Using --dangerously-skip-permissions mode'); + console.warn(warn('WARNING: Using --dangerously-skip-permissions mode')); console.warn( - '[!] This bypasses ALL permission checks. Use only in trusted environments.' + warn('This bypasses ALL permission checks. Use only in trusted environments.') ); } } else { @@ -179,21 +179,23 @@ export class HeadlessExecutor { ? lastSession.totalCost.toFixed(4) : '0.0000'; console.error( - `[i] Resuming session: ${lastSession.sessionId} (${lastSession.turns} turns, $${cost})` + info( + `Resuming session: ${lastSession.sessionId} (${lastSession.turns} turns, $${cost})` + ) ); } } else if (sessionId) { args.push('--resume', sessionId); if (process.env.CCS_DEBUG) { - console.error(`[i] Resuming specific session: ${sessionId}`); + console.error(info(`Resuming specific session: ${sessionId}`)); } } else { - console.warn('[!] No previous session found, starting new session'); + console.warn(warn('No previous session found, starting new session')); } } else if (sessionId) { args.push('--resume', sessionId); if (process.env.CCS_DEBUG) { - console.error(`[i] Resuming specific session: ${sessionId}`); + console.error(info(`Resuming specific session: ${sessionId}`)); } } @@ -219,7 +221,7 @@ export class HeadlessExecutor { // Debug log args if (process.env.CCS_DEBUG) { - console.error(`[i] Claude CLI args: ${args.join(' ')}`); + console.error(info(`Claude CLI args: ${args.join(' ')}`)); } // Initialize UI before spawning @@ -256,7 +258,7 @@ export class HeadlessExecutor { const cleanupHandler = () => { if (!proc.killed) { if (process.env.CCS_DEBUG) { - console.error('[!] Parent process terminating, killing delegated session...'); + console.error(warn('Parent process terminating, killing delegated session...')); } proc.kill('SIGTERM'); // Force kill if not dead after 2s @@ -420,7 +422,7 @@ export class HeadlessExecutor { // Skip malformed JSON lines (shouldn't happen with stream-json) if (process.env.CCS_DEBUG) { console.error( - `[!] Failed to parse stream-json line: ${(parseError as Error).message}` + warn(`Failed to parse stream-json line: ${(parseError as Error).message}`) ); } } @@ -495,7 +497,7 @@ export class HeadlessExecutor { // Fallback: no result message found (shouldn't happen) result.content = stdout; if (process.env.CCS_DEBUG) { - console.error(`[!] No result message found in stream-json output`); + console.error(warn('No result message found in stream-json output')); } } @@ -547,7 +549,7 @@ export class HeadlessExecutor { if (process.env.CCS_DEBUG) { console.error( - `[!] Timeout reached after ${timeout}ms, sending SIGTERM for graceful shutdown...` + warn(`Timeout reached after ${timeout}ms, sending SIGTERM for graceful shutdown...`) ); } @@ -558,7 +560,7 @@ export class HeadlessExecutor { setTimeout(() => { if (!proc.killed) { if (process.env.CCS_DEBUG) { - console.error(`[!] Process did not terminate gracefully, sending SIGKILL...`); + console.error(warn('Process did not terminate gracefully, sending SIGKILL...')); } proc.kill('SIGKILL'); } @@ -632,7 +634,7 @@ export class HeadlessExecutor { // If not last attempt, retry if (attempt < maxRetries) { - console.error(`[!] Attempt ${attempt + 1} failed, retrying...`); + console.error(warn(`Attempt ${attempt + 1} failed, retrying...`)); await this._sleep(1000 * (attempt + 1)); // Exponential backoff continue; } @@ -644,7 +646,7 @@ export class HeadlessExecutor { if (attempt < maxRetries) { console.error( - `[!] Attempt ${attempt + 1} errored: ${(error as Error).message}, retrying...` + warn(`Attempt ${attempt + 1} errored: ${(error as Error).message}, retrying...`) ); await this._sleep(1000 * (attempt + 1)); } diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 778e0a51..1844f8f3 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -1153,9 +1153,9 @@ class Doctor { if (fixed > 0) { console.log(''); - console.log(ok(`[OK] Fixed ${fixed} issue(s)`)); + console.log(ok(`Fixed ${fixed} issue(s)`)); } else { - console.log(info('[i] No fixable issues detected')); + console.log(info('No fixable issues detected')); } } diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index 9f5fc2f3..b886cf8f 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { info } from '../utils/ui'; /** * Recovery Manager Class @@ -123,13 +124,13 @@ class RecoveryManager { if (this.recovered.length === 0) return; console.log(''); - console.log('[i] Auto-recovery completed:'); + console.log(info('Auto-recovery completed:')); this.recovered.forEach((msg) => console.log(` - ${msg}`)); // Show login hint if created Claude settings if (this.recovered.some((msg) => msg.includes('settings.json'))) { console.log(''); - console.log('[i] Next step: Login to Claude CLI'); + console.log(info('Next step: Login to Claude CLI')); console.log(' Run: claude /login'); } diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 40640fe8..7edfcddc 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { ok, info, warn } from '../utils/ui'; interface SharedItem { name: string; @@ -61,7 +62,7 @@ class SharedManager { // Check if target points back to our shared dir or link path const sharedDir = path.join(this.homeDir, '.ccs', 'shared'); if (resolvedTarget.startsWith(sharedDir) || resolvedTarget === linkPath) { - console.log(`[!] Circular symlink detected: ${target} → ${resolvedTarget}`); + console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`)); return true; } } catch (_err) { @@ -79,7 +80,7 @@ class SharedManager { ensureSharedDirectories(): void { // Create ~/.claude/ if missing if (!fs.existsSync(this.claudeDir)) { - console.log('[i] Creating ~/.claude/ directory structure'); + console.log(info('Creating ~/.claude/ directory structure')); fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o700 }); } @@ -105,7 +106,7 @@ class SharedManager { // Check for circular symlink if (this.detectCircularSymlink(claudePath, sharedPath)) { - console.log(`[!] Skipping ${item.name}: circular symlink detected`); + console.log(warn(`Skipping ${item.name}: circular symlink detected`)); continue; } @@ -145,7 +146,7 @@ class SharedManager { fs.copyFileSync(claudePath, sharedPath); } console.log( - `[!] Symlink failed for ${item.name}, copied instead (enable Developer Mode)` + warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) ); } else { throw _err; @@ -186,7 +187,7 @@ class SharedManager { fs.copyFileSync(targetPath, linkPath); } console.log( - `[!] Symlink failed for ${item.name}, copied instead (enable Developer Mode)` + warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) ); } else { throw _err; @@ -212,7 +213,7 @@ class SharedManager { } } - console.log('[i] Migrating from v3.1.1 to v3.2.0...'); + console.log(info('Migrating from v3.1.1 to v3.2.0...')); // Ensure ~/.claude/ exists if (!fs.existsSync(this.claudeDir)) { @@ -256,7 +257,7 @@ class SharedManager { } if (copied > 0) { - console.log(`[OK] Migrated ${copied} ${item.name} to ~/.claude/${item.name}`); + console.log(ok(`Migrated ${copied} ${item.name} to ~/.claude/${item.name}`)); } } @@ -265,11 +266,11 @@ class SharedManager { // Only copy if ~/.claude/ version doesn't exist if (!fs.existsSync(claudePath)) { fs.copyFileSync(sharedPath, claudePath); - console.log(`[OK] Migrated ${item.name} to ~/.claude/${item.name}`); + console.log(ok(`Migrated ${item.name} to ~/.claude/${item.name}`)); } } } catch (_err) { - console.log(`[!] Failed to migrate ${item.name}: ${(_err as Error).message}`); + console.log(warn(`Failed to migrate ${item.name}: ${(_err as Error).message}`)); } } @@ -288,7 +289,7 @@ class SharedManager { this.linkSharedDirectories(instancePath); } } catch (_err) { - console.log(`[!] Failed to update instance ${instance}: ${(_err as Error).message}`); + console.log(warn(`Failed to update instance ${instance}: ${(_err as Error).message}`)); } } } catch (_err) { @@ -296,7 +297,7 @@ class SharedManager { } } - console.log('[OK] Migration to v3.2.0 complete'); + console.log(ok('Migration to v3.2.0 complete')); } /** @@ -304,14 +305,14 @@ class SharedManager { * Runs once on upgrade */ migrateToSharedSettings(): void { - console.log('[i] Migrating instances to shared settings.json...'); + console.log(info('Migrating instances to shared settings.json...')); // Ensure ~/.claude/settings.json exists (authoritative source) const claudeSettings = path.join(this.claudeDir, 'settings.json'); if (!fs.existsSync(claudeSettings)) { // Create empty settings if missing fs.writeFileSync(claudeSettings, JSON.stringify({}, null, 2), 'utf8'); - console.log('[i] Created ~/.claude/settings.json'); + console.log(info('Created ~/.claude/settings.json')); } // Ensure shared settings.json symlink exists @@ -319,7 +320,7 @@ class SharedManager { // Migrate each instance if (!fs.existsSync(this.instancesDir)) { - console.log('[i] No instances to migrate'); + console.log(info('No instances to migrate')); return; } @@ -348,7 +349,7 @@ class SharedManager { const backup = instanceSettings + '.pre-shared-migration'; if (!fs.existsSync(backup)) { fs.copyFileSync(instanceSettings, backup); - console.log(`[i] Backed up ${instance}/settings.json`); + console.log(info(`Backed up ${instance}/settings.json`)); } // Remove old settings.json @@ -365,18 +366,18 @@ class SharedManager { // Windows fallback if (process.platform === 'win32') { fs.copyFileSync(sharedSettings, instanceSettings); - console.log(`[!] Symlink failed for ${instance}, copied instead`); + console.log(warn(`Symlink failed for ${instance}, copied instead`)); migrated++; } else { throw _err; } } } catch (_err) { - console.log(`[!] Failed to migrate ${instance}: ${(_err as Error).message}`); + console.log(warn(`Failed to migrate ${instance}: ${(_err as Error).message}`)); } } - console.log(`[OK] Migrated ${migrated} instance(s), skipped ${skipped}`); + console.log(ok(`Migrated ${migrated} instance(s), skipped ${skipped}`)); } /** diff --git a/src/utils/claude-dir-installer.ts b/src/utils/claude-dir-installer.ts index 58d2cb54..9664f241 100644 --- a/src/utils/claude-dir-installer.ts +++ b/src/utils/claude-dir-installer.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { colored } from './helpers'; +import { ok, warn } from './ui'; // Ora fallback type for when ora is not available interface OraSpinner { @@ -117,7 +117,7 @@ export class ClaudeDirInstaller { const msg = `Copied .claude/ items (${itemCount.files} files, ${itemCount.dirs} directories)`; if (spinner) { - spinner.succeed(colored('[OK]', 'green') + ` ${msg}`); + spinner.succeed(ok(msg)); } else { console.log(`[OK] ${msg}`); } @@ -126,7 +126,7 @@ export class ClaudeDirInstaller { const error = err as Error; const msg = `Failed to copy .claude/ directory: ${error.message}`; if (spinner) { - spinner.fail(colored('[!]', 'yellow') + ` ${msg}`); + spinner.fail(warn(msg)); console.warn(' CCS items may not be available'); } else { console.warn(`[!] ${msg}`); diff --git a/src/utils/claude-symlink-manager.ts b/src/utils/claude-symlink-manager.ts index 29c98260..dc252610 100644 --- a/src/utils/claude-symlink-manager.ts +++ b/src/utils/claude-symlink-manager.ts @@ -15,7 +15,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { colored } from './helpers'; +import { ok, color } from './ui'; // Ora fallback type for when ora is not available interface OraSpinner { @@ -121,7 +121,7 @@ export class ClaudeSymlinkManager { const msg = `${installed}/${this.ccsItems.length} items installed to ~/.claude/`; if (spinner) { - spinner.succeed(colored('[OK]', 'green') + ` ${msg}`); + spinner.succeed(ok(msg)); } else { console.log(`[OK] ${msg}`); } @@ -395,7 +395,7 @@ export class ClaudeSymlinkManager { */ sync(): void { console.log(''); - console.log(colored('Syncing CCS Components...', 'cyan')); + console.log(color('Syncing CCS Components...', 'info')); console.log(''); this.install(false); } diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 5049e587..23504e9a 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -2,8 +2,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { Config, isConfig, Settings, isSettings } from '../types'; -import { expandPath } from './helpers'; -import { error } from './helpers'; +import { expandPath, error } from './helpers'; +import { info } from './ui'; // TODO: Replace with proper imports after converting these files // const { ErrorManager } = require('./error-manager'); @@ -107,7 +107,7 @@ export function getSettingsPath(profile: string): string { // const recovery = new RecoveryManager(); // recovery.ensureClaudeSettings(); - console.log('[i] Auto-created missing settings file'); + console.log(info('Auto-created missing settings file')); } else { error(`Settings file not found: ${expandedPath}`); } diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index 95d1076b..5a594813 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as https from 'https'; -import { colored } from './helpers'; +import { warn, color } from './ui'; const CACHE_DIR = path.join(os.homedir(), '.ccs', 'cache'); const UPDATE_CHECK_FILE = path.join(CACHE_DIR, 'update-check.json'); @@ -305,13 +305,11 @@ export async function checkForUpdates( */ export function showUpdateNotification(updateInfo: { current: string; latest: string }): void { console.log(''); - console.log(colored('═══════════════════════════════════════════════════════', 'cyan')); - console.log( - colored(` Update available: ${updateInfo.current} → ${updateInfo.latest}`, 'yellow') - ); - console.log(colored('═══════════════════════════════════════════════════════', 'cyan')); + console.log(color('═══════════════════════════════════════════════════════', 'info')); + console.log(warn(`Update available: ${updateInfo.current} → ${updateInfo.latest}`)); + console.log(color('═══════════════════════════════════════════════════════', 'info')); console.log(''); - console.log(` Run ${colored('ccs update', 'yellow')} to update`); + console.log(` Run ${color('ccs update', 'command')} to update`); console.log(''); } diff --git a/src/web-server/shutdown.ts b/src/web-server/shutdown.ts index 37b78899..5420fe5a 100644 --- a/src/web-server/shutdown.ts +++ b/src/web-server/shutdown.ts @@ -7,6 +7,7 @@ import { Server as HTTPServer } from 'http'; import { WebSocketServer } from 'ws'; +import { ok, info, warn } from '../utils/ui'; const SHUTDOWN_TIMEOUT = 10_000; // 10 seconds @@ -19,7 +20,7 @@ export function setupGracefulShutdown( cleanup?: () => void ): void { const shutdown = () => { - console.log('\n[i] Shutting down gracefully...'); + console.log('\n' + info('Shutting down gracefully...')); // Run cleanup first (closes file watchers + WebSocket clients) if (cleanup) { @@ -28,13 +29,13 @@ export function setupGracefulShutdown( // Close HTTP server server.close(() => { - console.log('[OK] Server closed'); + console.log(ok('Server closed')); process.exit(0); }); // Force shutdown if graceful shutdown takes too long setTimeout(() => { - console.log('[!] Force shutdown (timeout exceeded)'); + console.log(warn('Force shutdown (timeout exceeded)')); process.exit(1); }, SHUTDOWN_TIMEOUT); }; diff --git a/src/web-server/usage-disk-cache.ts b/src/web-server/usage-disk-cache.ts index c1ba8ee6..8a2a947d 100644 --- a/src/web-server/usage-disk-cache.ts +++ b/src/web-server/usage-disk-cache.ts @@ -12,6 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import type { DailyUsage, MonthlyUsage, SessionUsage } from './usage-types'; +import { ok, info, warn } from '../utils/ui'; // Cache configuration const CCS_DIR = path.join(os.homedir(), '.ccs'); @@ -58,7 +59,7 @@ export function readDiskCache(): UsageDiskCache | null { // Version check - invalidate if schema changed if (cache.version !== CACHE_VERSION) { - console.log('[i] Cache version mismatch, will refresh'); + console.log(info('Cache version mismatch, will refresh')); return null; } @@ -66,7 +67,7 @@ export function readDiskCache(): UsageDiskCache | null { return cache; } catch (err) { // Cache corrupted or unreadable - treat as miss - console.log('[i] Cache read failed, will refresh:', (err as Error).message); + console.log(info('Cache read failed, will refresh:') + ` ${(err as Error).message}`); return null; } } @@ -113,10 +114,10 @@ export function writeDiskCache( fs.writeFileSync(tempFile, JSON.stringify(cache), 'utf-8'); fs.renameSync(tempFile, CACHE_FILE); - console.log('[OK] Disk cache updated'); + console.log(ok('Disk cache updated')); } catch (err) { // Non-fatal - we can still serve from memory - console.log('[!] Failed to write disk cache:', (err as Error).message); + console.log(warn('Failed to write disk cache:') + ` ${(err as Error).message}`); } } @@ -143,9 +144,9 @@ export function clearDiskCache(): void { try { if (fs.existsSync(CACHE_FILE)) { fs.unlinkSync(CACHE_FILE); - console.log('[OK] Disk cache cleared'); + console.log(ok('Disk cache cleared')); } } catch (err) { - console.log('[!] Failed to clear disk cache:', (err as Error).message); + console.log(warn('Failed to clear disk cache:') + ` ${(err as Error).message}`); } } diff --git a/src/web-server/usage-routes.ts b/src/web-server/usage-routes.ts index fa98e9b7..803ba9e0 100644 --- a/src/web-server/usage-routes.ts +++ b/src/web-server/usage-routes.ts @@ -38,6 +38,7 @@ import { clearDiskCache, getCacheAge, } from './usage-disk-cache'; +import { ok, info, fail } from '../utils/ui'; // ============================================================================ // Multi-Instance Support - Aggregate usage from CCS profiles @@ -66,7 +67,7 @@ function getInstancePaths(): string[] { return fs.existsSync(projectsPath); }); } catch { - console.error('[!] Failed to read CCS instances directory'); + console.error(fail('Failed to read CCS instances directory')); return []; } } @@ -87,7 +88,7 @@ async function loadInstanceData(instancePath: string): Promise<{ } catch (_err) { // Instance may have no usage data - that's OK const instanceName = path.basename(instancePath); - console.log(`[i] No usage data in instance: ${instanceName}`); + console.log(info(`No usage data in instance: ${instanceName}`)); return { daily: [], monthly: [], session: [] }; } } @@ -283,7 +284,7 @@ async function getCachedData(key: string, ttl: number, loader: () => Promise< persistCacheIfComplete(); }) .catch((err) => { - console.error(`[!] Background refresh failed for ${key}:`, err); + console.error(fail(`Background refresh failed for ${key}: ${err}`)); }) .finally(() => { pendingRequests.delete(key); @@ -376,7 +377,7 @@ async function refreshFromSource(): Promise<{ instanceDataResults.push(data); } catch (err) { const instanceName = path.basename(instancePath); - console.error(`[!] Failed to load instance ${instanceName}:`, err); + console.error(fail(`Failed to load instance ${instanceName}: ${err}`)); } } @@ -392,7 +393,7 @@ async function refreshFromSource(): Promise<{ } if (instanceDataResults.length > 0) { - console.log(`[i] Aggregated usage data from ${instanceDataResults.length} CCS instance(s)`); + console.log(info(`Aggregated usage data from ${instanceDataResults.length} CCS instance(s)`)); } // Merge all data sources @@ -453,7 +454,7 @@ export async function prewarmUsageCache(): Promise<{ source: string; }> { const start = Date.now(); - console.log('[i] Pre-warming usage cache...'); + console.log(info('Pre-warming usage cache...')); try { const diskCache = readDiskCache(); @@ -468,7 +469,7 @@ export async function prewarmUsageCache(): Promise<{ const elapsed = Date.now() - start; console.log( - `[OK] Usage cache ready from disk (${elapsed}ms, cached ${getCacheAge(diskCache)})` + ok(`Usage cache ready from disk (${elapsed}ms, cached ${getCacheAge(diskCache)})`) ); return { timestamp: now, elapsed, source: 'disk-fresh' }; } @@ -483,15 +484,17 @@ export async function prewarmUsageCache(): Promise<{ const elapsed = Date.now() - start; console.log( - `[OK] Usage cache ready from disk (${elapsed}ms, stale ${getCacheAge(diskCache)}, refreshing...)` + ok( + `Usage cache ready from disk (${elapsed}ms, stale ${getCacheAge(diskCache)}, refreshing...)` + ) ); // Background refresh if (!isRefreshing) { isRefreshing = true; refreshFromSource() - .then(() => console.log('[OK] Background refresh complete')) - .catch((err) => console.error('[!] Background refresh failed:', err)) + .then(() => console.log(ok('Background refresh complete'))) + .catch((err) => console.error(fail(`Background refresh failed: ${err}`))) .finally(() => { isRefreshing = false; }); @@ -501,14 +504,14 @@ export async function prewarmUsageCache(): Promise<{ } // No usable disk cache - refresh from source (blocking for first startup only) - console.log('[i] No disk cache, loading from source...'); + console.log(info('No disk cache, loading from source...')); await refreshFromSource(); const elapsed = Date.now() - start; - console.log(`[OK] Usage cache ready (${elapsed}ms)`); + console.log(ok(`Usage cache ready (${elapsed}ms)`)); return { timestamp: Date.now(), elapsed, source: 'fresh' }; } catch (err) { - console.error('[!] Failed to prewarm usage cache:', err); + console.error(fail(`Failed to prewarm usage cache: ${err}`)); throw err; } } diff --git a/src/web-server/websocket.ts b/src/web-server/websocket.ts index 05c39a9c..9ecba5e8 100644 --- a/src/web-server/websocket.ts +++ b/src/web-server/websocket.ts @@ -6,6 +6,7 @@ import { WebSocketServer, WebSocket } from 'ws'; import { createFileWatcher, FileChangeEvent } from './file-watcher'; +import { info, warn } from '../utils/ui'; export interface WSMessage { type: string; @@ -29,7 +30,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } { // Handle new connections wss.on('connection', (ws) => { clients.add(ws); - console.log(`[WS] Client connected (${clients.size} total)`); + console.log(info(`[WS] Client connected (${clients.size} total)`)); // Send welcome message ws.send(JSON.stringify({ type: 'connected', timestamp: Date.now() })); @@ -40,18 +41,18 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } { const message = JSON.parse(data.toString()); handleClientMessage(ws, message); } catch { - console.log('[WS] Invalid message format'); + console.log(warn('[WS] Invalid message format')); } }); // Handle disconnect ws.on('close', () => { clients.delete(ws); - console.log(`[WS] Client disconnected (${clients.size} remaining)`); + console.log(info(`[WS] Client disconnected (${clients.size} remaining)`)); }); ws.on('error', (err) => { - console.log(`[WS] Error: ${err.message}`); + console.log(warn(`[WS] Error: ${err.message}`)); clients.delete(ws); }); }); @@ -66,13 +67,13 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } { // Future: selective subscriptions break; default: - console.log(`[WS] Unknown message type: ${message.type}`); + console.log(warn(`[WS] Unknown message type: ${message.type}`)); } } // Setup file watcher const watcher = createFileWatcher((event: FileChangeEvent) => { - console.log(`[FS] ${event.type}: ${event.path}`); + console.log(info(`[FS] ${event.type}: ${event.path}`)); broadcast({ type: event.type, path: event.path, From d343abca53eb0fd238d0ff2c59f674a05a651721 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 05:12:08 -0500 Subject: [PATCH 04/26] fix(config): prevent profile loss from strict config validation Relaxed isUnifiedConfig() type guard to accept version >= 1 and partial configs. Added mergeWithDefaults() to preserve user data while filling missing sections. Fixes profile "not found" after terminal restart. Closes #82 --- src/config/unified-config-loader.ts | 29 ++++++++++++++++++++++++++++- src/config/unified-config-types.ts | 12 +++++------- tests/unit/unified-config.test.ts | 19 ++++++++++++++----- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 505928f3..292ec9e5 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -90,12 +90,39 @@ export function loadUnifiedConfig(): UnifiedConfig | null { } } +/** + * Merge partial config with defaults. + * Preserves existing data while filling in missing sections. + */ +function mergeWithDefaults(partial: Partial): UnifiedConfig { + const defaults = createEmptyUnifiedConfig(); + return { + version: partial.version ?? defaults.version, + default: partial.default ?? defaults.default, + accounts: partial.accounts ?? defaults.accounts, + profiles: partial.profiles ?? defaults.profiles, + cliproxy: { + oauth_accounts: partial.cliproxy?.oauth_accounts ?? defaults.cliproxy.oauth_accounts, + providers: defaults.cliproxy.providers, // Always use defaults for providers + variants: partial.cliproxy?.variants ?? defaults.cliproxy.variants, + }, + preferences: { + ...defaults.preferences, + ...partial.preferences, + }, + }; +} + /** * Load config, preferring YAML if available, falling back to creating empty config. + * Merges with defaults to ensure all sections exist. */ export function loadOrCreateUnifiedConfig(): UnifiedConfig { const existing = loadUnifiedConfig(); - if (existing) return existing; + if (existing) { + // Merge with defaults to fill any missing sections + return mergeWithDefaults(existing); + } // Create empty config const config = createEmptyUnifiedConfig(); diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index e41cc4a1..ed313298 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -151,17 +151,15 @@ export function createEmptySecretsConfig(): SecretsConfig { /** * Type guard for UnifiedConfig. + * Relaxed validation: accepts configs with version >= 1 and any subset of sections. + * Missing sections will be filled with defaults during merge. */ export function isUnifiedConfig(obj: unknown): obj is UnifiedConfig { if (typeof obj !== 'object' || obj === null) return false; const config = obj as Record; - return ( - typeof config.version === 'number' && - config.version === UNIFIED_CONFIG_VERSION && - typeof config.accounts === 'object' && - typeof config.profiles === 'object' && - typeof config.cliproxy === 'object' - ); + // Only require version to be a number >= 1 (allow future versions) + // Sections are optional - will be merged with defaults in loadOrCreateUnifiedConfig + return typeof config.version === 'number' && config.version >= 1; } /** diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index 3ad80b22..b345bd34 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -124,14 +124,23 @@ describe('unified-config-types', () => { expect(isUnifiedConfig(null)).toBe(false); }); - it('should return false for wrong version', () => { + it('should return true for older version (relaxed validation)', () => { + // Fix for issue #82: Relaxed validation accepts version >= 1 + // to prevent profile loss when loading partially valid configs const config = { ...createEmptyUnifiedConfig(), version: 1 }; - expect(isUnifiedConfig(config)).toBe(false); + expect(isUnifiedConfig(config)).toBe(true); }); - it('should return false for missing fields', () => { - expect(isUnifiedConfig({ version: 2 })).toBe(false); - expect(isUnifiedConfig({ version: 2, accounts: {} })).toBe(false); + it('should return true for partial configs (relaxed validation)', () => { + // Fix for issue #82: Relaxed validation accepts partial configs + // Missing sections are merged with defaults in loadOrCreateUnifiedConfig + expect(isUnifiedConfig({ version: 2 })).toBe(true); + expect(isUnifiedConfig({ version: 2, accounts: {} })).toBe(true); + }); + + it('should return false for version < 1', () => { + expect(isUnifiedConfig({ version: 0 })).toBe(false); + expect(isUnifiedConfig({ version: -1 })).toBe(false); }); }); From 8b3316fa77831176217ce7cd92f6f9392beec382 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Dec 2025 10:20:24 +0000 Subject: [PATCH 05/26] chore(release): 5.16.0-dev.3 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 28356fec..80acdc4f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.16.0-dev.2 +5.16.0-dev.3 diff --git a/package.json b/package.json index b89e0194..bb33cb8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.16.0-dev.2", + "version": "5.16.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 932a74e199191741b019530e8665b9de5b97cdee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Dec 2025 10:25:07 +0000 Subject: [PATCH 06/26] chore(release): 5.16.0-dev.4 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 80acdc4f..7cd5a682 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.16.0-dev.3 +5.16.0-dev.4 diff --git a/package.json b/package.json index bb33cb8b..9305d266 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.16.0-dev.3", + "version": "5.16.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From a7ba1a198398c33af23f43fc07ff871ce068b4e7 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 05:36:49 -0500 Subject: [PATCH 07/26] fix(cliproxy): respect version pin when user installs specific version When a user explicitly installs a specific CLIProxy version via `ccs cliproxy --install `, this version should persist and not be auto-updated. Previously, subsequent commands would check for updates and overwrite the user's choice. Changes: - Add .version-pin file to persist user's explicit version choice - Update ensureCLIProxyBinary() to check for version pin and skip auto-update - Update --install command to save version pin with "(pinned)" confirmation - Update --latest command to clear version pin and enable auto-update - Add --update command as alias for --latest (for unpinning workflow) - Update status display to show "(pinned)" indicator when version is pinned - Update help text to document new pin/unpin behavior Closes #88 --- src/cliproxy/binary-manager.ts | 80 ++++++++++++++++++++++++++++++++ src/cliproxy/index.ts | 5 ++ src/commands/cliproxy-command.ts | 67 +++++++++++++++++++++----- 3 files changed, 140 insertions(+), 12 deletions(-) diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 5ebc8948..8137c108 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -39,6 +39,9 @@ import { /** Cache duration for version check (1 hour in milliseconds) */ const VERSION_CACHE_DURATION_MS = 60 * 60 * 1000; +/** Version pin file name - stores user's explicit version choice */ +const VERSION_PIN_FILE = '.version-pin'; + /** GitHub API URL for latest release */ const GITHUB_API_LATEST_RELEASE = 'https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest'; @@ -887,9 +890,26 @@ export class BinaryManager { /** * Convenience function to ensure binary is available + * Respects version pin if set by user via 'ccs cliproxy --install ' * @returns Path to CLIProxyAPI executable */ export async function ensureCLIProxyBinary(verbose = false): Promise { + const pinnedVersion = getPinnedVersion(); + + if (pinnedVersion) { + // Version is pinned - use forceVersion to prevent auto-update + if (verbose) { + console.error(`[cliproxy] Using pinned version: ${pinnedVersion}`); + } + const manager = new BinaryManager({ + version: pinnedVersion, + verbose, + forceVersion: true, + }); + return manager.ensureBinary(); + } + + // No pin - allow auto-update to latest const manager = new BinaryManager({ verbose }); return manager.ensureBinary(); } @@ -960,4 +980,64 @@ export async function fetchLatestCliproxyVersion(): Promise { return result.latestVersion; } +/** + * Get path to version pin file + * @returns Absolute path to .version-pin file + */ +export function getVersionPinPath(): string { + return path.join(getBinDir(), VERSION_PIN_FILE); +} + +/** + * Get pinned version if one exists + * @returns Pinned version string, or null if not pinned + */ +export function getPinnedVersion(): string | null { + const pinPath = getVersionPinPath(); + if (!fs.existsSync(pinPath)) { + return null; + } + try { + return fs.readFileSync(pinPath, 'utf8').trim(); + } catch { + return null; + } +} + +/** + * Save pinned version to persist user's explicit choice + * @param version Version to pin (e.g., "6.5.50") + */ +export function savePinnedVersion(version: string): void { + const pinPath = getVersionPinPath(); + try { + fs.mkdirSync(path.dirname(pinPath), { recursive: true }); + fs.writeFileSync(pinPath, version, 'utf8'); + } catch { + // Silent fail - not critical but log if verbose + } +} + +/** + * Clear pinned version (unpin) + */ +export function clearPinnedVersion(): void { + const pinPath = getVersionPinPath(); + if (fs.existsSync(pinPath)) { + try { + fs.unlinkSync(pinPath); + } catch { + // Silent fail + } + } +} + +/** + * Check if a version is currently pinned + * @returns true if a version is pinned + */ +export function isVersionPinned(): boolean { + return getPinnedVersion() !== null; +} + export default BinaryManager; diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index b3863907..ecf890b1 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -43,6 +43,11 @@ export { getInstalledCliproxyVersion, installCliproxyVersion, fetchLatestCliproxyVersion, + getPinnedVersion, + savePinnedVersion, + clearPinnedVersion, + isVersionPinned, + getVersionPinPath, } from './binary-manager'; // Config generation diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index a6fd1715..59d9dcb9 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -26,6 +26,10 @@ import { fetchLatestCliproxyVersion, isCLIProxyInstalled, getCLIProxyPath, + getPinnedVersion, + savePinnedVersion, + clearPinnedVersion, + isVersionPinned, } from '../cliproxy'; import { getAllAuthStatus, getOAuthConfig, triggerOAuth } from '../cliproxy/auth-handler'; import { getProviderAccounts } from '../cliproxy/account-manager'; @@ -803,8 +807,9 @@ async function showHelp(): Promise { // Binary Commands console.log(subheader('Binary Commands:')); const binaryCmds: [string, string][] = [ - ['--install ', 'Install a specific binary version'], - ['--latest', 'Install the latest binary version'], + ['--install ', 'Install and pin a specific version'], + ['--latest', 'Install the latest version (no pin)'], + ['--update', 'Unpin and update to latest version'], ]; const maxBinaryLen = Math.max(...binaryCmds.map(([cmd]) => cmd.length)); for (const [cmd, desc] of binaryCmds) { @@ -897,6 +902,7 @@ async function showStatus(verbose: boolean): Promise { const installed = isCLIProxyInstalled(); const currentVersion = getInstalledCliproxyVersion(); const binaryPath = getCLIProxyPath(); + const pinnedVersion = getPinnedVersion(); console.log(''); console.log(color('CLIProxyAPI Status', 'primary')); @@ -904,7 +910,10 @@ async function showStatus(verbose: boolean): Promise { if (installed) { console.log(` Installed: ${color('Yes', 'success')}`); - console.log(` Version: ${color(`v${currentVersion}`, 'info')}`); + const versionLabel = pinnedVersion + ? `${color(`v${currentVersion}`, 'info')} ${color('(pinned)', 'warning')}` + : color(`v${currentVersion}`, 'info'); + console.log(` Version: ${versionLabel}`); console.log(` Binary: ${dim(binaryPath)}`); } else { console.log(` Installed: ${color('No', 'error')}`); @@ -919,11 +928,19 @@ async function showStatus(verbose: boolean): Promise { const latestVersion = await fetchLatestCliproxyVersion(); if (latestVersion !== currentVersion) { - console.log( - ` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(update available)')}` - ); - console.log(''); - console.log(` ${dim(`Run "ccs cliproxy --latest" to update`)}`); + if (pinnedVersion) { + console.log( + ` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(pinned to v' + pinnedVersion + ')')}` + ); + console.log(''); + console.log(` ${dim('Run "ccs cliproxy --update" to unpin and update')}`); + } else { + console.log( + ` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(update available)')}` + ); + console.log(''); + console.log(` ${dim('Run "ccs cliproxy --latest" to update')}`); + } } else { console.log(` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(up to date)')}`); } @@ -940,7 +957,7 @@ async function showStatus(verbose: boolean): Promise { } /** - * Install a specific version + * Install a specific version (pins the version to prevent auto-update) */ async function installVersion(version: string, verbose: boolean): Promise { // Validate version format (basic semver check) @@ -954,8 +971,18 @@ async function installVersion(version: string, verbose: boolean): Promise try { await installCliproxyVersion(version, verbose); + + // Pin the version to prevent auto-update + savePinnedVersion(version); + + console.log(''); + console.log(ok(`CLIProxyAPI v${version} installed (pinned)`)); + console.log(''); + console.log(dim('This version will be used until you run:')); + console.log( + ` ${color('ccs cliproxy --update', 'command')} ${dim('# Update to latest and unpin')}` + ); console.log(''); - console.log(ok(`CLIProxyAPI v${version} installed successfully`)); } catch (error) { const err = error as Error; console.error(''); @@ -974,7 +1001,7 @@ async function installVersion(version: string, verbose: boolean): Promise } /** - * Install latest version + * Install latest version (clears any version pin) */ async function installLatest(verbose: boolean): Promise { console.log(info('Fetching latest CLIProxyAPI version...')); @@ -982,8 +1009,9 @@ async function installLatest(verbose: boolean): Promise { try { const latestVersion = await fetchLatestCliproxyVersion(); const currentVersion = getInstalledCliproxyVersion(); + const wasPinned = isVersionPinned(); - if (isCLIProxyInstalled() && latestVersion === currentVersion) { + if (isCLIProxyInstalled() && latestVersion === currentVersion && !wasPinned) { console.log(ok(`Already running latest version: v${latestVersion}`)); return; } @@ -992,11 +1020,20 @@ async function installLatest(verbose: boolean): Promise { if (isCLIProxyInstalled()) { console.log(info(`Current version: v${currentVersion}`)); } + if (wasPinned) { + console.log(info(`Removing version pin (was v${getPinnedVersion()})`)); + } console.log(''); await installCliproxyVersion(latestVersion, verbose); + + // Clear any version pin so auto-update works again + clearPinnedVersion(); + console.log(''); console.log(ok(`CLIProxyAPI updated to v${latestVersion}`)); + console.log(dim('Auto-update is now enabled.')); + console.log(''); } catch (error) { const err = error as Error; console.error(fail(`Failed to install latest version: ${err.message}`)); @@ -1057,6 +1094,12 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } + // Handle --update (unpin and update to latest) + if (args.includes('--update')) { + await installLatest(verbose); + return; + } + // Default: show status await showStatus(verbose); } From 111dba444cce2d292afcd0a2b152fae7dab03574 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Dec 2025 10:40:33 +0000 Subject: [PATCH 08/26] chore(release): 5.16.0-dev.5 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 7cd5a682..b368761c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.16.0-dev.4 +5.16.0-dev.5 diff --git a/package.json b/package.json index 9305d266..5883f2f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.16.0-dev.4", + "version": "5.16.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 53259ce1c7334eabb5916da380b61d369480cc74 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 05:49:48 -0500 Subject: [PATCH 09/26] docs: update project documentation with recent features --- README.md | 8 +++----- docs/codebase-summary.md | 23 +++++++++++++++++++++-- docs/project-overview-pdr.md | 14 ++++++++------ 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index cf51c7f5..efc8d540 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,9 @@ ![CCS Logo](docs/assets/ccs-logo-medium.png) -### One command, zero downtime, multiple accounts - -**Switch between multiple Claude accounts, GLM, and Kimi instantly.**
-Stop hitting rate limits. Keep working continuously.
-Features a modern React 19 dashboard with real-time updates. +### Switch between multiple Claude accounts, GLM, Kimi, and OAuth-based models (Gemini, Codex, Antigravity) instantly. +Stop hitting rate limits. Keep working continuously. +Features a modern React 19 dashboard with real-time updates and unified configuration. [![License](https://img.shields.io/badge/license-MIT-C15F3C?style=for-the-badge)](LICENSE) [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey?style=for-the-badge)]() diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 58a357b6..ae5c957d 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -74,8 +74,9 @@ ccs/ 4. **CLIProxy Integration** (`src/cliproxy/`) - OAuth-based provider integration - - Binary manager for cliproxy executables + - Binary manager for cliproxy executables, including the new **version pin feature** (see Recent Changes) - Auth handler for OAuth flows + - **Model Catalog**: Manages available models (Gemini, Codex, AGY, Qwen, Iflow) 5. **AI Delegation** (`src/delegation/`) - Headless Claude execution @@ -112,7 +113,7 @@ ccs/ - Dark mode support **Key Pages**: -- Dashboard: Overview and status +- Dashboard: Overview, status, usage analytics, CLIProxy controls, and system health monitoring - API Profiles: Model configuration - CLIProxy: OAuth provider setup - Accounts: Account management @@ -220,6 +221,23 @@ ccs/ - **Quality gates**: Pre-commit validation - **Comprehensive tests**: Unit, integration, native +## Recent Changes + +### Version Pin Feature (Issue #88) +- **Description**: Users can now explicitly pin a specific version of CLIProxy using `ccs cliproxy --install `. This creates a `.version-pin` file, preventing automatic updates and ensuring stability for specific project requirements. + +### Profile Persistence Fix (Issue #82) +- **Description**: Relaxed configuration validation rules to prevent unintended data loss when handling user profiles, improving robustness and user experience. + +### UI Dashboard Capabilities +- **Description**: The web-based UI now provides comprehensive dashboard functionalities, including usage analytics, CLIProxy controls, and system health monitoring, enhancing user visibility and control. + +### Multi-Provider Support +- **Description**: The CLIProxy now supports multiple AI model providers, including Gemini, Codex, AGY, Qwen, and Iflow, offering greater flexibility and choice for users. + +### Unified Config System +- **Description**: A consolidated configuration system ensures consistent management of settings, feature flags, and secrets across the CLI and its components. + ## Architecture Patterns ### Modular Design @@ -229,6 +247,7 @@ ccs/ - **Error boundaries**: Graceful error handling ### Configuration Management +- **Unified Config System**: Consolidated management of settings, feature flags, and secrets across the CLI and its components (see Recent Changes). - **Shared data**: Symlinks for commands, skills, agents - **Isolated state**: Profile-specific sessions, logs - **Auto-recovery**: Self-healing configurations diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 0e82110b..49d11918 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -2,7 +2,7 @@ ## Executive Summary -CCS (Claude Code Switch) is a TypeScript-based CLI tool that enables instant profile switching between Claude Sonnet 4.5, GLM 4.6, GLMT (GLM with Thinking), and Kimi for Coding models. The project features a modern React 19 web dashboard with real-time WebSocket integration, comprehensive TypeScript architecture, and cross-platform support. Current architecture includes: +CCS (Claude Code Switch) is a TypeScript-based CLI tool that enables instant profile switching between Claude, GLM, Kimi, Gemini, Codex, and Antigravity (and any OAuth-based models) models. It features a version pin mechanism for precise model control and a unified configuration system for seamless management across all providers. The project features a modern React 19 web dashboard with real-time WebSocket integration, comprehensive TypeScript architecture, and cross-platform support. Current architecture includes: - **TypeScript Core**: 43 source files with 100% type coverage - **React 19 Dashboard**: Modern UI with Vite, shadcn/ui, and real-time features @@ -13,13 +13,14 @@ CCS (Claude Code Switch) is a TypeScript-based CLI tool that enables instant pro ## Product Vision ### Mission Statement -Provide developers with instant, zero-downtime switching between AI models, optimizing for cost, performance, and rate limit management while maintaining a seamless workflow experience. +Provide developers with instant, zero-downtime switching between diverse AI models, ensuring precise version control, optimizing for cost, performance, and rate limit management while maintaining a seamless workflow experience. ### Core Value Proposition -- **Instant Switching**: One command to change AI models without file editing -- **Zero Downtime**: Never interrupt development workflow during model switches -- **Cost Optimization**: Use the right model for each task automatically -- **Developer Experience**: Maintain familiar Claude CLI interface with enhanced capabilities +- **Instant Switching**: One command to change AI models and providers without file editing +- **Zero Downtime**: Never interrupt development workflow during model or provider switches +- **Version Pinning**: Lock to specific model versions for consistent behavior +- **Cost Optimization**: Use the right model/provider for each task automatically +- **Developer Experience**: Maintain familiar Claude CLI interface with enhanced capabilities and multi-provider support ## Product Development Requirements (PDR) @@ -457,6 +458,7 @@ The CCS project demonstrates successful iterative evolution balancing simplifica - **Developer Experience**: Familiar Claude CLI interface with AI delegation - **Maintainability**: Modular design, clear responsibilities - **Performance**: Minimal overhead, direct CLI execution +- **Unified Configuration**: Centralized management for all AI models and providers ### Breaking Changes (v3.x → v4.x) - **Zero Breaking Changes**: v4.x fully backward compatible with v3.x From 3c7e071b6144a5d5a731c9258835230e3c855773 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Dec 2025 10:52:21 +0000 Subject: [PATCH 10/26] chore(release): 5.16.0-dev.6 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index b368761c..3f44c382 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.16.0-dev.5 +5.16.0-dev.6 diff --git a/package.json b/package.json index 5883f2f5..b295e4dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.16.0-dev.5", + "version": "5.16.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 5ef3e324f8319e2d16bbe250cc958d3d0dec10fe Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 05:57:58 -0500 Subject: [PATCH 11/26] ci(release): add issue labeling for dev releases - Add label-pending-release.yml workflow to tag issues when PR created - Update dev-release.yml to comment and label issues after npm publish - Labels: pending-release (yellow), released-dev (blue) - Auto-removes pending-release when released-dev is applied --- .github/workflows/dev-release.yml | 58 +++++++++++++++++++++ .github/workflows/label-pending-release.yml | 40 ++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 .github/workflows/label-pending-release.yml diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index b1967ad1..761a6f79 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -12,6 +12,7 @@ jobs: permissions: contents: write + issues: write steps: - name: Checkout @@ -96,6 +97,63 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish --tag dev + - name: Tag resolved issues + env: + GH_TOKEN: ${{ secrets.PAT_TOKEN }} + run: | + NEW_VERSION="${{ steps.bump.outputs.new }}" + + # Find commits since last dev release (look for version bump commits) + LAST_RELEASE_COMMIT=$(git log --oneline --grep="chore(release):" -n 2 | tail -1 | cut -d' ' -f1) + + if [[ -n "$LAST_RELEASE_COMMIT" ]]; then + RANGE="${LAST_RELEASE_COMMIT}..HEAD" + else + RANGE="HEAD~20..HEAD" + fi + + echo "Checking commits in range: $RANGE" + + # Extract issue numbers from commits + ISSUES=$(git log $RANGE --pretty=format:"%s %b" | \ + grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \ + grep -oE "#[0-9]+" | sort -u || true) + + if [[ -z "$ISSUES" ]]; then + echo "No linked issues found in commits" + exit 0 + fi + + # Create labels if they don't exist + gh label create "released-dev" \ + --color "1d76db" \ + --description "Fix available in @dev npm channel" \ + --repo ${{ github.repository }} 2>/dev/null || true + + for ISSUE in $ISSUES; do + NUM=${ISSUE#\#} + echo "Tagging issue #$NUM as released-dev" + + # Add comment + gh issue comment $NUM --body ":test_tube: This fix is available for testing in version \`$NEW_VERSION\` + +Install with: +\`\`\`bash +npm install @kaitranntt/ccs@dev +\`\`\` + +Please test and confirm the fix works as expected." --repo ${{ github.repository }} || true + + # Add released-dev label, remove pending-release if present + gh issue edit $NUM \ + --add-label "released-dev" \ + --remove-label "pending-release" \ + --repo ${{ github.repository }} 2>/dev/null || \ + gh issue edit $NUM \ + --add-label "released-dev" \ + --repo ${{ github.repository }} || true + done + - name: Commit version bump run: | git config user.name "github-actions[bot]" diff --git a/.github/workflows/label-pending-release.yml b/.github/workflows/label-pending-release.yml new file mode 100644 index 00000000..b427cd5a --- /dev/null +++ b/.github/workflows/label-pending-release.yml @@ -0,0 +1,40 @@ +name: Label Pending Release + +on: + pull_request: + types: [opened, reopened] + branches: [dev] + +jobs: + label: + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - name: Label linked issues as pending-release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Extract issue numbers from PR title and body + PR_TEXT="${{ github.event.pull_request.title }} ${{ github.event.pull_request.body }}" + ISSUES=$(echo "$PR_TEXT" | grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | grep -oE "#[0-9]+" | sort -u || true) + + if [[ -z "$ISSUES" ]]; then + echo "No linked issues found in PR" + exit 0 + fi + + for ISSUE in $ISSUES; do + NUM=${ISSUE#\#} + echo "Labeling issue #$NUM as pending-release" + + # Create label if doesn't exist + gh label create "pending-release" \ + --color "fbca04" \ + --description "Fix in review, awaiting merge" \ + --repo ${{ github.repository }} 2>/dev/null || true + + # Add label to issue + gh issue edit $NUM --add-label "pending-release" --repo ${{ github.repository }} || true + done From d64115f91a7005f2c4ff09a63831da2aac050ba2 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 15:33:22 -0500 Subject: [PATCH 12/26] feat(analytics): add 24H hourly chart with caching and UI improvements - Add GitHub link button next to connection status for quick issue reporting - Add 24H button on analytics page with hourly granularity chart - Add /api/usage/hourly endpoint with date range filtering - Add hourly data aggregation and caching (disk + memory) - Fix timezone display: convert UTC hours to local time in chart - Fix CLIProxy Stats card loading state synchronization - Bump disk cache version to 3 (includes hourly data) --- src/cliproxy/auth-handler.ts | 4 +- src/cliproxy/cliproxy-executor.ts | 37 +++- src/cliproxy/config-generator.ts | 4 +- src/management/doctor.ts | 138 +++++++++++--- src/utils/error-manager.ts | 16 +- src/utils/platform-commands.ts | 180 ++++++++++++++++++ src/web-server/data-aggregator.ts | 111 +++++++++++ src/web-server/usage-disk-cache.ts | 9 +- src/web-server/usage-routes.ts | 123 +++++++++++- src/web-server/usage-types.ts | 14 ++ ui/src/App.tsx | 4 +- .../analytics/cliproxy-stats-card.tsx | 8 +- .../analytics/usage-trend-chart.tsx | 61 ++++-- ui/src/components/github-link.tsx | 20 ++ ui/src/components/quick-setup-wizard.tsx | 4 +- ui/src/hooks/use-usage.ts | 25 +++ ui/src/pages/analytics.tsx | 36 +++- 17 files changed, 725 insertions(+), 69 deletions(-) create mode 100644 src/utils/platform-commands.ts create mode 100644 ui/src/components/github-link.tsx diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index 32567662..6a3a5d80 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -20,6 +20,7 @@ import { ok, fail, info, warn } from '../utils/ui'; import { ensureCLIProxyBinary } from './binary-manager'; import { generateConfig, getProviderAuthDir } from './config-generator'; import { CLIProxyProvider } from './types'; +import { getKillCLIProxyCommand } from '../utils/platform-commands'; import { AccountInfo, discoverExistingAccounts, @@ -636,7 +637,8 @@ export async function triggerOAuth( console.error(' Try: ccs qwen --auth --verbose'); } else { console.error(' The OAuth flow may have been cancelled or callback port was in use'); - console.error(` Try: pkill -f cli-proxy-api && ccs ${provider} --auth`); + console.error(` Try: ${getKillCLIProxyCommand()} && ccs ${provider} --auth`); + console.error(' Or run: ccs doctor --fix'); } resolve(null); } diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 744a66a0..a14ac2c8 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -37,6 +37,8 @@ import { renameAccount, getDefaultAccount, } from './account-manager'; +import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands'; +import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -283,7 +285,33 @@ export async function execClaudeWithCLIProxy( const configPath = generateConfig(provider, cfg.port); log(`Config written: ${configPath}`); - // 6. Spawn CLIProxyAPI binary + // 6a. Pre-flight check: ensure port is free (auto-kill zombie CLIProxy) + const portProcess = await getPortProcess(cfg.port); + if (portProcess) { + if (isCLIProxyProcess(portProcess)) { + // Zombie CLIProxy from previous run - auto-kill it + log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`); + const killed = killProcessOnPort(cfg.port, verbose); + if (killed) { + console.log(info(`Cleaned up zombie CLIProxy process`)); + // Wait a bit for port to be released + await new Promise((r) => setTimeout(r, 500)); + } + } else { + // Non-CLIProxy process blocking the port - warn user + console.error(''); + console.error( + warn(`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`) + ); + console.error(''); + console.error('To fix this, close the blocking application or run:'); + console.error(` ${getPortCheckCommand(cfg.port)}`); + console.error(''); + throw new Error(`Port ${cfg.port} is in use by another application`); + } + } + + // 6b. Spawn CLIProxyAPI binary const proxyArgs = ['--config', configPath]; log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); @@ -308,7 +336,7 @@ export async function execClaudeWithCLIProxy( console.error(fail(`CLIProxy spawn error: ${error.message}`)); }); - // 5. Wait for proxy readiness via TCP polling + // 7. Wait for proxy readiness via TCP polling const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`); readySpinner.start(); @@ -329,9 +357,10 @@ export async function execClaudeWithCLIProxy( console.error(' 3. Invalid configuration'); console.error(''); console.error('Troubleshooting:'); - console.error(` - Check if port ${cfg.port} is in use: lsof -i :${cfg.port}`); + console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`); console.error(' - Run with --verbose for detailed logs'); - console.error(` - Check config: cat ${configPath}`); + console.error(` - View config: ${getCatCommand(configPath)}`); + console.error(' - Try: ccs doctor --fix'); console.error(''); throw new Error(`CLIProxy startup failed: ${err.message}`); diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 92e70e1e..6c0e0a51 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -122,6 +122,8 @@ export function getBinDir(): string { */ function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string { const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories + // Convert Windows backslashes to forward slashes for YAML compatibility + const authDirNormalized = authDir.split(path.sep).join('/'); // Unified config with enhanced CLIProxyAPI features const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION} @@ -184,7 +186,7 @@ api-keys: - "${CCS_INTERNAL_API_KEY}" # OAuth tokens directory (auto-discovered by CLIProxyAPI) -auth-dir: "${authDir.replace(/\\\\/g, '/')}" +auth-dir: "${authDirNormalized}" `; return config; diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 1844f8f3..c26c7073 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -24,6 +24,7 @@ import { import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; import { getEnvironmentDiagnostics } from './environment-diagnostics'; import { checkAuthCodePorts } from './oauth-port-diagnostics'; +import { killProcessOnPort, getPlatformName } from '../utils/platform-commands'; // Make ora optional (might not be available during npm install postinstall) // ora v9+ is an ES module, need to use .default for CommonJS @@ -1117,46 +1118,135 @@ class Doctor { } /** - * Fix detected issues - * Currently fixes: shared symlinks broken by Claude CLI's atomic writes + * Fix detected issues (--fix flag) + * Fixes: + * 1. Zombie CLIProxy processes blocking ports + * 2. Outdated CLIProxy config files + * 3. Shared symlinks broken by Claude CLI's atomic writes + * 4. OAuth callback ports blocked by CLIProxy */ async fixIssues(): Promise { console.log(''); - console.log(header('ATTEMPTING FIXES')); + console.log(header('AUTO-FIX MODE')); + console.log(''); + console.log(info(`Platform: ${getPlatformName()}`)); console.log(''); let fixed = 0; - // Fix shared symlinks (settings.json broken by Claude CLI toggle thinking, etc.) - const sharedSettings = path.join(this.homedir, '.ccs', 'shared', 'settings.json'); - if (fs.existsSync(sharedSettings)) { + // Fix 1: Kill zombie CLIProxy processes + const zombieSpinner = ora('Checking for zombie CLIProxy processes').start(); + try { + // Check main CLIProxy port + const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); + if (portProcess && isCLIProxyProcess(portProcess)) { + zombieSpinner.text = 'Killing zombie CLIProxy process...'; + const killed = killProcessOnPort(CLIPROXY_DEFAULT_PORT, true); + if (killed) { + zombieSpinner.succeed( + `${ok('Fixed')} Killed zombie CLIProxy on port ${CLIPROXY_DEFAULT_PORT}` + ); + fixed++; + } else { + zombieSpinner.warn(`${warn('Partial')} CLIProxy detected but could not kill`); + } + } else if (portProcess) { + zombieSpinner.info( + `${info('Info')} Port ${CLIPROXY_DEFAULT_PORT} used by ${portProcess.processName} (not CLIProxy)` + ); + } else { + zombieSpinner.succeed(`${ok('OK')} No zombie CLIProxy processes found`); + } + } catch (err) { + zombieSpinner.fail(`${fail('Error')} Could not check processes: ${(err as Error).message}`); + } + + // Fix 2: Kill CLIProxy processes on OAuth callback ports + const oauthPorts = [8085, 1455, 51121]; // Gemini, Codex, Agy + for (const port of oauthPorts) { + const oauthSpinner = ora(`Checking OAuth port ${port}`).start(); try { - const stats = fs.lstatSync(sharedSettings); - if (!stats.isSymbolicLink()) { - const spinner = ora('Fixing shared settings.json symlink').start(); - try { - // Import and use SharedManager to fix symlinks - const SharedManagerModule = await import('./shared-manager'); - const SharedManager = SharedManagerModule.default; - const sharedManager = new SharedManager(); - sharedManager.ensureSharedDirectories(); - spinner.succeed(ok('Fixed') + ' Shared settings.json symlink restored'); + const portProcess = await getPortProcess(port); + if (portProcess && isCLIProxyProcess(portProcess)) { + oauthSpinner.text = `Freeing OAuth port ${port}...`; + const killed = killProcessOnPort(port, true); + if (killed) { + oauthSpinner.succeed(`${ok('Fixed')} Freed OAuth port ${port}`); fixed++; - } catch (err) { - spinner.fail(fail('Failed') + ` Could not fix: ${(err as Error).message}`); + } else { + oauthSpinner.warn(`${warn('Partial')} CLIProxy on port ${port} but could not kill`); } + } else if (portProcess) { + oauthSpinner.info( + `${info('Info')} Port ${port} used by ${portProcess.processName} - please close manually` + ); + } else { + oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} is free`); } } catch (_err) { - // Ignore stat errors + oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} check passed`); } } - if (fixed > 0) { - console.log(''); - console.log(ok(`Fixed ${fixed} issue(s)`)); - } else { - console.log(info('No fixable issues detected')); + // Fix 3: Regenerate outdated CLIProxy config + const configSpinner = ora('Checking CLIProxy config version').start(); + try { + if (configNeedsRegeneration()) { + configSpinner.text = 'Upgrading CLIProxy config...'; + regenerateConfig(); + configSpinner.succeed( + `${ok('Fixed')} Upgraded CLIProxy config to v${CLIPROXY_CONFIG_VERSION}` + ); + fixed++; + } else { + configSpinner.succeed(`${ok('OK')} CLIProxy config is up to date`); + } + } catch (err) { + configSpinner.fail(`${fail('Error')} Could not upgrade config: ${(err as Error).message}`); } + + // Fix 4: Fix shared symlinks (settings.json broken by Claude CLI toggle thinking, etc.) + const symlinkSpinner = ora('Checking shared settings.json symlink').start(); + const sharedSettings = path.join(this.homedir, '.ccs', 'shared', 'settings.json'); + try { + if (fs.existsSync(sharedSettings)) { + const stats = fs.lstatSync(sharedSettings); + if (!stats.isSymbolicLink()) { + symlinkSpinner.text = 'Restoring shared settings.json symlink...'; + const SharedManagerModule = await import('./shared-manager'); + const SharedManager = SharedManagerModule.default; + const sharedManager = new SharedManager(); + sharedManager.ensureSharedDirectories(); + symlinkSpinner.succeed(`${ok('Fixed')} Restored shared settings.json symlink`); + fixed++; + } else { + symlinkSpinner.succeed(`${ok('OK')} Shared settings.json symlink is valid`); + } + } else { + symlinkSpinner.succeed(`${ok('OK')} Shared settings.json not yet created`); + } + } catch (err) { + symlinkSpinner.fail(`${fail('Error')} Could not fix symlink: ${(err as Error).message}`); + } + + // Summary + console.log(''); + if (fixed > 0) { + console.log(ok(`Auto-fix complete: ${fixed} issue(s) resolved`)); + console.log(''); + console.log(info('Try your command again. If issues persist, run:')); + console.log(` ${color('ccs doctor', 'command')} - for full diagnostics`); + } else { + console.log(ok('No issues found that needed fixing')); + console.log(''); + console.log(info('If you still have issues:')); + console.log(` 1. Run ${color('ccs doctor', 'command')} for diagnostics`); + console.log( + ` 2. Try ${color('ccs --auth --verbose', 'command')} for detailed logs` + ); + console.log(` 3. Restart your terminal/computer`); + } + console.log(''); } /** diff --git a/src/utils/error-manager.ts b/src/utils/error-manager.ts index 33380dd7..98bb4acf 100644 --- a/src/utils/error-manager.ts +++ b/src/utils/error-manager.ts @@ -1,5 +1,6 @@ import { initUI, header, color, dim, errorBox } from './ui'; import { ERROR_CODES, getErrorDocUrl, ErrorCode } from './error-codes'; +import { getPortCheckCommand, getKillPidCommand } from './platform-commands'; /** * Error types with structured messages (Legacy - kept for compatibility) @@ -214,6 +215,7 @@ export class ErrorManager { */ static async showPortConflict(port: number): Promise { await initUI(); + const isWindows = process.platform === 'win32'; console.error(''); console.error( @@ -224,13 +226,19 @@ export class ErrorManager { console.error(header('SOLUTIONS')); console.error(''); console.error(' 1. Find process using port:'); - console.error(` ${color(`lsof -i :${port}`, 'command')} (macOS/Linux)`); - console.error(` ${color(`netstat -ano | findstr ${port}`, 'command')} (Windows)`); + console.error(` ${color(getPortCheckCommand(port), 'command')}`); console.error(''); console.error(' 2. Kill the process:'); - console.error(` ${color(`lsof -ti:${port} | xargs kill -9`, 'command')}`); + if (isWindows) { + console.error( + ` ${color(`taskkill /F /PID `, 'command')} (replace with actual ID)` + ); + } else { + console.error(` ${color(getKillPidCommand(12345).replace('12345', ''), 'command')}`); + } console.error(''); - console.error(' 3. Wait and retry (process may exit on its own)'); + console.error(' 3. Auto-fix: Run:'); + console.error(` ${color('ccs doctor --fix', 'command')}`); console.error(''); } diff --git a/src/utils/platform-commands.ts b/src/utils/platform-commands.ts new file mode 100644 index 00000000..98672a64 --- /dev/null +++ b/src/utils/platform-commands.ts @@ -0,0 +1,180 @@ +/** + * Platform-aware command suggestions and utilities + * + * Provides OS-specific commands for troubleshooting messages + * to help non-technical users on Windows, macOS, and Linux. + */ + +import { execSync } from 'child_process'; + +const isWindows = process.platform === 'win32'; +const isMac = process.platform === 'darwin'; + +/** + * Get platform-specific command to check what's using a port + */ +export function getPortCheckCommand(port: number): string { + if (isWindows) { + return `netstat -ano | findstr :${port}`; + } + return `lsof -i :${port}`; +} + +/** + * Get platform-specific command to view file contents + */ +export function getCatCommand(filePath: string): string { + if (isWindows) { + // Use type for CMD, Get-Content for PowerShell + return `type "${filePath}"`; + } + return `cat "${filePath}"`; +} + +/** + * Get platform-specific command to kill CLIProxy processes + */ +export function getKillCLIProxyCommand(): string { + if (isWindows) { + return 'taskkill /F /IM cli-proxy-api.exe'; + } + return 'pkill -f cli-proxy-api'; +} + +/** + * Get platform-specific command to kill a process by PID + */ +export function getKillPidCommand(pid: number): string { + if (isWindows) { + return `taskkill /F /PID ${pid}`; + } + return `kill -9 ${pid}`; +} + +/** + * Get human-friendly platform name + */ +export function getPlatformName(): string { + if (isWindows) return 'Windows'; + if (isMac) return 'macOS'; + return 'Linux'; +} + +/** + * Kill process by PID (cross-platform) + * @returns true if killed successfully, false otherwise + */ +export function killProcessByPid(pid: number, verbose = false): boolean { + try { + if (isWindows) { + execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' }); + } else { + execSync(`kill -9 ${pid}`, { stdio: 'pipe' }); + } + if (verbose) { + console.error(`[cleanup] Killed process ${pid}`); + } + return true; + } catch { + return false; + } +} + +/** + * Kill all CLIProxy processes (cross-platform) + * @returns number of processes killed + */ +export function killAllCLIProxyProcesses(verbose = false): number { + let killed = 0; + + try { + if (isWindows) { + // Windows: taskkill by image name + // Use /T to kill child processes too + execSync('taskkill /F /IM cli-proxy-api.exe /T 2>nul', { stdio: 'pipe' }); + killed++; + } else { + // Unix: pkill with pattern matching + try { + execSync('pkill -9 -f cli-proxy-api', { stdio: 'pipe' }); + killed++; + } catch { + // pkill returns non-zero if no processes matched - that's OK + } + } + } catch { + // No processes to kill or command failed + } + + if (verbose && killed > 0) { + console.error(`[cleanup] Killed ${killed} CLIProxy process(es)`); + } + + return killed; +} + +/** + * Kill process on specific port (cross-platform) + * @returns true if a process was killed, false otherwise + */ +export function killProcessOnPort(port: number, verbose = false): boolean { + try { + if (isWindows) { + // Windows: netstat + taskkill + const result = execSync(`netstat -ano | findstr :${port}`, { + encoding: 'utf-8', + stdio: 'pipe', + }); + const lines = result.trim().split('\n'); + let killed = false; + for (const line of lines) { + const parts = line.trim().split(/\s+/); + const pid = parts[parts.length - 1]; + if (pid && /^\d+$/.test(pid)) { + try { + execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' }); + if (verbose) { + console.error(`[cleanup] Killed process ${pid} on port ${port}`); + } + killed = true; + } catch { + // Process may have already exited + } + } + } + return killed; + } else { + // Unix: lsof + kill + const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', stdio: 'pipe' }); + const pids = result + .trim() + .split('\n') + .filter((p) => p); + for (const pid of pids) { + try { + execSync(`kill -9 ${pid}`, { stdio: 'pipe' }); + if (verbose) { + console.error(`[cleanup] Killed process ${pid} on port ${port}`); + } + } catch { + // Process may have already exited + } + } + return pids.length > 0; + } + } catch { + // No process on port or command failed + return false; + } +} + +export default { + getPortCheckCommand, + getCatCommand, + getKillCLIProxyCommand, + getKillPidCommand, + getPlatformName, + killProcessByPid, + killAllCLIProxyProcesses, + killProcessOnPort, +}; diff --git a/src/web-server/data-aggregator.ts b/src/web-server/data-aggregator.ts index 6d558615..0ebd882f 100644 --- a/src/web-server/data-aggregator.ts +++ b/src/web-server/data-aggregator.ts @@ -10,6 +10,7 @@ import { calculateCost } from './model-pricing'; import { type ModelBreakdown, type DailyUsage, + type HourlyUsage, type MonthlyUsage, type SessionUsage, } from './usage-types'; @@ -28,6 +29,13 @@ function extractMonth(timestamp: string): string { return timestamp.slice(0, 7); } +/** Extract YYYY-MM-DD HH:00 from ISO timestamp */ +function extractHour(timestamp: string): string { + const date = timestamp.slice(0, 10); + const hour = timestamp.slice(11, 13) || '00'; + return `${date} ${hour}:00`; +} + /** Create model breakdown from accumulated data */ function createModelBreakdown( modelName: string, @@ -152,6 +160,99 @@ export function aggregateDailyUsage( return dailyUsage; } +// ============================================================================ +// HOURLY AGGREGATION +// ============================================================================ + +/** + * Aggregate raw entries into hourly usage summaries + * Groups by hour (YYYY-MM-DD HH:00), calculates costs per model + */ +export function aggregateHourlyUsage( + entries: RawUsageEntry[], + source = 'custom-parser' +): HourlyUsage[] { + // Group entries by hour + const byHour = new Map(); + + for (const entry of entries) { + const hour = extractHour(entry.timestamp); + const existing = byHour.get(hour) || []; + existing.push(entry); + byHour.set(hour, existing); + } + + // Build hourly summaries + const hourlyUsage: HourlyUsage[] = []; + + for (const [hour, hourEntries] of byHour) { + // Aggregate by model + const modelMap = new Map(); + let totalInput = 0; + let totalOutput = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + + for (const entry of hourEntries) { + const model = entry.model; + const acc = modelMap.get(model) || { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + + acc.inputTokens += entry.inputTokens; + acc.outputTokens += entry.outputTokens; + acc.cacheCreationTokens += entry.cacheCreationTokens; + acc.cacheReadTokens += entry.cacheReadTokens; + modelMap.set(model, acc); + + totalInput += entry.inputTokens; + totalOutput += entry.outputTokens; + totalCacheCreation += entry.cacheCreationTokens; + totalCacheRead += entry.cacheReadTokens; + } + + // Build model breakdowns + const modelBreakdowns: ModelBreakdown[] = []; + let totalCost = 0; + + for (const [modelName, acc] of modelMap) { + const breakdown = createModelBreakdown( + modelName, + acc.inputTokens, + acc.outputTokens, + acc.cacheCreationTokens, + acc.cacheReadTokens + ); + modelBreakdowns.push(breakdown); + totalCost += breakdown.cost; + } + + // Sort breakdowns by cost descending + modelBreakdowns.sort((a, b) => b.cost - a.cost); + + hourlyUsage.push({ + hour, + source, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + cost: totalCost, + totalCost, + modelsUsed: Array.from(modelMap.keys()), + modelBreakdowns, + }); + } + + // Sort by hour descending (most recent first) + hourlyUsage.sort((a, b) => b.hour.localeCompare(a.hour)); + + return hourlyUsage; +} + // ============================================================================ // MONTHLY AGGREGATION // ============================================================================ @@ -372,6 +473,14 @@ export async function loadDailyUsageData(options?: ParserOptions): Promise { + const entries = await scanProjectsDirectory(options); + return aggregateHourlyUsage(entries); +} + /** * Load monthly usage data (replaces better-ccusage loadMonthlyUsageData) */ @@ -393,12 +502,14 @@ export async function loadSessionData(options?: ParserOptions): Promise { const entries = await scanProjectsDirectory(options); return { daily: aggregateDailyUsage(entries), + hourly: aggregateHourlyUsage(entries), monthly: aggregateMonthlyUsage(entries), session: aggregateSessionUsage(entries), }; diff --git a/src/web-server/usage-disk-cache.ts b/src/web-server/usage-disk-cache.ts index 8a2a947d..0af47521 100644 --- a/src/web-server/usage-disk-cache.ts +++ b/src/web-server/usage-disk-cache.ts @@ -11,7 +11,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import type { DailyUsage, MonthlyUsage, SessionUsage } from './usage-types'; +import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './usage-types'; import { ok, info, warn } from '../utils/ui'; // Cache configuration @@ -26,13 +26,14 @@ export interface UsageDiskCache { version: number; timestamp: number; daily: DailyUsage[]; + hourly: HourlyUsage[]; monthly: MonthlyUsage[]; session: SessionUsage[]; } // Current cache version - increment to invalidate old caches -// v2: Updated model pricing (Opus 4.5: $5/$25, Gemini 3, GLM, Kimi, etc.) -const CACHE_VERSION = 2; +// v3: Added hourly data to cache +const CACHE_VERSION = 3; /** * Ensure ~/.ccs/cache directory exists @@ -95,6 +96,7 @@ export function isDiskCacheStale(cache: UsageDiskCache | null): boolean { */ export function writeDiskCache( daily: DailyUsage[], + hourly: HourlyUsage[], monthly: MonthlyUsage[], session: SessionUsage[] ): void { @@ -105,6 +107,7 @@ export function writeDiskCache( version: CACHE_VERSION, timestamp: Date.now(), daily, + hourly, monthly, session, }; diff --git a/src/web-server/usage-routes.ts b/src/web-server/usage-routes.ts index 803ba9e0..c6835a90 100644 --- a/src/web-server/usage-routes.ts +++ b/src/web-server/usage-routes.ts @@ -20,9 +20,11 @@ import { loadMonthlyUsageData, loadSessionData, loadAllUsageData, + loadHourlyUsageData, } from './data-aggregator'; import type { DailyUsage, + HourlyUsage, MonthlyUsage, SessionUsage, Anomaly, @@ -78,6 +80,7 @@ function getInstancePaths(): string[] { */ async function loadInstanceData(instancePath: string): Promise<{ daily: DailyUsage[]; + hourly: HourlyUsage[]; monthly: MonthlyUsage[]; session: SessionUsage[]; }> { @@ -89,7 +92,7 @@ async function loadInstanceData(instancePath: string): Promise<{ // Instance may have no usage data - that's OK const instanceName = path.basename(instancePath); console.log(info(`No usage data in instance: ${instanceName}`)); - return { daily: [], monthly: [], session: [] }; + return { daily: [], hourly: [], monthly: [], session: [] }; } } @@ -168,6 +171,52 @@ function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] { return Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month)); } +/** + * Merge hourly usage data from multiple sources + * Combines entries with same hour by aggregating tokens + */ +function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] { + const hourMap = new Map(); + + for (const source of sources) { + for (const hour of source) { + const existing = hourMap.get(hour.hour); + if (existing) { + existing.inputTokens += hour.inputTokens; + existing.outputTokens += hour.outputTokens; + existing.cacheCreationTokens += hour.cacheCreationTokens; + existing.cacheReadTokens += hour.cacheReadTokens; + existing.totalCost += hour.totalCost; + const modelSet = new Set([...existing.modelsUsed, ...hour.modelsUsed]); + existing.modelsUsed = Array.from(modelSet); + // Merge model breakdowns + for (const breakdown of hour.modelBreakdowns) { + const existingBreakdown = existing.modelBreakdowns.find( + (b) => b.modelName === breakdown.modelName + ); + if (existingBreakdown) { + existingBreakdown.inputTokens += breakdown.inputTokens; + existingBreakdown.outputTokens += breakdown.outputTokens; + existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens; + existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens; + existingBreakdown.cost += breakdown.cost; + } else { + existing.modelBreakdowns.push({ ...breakdown }); + } + } + } else { + hourMap.set(hour.hour, { + ...hour, + modelsUsed: [...hour.modelsUsed], + modelBreakdowns: hour.modelBreakdowns.map((b) => ({ ...b })), + }); + } + } + } + + return Array.from(hourMap.values()).sort((a, b) => a.hour.localeCompare(b.hour)); +} + /** * Merge session data from multiple sources * Deduplicates by sessionId (same session shouldn't appear in multiple instances) @@ -246,12 +295,13 @@ let diskCacheInitialized = false; */ function persistCacheIfComplete(): void { const daily = cache.get('daily') as CacheEntry | undefined; + const hourly = cache.get('hourly') as CacheEntry | undefined; const monthly = cache.get('monthly') as CacheEntry | undefined; const session = cache.get('session') as CacheEntry | undefined; // Write if we have at least daily data (the most essential) if (daily) { - writeDiskCache(daily.data, monthly?.data ?? [], session?.data ?? []); + writeDiskCache(daily.data, hourly?.data ?? [], monthly?.data ?? [], session?.data ?? []); } } @@ -338,6 +388,13 @@ async function getCachedSessionData(): Promise { }); } +/** Cached loader for hourly usage data */ +async function getCachedHourlyData(): Promise { + return getCachedData('hourly', CACHE_TTL.daily, async () => { + return await loadHourlyUsageData(); + }); +} + /** * Clear all cached data (useful for manual refresh) */ @@ -357,6 +414,7 @@ let isRefreshing = false; */ async function refreshFromSource(): Promise<{ daily: DailyUsage[]; + hourly: HourlyUsage[]; monthly: MonthlyUsage[]; session: SessionUsage[]; }> { @@ -367,6 +425,7 @@ async function refreshFromSource(): Promise<{ const instancePaths = getInstancePaths(); const instanceDataResults: Array<{ daily: DailyUsage[]; + hourly: HourlyUsage[]; monthly: MonthlyUsage[]; session: SessionUsage[]; }> = []; @@ -383,11 +442,13 @@ async function refreshFromSource(): Promise<{ // Collect successful instance data const allDailySources: DailyUsage[][] = [defaultData.daily]; + const allHourlySources: HourlyUsage[][] = [defaultData.hourly]; const allMonthlySources: MonthlyUsage[][] = [defaultData.monthly]; const allSessionSources: SessionUsage[][] = [defaultData.session]; for (const result of instanceDataResults) { allDailySources.push(result.daily); + allHourlySources.push(result.hourly); allMonthlySources.push(result.monthly); allSessionSources.push(result.session); } @@ -398,20 +459,22 @@ async function refreshFromSource(): Promise<{ // Merge all data sources const daily = mergeDailyData(allDailySources); + const hourly = mergeHourlyData(allHourlySources); const monthly = mergeMonthlyData(allMonthlySources); const session = mergeSessionData(allSessionSources); // Update in-memory cache const now = Date.now(); cache.set('daily', { data: daily, timestamp: now }); + cache.set('hourly', { data: hourly, timestamp: now }); cache.set('monthly', { data: monthly, timestamp: now }); cache.set('session', { data: session, timestamp: now }); lastFetchTimestamp = now; // Persist to disk - writeDiskCache(daily, monthly, session); + writeDiskCache(daily, hourly, monthly, session); - return { daily, monthly, session }; + return { daily, hourly, monthly, session }; } // ============================================================================ @@ -433,6 +496,7 @@ function ensureDiskCacheLoaded(): void { // Load disk cache into memory (regardless of freshness) // SWR pattern in getCachedData() will handle background refresh cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); + cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); lastFetchTimestamp = diskCache.timestamp; @@ -463,6 +527,7 @@ export async function prewarmUsageCache(): Promise<{ if (diskCache && isDiskCacheFresh(diskCache)) { const now = Date.now(); cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); + cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); lastFetchTimestamp = diskCache.timestamp; @@ -478,6 +543,7 @@ export async function prewarmUsageCache(): Promise<{ if (diskCache && isDiskCacheStale(diskCache)) { const now = Date.now(); cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); + cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); lastFetchTimestamp = diskCache.timestamp; @@ -753,6 +819,55 @@ usageRoutes.get( } ); +/** + * GET /api/usage/hourly + * + * Returns hourly usage trends for chart visualization. + * Query: ?since=YYYYMMDD&until=YYYYMMDD (defaults to last 24 hours) + */ +usageRoutes.get( + '/hourly', + async (req: Request, res: Response) => { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + + const hourlyData = await getCachedHourlyData(); + + // Filter by date range + const filtered = hourlyData.filter((h) => { + // Extract date from hour format "YYYY-MM-DD HH:00" + const hourDate = h.hour.slice(0, 10).replace(/-/g, ''); + if (since && hourDate < since) return false; + if (until && hourDate > until) return false; + return true; + }); + + // Transform for chart consumption + const trends = filtered.map((hour) => ({ + hour: hour.hour, + tokens: hour.inputTokens + hour.outputTokens, + inputTokens: hour.inputTokens, + outputTokens: hour.outputTokens, + cacheTokens: hour.cacheCreationTokens + hour.cacheReadTokens, + cost: Math.round(hour.totalCost * 100) / 100, + modelsUsed: hour.modelsUsed.length, + requests: hour.modelBreakdowns.length, + })); + + // Sort by hour ascending for chart display + trends.sort((a, b) => a.hour.localeCompare(b.hour)); + + res.json({ + success: true, + data: trends, + }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch hourly usage'); + } + } +); + /** * GET /api/usage/models * diff --git a/src/web-server/usage-types.ts b/src/web-server/usage-types.ts index b5ac7288..820bfc53 100644 --- a/src/web-server/usage-types.ts +++ b/src/web-server/usage-types.ts @@ -37,6 +37,20 @@ export interface DailyUsage { modelBreakdowns: ModelBreakdown[]; } +/** Hourly usage aggregation (YYYY-MM-DD HH:00) */ +export interface HourlyUsage { + hour: string; // Format: "YYYY-MM-DD HH:00" + source: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + totalCost: number; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; +} + /** Monthly usage aggregation (YYYY-MM) */ export interface MonthlyUsage { month: string; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2cb49a79..6db0f20a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { SidebarProvider } from '@/components/ui/sidebar'; import { AppSidebar } from '@/components/app-sidebar'; import { ThemeToggle } from '@/components/theme-toggle'; +import { GitHubLink } from '@/components/github-link'; import { ConnectionIndicator } from '@/components/connection-indicator'; import { LocalhostDisclaimer } from '@/components/localhost-disclaimer'; import { Toaster } from 'sonner'; @@ -48,8 +49,9 @@ function Layout() {
-
+
+
diff --git a/ui/src/components/analytics/cliproxy-stats-card.tsx b/ui/src/components/analytics/cliproxy-stats-card.tsx index ba21ec9c..273ab9cc 100644 --- a/ui/src/components/analytics/cliproxy-stats-card.tsx +++ b/ui/src/components/analytics/cliproxy-stats-card.tsx @@ -18,13 +18,17 @@ import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats' interface CliproxyStatsCardProps { className?: string; + isLoading?: boolean; // External loading state from parent (for synchronized loading UI) } -export function CliproxyStatsCard({ className }: CliproxyStatsCardProps) { +export function CliproxyStatsCard({ + className, + isLoading: externalLoading, +}: CliproxyStatsCardProps) { const { data: status, isLoading: statusLoading } = useCliproxyStatus(); const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running); - const isLoading = statusLoading || (status?.running && statsLoading); + const isLoading = externalLoading || statusLoading || (status?.running && statsLoading); if (isLoading) { return ( diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx index 69f73400..d4bc7eeb 100644 --- a/ui/src/components/analytics/usage-trend-chart.tsx +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -2,7 +2,7 @@ * Usage Trend Chart Component * * Displays usage trends over time with tokens and cost. - * Supports daily and monthly granularity with interactive tooltips. + * Supports daily, hourly, and monthly granularity with interactive tooltips. */ import { useMemo } from 'react'; @@ -17,15 +17,15 @@ import { } from 'recharts'; import { format } from 'date-fns'; import { Skeleton } from '@/components/ui/skeleton'; -import type { DateRange } from 'react-day-picker'; import { cn } from '@/lib/utils'; -import type { DailyUsage } from '@/hooks/use-usage'; +import type { DailyUsage, HourlyUsage } from '@/hooks/use-usage'; + +type ChartData = DailyUsage | HourlyUsage; interface UsageTrendChartProps { - data: DailyUsage[]; + data: ChartData[]; isLoading?: boolean; - dateRange?: DateRange; - granularity?: 'daily' | 'monthly'; + granularity?: 'daily' | 'monthly' | 'hourly'; className?: string; } @@ -34,15 +34,22 @@ export function UsageTrendChart({ isLoading, granularity = 'daily', className, -}: Omit) { +}: UsageTrendChartProps) { const chartData = useMemo(() => { if (!data || data.length === 0) return []; - return [...data].reverse().map((item) => ({ - ...item, - dateFormatted: formatDate(item.date, granularity), - costRounded: Number(item.cost.toFixed(4)), - })); + // For hourly data, already sorted ascending from API + const sortedData = granularity === 'hourly' ? data : [...data].reverse(); + + return sortedData.map((item) => { + // Handle hourly vs daily data format + const timeKey = 'hour' in item ? item.hour : (item as DailyUsage).date; + return { + ...item, + dateFormatted: formatTime(timeKey, granularity), + costRounded: Number(item.cost.toFixed(4)), + }; + }); }, [data, granularity]); if (isLoading) { @@ -52,7 +59,9 @@ export function UsageTrendChart({ if (!data || data.length === 0) { return (
-

No usage data available

+

+ {granularity === 'hourly' ? 'No usage data for today' : 'No usage data available'} +

); } @@ -103,7 +112,7 @@ export function UsageTrendChart({ content={({ active, payload, label }) => { if (!active || !payload || !payload.length) return null; - const data = payload[0].payload; + const tooltipData = payload[0].payload; return (

{label}

@@ -115,7 +124,11 @@ export function UsageTrendChart({ : `$${entry.value}`}

))} -

Requests: {data.requests}

+ {'requests' in tooltipData && ( +

+ Requests: {tooltipData.requests} +

+ )}
); }} @@ -149,14 +162,26 @@ export function UsageTrendChart({ } // Helper functions -function formatDate(dateStr: string, granularity: 'daily' | 'monthly'): string { - const date = new Date(dateStr); +function formatTime(timeStr: string, granularity: 'daily' | 'monthly' | 'hourly'): string { + if (granularity === 'hourly') { + // Format: "YYYY-MM-DD HH:00" -> convert UTC to local time -> "HH:00" + // Parse as UTC and format in local timezone + const [datePart, timePart] = timeStr.split(' '); + if (datePart && timePart) { + // Create date in UTC: "2025-12-12 20:00" -> "2025-12-12T20:00:00Z" + const utcDate = new Date(`${datePart}T${timePart}:00Z`); + return format(utcDate, 'HH:mm'); + } + return timeStr; + } + + const date = new Date(timeStr); if (granularity === 'monthly') { return format(date, 'MMM yyyy'); } - // For daily, show shorter format if range is > 30 days + // For daily, show shorter format return format(date, 'MMM dd'); } diff --git a/ui/src/components/github-link.tsx b/ui/src/components/github-link.tsx new file mode 100644 index 00000000..8ece4cab --- /dev/null +++ b/ui/src/components/github-link.tsx @@ -0,0 +1,20 @@ +/** + * GitHub Link Button + * + * Links to CCS GitHub issues page for bug reports and feature requests. + */ + +import { Github } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const GITHUB_REPO_URL = 'https://github.com/kaitranntt/ccs/issues'; + +export function GitHubLink() { + return ( + + ); +} diff --git a/ui/src/components/quick-setup-wizard.tsx b/ui/src/components/quick-setup-wizard.tsx index ab4e58a5..dfc3d6b4 100644 --- a/ui/src/components/quick-setup-wizard.tsx +++ b/ui/src/components/quick-setup-wizard.tsx @@ -432,9 +432,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
{m.name} {m.description && ( - - - {m.description} - + - {m.description} )}
diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts index 3bcbde88..39e8e393 100644 --- a/ui/src/hooks/use-usage.ts +++ b/ui/src/hooks/use-usage.ts @@ -43,6 +43,17 @@ export interface DailyUsage { modelsUsed: number; } +export interface HourlyUsage { + hour: string; + tokens: number; + inputTokens: number; + outputTokens: number; + cacheTokens: number; + cost: number; + modelsUsed: number; + requests: number; +} + export interface ModelUsage { model: string; tokens: number; @@ -147,6 +158,12 @@ export const usageApi = { if (options?.profile) params.append('profile', options.profile); return request(`/usage/daily?${params}`); }, + hourly: (options?: UsageQueryOptions) => { + const params = new URLSearchParams(); + if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); + if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + return request(`/usage/hourly?${params}`); + }, models: (options?: UsageQueryOptions) => { const params = new URLSearchParams(); if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); @@ -224,6 +241,14 @@ export function useUsageTrends(options?: UsageQueryOptions) { }); } +export function useHourlyUsage(options?: UsageQueryOptions) { + return useQuery({ + queryKey: ['usage', 'hourly', options], + queryFn: () => usageApi.hourly(options), + staleTime: 60 * 1000, // 1 minute + }); +} + export function useModelUsage(options?: UsageQueryOptions) { return useQuery({ queryKey: ['usage', 'models', options], diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx index aa4a4f0c..3c3e9d64 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -23,6 +23,7 @@ import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucid import { useUsageSummary, useUsageTrends, + useHourlyUsage, useModelUsage, useRefreshUsage, useUsageStatus, @@ -48,6 +49,7 @@ export function AnalyticsPage() { const [isRefreshing, setIsRefreshing] = useState(false); const [selectedModel, setSelectedModel] = useState(null); const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null); + const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily'); const popoverAnchorRef = useRef(null); // Refresh hook @@ -71,10 +73,24 @@ export function AnalyticsPage() { // Fetch data const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions); const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions); + const { data: hourlyData, isLoading: isHourlyLoading } = useHourlyUsage(apiOptions); const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions); const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 }); const { data: status } = useUsageStatus(); + // Handle "24H" preset click + const handleTodayClick = useCallback(() => { + const now = new Date(); + setDateRange({ from: subDays(now, 1), to: now }); + setViewMode('hourly'); + }, []); + + // Handle date range changes from DateRangeFilter + const handleDateRangeChange = useCallback((range: DateRange | undefined) => { + setDateRange(range); + setViewMode('daily'); // Switch back to daily view for multi-day ranges + }, []); + // Format "Last updated" text const lastUpdatedText = useMemo(() => { if (!status?.lastFetch) return null; @@ -102,9 +118,17 @@ export function AnalyticsPage() {

Track usage & insights

+ - Usage Trends + {viewMode === 'hourly' ? 'Last 24 Hours' : 'Usage Trends'} - + @@ -291,7 +319,7 @@ export function AnalyticsPage() { /> {/* CLIProxy Stats - 2/10 width */} - +
{/* Model Details Popover - positioned at cursor */} From 84484c06c33b19a198d876bc7c071d9f83f3741f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 15:54:11 -0500 Subject: [PATCH 13/26] fix(cliproxy): inherit stdin for OAuth interactive prompts Change stdio from 'ignore' to 'inherit' for stdin in auth subprocess, allowing CLIProxy to receive user input for email/alias prompts during Qwen and other OAuth flows. Fixes #91 --- src/cliproxy/auth-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index 6a3a5d80..d1d8840a 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -553,7 +553,7 @@ export async function triggerOAuth( return new Promise((resolve) => { // Spawn CLIProxyAPI with auth flag (and --no-browser if headless) const authProcess = spawn(binaryPath, args, { - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['inherit', 'pipe', 'pipe'], env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir, From 763928f2820f1c018e127e506c0d1590aecbeafa Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 15:57:24 -0500 Subject: [PATCH 14/26] fix(ci): resolve YAML syntax error in dev-release workflow Multi-line heredoc in issue comment was breaking YAML parsing. Use inline multi-line string with proper indentation instead. --- .github/workflows/dev-release.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 761a6f79..0631f298 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -134,15 +134,16 @@ jobs: NUM=${ISSUE#\#} echo "Tagging issue #$NUM as released-dev" - # Add comment - gh issue comment $NUM --body ":test_tube: This fix is available for testing in version \`$NEW_VERSION\` + # Add comment (using printf to handle newlines safely) + COMMENT=":test_tube: This fix is available for testing in version \\\`$NEW_VERSION\\\` -Install with: -\`\`\`bash -npm install @kaitranntt/ccs@dev -\`\`\` + Install with: + \\\`\\\`\\\`bash + npm install @kaitranntt/ccs@dev + \\\`\\\`\\\` -Please test and confirm the fix works as expected." --repo ${{ github.repository }} || true + Please test and confirm the fix works as expected." + gh issue comment "$NUM" --repo "${{ github.repository }}" --body "$COMMENT" || true # Add released-dev label, remove pending-release if present gh issue edit $NUM \ From 76f358d65b078e7f48efffe6d1dce2b708e7fe85 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Dec 2025 20:59:58 +0000 Subject: [PATCH 15/26] chore(release): 5.16.0-dev.7 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 3f44c382..b71ddd9f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.16.0-dev.6 +5.16.0-dev.7 diff --git a/package.json b/package.json index b295e4dd..923de752 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.16.0-dev.6", + "version": "5.16.0-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From d39095c7d6923219d7ade0fca59cc555a4489cc9 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 16:11:06 -0500 Subject: [PATCH 16/26] perf(ci): reduce test redundancy from 4x to 1x per release - Remove tests from pre-commit hook (keep typecheck+lint only) - Remove build+validate from prepublishOnly/prepack (CI handles it) - Add HUSKY=0 to CI commit step to skip hooks Before: 4 test runs per release (pre-commit, CI validate, prepublishOnly, prepack) After: 1 test run (CI validate only) --- .github/workflows/dev-release.yml | 2 ++ .husky/pre-commit | 7 ++++--- package.json | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 0631f298..0f5b2e91 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -156,6 +156,8 @@ jobs: done - name: Commit version bump + env: + HUSKY: 0 run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" diff --git a/.husky/pre-commit b/.husky/pre-commit index b6c9553b..bb471b6e 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,8 @@ -# Run validation before commit (typecheck + lint + format + tests) -bun run validate +# Run quick checks before commit (typecheck + lint + format only) +# Tests are run by CI - no need to duplicate locally +bun run typecheck && bun run lint:fix && bun run format:check -# Validate UI if changes detected +# Validate UI if changes detected (typecheck + lint only) if git diff --cached --name-only | grep -q "^ui/"; then echo "[i] UI changes detected, running ui:validate..." bun run ui:validate diff --git a/package.json b/package.json index 923de752..9e021c6b 100644 --- a/package.json +++ b/package.json @@ -74,8 +74,8 @@ "ui:build": "cd ui && bun run build", "ui:preview": "cd ui && bun run preview", "ui:validate": "cd ui && bun run validate", - "prepublishOnly": "bun run build:all && bun run validate && node scripts/sync-version.js", - "prepack": "bun run build:all && bun run validate && node scripts/sync-version.js", + "prepublishOnly": "node scripts/sync-version.js", + "prepack": "node scripts/sync-version.js", "prepare": "husky", "postinstall": "node scripts/postinstall.js" }, From 99f3a674b858021e577edd7650afd872a1e251ae Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 16:11:46 -0500 Subject: [PATCH 17/26] perf(ci): add HUSKY=0 to release workflow --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index adcc1f23..9ca53cf5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,6 +46,7 @@ jobs: - name: Release env: + HUSKY: 0 GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} GH_TOKEN: ${{ secrets.PAT_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} From 0736cce4f841480e101b3f5e49b317c10cc3b630 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Dec 2025 21:12:49 +0000 Subject: [PATCH 18/26] chore(release): 5.16.0-dev.9 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index b71ddd9f..0980a340 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.16.0-dev.7 +5.16.0-dev.9 diff --git a/package.json b/package.json index 9e021c6b..e1cb9770 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.16.0-dev.7", + "version": "5.16.0-dev.9", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 27f9ea8f0f518c40096404f11f5964d0c42fdfdc Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 16:14:36 -0500 Subject: [PATCH 19/26] fix(ci): improve issue tagging - use bot, skip duplicates, simpler msg - Use GITHUB_TOKEN for comments (posts as github-actions[bot]) - Skip issues already labeled released-dev (avoid spam) - Simpler one-line comment format --- .github/workflows/dev-release.yml | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 0f5b2e91..80ae031a 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -99,7 +99,7 @@ jobs: - name: Tag resolved issues env: - GH_TOKEN: ${{ secrets.PAT_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | NEW_VERSION="${{ steps.bump.outputs.new }}" @@ -132,27 +132,20 @@ jobs: for ISSUE in $ISSUES; do NUM=${ISSUE#\#} + + # Skip if already tagged (avoid spam) + if gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '.labels[].name' | grep -q "released-dev"; then + echo "Issue #$NUM already has released-dev label, skipping" + continue + fi + echo "Tagging issue #$NUM as released-dev" - # Add comment (using printf to handle newlines safely) - COMMENT=":test_tube: This fix is available for testing in version \\\`$NEW_VERSION\\\` + # Add comment + gh issue comment "$NUM" --repo "${{ github.repository }}" --body ":test_tube: Available in \`$NEW_VERSION\`. Install: \`npm i @kaitranntt/ccs@dev\`" || true - Install with: - \\\`\\\`\\\`bash - npm install @kaitranntt/ccs@dev - \\\`\\\`\\\` - - Please test and confirm the fix works as expected." - gh issue comment "$NUM" --repo "${{ github.repository }}" --body "$COMMENT" || true - - # Add released-dev label, remove pending-release if present - gh issue edit $NUM \ - --add-label "released-dev" \ - --remove-label "pending-release" \ - --repo ${{ github.repository }} 2>/dev/null || \ - gh issue edit $NUM \ - --add-label "released-dev" \ - --repo ${{ github.repository }} || true + # Add released-dev label + gh issue edit "$NUM" --add-label "released-dev" --repo "${{ github.repository }}" || true done - name: Commit version bump From 466cb0ad8b0f45a1ec3aa1a9ea6c4ba42c9309f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Dec 2025 21:15:43 +0000 Subject: [PATCH 20/26] chore(release): 5.16.0-dev.10 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 0980a340..7ad95a34 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.16.0-dev.9 +5.16.0-dev.10 diff --git a/package.json b/package.json index e1cb9770..103cc4ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.16.0-dev.9", + "version": "5.16.0-dev.10", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 8c1b8e49aecf3b7901c3ddf8b2f1ba69233671ec Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 16:21:34 -0500 Subject: [PATCH 21/26] fix(update): add shell option for Windows npm/cache spawn On Windows, spawn needs shell:true to find npm.cmd. Without it, ENOENT error occurs causing update failure. Fixes #85 --- src/commands/update-command.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index 406dd9df..38225ca1 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -235,6 +235,7 @@ async function performNpmUpdate( const performUpdate = (): void => { const child = spawn(updateCommand, updateArgs, { stdio: 'inherit', + shell: process.platform === 'win32', }); child.on('exit', (code) => { @@ -270,6 +271,7 @@ async function performNpmUpdate( console.log(info('Clearing package cache...')); const cacheChild = spawn(cacheCommand, cacheArgs, { stdio: 'inherit', + shell: process.platform === 'win32', }); cacheChild.on('exit', (code) => { From 53e24484f65e9daa9ed207101692556ae5fa197b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Dec 2025 21:22:40 +0000 Subject: [PATCH 22/26] chore(release): 5.16.0-dev.11 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 7ad95a34..7ee87f77 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.16.0-dev.10 +5.16.0-dev.11 diff --git a/package.json b/package.json index 103cc4ca..864d7f5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.16.0-dev.10", + "version": "5.16.0-dev.11", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From bace084e75442a4321659fceea19b89ffdfa9b6b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 16:24:49 -0500 Subject: [PATCH 23/26] fix(update): avoid Node deprecation warning on Windows spawn --- src/commands/update-command.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index 38225ca1..5f344dcb 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -232,11 +232,13 @@ async function performNpmUpdate( console.log(info(`${isReinstall ? 'Reinstalling' : 'Updating'} via ${packageManager}...`)); console.log(''); + const isWindows = process.platform === 'win32'; + const performUpdate = (): void => { - const child = spawn(updateCommand, updateArgs, { - stdio: 'inherit', - shell: process.platform === 'win32', - }); + // On Windows, use shell with full command string to avoid deprecation warning + const child = isWindows + ? spawn(`${updateCommand} ${updateArgs.join(' ')}`, [], { stdio: 'inherit', shell: true }) + : spawn(updateCommand, updateArgs, { stdio: 'inherit' }); child.on('exit', (code) => { if (code === 0) { @@ -269,10 +271,10 @@ async function performNpmUpdate( if (cacheCommand && cacheArgs) { console.log(info('Clearing package cache...')); - const cacheChild = spawn(cacheCommand, cacheArgs, { - stdio: 'inherit', - shell: process.platform === 'win32', - }); + // On Windows, use shell with full command string to avoid deprecation warning + const cacheChild = isWindows + ? spawn(`${cacheCommand} ${cacheArgs.join(' ')}`, [], { stdio: 'inherit', shell: true }) + : spawn(cacheCommand, cacheArgs, { stdio: 'inherit' }); cacheChild.on('exit', (code) => { if (code !== 0) { From bd39f9a32105f79e684213471e353d437d5dbd77 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Dec 2025 21:25:56 +0000 Subject: [PATCH 24/26] chore(release): 5.16.0-dev.12 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 7ee87f77..3fce944c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.16.0-dev.11 +5.16.0-dev.12 diff --git a/package.json b/package.json index 864d7f5a..a07638b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.16.0-dev.11", + "version": "5.16.0-dev.12", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From d616e613c857176a0cfb3f5f0dc9485b11326344 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 16:26:38 -0500 Subject: [PATCH 25/26] fix(update): suppress Node deprecation warnings on Windows --- src/commands/update-command.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index 5f344dcb..5cef805a 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -236,8 +236,13 @@ async function performNpmUpdate( const performUpdate = (): void => { // On Windows, use shell with full command string to avoid deprecation warning + // Also suppress Node deprecation warnings that may come from package managers const child = isWindows - ? spawn(`${updateCommand} ${updateArgs.join(' ')}`, [], { stdio: 'inherit', shell: true }) + ? spawn(`${updateCommand} ${updateArgs.join(' ')}`, [], { + stdio: 'inherit', + shell: true, + env: { ...process.env, NODE_NO_WARNINGS: '1' }, + }) : spawn(updateCommand, updateArgs, { stdio: 'inherit' }); child.on('exit', (code) => { @@ -273,7 +278,11 @@ async function performNpmUpdate( console.log(info('Clearing package cache...')); // On Windows, use shell with full command string to avoid deprecation warning const cacheChild = isWindows - ? spawn(`${cacheCommand} ${cacheArgs.join(' ')}`, [], { stdio: 'inherit', shell: true }) + ? spawn(`${cacheCommand} ${cacheArgs.join(' ')}`, [], { + stdio: 'inherit', + shell: true, + env: { ...process.env, NODE_NO_WARNINGS: '1' }, + }) : spawn(cacheCommand, cacheArgs, { stdio: 'inherit' }); cacheChild.on('exit', (code) => { From 9b91071e07ef22adcd7844dfb75c9d6cc532af2e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Dec 2025 21:27:48 +0000 Subject: [PATCH 26/26] chore(release): 5.16.0-dev.13 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 3fce944c..ba755b70 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.16.0-dev.12 +5.16.0-dev.13 diff --git a/package.json b/package.json index a07638b7..e7ea9bd2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.16.0-dev.12", + "version": "5.16.0-dev.13", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",