mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 08:15:17 +00:00
Merge pull request #93 from kaitranntt/dev
feat(release): Web Dashboard, Analytics, Multi-Account CLIProxy & Bug Fixes
This commit is contained in:
51 files changed
+1330
-543
No files matched your search
@@ -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
|
||||
@@ -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
@@ -32,4 +32,7 @@ yarn.lock
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
|
||||
.claude/active-plan
|
||||
.claude/active-plan
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
+4
-3
@@ -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
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
|
||||

|
||||
|
||||
### 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)
|
||||
[]()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@ export {
|
||||
getInstalledCliproxyVersion,
|
||||
installCliproxyVersion,
|
||||
fetchLatestCliproxyVersion,
|
||||
getPinnedVersion,
|
||||
savePinnedVersion,
|
||||
clearPinnedVersion,
|
||||
isVersionPinned,
|
||||
getVersionPinPath,
|
||||
} from './binary-manager';
|
||||
|
||||
// Config generation
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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('');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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('');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in new issue
Block a user