From 29d42b9ff97d11d4771bfc6985c065bc79efe0bf Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sat, 26 Sep 2026 17:05:53 +0800 Subject: [PATCH] refactor(config): remove legacy ccusage service and deprecated ccotel config Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 1 - cmd/daemon/main.go | 12 - docs/CONFIG.md | 24 +- model/ccusage_cov_test.go | 124 --------- model/ccusage_service.go | 454 --------------------------------- model/ccusage_service.types.go | 30 --- model/ccusage_service_test.go | 422 ------------------------------ model/config.go | 16 -- model/config_cov_test.go | 15 -- model/config_extra_test.go | 14 - model/types.go | 15 -- 11 files changed, 1 insertion(+), 1126 deletions(-) delete mode 100644 model/ccusage_cov_test.go delete mode 100644 model/ccusage_service.go delete mode 100644 model/ccusage_service.types.go delete mode 100644 model/ccusage_service_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 64c4ba8..a6c7a93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,6 @@ Optional daemon services (feature-gated via config): - **AICodeOtelServer**: gRPC OTEL collector for AI coding CLI metrics/logs (Claude Code, Codex) - **HeartbeatResyncService**: Periodic resync of failed heartbeats (30-min interval) - **CleanupTimerService**: Periodic log file cleanup (24-hour interval) -- **CCUsageService**: Integration with ccusage CLI Services initialize in `cmd/daemon/main.go`: check enabled flag → create → start → defer stop. All run concurrently with graceful shutdown on SIGINT/SIGTERM. diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index b262fc3..74dbc37 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -86,7 +86,6 @@ func main() { daemon.Init(daemonConfigService, version) model.InjectVar(version) - cmdService := model.NewCommandService() // When the bolt storage engine is enabled, the daemon owns the bolt-backed // command store for its lifetime (bbolt holds an exclusive file lock). @@ -132,17 +131,6 @@ func main() { go daemon.SocketTopicProcessor(msg) - // Start CCUsage service if enabled (v1 - ccusage CLI based) - if cfg.CCUsage != nil && cfg.CCUsage.Enabled != nil && *cfg.CCUsage.Enabled { - ccUsageService := model.NewCCUsageService(cfg, cmdService) - if err := ccUsageService.Start(ctx); err != nil { - slog.Error("Failed to start CCUsage service", slog.Any("err", err)) - } else { - slog.Info("CCUsage service started") - defer ccUsageService.Stop() - } - } - // Start AICodeOtel service if enabled (OTEL gRPC passthrough for Claude Code, Codex, etc.) var aiCodeOtelServer *daemon.AICodeOtelServer if cfg.AICodeOtel != nil && cfg.AICodeOtel.Enabled != nil && *cfg.AICodeOtel.Enabled { diff --git a/docs/CONFIG.md b/docs/CONFIG.md index aa433d4..b3fbdbc 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -289,15 +289,6 @@ Quota sync is automatic and has no configuration switch. It runs only when: The Codex access token never goes to ShellTime. The daemon uses it only for the direct Codex request, then sends ShellTime a summary containing the plan, the windows Codex returned, their usage percentages and reset times, and credit status. The set and duration of windows are dynamic; for example, an account may return only a weekly window, so ShellTime does not synthesize a 5-hour window. -### CCUsage (Legacy) - -CLI-based collection (older method): - -```yaml -ccusage: - enabled: false -``` - ### Code Tracking Track coding activity heartbeats: @@ -357,7 +348,7 @@ Credentials can be embedded in the URL, e.g. `http://user:pass@proxy:8080` or `s - SOCKS4 is not supported. - An invalid proxy URL is logged as a warning, and the environment-variable proxy is used instead. - The daemon reads the proxy on startup, so restart it after changing this setting. -- The optional OTEL metrics exporter (`enableMetrics`) and the `ccusage` subprocess only honor the environment variables. +- The optional OTEL metrics exporter (`enableMetrics`) only honors the environment variables. - Put a machine-specific proxy in `config.local.yaml` to keep it out of a shared config. --- @@ -466,9 +457,6 @@ aiCodeOtel: grpcPort: 54027 debug: false -ccusage: - enabled: false - codeTracking: enabled: false # apiEndpoint: "https://api.custom-heartbeat.com" # Optional: custom endpoint @@ -535,16 +523,6 @@ Or unset your token: token: "" ``` -### What's the difference between AICodeOtel and CCUsage? - -| Feature | AICodeOtel | CCUsage | -|---------|------------|---------| -| Method | gRPC passthrough | CLI parsing | -| Performance | Better | More overhead | -| Data richness | Full OTEL data | Basic metrics | -| Sources | Claude Code, Codex, etc. | Claude Code only | -| Recommended | Yes | Legacy | - ### How do I test my exclude patterns? Use this Go regex tester with your patterns: diff --git a/model/ccusage_cov_test.go b/model/ccusage_cov_test.go deleted file mode 100644 index 23d4ca8..0000000 --- a/model/ccusage_cov_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package model - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "runtime" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestCCUsage_collectData_ExitErrorWithStderr covers the *exec.ExitError branch: -// a fake bunx that prints to stderr and exits non-zero, surfacing the stderr in -// the error message. -func TestCCUsage_collectData_ExitErrorWithStderr(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("uses /bin/sh script") - } - binDir := t.TempDir() - fakeBunx := filepath.Join(binDir, "bunx") - require.NoError(t, os.WriteFile(fakeBunx, []byte("#!/bin/sh\necho 'boom on stderr' >&2\nexit 3\n"), 0o755)) - t.Setenv("SHELL", "/bin/sh") - - cmd := NewMockCommandService(t) - cmd.On("LookPath", "bunx").Return(fakeBunx, nil) - cmd.On("LookPath", "npx").Return("", errors.New("not found")) - - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - _, err := svc.collectData(context.Background(), time.Time{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "ccusage command failed") - assert.Contains(t, err.Error(), "boom on stderr") -} - -// TestCCUsage_collectData_UsernameFromUserCurrent covers the branch where USER is -// empty so the username is resolved via user.Current(). -func TestCCUsage_collectData_UsernameFromUserCurrent(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("uses /bin/sh script") - } - binDir := t.TempDir() - fakeBunx := filepath.Join(binDir, "bunx") - require.NoError(t, os.WriteFile(fakeBunx, []byte("#!/bin/sh\necho '{\"projects\":{},\"totals\":{}}'\n"), 0o755)) - t.Setenv("SHELL", "/bin/sh") - t.Setenv("USER", "") // force the user.Current() fallback branch - - cmd := NewMockCommandService(t) - cmd.On("LookPath", "bunx").Return(fakeBunx, nil) - cmd.On("LookPath", "npx").Return("", errors.New("not found")) - - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - data, err := svc.collectData(context.Background(), time.Time{}) - require.NoError(t, err) - assert.NotEmpty(t, data.Username, "username resolved via user.Current() when USER unset") -} - -// TestCCUsage_getLastSyncTimestamp_ParseError covers the branch where the server -// returns a non-empty but unparseable timestamp -> returns an error. -func TestCCUsage_getLastSyncTimestamp_ParseError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":{"fetchUser":{"id":1,"ccusage":{"lastSyncAt":"not-a-timestamp"}}}}`)) - })) - defer server.Close() - - cmd := NewMockCommandService(t) - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - _, err := svc.getLastSyncTimestamp(context.Background(), Endpoint{Token: "t", APIEndpoint: server.URL}) - require.Error(t, err, "unparseable timestamp surfaces a parse error") -} - -// TestCCUsage_CollectCCUsage_CollectErrorWrapped covers CollectCCUsage's branch -// where collectData fails (no bunx/npx) and the error is wrapped. -func TestCCUsage_CollectCCUsage_CollectErrorWrapped(t *testing.T) { - cmd := NewMockCommandService(t) - cmd.On("LookPath", "bunx").Return("", errors.New("nope")) - cmd.On("LookPath", "npx").Return("", errors.New("nope")) - - // No credentials -> skips last-sync fetch and send; only collectData runs. - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - err := svc.CollectCCUsage(context.Background()) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to collect ccusage data") -} - -// TestCCUsage_CollectCCUsage_SendErrorWrapped covers the branch where collection -// succeeds but the server rejects the batch send, wrapping the send error. -func TestCCUsage_CollectCCUsage_SendErrorWrapped(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("uses /bin/sh script") - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/graphql": - _, _ = w.Write([]byte(`{"data":{"fetchUser":{"id":1,"ccusage":{"lastSyncAt":""}}}}`)) - default: - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"error":"batch rejected"}`)) - } - })) - defer server.Close() - - binDir := t.TempDir() - fakeBunx := filepath.Join(binDir, "bunx") - usageJSON := `{"projects":{"projA":[{"date":"20260101","totalCost":0.1,"modelBreakdowns":[]}]},"totals":{}}` - require.NoError(t, os.WriteFile(fakeBunx, []byte("#!/bin/sh\necho '"+usageJSON+"'\n"), 0o755)) - t.Setenv("SHELL", "/bin/sh") - - cmd := NewMockCommandService(t) - cmd.On("LookPath", "bunx").Return(fakeBunx, nil) - cmd.On("LookPath", "npx").Return("", errors.New("not found")) - - cfg := ShellTimeConfig{Token: "tok", APIEndpoint: server.URL} - svc := NewCCUsageService(cfg, cmd).(*ccUsageService) - err := svc.CollectCCUsage(context.Background()) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to send usage data") -} diff --git a/model/ccusage_service.go b/model/ccusage_service.go deleted file mode 100644 index 97a93f8..0000000 --- a/model/ccusage_service.go +++ /dev/null @@ -1,454 +0,0 @@ -package model - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "os" - "os/exec" - "os/user" - "runtime" - "strings" - "time" -) - -// CCUsageData represents the usage data collected from ccusage command -type CCUsageData struct { - Timestamp string `json:"timestamp"` - Hostname string `json:"hostname"` - Username string `json:"username"` - OS string `json:"os"` - OSVersion string `json:"osVersion"` - Data CCUsageProjectDailyOutput `json:"data"` -} - -// CCUsageService defines the interface for CC usage collection -type CCUsageService interface { - Start(ctx context.Context) error - Stop() - CollectCCUsage(ctx context.Context) error -} - -// ccUsageService implements the CCUsageService interface -type ccUsageService struct { - config ShellTimeConfig - ticker *time.Ticker - stopChan chan struct{} - commandService CommandService -} - -// NewCCUsageService creates a new CCUsage service -func NewCCUsageService(config ShellTimeConfig, cmdService CommandService) CCUsageService { - return &ccUsageService{ - config: config, - stopChan: make(chan struct{}), - commandService: cmdService, - } -} - -// Start begins the periodic usage collection -func (s *ccUsageService) Start(ctx context.Context) error { - // Check if CCUsage is enabled - if s.config.CCUsage == nil || s.config.CCUsage.Enabled == nil || !*s.config.CCUsage.Enabled { - slog.Info("CCUsage collection is disabled") - return nil - } - - slog.Info("Starting CCUsage collection service") - - // Create a ticker for hourly collection - s.ticker = time.NewTicker(1 * time.Hour) - - // Run initial collection - if err := s.CollectCCUsage(ctx); err != nil { - slog.Warn("Initial CCUsage collection failed", "error", err) - } - - // Start the collection loop - go func() { - for { - select { - case <-s.ticker.C: - if err := s.CollectCCUsage(ctx); err != nil { - slog.Error("CCUsage collection failed", "error", err) - } - case <-s.stopChan: - slog.Info("Stopping CCUsage collection service") - return - case <-ctx.Done(): - slog.Info("Context cancelled, stopping CCUsage collection service") - return - } - } - }() - - return nil -} - -// Stop halts the usage collection -func (s *ccUsageService) Stop() { - if s.ticker != nil { - s.ticker.Stop() - } - close(s.stopChan) -} - -// CollectCCUsage collects and sends usage data to the server -func (s *ccUsageService) CollectCCUsage(ctx context.Context) error { - ctx, span := modelTracer.Start(ctx, "ccusage.collect") - defer span.End() - - slog.Debug("Collecting CCUsage data") - - since := time.Time{} - - // Get the last sync timestamp from server if we have credentials - if s.config.Token != "" && s.config.APIEndpoint != "" { - endpoint := Endpoint{ - Token: s.config.Token, - APIEndpoint: s.config.APIEndpoint, - } - - // Try to get last sync timestamp, but don't fail if it doesn't work - lastSync, err := s.getLastSyncTimestamp(ctx, endpoint) - if err != nil { - slog.Warn("Failed to get last sync timestamp", "error", err) - } - since = lastSync - slog.Debug("Got last sync timestamp", "since", since) - } - - // Collect data from ccusage command - data, err := s.collectData(ctx, since) - if err != nil { - return fmt.Errorf("failed to collect ccusage data: %w", err) - } - - // Send to server - if s.config.Token != "" && s.config.APIEndpoint != "" { - endpoint := Endpoint{ - Token: s.config.Token, - APIEndpoint: s.config.APIEndpoint, - } - - err = s.sendData(ctx, endpoint, data) - if err != nil { - return fmt.Errorf("failed to send usage data: %w", err) - } - } - - slog.Debug("CCUsage data collection completed") - return nil -} - -// getLastSyncTimestamp fetches the last CCUsage sync timestamp from the server via GraphQL -func (s *ccUsageService) getLastSyncTimestamp(ctx context.Context, endpoint Endpoint) (time.Time, error) { - // Get current hostname - hostname, err := os.Hostname() - if err != nil { - slog.Warn("Failed to get hostname", "error", err) - hostname = "unknown" - } - - query := `query fetchUserCCUsageLastSync($hostname: String!) { - fetchUser { - id - ccusage(filter: { hostname: $hostname }) { - lastSyncAt - } - } - }` - - type fetchUserResponse struct { - FetchUser struct { - ID int `json:"id"` - CCUsage struct { - LastSyncAt string `json:"lastSyncAt"` - } `json:"ccusage"` - } `json:"fetchUser"` - } - - var result GraphQLResponse[fetchUserResponse] - - variables := map[string]interface{}{ - "hostname": hostname, - } - - slog.Debug("Fetching CCUsage last sync", "hostname", hostname) - - err = SendGraphQLRequest(GraphQLRequestOptions[GraphQLResponse[fetchUserResponse]]{ - Context: ctx, - Endpoint: endpoint, - Query: query, - Variables: variables, - Response: &result, - Timeout: time.Second * 10, - }) - - if err != nil { - slog.Warn("Failed to fetch CCUsage last sync", "error", err) - return time.Time{}, nil // Return nil to skip the since parameter - } - - lastSyncAtStr := result.Data.FetchUser.CCUsage.LastSyncAt - - if lastSyncAtStr == "" { - return time.Time{}, nil - } - lastSyncAt, err := time.Parse(time.RFC3339, lastSyncAtStr) - if err != nil { - slog.Warn("Failed to parse last sync timestamp", "error", err) - return time.Time{}, err // Return nil to skip the since parameter - } - - year2023 := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) - if lastSyncAt.Before(year2023) { - return time.Time{}, nil - } - - return lastSyncAt, nil -} - -// collectData collects usage data using bunx or npx ccusage command -func (s *ccUsageService) collectData(ctx context.Context, since time.Time) (*CCUsageData, error) { - // Check if bunx exists using command service lookPath that checks common installation locations - bunxPath, bunxErr := s.commandService.LookPath("bunx") - npxPath, npxErr := s.commandService.LookPath("npx") - - if bunxErr != nil && npxErr != nil { - slog.Warn("error looking for bunx or npx", "bunxErr", bunxErr, "npxErr", npxErr) - return nil, fmt.Errorf("neither bunx nor npx found in system PATH or common installation locations") - } - - // Build command arguments - args := []string{"ccusage", "daily", "--instances", "--json"} - - // Add since parameter if provided - if !since.IsZero() { - // Convert Unix timestamp (seconds) to ISO 8601 date string - sinceDate := since.Format("20060102") - args = append(args, "--since", sinceDate) - slog.Debug("Using since parameter", "sinceDate", sinceDate, "since", since) - } - - // Get user's shell to run command with proper environment - shell := getUserShell() - - var cmd *exec.Cmd - if bunxErr == nil { - // Use bunx if available - cmdStr := bunxPath + " " + shellEscapeArgs(args) - cmd = exec.CommandContext(ctx, shell, "-c", cmdStr) - slog.Debug("Using bunx to collect ccusage data", "shell", shell) - } else { - // Fall back to npx with --yes flag to auto-accept prompts - npxArgs := append([]string{"--yes"}, args...) - cmdStr := npxPath + " " + shellEscapeArgs(npxArgs) - cmd = exec.CommandContext(ctx, shell, "-c", cmdStr) - slog.Debug("Using npx to collect ccusage data", "shell", shell) - } - - // Execute the command - output, err := cmd.Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return nil, fmt.Errorf("ccusage command failed: %v, stderr: %s", err, string(exitErr.Stderr)) - } - return nil, fmt.Errorf("failed to execute ccusage command: %w", err) - } - - // Parse JSON output - var ccusageOutput CCUsageProjectDailyOutput - if err := json.Unmarshal(output, &ccusageOutput); err != nil { - return nil, fmt.Errorf("failed to parse ccusage output: %w", err) - } - - // Get system information for metadata - hostname, err := os.Hostname() - if err != nil { - slog.Warn("Failed to get hostname", "error", err) - hostname = "unknown" - } - - username := os.Getenv("USER") - if username == "" { - currentUser, err := user.Current() - if err != nil { - slog.Warn("Failed to get username", "error", err) - username = "unknown" - } else { - username = currentUser.Username - } - } - - sysInfo, err := GetOSAndVersion() - if err != nil { - slog.Warn("Failed to get OS info", "error", err) - sysInfo = &SysInfo{ - Os: "unknown", - Version: "unknown", - } - } - - data := &CCUsageData{ - Timestamp: time.Now().Format(time.RFC3339), - Hostname: hostname, - Username: username, - OS: sysInfo.Os, - OSVersion: sysInfo.Version, - Data: ccusageOutput, - } - - return data, nil -} - -// sendData sends the collected usage data to the server -func (s *ccUsageService) sendData(ctx context.Context, endpoint Endpoint, data *CCUsageData) error { - // CCUsage batch request types matching server handler - type ccUsageModelBreakdown struct { - ModelName string `json:"modelName"` - InputTokens int `json:"inputTokens"` - OutputTokens int `json:"outputTokens"` - CacheCreationTokens int `json:"cacheCreationTokens"` - CacheReadTokens int `json:"cacheReadTokens"` - Cost float64 `json:"cost"` - } - - type ccUsageDailyData struct { - InputTokens int `json:"inputTokens"` - OutputTokens int `json:"outputTokens"` - CacheCreationTokens int `json:"cacheCreationTokens"` - CacheReadTokens int `json:"cacheReadTokens"` - TotalTokens int `json:"totalTokens"` - TotalCost float64 `json:"totalCost"` - ModelsUsed []string `json:"modelsUsed"` - ModelBreakdowns []ccUsageModelBreakdown `json:"modelBreakdowns"` - } - - type ccUsageEntry struct { - Project string `json:"project"` - Date string `json:"date"` // YYYYMMDD format - Usage ccUsageDailyData `json:"usage"` - } - - type ccUsageBatchPayload struct { - Host string `json:"host"` - Entries []ccUsageEntry `json:"entries"` - } - - type ccUsageResponse struct { - Success bool `json:"success"` - SuccessCount int `json:"successCount"` - TotalCount int `json:"totalCount"` - FailedProjects []string `json:"failedProjects,omitempty"` - } - - // Transform CCUsageData to batch format - var entries []ccUsageEntry - - // Iterate through all projects in the collected data - for projectName, projectDays := range data.Data.Projects { - for _, dayData := range projectDays { - // Convert model breakdowns - modelBreakdowns := make([]ccUsageModelBreakdown, len(dayData.ModelBreakdowns)) - for i, mb := range dayData.ModelBreakdowns { - modelBreakdowns[i] = ccUsageModelBreakdown{ - ModelName: mb.ModelName, - InputTokens: mb.InputTokens, - OutputTokens: mb.OutputTokens, - CacheCreationTokens: mb.CacheCreationTokens, - CacheReadTokens: mb.CacheReadTokens, - Cost: mb.Cost, - } - } - - entry := ccUsageEntry{ - Project: projectName, - Date: dayData.Date, // Already in YYYYMMDD format from ccusage - Usage: ccUsageDailyData{ - InputTokens: dayData.InputTokens, - OutputTokens: dayData.OutputTokens, - CacheCreationTokens: dayData.CacheCreationTokens, - CacheReadTokens: dayData.CacheReadTokens, - TotalTokens: dayData.TotalTokens, - TotalCost: dayData.TotalCost, - ModelsUsed: dayData.ModelsUsed, - ModelBreakdowns: modelBreakdowns, - }, - } - entries = append(entries, entry) - } - } - - if len(entries) == 0 { - slog.Debug("No CCUsage entries to send") - return nil - } - - payload := ccUsageBatchPayload{ - Host: data.Hostname, - Entries: entries, - } - - var resp ccUsageResponse - - err := SendHTTPRequestJSON(HTTPRequestOptions[ccUsageBatchPayload, ccUsageResponse]{ - Context: ctx, - Endpoint: endpoint, - Method: http.MethodPost, - Path: "/api/v1/ccusage/batch", - Payload: payload, - Response: &resp, - }) - - if err != nil { - return fmt.Errorf("failed to send CCUsage data: %w", err) - } - - if !resp.Success { - if len(resp.FailedProjects) > 0 { - return fmt.Errorf("server rejected CCUsage data for projects: %v", resp.FailedProjects) - } - return fmt.Errorf("server rejected CCUsage data: %d/%d entries failed", resp.TotalCount-resp.SuccessCount, resp.TotalCount) - } - - slog.Debug("CCUsage data sent successfully", "successCount", resp.SuccessCount, "totalCount", resp.TotalCount) - return nil -} - -// getUserShell returns the user's shell executable path -// It checks the SHELL environment variable first, then falls back to sensible defaults -func getUserShell() string { - // Try to get the shell from environment variable - shell := os.Getenv("SHELL") - if shell != "" { - return shell - } - - // Fall back to platform-specific defaults - if runtime.GOOS == "windows" { - // On Windows, prefer PowerShell, fall back to cmd - if pwsh, err := exec.LookPath("pwsh"); err == nil { - return pwsh - } - if powershell, err := exec.LookPath("powershell"); err == nil { - return powershell - } - return "cmd" - } - - // On Unix-like systems, default to sh (POSIX shell) - return "/bin/sh" -} - -// shellEscapeArgs joins arguments with spaces and escapes them for safe shell execution -func shellEscapeArgs(args []string) string { - escaped := make([]string, len(args)) - for i, arg := range args { - // Simple shell escaping: wrap in single quotes and escape single quotes - escaped[i] = "'" + strings.ReplaceAll(arg, "'", "'\"'\"'") + "'" - } - return strings.Join(escaped, " ") -} diff --git a/model/ccusage_service.types.go b/model/ccusage_service.types.go deleted file mode 100644 index 3b1f61b..0000000 --- a/model/ccusage_service.types.go +++ /dev/null @@ -1,30 +0,0 @@ -package model - -type CCUsageProjectDailyOutput struct { - Projects map[string][]struct { - Date string `json:"date"` - InputTokens int `json:"inputTokens"` - OutputTokens int `json:"outputTokens"` - CacheCreationTokens int `json:"cacheCreationTokens"` - CacheReadTokens int `json:"cacheReadTokens"` - TotalTokens int `json:"totalTokens"` - TotalCost float64 `json:"totalCost"` - ModelsUsed []string `json:"modelsUsed"` - ModelBreakdowns []struct { - ModelName string `json:"modelName"` - InputTokens int `json:"inputTokens"` - OutputTokens int `json:"outputTokens"` - CacheCreationTokens int `json:"cacheCreationTokens"` - CacheReadTokens int `json:"cacheReadTokens"` - Cost float64 `json:"cost"` - } `json:"modelBreakdowns"` - } `json:"projects"` - Totals struct { - InputTokens int `json:"inputTokens"` - OutputTokens int `json:"outputTokens"` - CacheCreationTokens int `json:"cacheCreationTokens"` - CacheReadTokens int `json:"cacheReadTokens"` - TotalCost float64 `json:"totalCost"` - TotalTokens int `json:"totalTokens"` - } `json:"totals"` -} diff --git a/model/ccusage_service_test.go b/model/ccusage_service_test.go deleted file mode 100644 index b2d5de6..0000000 --- a/model/ccusage_service_test.go +++ /dev/null @@ -1,422 +0,0 @@ -package model - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "runtime" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCCUsage_NewService(t *testing.T) { - cmd := NewMockCommandService(t) - svc := NewCCUsageService(ShellTimeConfig{}, cmd) - require.NotNil(t, svc) - var _ CCUsageService = svc -} - -func TestCCUsage_StartDisabled(t *testing.T) { - cmd := NewMockCommandService(t) - - // CCUsage nil -> disabled, returns nil without touching command service. - svc := NewCCUsageService(ShellTimeConfig{}, cmd) - require.NoError(t, svc.Start(context.Background())) - - // Enabled explicitly false -> disabled. - off := false - svc2 := NewCCUsageService(ShellTimeConfig{CCUsage: &CCUsage{Enabled: &off}}, cmd) - require.NoError(t, svc2.Start(context.Background())) -} - -func TestCCUsage_Stop_BeforeStartDoesNotPanic(t *testing.T) { - cmd := NewMockCommandService(t) - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - // ticker is nil before Start; Stop must guard against that. - assert.NotPanics(t, func() { svc.Stop() }) -} - -func TestGetUserShell(t *testing.T) { - t.Run("uses SHELL env when set", func(t *testing.T) { - t.Setenv("SHELL", "/usr/bin/zsh") - assert.Equal(t, "/usr/bin/zsh", getUserShell()) - }) - - t.Run("falls back to default when SHELL unset", func(t *testing.T) { - t.Setenv("SHELL", "") - got := getUserShell() - if runtime.GOOS == "windows" { - assert.NotEmpty(t, got) - } else { - assert.Equal(t, "/bin/sh", got) - } - }) -} - -func TestShellEscapeArgs(t *testing.T) { - cases := []struct { - name string - in []string - want string - }{ - {"simple", []string{"a", "b"}, "'a' 'b'"}, - {"empty", []string{}, ""}, - {"single quote inside", []string{"it's"}, `'it'"'"'s'`}, - {"spaces preserved", []string{"hello world"}, "'hello world'"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, shellEscapeArgs(tc.in)) - }) - } -} - -func TestCCUsage_collectData_NeitherBinaryFound(t *testing.T) { - cmd := NewMockCommandService(t) - cmd.On("LookPath", "bunx").Return("", errors.New("not found")) - cmd.On("LookPath", "npx").Return("", errors.New("not found")) - - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - _, err := svc.collectData(context.Background(), time.Time{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "neither bunx nor npx found") -} - -func TestCCUsage_collectData_SuccessViaFakeBunx(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("uses /bin/sh script") - } - // Fake bunx: a shell script that prints valid ccusage JSON regardless of args. - binDir := t.TempDir() - fakeBunx := filepath.Join(binDir, "bunx") - script := `#!/bin/sh -cat <<'JSON' -{"projects":{"projA":[{"date":"20260101","inputTokens":10,"outputTokens":20,"totalTokens":30,"totalCost":0.5,"modelsUsed":["claude"],"modelBreakdowns":[{"modelName":"claude","inputTokens":10,"outputTokens":20,"cost":0.5}]}]},"totals":{"inputTokens":10,"outputTokens":20,"totalTokens":30,"totalCost":0.5}} -JSON -` - require.NoError(t, os.WriteFile(fakeBunx, []byte(script), 0o755)) - t.Setenv("SHELL", "/bin/sh") - - cmd := NewMockCommandService(t) - cmd.On("LookPath", "bunx").Return(fakeBunx, nil) - cmd.On("LookPath", "npx").Return("", errors.New("not found")) - - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - data, err := svc.collectData(context.Background(), time.Time{}) - require.NoError(t, err) - require.NotNil(t, data) - assert.NotEmpty(t, data.Timestamp) - assert.NotEmpty(t, data.Hostname) - require.Contains(t, data.Data.Projects, "projA") - require.Len(t, data.Data.Projects["projA"], 1) - day := data.Data.Projects["projA"][0] - assert.Equal(t, "20260101", day.Date) - assert.Equal(t, 10, day.InputTokens) - assert.Equal(t, 0.5, day.TotalCost) -} - -func TestCCUsage_collectData_WithSinceUsesNpxFallback(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("uses /bin/sh script") - } - binDir := t.TempDir() - fakeNpx := filepath.Join(binDir, "npx") - // Echo args to verify --since is forwarded, then print minimal JSON. - script := `#!/bin/sh -echo "$@" >&2 -echo '{"projects":{},"totals":{}}' -` - require.NoError(t, os.WriteFile(fakeNpx, []byte(script), 0o755)) - t.Setenv("SHELL", "/bin/sh") - - cmd := NewMockCommandService(t) - // bunx missing -> npx fallback path taken. - cmd.On("LookPath", "bunx").Return("", errors.New("not found")) - cmd.On("LookPath", "npx").Return(fakeNpx, nil) - - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - since := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC) - data, err := svc.collectData(context.Background(), since) - require.NoError(t, err) - require.NotNil(t, data) - assert.Empty(t, data.Data.Projects) -} - -func TestCCUsage_collectData_InvalidJSON(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("uses /bin/sh script") - } - binDir := t.TempDir() - fakeBunx := filepath.Join(binDir, "bunx") - require.NoError(t, os.WriteFile(fakeBunx, []byte("#!/bin/sh\necho 'not json'\n"), 0o755)) - t.Setenv("SHELL", "/bin/sh") - - cmd := NewMockCommandService(t) - cmd.On("LookPath", "bunx").Return(fakeBunx, nil) - cmd.On("LookPath", "npx").Return("", errors.New("not found")) - - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - _, err := svc.collectData(context.Background(), time.Time{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse ccusage output") -} - -func TestCCUsage_getLastSyncTimestamp(t *testing.T) { - t.Run("parses recent RFC3339 timestamp", func(t *testing.T) { - recent := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":{"fetchUser":{"id":1,"ccusage":{"lastSyncAt":"` + recent + `"}}}}`)) - })) - defer server.Close() - - cmd := NewMockCommandService(t) - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - endpoint := Endpoint{Token: "t", APIEndpoint: server.URL} - got, err := svc.getLastSyncTimestamp(context.Background(), endpoint) - require.NoError(t, err) - assert.WithinDuration(t, time.Now().Add(-time.Hour), got, 2*time.Second) - }) - - t.Run("empty lastSyncAt returns zero time", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":{"fetchUser":{"id":1,"ccusage":{"lastSyncAt":""}}}}`)) - })) - defer server.Close() - - cmd := NewMockCommandService(t) - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - got, err := svc.getLastSyncTimestamp(context.Background(), Endpoint{Token: "t", APIEndpoint: server.URL}) - require.NoError(t, err) - assert.True(t, got.IsZero()) - }) - - t.Run("timestamp before 2023 is treated as zero", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":{"fetchUser":{"id":1,"ccusage":{"lastSyncAt":"2020-01-01T00:00:00Z"}}}}`)) - })) - defer server.Close() - - cmd := NewMockCommandService(t) - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - got, err := svc.getLastSyncTimestamp(context.Background(), Endpoint{Token: "t", APIEndpoint: server.URL}) - require.NoError(t, err) - assert.True(t, got.IsZero()) - }) - - t.Run("server error is swallowed and returns zero time", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"error":"down"}`)) - })) - defer server.Close() - - cmd := NewMockCommandService(t) - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - got, err := svc.getLastSyncTimestamp(context.Background(), Endpoint{Token: "t", APIEndpoint: server.URL}) - require.NoError(t, err) // intentionally swallowed - assert.True(t, got.IsZero()) - }) -} - -func TestCCUsage_sendData(t *testing.T) { - t.Run("transforms projects into entries and posts batch", func(t *testing.T) { - var gotPath string - var payload struct { - Host string `json:"host"` - Entries []struct { - Project string `json:"project"` - Date string `json:"date"` - Usage struct { - InputTokens int `json:"inputTokens"` - TotalCost float64 `json:"totalCost"` - } `json:"usage"` - } `json:"entries"` - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"success":true,"successCount":1,"totalCount":1}`)) - })) - defer server.Close() - - cmd := NewMockCommandService(t) - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - - var data CCUsageData - data.Hostname = "host1" - raw := `{"projects":{"projA":[{"date":"20260101","inputTokens":10,"outputTokens":20,"totalTokens":30,"totalCost":1.5,"modelsUsed":["m"],"modelBreakdowns":[{"modelName":"m","inputTokens":10,"outputTokens":20,"cost":1.5}]}]},"totals":{}}` - require.NoError(t, json.Unmarshal([]byte(raw), &data.Data)) - - err := svc.sendData(context.Background(), Endpoint{Token: "t", APIEndpoint: server.URL}, &data) - require.NoError(t, err) - assert.Equal(t, "/api/v1/ccusage/batch", gotPath) - assert.Equal(t, "host1", payload.Host) - require.Len(t, payload.Entries, 1) - assert.Equal(t, "projA", payload.Entries[0].Project) - assert.Equal(t, "20260101", payload.Entries[0].Date) - assert.Equal(t, 10, payload.Entries[0].Usage.InputTokens) - assert.Equal(t, 1.5, payload.Entries[0].Usage.TotalCost) - }) - - t.Run("no entries short-circuits without request", func(t *testing.T) { - called := false - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - called = true - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - cmd := NewMockCommandService(t) - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - err := svc.sendData(context.Background(), Endpoint{Token: "t", APIEndpoint: server.URL}, &CCUsageData{Hostname: "h"}) - require.NoError(t, err) - assert.False(t, called) - }) - - t.Run("server rejection with failed projects returns error", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"success":false,"successCount":0,"totalCount":1,"failedProjects":["projA"]}`)) - })) - defer server.Close() - - cmd := NewMockCommandService(t) - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - - var data CCUsageData - data.Hostname = "h" - raw := `{"projects":{"projA":[{"date":"20260101","totalCost":1.0,"modelBreakdowns":[]}]},"totals":{}}` - require.NoError(t, json.Unmarshal([]byte(raw), &data.Data)) - - err := svc.sendData(context.Background(), Endpoint{Token: "t", APIEndpoint: server.URL}, &data) - require.Error(t, err) - assert.Contains(t, err.Error(), "projA") - }) - - t.Run("http error wraps message", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"error":"bad batch"}`)) - })) - defer server.Close() - - cmd := NewMockCommandService(t) - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - - var data CCUsageData - data.Hostname = "h" - raw := `{"projects":{"projA":[{"date":"20260101","modelBreakdowns":[]}]},"totals":{}}` - require.NoError(t, json.Unmarshal([]byte(raw), &data.Data)) - - err := svc.sendData(context.Background(), Endpoint{Token: "t", APIEndpoint: server.URL}, &data) - require.Error(t, err) - assert.Contains(t, err.Error(), "bad batch") - }) -} - -func TestCCUsage_CollectCCUsage_WithCredentials(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("uses /bin/sh script") - } - // Full happy path: fetch last-sync (GraphQL), collect via fake bunx, send batch. - var sawGraphQL, sawBatch bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/graphql": - sawGraphQL = true - _, _ = w.Write([]byte(`{"data":{"fetchUser":{"id":1,"ccusage":{"lastSyncAt":""}}}}`)) - case "/api/v1/ccusage/batch": - sawBatch = true - _, _ = w.Write([]byte(`{"success":true,"successCount":1,"totalCount":1}`)) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - binDir := t.TempDir() - fakeBunx := filepath.Join(binDir, "bunx") - usageJSON := `{"projects":{"projA":[{"date":"20260101","inputTokens":1,"totalTokens":1,"totalCost":0.1,"modelBreakdowns":[]}]},"totals":{}}` - require.NoError(t, os.WriteFile(fakeBunx, []byte("#!/bin/sh\necho '"+usageJSON+"'\n"), 0o755)) - t.Setenv("SHELL", "/bin/sh") - - cmd := NewMockCommandService(t) - cmd.On("LookPath", "bunx").Return(fakeBunx, nil) - cmd.On("LookPath", "npx").Return("", errors.New("not found")) - - cfg := ShellTimeConfig{Token: "tok", APIEndpoint: server.URL} - svc := NewCCUsageService(cfg, cmd).(*ccUsageService) - require.NoError(t, svc.CollectCCUsage(context.Background())) - assert.True(t, sawGraphQL, "should fetch last sync timestamp") - assert.True(t, sawBatch, "should send the batch") -} - -func TestCCUsage_StartEnabled_RunsInitialCollectionThenStops(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("uses /bin/sh script") - } - // Enabled config triggers an immediate initial collection on Start. Provide - // a fake bunx + server so it succeeds, then Stop to halt the ticker loop. - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/graphql": - _, _ = w.Write([]byte(`{"data":{"fetchUser":{"id":1,"ccusage":{"lastSyncAt":""}}}}`)) - default: - _, _ = w.Write([]byte(`{"success":true,"successCount":0,"totalCount":0}`)) - } - })) - defer server.Close() - - binDir := t.TempDir() - fakeBunx := filepath.Join(binDir, "bunx") - require.NoError(t, os.WriteFile(fakeBunx, []byte("#!/bin/sh\necho '{\"projects\":{},\"totals\":{}}'\n"), 0o755)) - t.Setenv("SHELL", "/bin/sh") - - cmd := NewMockCommandService(t) - cmd.On("LookPath", "bunx").Return(fakeBunx, nil) - cmd.On("LookPath", "npx").Return("", errors.New("not found")) - - on := true - cfg := ShellTimeConfig{Token: "tok", APIEndpoint: server.URL, CCUsage: &CCUsage{Enabled: &on}} - svc := NewCCUsageService(cfg, cmd) - - require.NoError(t, svc.Start(context.Background())) - // Stop should not block; the background loop must exit promptly. - done := make(chan struct{}) - go func() { svc.Stop(); close(done) }() - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("Stop blocked") - } -} - -func TestCCUsage_CollectCCUsage_NoCredentialsButCollects(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("uses /bin/sh script") - } - // No token/endpoint => skips both the last-sync fetch and the send, but - // still runs collectData. Provide a fake bunx so collection succeeds. - binDir := t.TempDir() - fakeBunx := filepath.Join(binDir, "bunx") - require.NoError(t, os.WriteFile(fakeBunx, []byte("#!/bin/sh\necho '{\"projects\":{},\"totals\":{}}'\n"), 0o755)) - t.Setenv("SHELL", "/bin/sh") - - cmd := NewMockCommandService(t) - cmd.On("LookPath", "bunx").Return(fakeBunx, nil) - cmd.On("LookPath", "npx").Return("", errors.New("not found")) - - svc := NewCCUsageService(ShellTimeConfig{}, cmd).(*ccUsageService) - require.NoError(t, svc.CollectCCUsage(context.Background())) -} diff --git a/model/config.go b/model/config.go index 44e78c2..959aacc 100644 --- a/model/config.go +++ b/model/config.go @@ -152,13 +152,6 @@ func mergeConfig(base, local *ShellTimeConfig) { if len(local.Exclude) > 0 { base.Exclude = local.Exclude } - if local.CCUsage != nil { - base.CCUsage = local.CCUsage - } - // Migrate deprecated ccotel from local config - if local.CCOtel != nil && local.AICodeOtel == nil { - local.AICodeOtel = local.CCOtel - } if local.AICodeOtel != nil { base.AICodeOtel = local.AICodeOtel } @@ -174,9 +167,6 @@ func mergeConfig(base, local *ShellTimeConfig) { if local.Proxy != nil { base.Proxy = local.Proxy } - if local.LogCleanup != nil { - base.LogCleanup = local.LogCleanup - } } func (cs *configService) ReadConfigFile(ctx context.Context, opts ...ReadConfigOption) (config ShellTimeConfig, err error) { @@ -221,12 +211,6 @@ func (cs *configService) ReadConfigFile(ctx context.Context, opts ...ReadConfigO return } - // Migrate deprecated ccotel field to AICodeOtel (silent migration) - if config.CCOtel != nil && config.AICodeOtel == nil { - config.AICodeOtel = config.CCOtel - config.CCOtel = nil - } - // Read and merge local config if exists if files.localFile != "" { if localConfig, localErr := os.ReadFile(files.localFile); localErr == nil { diff --git a/model/config_cov_test.go b/model/config_cov_test.go index 8315735..bdb62cb 100644 --- a/model/config_cov_test.go +++ b/model/config_cov_test.go @@ -35,7 +35,6 @@ func TestMergeConfig_AllOverrides(t *testing.T) { AI: &AIConfig{}, Endpoints: []Endpoint{{Token: "e", APIEndpoint: "https://ep"}}, Exclude: []string{"secret"}, - CCUsage: &CCUsage{Enabled: &on}, AICodeOtel: &AICodeOtel{Enabled: &on}, LogCleanup: &LogCleanup{Enabled: &truthy, ThresholdMB: 42}, SocketPath: "/tmp/local.sock", @@ -56,7 +55,6 @@ func TestMergeConfig_AllOverrides(t *testing.T) { require.NotNil(t, base.AI) require.Len(t, base.Endpoints, 1) require.Len(t, base.Exclude, 1) - require.NotNil(t, base.CCUsage) require.NotNil(t, base.AICodeOtel) require.NotNil(t, base.LogCleanup) assert.EqualValues(t, 42, base.LogCleanup.ThresholdMB) @@ -66,19 +64,6 @@ func TestMergeConfig_AllOverrides(t *testing.T) { assert.Equal(t, "socks5://127.0.0.1:1080", base.Proxy.URL) } -// TestMergeConfig_CCOtelMigration covers the deprecated CCOtel -> AICodeOtel -// migration branch inside mergeConfig (local has CCOtel, no AICodeOtel). -func TestMergeConfig_CCOtelMigration(t *testing.T) { - base := &ShellTimeConfig{} - on := true - local := &ShellTimeConfig{ - CCOtel: &AICodeOtel{Enabled: &on, GRPCPort: 1234}, - } - mergeConfig(base, local) - require.NotNil(t, base.AICodeOtel, "CCOtel should migrate into AICodeOtel on base") - assert.Equal(t, 1234, base.AICodeOtel.GRPCPort) -} - // TestMergeConfig_NoOverrides ensures zero-valued local fields leave base intact. func TestMergeConfig_NoOverrides(t *testing.T) { base := &ShellTimeConfig{Token: "keep", FlushCount: 7, GCTime: 9, SocketPath: "/keep"} diff --git a/model/config_extra_test.go b/model/config_extra_test.go index bb01406..83d25af 100644 --- a/model/config_extra_test.go +++ b/model/config_extra_test.go @@ -75,17 +75,3 @@ func TestReadConfigFile_AICodeOtelDefaultPort(t *testing.T) { require.NotNil(t, cfg.AICodeOtel) assert.Equal(t, 54027, cfg.AICodeOtel.GRPCPort, "default gRPC port applied when enabled but unset") } - -func TestReadConfigFile_DeprecatedCCOtelMigratesToAICodeOtel(t *testing.T) { - dir := t.TempDir() - // Only the deprecated ccotel field is set; it should migrate to AICodeOtel. - require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), - []byte("token: tok\nccotel:\n enabled: true\n grpcPort: 9999\n"), 0o644)) - - cs := NewConfigService(dir) - cfg, err := cs.ReadConfigFile(context.Background()) - require.NoError(t, err) - require.NotNil(t, cfg.AICodeOtel, "ccotel should migrate to AICodeOtel") - assert.Nil(t, cfg.CCOtel, "deprecated field cleared after migration") - assert.Equal(t, 9999, cfg.AICodeOtel.GRPCPort) -} diff --git a/model/types.go b/model/types.go index 465fd31..3eab384 100644 --- a/model/types.go +++ b/model/types.go @@ -24,10 +24,6 @@ type AIConfig struct { ShareContext *bool `toml:"shareContext,omitempty" yaml:"shareContext,omitempty" json:"shareContext,omitempty"` } -type CCUsage struct { - Enabled *bool `toml:"enabled" yaml:"enabled" json:"enabled"` -} - // AICodeOtel configuration for OTEL-based AI CLI tracking (Claude Code, Codex, etc.) // The processor auto-detects the source from service.name attribute type AICodeOtel struct { @@ -81,13 +77,6 @@ type ShellTimeConfig struct { // Commands matching any of these patterns will not be synced to the server Exclude []string `toml:"exclude,omitempty" yaml:"exclude,omitempty" json:"exclude,omitempty"` - // CCUsage configuration for Claude Code usage tracking (v1 - ccusage CLI based) - CCUsage *CCUsage `toml:"ccusage" yaml:"ccusage" json:"ccusage"` - - // CCOtel is deprecated, use AICodeOtel instead - // Deprecated: This field will be removed in a future version - CCOtel *AICodeOtel `toml:"ccotel" yaml:"ccotel" json:"ccotel"` - // AICodeOtel configuration for OTEL-based AI CLI tracking (Claude Code, Codex, etc.) AICodeOtel *AICodeOtel `toml:"aiCodeOtel" yaml:"aiCodeOtel" json:"aiCodeOtel"` @@ -151,10 +140,6 @@ var DefaultConfig = ShellTimeConfig{ Encrypted: new(true), AI: DefaultAIConfig, Exclude: []string{}, - CCUsage: new(CCUsage{ - Enabled: new(true), - }), - CCOtel: nil, AICodeOtel: new(AICodeOtel{ Enabled: new(true), GRPCPort: 54027,