Merge pull request #93 from kaitranntt/dev

feat(release): Web Dashboard, Analytics, Multi-Account CLIProxy & Bug Fixes
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2025-12-12 16:32:38 -05:00
commit 1a05c1bdbf
51 files changed
+1330 -543

No files matched your search

+54
View File
@@ -12,6 +12,7 @@ jobs:
permissions:
contents: write
issues: write
steps:
- name: Checkout
@@ -96,7 +97,60 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --tag dev
- name: Tag resolved issues
env:
GH_TOKEN: ${{ secrets.GITHUB_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#\#}
# 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
gh issue comment "$NUM" --repo "${{ github.repository }}" --body ":test_tube: Available in \`$NEW_VERSION\`. Install: \`npm i @kaitranntt/ccs@dev\`" || true
# Add released-dev label
gh issue edit "$NUM" --add-label "released-dev" --repo "${{ github.repository }}" || true
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"
@@ -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
+1
View File
@@ -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 }}
+4 -1
View File
@@ -32,4 +32,7 @@ yarn.lock
pnpm-lock.yaml
package-lock.json
.claude/active-plan
.claude/active-plan
# Logs directory
logs/
+4 -3
View File
@@ -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
+3 -5
View File
@@ -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.**<br>
Stop hitting rate limits. Keep working continuously.<br>
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)]()
+1 -1
View File
@@ -1 +1 @@
5.16.0
5.16.0-dev.13
+21 -2
View File
@@ -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 <version>`. 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
+8 -6
View File
@@ -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
-175
View File
@@ -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
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "5.16.0",
"version": "5.16.0-dev.13",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
@@ -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"
},
+25 -20
View File
@@ -16,9 +16,11 @@ 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';
import { getKillCLIProxyCommand } from '../utils/platform-commands';
import {
AccountInfo,
discoverExistingAccounts,
@@ -460,7 +462,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 +482,7 @@ export async function triggerOAuth(
});
if (!confirmed) {
console.log('[i] Cancelled');
console.log(info('Cancelled'));
return null;
}
}
@@ -487,12 +491,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 +505,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 +536,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('');
}
@@ -549,7 +553,7 @@ export async function triggerOAuth(
return new Promise<AccountInfo | null>((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,
@@ -572,7 +576,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 +594,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 +605,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 +622,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');
@@ -633,20 +637,21 @@ 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);
}
} 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 +660,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 +740,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;
+87 -4
View File
@@ -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,
@@ -38,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';
@@ -100,9 +104,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 +441,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;
@@ -884,9 +890,26 @@ export class BinaryManager {
/**
* Convenience function to ensure binary is available
* Respects version pin if set by user via 'ccs cliproxy --install <version>'
* @returns Path to CLIProxyAPI executable
*/
export async function ensureCLIProxyBinary(verbose = false): Promise<string> {
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();
}
@@ -938,7 +961,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();
}
@@ -957,4 +980,64 @@ export async function fetchLatestCliproxyVersion(): Promise<string> {
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;
+47 -19
View File
@@ -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 {
@@ -36,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 = {
@@ -152,7 +155,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 +173,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 +186,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 +223,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 +268,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}`);
@@ -284,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(' ')}`);
@@ -306,10 +333,10 @@ 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
// 7. Wait for proxy readiness via TCP polling
const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`);
readySpinner.start();
@@ -322,7 +349,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`);
@@ -330,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}`);
@@ -398,7 +426,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);
});
+6 -3
View File
@@ -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';
@@ -121,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}
@@ -183,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;
@@ -373,10 +376,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}`));
}
}
+5
View File
@@ -43,6 +43,11 @@ export {
getInstalledCliproxyVersion,
installCliproxyVersion,
fetchLatestCliproxyVersion,
getPinnedVersion,
savePinnedVersion,
clearPinnedVersion,
isVersionPinned,
getVersionPinPath,
} from './binary-manager';
// Config generation
+64 -21
View File
@@ -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<void> {
// Binary Commands
console.log(subheader('Binary Commands:'));
const binaryCmds: [string, string][] = [
['--install <version>', 'Install a specific binary version'],
['--latest', 'Install the latest binary version'],
['--install <version>', '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<void> {
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<void> {
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<void> {
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,26 +957,36 @@ async function showStatus(verbose: boolean): Promise<void> {
}
/**
* Install a specific version
* Install a specific version (pins the version to prevent auto-update)
*/
async function installVersion(version: string, verbose: boolean): Promise<void> {
// 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);
// 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('');
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:');
@@ -974,32 +1001,42 @@ async function installVersion(version: string, verbose: boolean): Promise<void>
}
/**
* Install latest version
* Install latest version (clears any version pin)
*/
async function installLatest(verbose: boolean): Promise<void> {
console.log('[i] Fetching latest CLIProxyAPI version...');
console.log(info('Fetching latest CLIProxyAPI version...'));
try {
const latestVersion = await fetchLatestCliproxyVersion();
const currentVersion = getInstalledCliproxyVersion();
const wasPinned = isVersionPinned();
if (isCLIProxyInstalled() && latestVersion === currentVersion) {
console.log(`[OK] Already running latest version: v${latestVersion}`);
if (isCLIProxyInstalled() && latestVersion === currentVersion && !wasPinned) {
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}`));
}
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('');
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);
}
}
@@ -1057,6 +1094,12 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
return;
}
// Handle --update (unpin and update to latest)
if (args.includes('--update')) {
await installLatest(verbose);
return;
}
// Default: show status
await showStatus(verbose);
}
+10 -6
View File
@@ -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<void> {
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<void> {
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);
}
+21 -20
View File
@@ -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<void> {
await initUI();
// Handle --list-backups
if (args.includes('--list-backups')) {
listBackups();
@@ -31,9 +34,9 @@ export async function handleMigrateCommand(args: string[]): Promise<void> {
const backupPath = args[rollbackIndex + 1];
if (!backupPath) {
console.error('[X] Error: --rollback requires backup path');
console.log('[i] Usage: ccs migrate --rollback <backup-path>');
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 <backup-path>'));
console.log(info('Use --list-backups to see available backups'));
process.exit(1);
}
@@ -43,13 +46,13 @@ export async function handleMigrateCommand(args: string[]): Promise<void> {
// 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<void> {
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<void> {
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<void> {
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<void> {
}
async function handleRollback(backupPath: string): Promise<void> {
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 <backup-path>');
console.log(info('To rollback: ccs migrate --rollback <backup-path>'));
}
/**
+10 -9
View File
@@ -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<void> {
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<void
});
if (result.alreadyInstalled && !force) {
console.log(colored('[OK] Shell completion already installed', 'green'));
console.log(` Use ${colored('--force', 'yellow')} to reinstall`);
console.log(ok('Shell completion already installed'));
console.log(` Use ${color('--force', 'warning')} to reinstall`);
console.log('');
return;
}
console.log(colored('[OK] Shell completion installed successfully!', 'green'));
console.log(ok('Shell completion installed successfully!'));
console.log('');
console.log(result.message);
console.log('');
console.log(colored('To activate:', 'cyan'));
console.log(color('To activate:', 'info'));
console.log(` ${result.reload}`);
console.log('');
console.log(colored('Then test:', 'cyan'));
console.log(color('Then test:', 'info'));
console.log(' ccs <TAB> # See available profiles');
console.log(' ccs auth <TAB> # 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');
+5 -4
View File
@@ -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<void> {
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<void> {
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);
+61 -57
View File
@@ -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<void> {
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,29 +229,35 @@ 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 isWindows = process.platform === 'win32';
const performUpdate = (): void => {
const child = spawn(updateCommand, updateArgs, {
stdio: 'inherit',
});
// 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,
env: { ...process.env, NODE_NO_WARNINGS: '1' },
})
: spawn(updateCommand, updateArgs, { stdio: 'inherit' });
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 +265,35 @@ 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'));
const cacheChild = spawn(cacheCommand, cacheArgs, {
stdio: 'inherit',
});
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,
env: { ...process.env, NODE_NO_WARNINGS: '1' },
})
: 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 +305,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 +322,7 @@ function handleDirectBetaNotSupported(): void {
* Perform update via direct installer (curl/irm)
*/
async function performDirectUpdate(): Promise<void> {
console.log(colored('Updating via installer...', 'cyan'));
console.log(info('Updating via installer...'));
console.log('');
const isWindows = process.platform === 'win32';
@@ -346,19 +350,19 @@ async function performDirectUpdate(): Promise<void> {
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 +371,13 @@ async function performDirectUpdate(): Promise<void> {
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);
+20 -17
View File
@@ -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<void> {
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);
}
+4 -7
View File
@@ -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<void> {
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('');
+28 -1
View File
@@ -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>): 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();
+5 -7
View File
@@ -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<string, unknown>;
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;
}
/**
+6 -5
View File
@@ -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 <profile> -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');
+17 -15
View File
@@ -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));
}
+114 -24
View File
@@ -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<void> {
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(`[OK] Fixed ${fixed} issue(s)`));
} else {
console.log(info('[i] 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 <provider> --auth --verbose', 'command')} for detailed logs`
);
console.log(` 3. Restart your terminal/computer`);
}
console.log('');
}
/**
+3 -2
View File
@@ -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');
}
+19 -18
View File
@@ -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}`));
}
/**
+3 -3
View File
@@ -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}`);
+3 -3
View File
@@ -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);
}
+3 -3
View File
@@ -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}`);
}
+12 -4
View File
@@ -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<void> {
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 <PID>`, 'command')} (replace <PID> with actual ID)`
);
} else {
console.error(` ${color(getKillPidCommand(12345).replace('12345', '<PID>'), '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('');
}
+180
View File
@@ -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,
};
+5 -7
View File
@@ -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('');
}
+111
View File
@@ -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<string, RawUsageEntry[]>();
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<string, ModelAccumulator>();
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<Daily
return aggregateDailyUsage(entries);
}
/**
* Load hourly usage data for today's chart
*/
export async function loadHourlyUsageData(options?: ParserOptions): Promise<HourlyUsage[]> {
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<SessionU
*/
export async function loadAllUsageData(options?: ParserOptions): Promise<{
daily: DailyUsage[];
hourly: HourlyUsage[];
monthly: MonthlyUsage[];
session: SessionUsage[];
}> {
const entries = await scanProjectsDirectory(options);
return {
daily: aggregateDailyUsage(entries),
hourly: aggregateHourlyUsage(entries),
monthly: aggregateMonthlyUsage(entries),
session: aggregateSessionUsage(entries),
};
+4 -3
View File
@@ -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);
};
+13 -9
View File
@@ -11,7 +11,8 @@
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
const CCS_DIR = path.join(os.homedir(), '.ccs');
@@ -25,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
@@ -58,7 +60,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 +68,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;
}
}
@@ -94,6 +96,7 @@ export function isDiskCacheStale(cache: UsageDiskCache | null): boolean {
*/
export function writeDiskCache(
daily: DailyUsage[],
hourly: HourlyUsage[],
monthly: MonthlyUsage[],
session: SessionUsage[]
): void {
@@ -104,6 +107,7 @@ export function writeDiskCache(
version: CACHE_VERSION,
timestamp: Date.now(),
daily,
hourly,
monthly,
session,
};
@@ -113,10 +117,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 +147,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}`);
}
}
+135 -17
View File
@@ -20,9 +20,11 @@ import {
loadMonthlyUsageData,
loadSessionData,
loadAllUsageData,
loadHourlyUsageData,
} from './data-aggregator';
import type {
DailyUsage,
HourlyUsage,
MonthlyUsage,
SessionUsage,
Anomaly,
@@ -38,6 +40,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 +69,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 [];
}
}
@@ -77,6 +80,7 @@ function getInstancePaths(): string[] {
*/
async function loadInstanceData(instancePath: string): Promise<{
daily: DailyUsage[];
hourly: HourlyUsage[];
monthly: MonthlyUsage[];
session: SessionUsage[];
}> {
@@ -87,8 +91,8 @@ 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}`);
return { daily: [], monthly: [], session: [] };
console.log(info(`No usage data in instance: ${instanceName}`));
return { daily: [], hourly: [], monthly: [], session: [] };
}
}
@@ -167,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<string, HourlyUsage>();
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)
@@ -245,12 +295,13 @@ let diskCacheInitialized = false;
*/
function persistCacheIfComplete(): void {
const daily = cache.get('daily') as CacheEntry<DailyUsage[]> | undefined;
const hourly = cache.get('hourly') as CacheEntry<HourlyUsage[]> | undefined;
const monthly = cache.get('monthly') as CacheEntry<MonthlyUsage[]> | undefined;
const session = cache.get('session') as CacheEntry<SessionUsage[]> | 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 ?? []);
}
}
@@ -283,7 +334,7 @@ async function getCachedData<T>(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);
@@ -337,6 +388,13 @@ async function getCachedSessionData(): Promise<SessionUsage[]> {
});
}
/** Cached loader for hourly usage data */
async function getCachedHourlyData(): Promise<HourlyUsage[]> {
return getCachedData('hourly', CACHE_TTL.daily, async () => {
return await loadHourlyUsageData();
});
}
/**
* Clear all cached data (useful for manual refresh)
*/
@@ -356,6 +414,7 @@ let isRefreshing = false;
*/
async function refreshFromSource(): Promise<{
daily: DailyUsage[];
hourly: HourlyUsage[];
monthly: MonthlyUsage[];
session: SessionUsage[];
}> {
@@ -366,6 +425,7 @@ async function refreshFromSource(): Promise<{
const instancePaths = getInstancePaths();
const instanceDataResults: Array<{
daily: DailyUsage[];
hourly: HourlyUsage[];
monthly: MonthlyUsage[];
session: SessionUsage[];
}> = [];
@@ -376,41 +436,45 @@ 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}`));
}
}
// 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);
}
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
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 };
}
// ============================================================================
@@ -432,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;
@@ -453,7 +518,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();
@@ -462,13 +527,14 @@ 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;
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' };
}
@@ -477,21 +543,24 @@ 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;
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 +570,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;
}
}
@@ -750,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<object, object, object, UsageQuery>, 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
*
+14
View File
@@ -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;
+7 -6
View File
@@ -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,
+14 -5
View File
@@ -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);
});
});
+3 -1
View File
@@ -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() {
<AppSidebar />
<main className="flex-1 flex flex-col min-h-0 overflow-hidden">
<header className="flex h-12 items-center justify-end px-4 border-b shrink-0">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<ConnectionIndicator />
<GitHubLink />
<ThemeToggle />
</div>
</header>
@@ -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 (
@@ -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, 'dateRange'>) {
}: 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 (
<div className={cn('h-full flex items-center justify-center', className)}>
<p className="text-muted-foreground">No usage data available</p>
<p className="text-muted-foreground">
{granularity === 'hourly' ? 'No usage data for today' : 'No usage data available'}
</p>
</div>
);
}
@@ -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 (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<p className="font-medium mb-2">{label}</p>
@@ -115,7 +124,11 @@ export function UsageTrendChart({
: `$${entry.value}`}
</p>
))}
<p className="text-sm text-muted-foreground mt-1">Requests: {data.requests}</p>
{'requests' in tooltipData && (
<p className="text-sm text-muted-foreground mt-1">
Requests: {tooltipData.requests}
</p>
)}
</div>
);
}}
@@ -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');
}
+20
View File
@@ -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 (
<Button variant="ghost" size="icon" asChild title="Report an issue on GitHub">
<a href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
<Github className="w-4 h-4" />
</a>
</Button>
);
}
+1 -3
View File
@@ -432,9 +432,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
<div className="flex items-center gap-2">
<span>{m.name}</span>
{m.description && (
<span className="text-xs text-muted-foreground">
- {m.description}
</span>
<span className="text-xs text-muted-foreground">- {m.description}</span>
)}
</div>
</SelectItem>
+25
View File
@@ -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<DailyUsage[]>(`/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<HourlyUsage[]>(`/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],
+32 -4
View File
@@ -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<ModelUsage | null>(null);
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily');
const popoverAnchorRef = useRef<HTMLDivElement>(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() {
<p className="text-sm text-muted-foreground">Track usage & insights</p>
</div>
<div className="flex items-center gap-2">
<Button
variant={viewMode === 'hourly' ? 'default' : 'outline'}
size="sm"
className="h-8"
onClick={handleTodayClick}
>
24H
</Button>
<DateRangeFilter
value={dateRange}
onChange={setDateRange}
onChange={handleDateRangeChange}
presets={[
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
{ label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } },
@@ -140,11 +164,15 @@ export function AnalyticsPage() {
<CardHeader className="px-3 py-2 shrink-0">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Usage Trends
{viewMode === 'hourly' ? 'Last 24 Hours' : 'Usage Trends'}
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0">
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} />
<UsageTrendChart
data={viewMode === 'hourly' ? hourlyData || [] : trends || []}
isLoading={viewMode === 'hourly' ? isHourlyLoading : isTrendsLoading}
granularity={viewMode === 'hourly' ? 'hourly' : 'daily'}
/>
</CardContent>
</Card>
@@ -291,7 +319,7 @@ export function AnalyticsPage() {
/>
{/* CLIProxy Stats - 2/10 width */}
<CliproxyStatsCard className="lg:col-span-2" />
<CliproxyStatsCard isLoading={isSummaryLoading} className="lg:col-span-2" />
</div>
{/* Model Details Popover - positioned at cursor */}