From 379db699d1133aec4564d2e192e2942577385f40 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 07:19:24 +0000 Subject: [PATCH] feat(cli): rebuild doctor with fix guidance, --fix, --offline and json output `shelltime doctor` now checks the whole setup and, for every problem, says what it breaks and the exact command or config change that fixes it: - system: shelltime on PATH, newer release available - storage: ~/.shelltime exists and is writable, log size vs logCleanup - configuration: which file is used, shadowed and unparseable local files, apiEndpoint, invalid exclude regexes, proxy URL, enableMetrics - account: token present and accepted by the server (401/403 vs unreachable) - privacy: data masking, encryption without a running daemon, legacy tokens without an encryption key - daemon: binary, service, socket, CLI/daemon version mismatch - shell hooks: rc lines, hook script, bash-preexec - Claude Code / Codex: OTEL config, legacy shell OTEL blocks, statusline, Codex sign-in - AI usage receiver: aiCodeOtel disabled, port mismatch, not listening - sync: queued failed uploads and heartbeats The report ends with a "How to fix" list grouped by action. `--fix` applies the safe fixes (hooks, cc/codex install, daemon install, log cleanup) after confirmation (`--yes` skips it), `--offline` skips the network checks and `--format json` prints a machine-readable report. doctor exits 1 when any check fails and no longer aborts on a config error or on an unsupported OS. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KzQHztW9fdhyWDwPmanhLN --- README.md | 2 +- cmd/cli/main.go | 14 +- commands/daemon.status.go | 4 + commands/doctor.go | 388 +++++++-- commands/doctor_alias_cov_test.go | 55 -- commands/doctor_checks.go | 983 ++++++++++++++++++++++ commands/doctor_test.go | 790 +++++++++++++++++ commands/misc_commands_test.go | 66 -- docs/CONFIG.md | 19 +- model/aicode_otel_claude_settings.go | 26 +- model/aicode_otel_claude_settings_test.go | 26 +- model/aicode_otel_env.go | 13 +- model/aicode_otel_env_test.go | 4 +- model/codex_otel_config.go | 30 +- model/codex_otel_config_test.go | 28 +- model/config.go | 20 +- model/config_test.go | 24 + model/types.go | 2 +- 18 files changed, 2269 insertions(+), 225 deletions(-) create mode 100644 commands/doctor_checks.go create mode 100644 commands/doctor_test.go diff --git a/README.md b/README.md index ac3031b..cc21559 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ shelltime codex install | `shelltime init` | Bootstrap auth, hooks, daemon, and AI-code integrations | | `shelltime auth` | Authenticate with `shelltime.xyz` | | `shelltime update` | Download and install the latest release in place | -| `shelltime doctor` | Check installation and environment health | +| `shelltime doctor` | Diagnose setup problems and show how to fix each one (`--fix` applies the safe fixes, `--offline`, `--format json`) | | `shelltime web` | Open the ShellTime dashboard in a browser | ### Tracking and sync diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 22f362c..311ca4a 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "log/slog" "os" @@ -22,6 +23,11 @@ var ( ) func main() { + os.Exit(run()) +} + +// run holds main's body so its deferred cleanup finishes before main sets the exit code. +func run() int { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() cli.VersionFlag = &cli.BoolFlag{ @@ -113,8 +119,14 @@ func main() { commands.UpdateCommand, } err = app.Run(os.Args) - if err != nil { + // doctor already reported its problems; it only needs a non-zero exit code. + doctorFailed := errors.Is(err, commands.ErrDoctorFoundProblems) + if err != nil && !doctorFailed { slog.Error("CLI error", slog.Any("err", err)) } commands.CloseLogger() + if doctorFailed { + return 1 + } + return 0 } diff --git a/commands/daemon.status.go b/commands/daemon.status.go index e3572eb..fc6e067 100644 --- a/commands/daemon.status.go +++ b/commands/daemon.status.go @@ -116,6 +116,10 @@ func requestDaemonStatus(socketPath string, timeout time.Duration) (*daemon.Stat return nil, 0, err } defer conn.Close() + // Bound the whole exchange: a socket file whose owner never answers would otherwise block. + if err := conn.SetDeadline(start.Add(timeout)); err != nil { + return nil, 0, err + } // Send status request msg := daemon.SocketMessage{ diff --git a/commands/doctor.go b/commands/doctor.go index 617d336..95e148f 100644 --- a/commands/doctor.go +++ b/commands/doctor.go @@ -1,9 +1,13 @@ package commands import ( + "bufio" + "encoding/json" + "errors" "fmt" + "io" + "log/slog" "os" - "path/filepath" "runtime" "strings" @@ -12,13 +16,34 @@ import ( "github.com/urfave/cli/v2" ) -const ( - logFileMaxSize = 100 * 1024 * 1024 // 100MB -) +// ErrDoctorFoundProblems is returned by `shelltime doctor` when at least one check fails, so the +// process exits non-zero. +var ErrDoctorFoundProblems = errors.New("shelltime doctor found problems") var DoctorCommand *cli.Command = &cli.Command{ - Name: "doctor", - Usage: "Check the shelltime setup and environment", + Name: "doctor", + Usage: "Diagnose the shelltime setup and show how to fix any problems", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "offline", + Usage: "skip checks that need the network (token, encryption key, latest version)", + }, + &cli.StringFlag{ + Name: "format", + Aliases: []string{"f"}, + Value: "table", + Usage: "output format (table/json)", + }, + &cli.BoolFlag{ + Name: "fix", + Usage: "after the report, apply the automatic fixes (asks for confirmation)", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "with --fix, apply the fixes without asking", + }, + }, Action: commandDoctor, OnUsageError: func(cCtx *cli.Context, err error, isSubcommand bool) error { color.Red.Println(err.Error()) @@ -26,115 +51,302 @@ var DoctorCommand *cli.Command = &cli.Command{ }, } +type doctorStatus string + +const ( + doctorOK doctorStatus = "ok" + doctorInfo doctorStatus = "info" + doctorWarn doctorStatus = "warn" + doctorFail doctorStatus = "fail" + doctorSkip doctorStatus = "skip" +) + +// doctorResult is the outcome of one check. Message says what was found in plain language and +// Fix says exactly what to run or change. +type doctorResult struct { + ID string `json:"id"` + Section string `json:"section"` + Status doctorStatus `json:"status"` + Message string `json:"message"` + Fix string `json:"fix,omitempty"` + AutoFix *doctorFix `json:"autoFix,omitempty"` +} + +// doctorFix is a safe, idempotent fix that `doctor --fix` can apply. Fixes are deduplicated by Key. +type doctorFix struct { + Key string `json:"key"` + Label string `json:"label"` + Run func(c *cli.Context) error `json:"-"` +} + +type doctorSummary struct { + OK int `json:"ok"` + Info int `json:"info"` + Warn int `json:"warn"` + Fail int `json:"fail"` + Skip int `json:"skip"` +} + +type doctorReport struct { + Version string `json:"version"` + OS string `json:"os"` + Arch string `json:"arch"` + Summary doctorSummary `json:"summary"` + Checks []doctorResult `json:"checks"` +} + func commandDoctor(c *cli.Context) error { - ctx := c.Context - _ = ctx // placeholder for future use with tracing + format := c.String("format") + if format != "table" && format != "json" { + return fmt.Errorf("unsupported format: %s (use table or json)", format) + } + fix := c.Bool("fix") + if fix && format == "json" { + return fmt.Errorf("--fix can't be combined with --format json") + } - color.Cyan.Println("๐Ÿฉบ Running Shelltime Doctor...") + baseFolder := os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER) + if info, err := os.Stat(baseFolder); err == nil && info.IsDir() { + SetupLogger(baseFolder) + } else if !SKIP_LOGGER_SETTINGS { + // Don't create ~/.shelltime just to log, and keep library logs out of the report. + slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) + } - // 1. Print basic system info - printSectionHeader("System Information") - fmt.Printf(" OS: %s\n", runtime.GOOS) - fmt.Printf(" Arch: %s\n", runtime.GOARCH) + opts := doctorOptions{offline: c.Bool("offline")} - // 2. Check ~/.shelltime folder - printSectionHeader("Shelltime Directory") - shelltimeDir := os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER) - info, err := os.Stat(shelltimeDir) - if err != nil { - if os.IsNotExist(err) { - printError(fmt.Sprintf("Directory %s does not exist.", shelltimeDir)) - } else { - printError(fmt.Sprintf("Error accessing directory %s: %v", shelltimeDir, err)) + if format == "json" { + results := runDoctorChecks(c.Context, opts) + if err := writeDoctorJSON(results); err != nil { + return err } - } else if !info.IsDir() { - printError(fmt.Sprintf("%s is not a directory.", shelltimeDir)) - } else { - printSuccess(fmt.Sprintf("Directory %s found and accessible.", shelltimeDir)) + return doctorOutcome(results) } - // 3. Check ~/.shelltime/log.log size - logFilePath := filepath.Join(shelltimeDir, "log.log") - logInfo, err := os.Stat(logFilePath) - if err != nil { - if os.IsNotExist(err) { - printInfo(fmt.Sprintf("Log file %s does not exist.", logFilePath)) - } else { - printError(fmt.Sprintf("Error accessing log file %s: %v", logFilePath, err)) - } - } else { - if logInfo.Size() > logFileMaxSize { - printWarning(fmt.Sprintf("Log file %s is large (%dMB). Consider archiving or clearing it.", logFilePath, logInfo.Size()/(1024*1024))) - } else { - printSuccess(fmt.Sprintf("Log file %s size is normal (%dMB).", logFilePath, logInfo.Size()/(1024*1024))) + color.Cyan.Println("๐Ÿฉบ Running shelltime doctor...") + results := runDoctorChecks(c.Context, opts) + printDoctorReport(results) + + fixes := collectDoctorFixes(results) + summary := summarizeDoctor(results) + switch { + case fix && len(fixes) == 0 && summary.Fail+summary.Warn > 0: + color.Yellow.Println("\nNone of these problems can be fixed automatically; follow the steps above.") + case fix && len(fixes) > 0: + if applyDoctorFixes(c, fixes, c.Bool("yes")) { + color.Cyan.Println("\n๐Ÿฉบ Re-running checks...") + results = runDoctorChecks(c.Context, opts) + printDoctorSummary(results, false) } + case len(fixes) > 0: + color.Cyan.Printf("\nRun 'shelltime doctor --fix' to apply %d of these fixes automatically.\n", len(fixes)) } - // 4. Check configuration - printSectionHeader("Configuration") - cfg, err := configService.ReadConfigFile(ctx) - if err != nil { - printError(fmt.Sprintf("Error reading config file: %v", err)) - return err - } - printSuccess("Configuration file is valid.") - if cfg.EnableMetrics != nil && *cfg.EnableMetrics { - printWarning("Metrics are enabled. it would has performance impact.") + return doctorOutcome(results) +} + +func doctorOutcome(results []doctorResult) error { + if summarizeDoctor(results).Fail > 0 { + return ErrDoctorFoundProblems } + return nil +} - if cfg.DataMasking != nil && *cfg.DataMasking { - printSuccess("Data masking is enabled.") +func summarizeDoctor(results []doctorResult) doctorSummary { + var s doctorSummary + for _, r := range results { + switch r.Status { + case doctorOK: + s.OK++ + case doctorInfo: + s.Info++ + case doctorWarn: + s.Warn++ + case doctorFail: + s.Fail++ + case doctorSkip: + s.Skip++ + } } - if cfg.Encrypted != nil && *cfg.Encrypted { - printSuccess("Encrypted is enabled.") + return s +} + +func doctorVersion() string { + if commitID == "" { + return "dev" } + return commitID +} - // 5. Check daemon process - printSectionHeader("Daemon Process") - daemonInstaller, err := model.NewDaemonInstaller("", "", "") +func writeDoctorJSON(results []doctorResult) error { + report := doctorReport{ + Version: doctorVersion(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Summary: summarizeDoctor(results), + Checks: results, + } + data, err := json.MarshalIndent(report, "", " ") if err != nil { - printError(fmt.Sprintf("Error checking daemon installer: %v", err)) - return err + return fmt.Errorf("failed to marshal doctor report: %w", err) + } + _, err = fmt.Fprintln(doctorOut, string(data)) + return err +} + +func printDoctorReport(results []doctorResult) { + section := "" + for _, r := range results { + if r.Section != section { + printSectionHeader(r.Section) + section = r.Section + } + printDoctorResult(r) + } + printDoctorSummary(results, true) +} + +func printDoctorResult(r doctorResult) { + switch r.Status { + case doctorOK: + printSuccess(r.Message) + case doctorInfo: + printInfo(r.Message) + case doctorWarn: + printWarning(r.Message) + case doctorFail: + printError(r.Message) + case doctorSkip: + printSkip(r.Message) } - if err := daemonInstaller.Check(); err != nil { - printWarning(fmt.Sprintf("Daemon is not running: %v. it's ok if you haven't installed it yet.", err)) - } else { - printSuccess("Daemon is running.") + if r.Fix == "" { + return } + label := "fix" + if r.Status != doctorWarn && r.Status != doctorFail { + label = "tip" + } + color.Gray.Printf(" โ†’ %s: %s\n", label, r.Fix) +} + +// doctorAction is one step of the "How to fix" list together with the problems it resolves. +type doctorAction struct { + fix string + auto bool + problems []doctorResult +} - // 6. Check user's current shell and PATH - printSectionHeader("Shell Environment") - currentShell := os.Getenv("SHELL") - if currentShell == "" { - printWarning("Could not determine current shell from $SHELL environment variable.") - } else { - fmt.Printf(" Current Shell: %s\n", currentShell) +// groupDoctorActions groups warnings and failures by the action that fixes them (the auto-fix key, +// else the fix text), so one command that solves several problems is listed once. Actions that fix +// a failure come first. +func groupDoctorActions(results []doctorResult) []*doctorAction { + var actions []*doctorAction + byKey := map[string]*doctorAction{} + for _, status := range []doctorStatus{doctorFail, doctorWarn} { + for _, r := range results { + if r.Status != status { + continue + } + key := "fix:" + r.Fix + if r.AutoFix != nil { + key = "auto:" + r.AutoFix.Key + } + if r.Fix == "" { + key = "id:" + r.ID + } + action, ok := byKey[key] + if !ok { + action = &doctorAction{fix: r.Fix, auto: r.AutoFix != nil} + byKey[key] = action + actions = append(actions, action) + } + action.problems = append(action.problems, r) + } } + return actions +} - zshHookService := model.NewZshHookService() - fishHookService := model.NewFishHookService() - bashHookService := model.NewBashHookService() +// printDoctorSummary prints the counts and the "How to fix" list. markAuto tags the steps +// `doctor --fix` can handle. +func printDoctorSummary(results []doctorResult, markAuto bool) { + s := summarizeDoctor(results) + fmt.Println() + color.Style{color.FgCyan, color.OpBold}.Println("Summary") + fmt.Printf(" %d passed ยท %d warnings ยท %d failed ยท %d skipped\n", s.OK, s.Warn, s.Fail, s.Skip) - hookServices := []model.ShellHookService{ - zshHookService, - fishHookService, - bashHookService, + actions := groupDoctorActions(results) + if len(actions) == 0 { + color.Green.Println("\nโœ“ Everything looks good.") + return } - for _, hookService := range hookServices { - if !hookService.Match(currentShell) { + fmt.Println() + color.Style{color.FgCyan, color.OpBold}.Println("How to fix") + for i, action := range actions { + heading := action.fix + if heading == "" { + heading = action.problems[0].Message + } + auto := "" + if markAuto && action.auto { + auto = color.Green.Render(" (auto-fixable)") + } + fmt.Printf(" %d. %s%s\n", i+1, heading, auto) + if action.fix == "" { continue } - if err := hookService.Check(); err == nil { - printSuccess(fmt.Sprintf("Hook is already installed for %s.", hookService.ShellName())) - } else { - printError(fmt.Sprintf("Hook is NOT installed for %s.", hookService.ShellName())) - printInfo("Consider running 'shelltime hooks install' to install it.") + for _, r := range action.problems { + mark := color.Yellow.Render("โš ๏ธ") + if r.Status == doctorFail { + mark = color.Red.Render("โœ—") + } + color.Gray.Printf(" %s [%s] %s\n", mark, r.Section, r.Message) } } +} - color.Green.Println("\nDoctor check complete.") - return nil +// collectDoctorFixes returns the automatic fixes for every failure, then every warning, once per Key. +func collectDoctorFixes(results []doctorResult) []*doctorFix { + var fixes []*doctorFix + for _, action := range groupDoctorActions(results) { + for _, r := range action.problems { + if r.AutoFix != nil { + fixes = append(fixes, r.AutoFix) + break + } + } + } + return fixes +} + +// applyDoctorFixes asks for confirmation (unless assumeYes) and runs the fixes. It reports whether +// any fix was attempted. +func applyDoctorFixes(c *cli.Context, fixes []*doctorFix, assumeYes bool) bool { + fmt.Println() + color.Style{color.FgCyan, color.OpBold}.Println("Automatic fixes") + for i, f := range fixes { + fmt.Printf(" %d. %s\n", i+1, f.Label) + } + + if !assumeYes { + fmt.Printf("\nApply %d fix(es)? [y/N]: ", len(fixes)) + answer, _ := bufio.NewReader(doctorStdin).ReadString('\n') + answer = strings.ToLower(strings.TrimSpace(answer)) + if answer != "y" && answer != "yes" { + color.Yellow.Println("No changes made.") + return false + } + } + + for _, f := range fixes { + color.Cyan.Printf("\nโ†’ %s\n", f.Label) + if err := f.Run(c); err != nil { + printError(fmt.Sprintf("Fix failed: %v", err)) + continue + } + printSuccess("Done.") + } + return true } func printSectionHeader(title string) { @@ -157,3 +369,7 @@ func printWarning(message string) { func printInfo(message string) { color.Gray.Printf(" โ„น๏ธ %s\n", message) } + +func printSkip(message string) { + color.Gray.Printf(" - %s\n", message) +} diff --git a/commands/doctor_alias_cov_test.go b/commands/doctor_alias_cov_test.go index ee72633..2b14c83 100644 --- a/commands/doctor_alias_cov_test.go +++ b/commands/doctor_alias_cov_test.go @@ -1,7 +1,6 @@ package commands import ( - "fmt" "net/http" "net/http/httptest" "os" @@ -18,60 +17,6 @@ import ( "go.opentelemetry.io/otel/trace/noop" ) -// x3SetupDoctor isolates HOME and installs a mock ConfigService for doctor tests. -func x3SetupDoctor(t *testing.T) (string, *model.MockConfigService) { - t.Helper() - otel.SetTracerProvider(noop.NewTracerProvider()) - SKIP_LOGGER_SETTINGS = true - home := t.TempDir() - t.Setenv("HOME", home) - orig := configService - mc := model.NewMockConfigService(t) - configService = mc - t.Cleanup(func() { configService = orig }) - return home, mc -} - -// TestX3Doctor_ShelltimeDirIsFile covers the "!info.IsDir()" branch: the -// ~/.shelltime path exists but is a regular file rather than a directory. -func TestX3Doctor_ShelltimeDirIsFile(t *testing.T) { - home, mc := x3SetupDoctor(t) - t.Setenv("SHELL", "/bin/bash") - // Create a *file* named .shelltime so os.Stat succeeds but IsDir() is false. - require.NoError(t, os.WriteFile(filepath.Join(home, model.COMMAND_BASE_STORAGE_FOLDER), []byte("x"), 0644)) - - mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{}, nil) - - app := &cli.App{Name: "t", Commands: []*cli.Command{DoctorCommand}} - require.NoError(t, app.Run([]string{"t", "doctor"})) -} - -// TestX3Doctor_NormalLogFileAndInstalledHook covers two branches at once: -// - the log file exists and is below the size threshold (normal-size branch); -// - the bash hook is installed and Check() succeeds for the current ($SHELL) -// shell (the "Hook is already installed" branch). -func TestX3Doctor_NormalLogFileAndInstalledHook(t *testing.T) { - home, mc := x3SetupDoctor(t) - t.Setenv("SHELL", "/bin/bash") - - base := filepath.Join(home, model.COMMAND_BASE_STORAGE_FOLDER) - require.NoError(t, os.MkdirAll(base, 0755)) - // Small log.log -> "size is normal" branch. - require.NoError(t, os.WriteFile(filepath.Join(base, "log.log"), []byte("ok\n"), 0644)) - - // Seed .bashrc with the exact bash hook lines so bashHookService.Check passes. - bashrc := filepath.Join(home, ".bashrc") - content := "# Added by shelltime CLI\n" + - fmt.Sprintf("export PATH=\"$HOME/%s/bin:$PATH\"\n", model.COMMAND_BASE_STORAGE_FOLDER) + - fmt.Sprintf("source %s\n", filepath.Join(base, "hooks", "bash.bash")) - require.NoError(t, os.WriteFile(bashrc, []byte(content), 0644)) - - mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{}, nil) - - app := &cli.App{Name: "t", Commands: []*cli.Command{DoctorCommand}} - require.NoError(t, app.Run([]string{"t", "doctor"})) -} - // --- alias import: fish path -------------------------------------------------- // TestX3ImportAliases_SendsFishAliases covers the fish-config branch of diff --git a/commands/doctor_checks.go b/commands/doctor_checks.go new file mode 100644 index 0000000..54b511b --- /dev/null +++ b/commands/doctor_checks.go @@ -0,0 +1,983 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "github.com/malamtime/cli/daemon" + "github.com/malamtime/cli/model" + "github.com/urfave/cli/v2" +) + +const ( + doctorDaemonProbeTimeout = 2 * time.Second + doctorNetworkTimeout = 8 * time.Second + doctorUpdateTimeout = 3 * time.Second + doctorStatusLineCommand = "shelltime cc statusline" +) + +var errDoctorServiceUnsupported = errors.New("daemon service is not supported on this OS") + +// Seams so tests can stub out the parts of doctor that touch the system or the network. +var ( + doctorLookPath = exec.LookPath + doctorDialTCP = net.DialTimeout + doctorFetchLatestVersion = model.FetchLatestVersion + doctorResolveDaemonBinary = model.ResolveDaemonBinaryPath + doctorCodexInstallationStatus = daemon.CodexInstallationStatus + doctorDaemonServiceCheck = func() error { + installer, err := model.NewDaemonInstaller("", "", "") + if err != nil { + return errDoctorServiceUnsupported + } + return installer.Check() + } + doctorRunDaemonInstall = commandDaemonInstall + doctorRunCCInstall = commandCCInstall + doctorRunCodexInstall = commandCodexInstall + + // doctorDaemonStartWait bounds how long the daemon fix waits for the new daemon's socket. + doctorDaemonStartWait = 5 * time.Second + + doctorStdin io.Reader = os.Stdin + doctorOut io.Writer = os.Stdout +) + +type doctorOptions struct { + offline bool +} + +// doctorEnv is gathered once per run and shared by every section. Sections run in order, and some +// record what they found (login, which AI tools report usage) for the sections after them. +type doctorEnv struct { + ctx context.Context + offline bool + baseDir string + configPath string + cfg model.ShellTimeConfig + cfgErr error + shell string + socketPath string + + daemonStatus *daemon.StatusResponse + + login string + claudeOtel bool + codexOtel bool +} + +type doctorSection struct { + title string + run func(env *doctorEnv) []doctorResult +} + +func doctorSections() []doctorSection { + return []doctorSection{ + {"System", doctorCheckSystem}, + {"Storage", doctorCheckStorage}, + {"Configuration", doctorCheckConfig}, + {"Account", doctorCheckAccount}, + {"Privacy", doctorCheckPrivacy}, + {"Daemon", doctorCheckDaemon}, + {"Shell Hooks", doctorCheckHooks}, + {"Claude Code", doctorCheckClaude}, + {"Codex", doctorCheckCodex}, + {"AI Usage Receiver", doctorCheckOtelReceiver}, + {"Sync", doctorCheckSync}, + } +} + +func runDoctorChecks(ctx context.Context, opts doctorOptions) []doctorResult { + env := newDoctorEnv(ctx, opts) + // Show paths under the home directory as ~/... to keep lines short and reports shareable. + home, _ := os.UserHomeDir() + tilde := func(s string) string { + if home == "" || home == "/" { + return s + } + return strings.ReplaceAll(s, home+string(filepath.Separator), "~"+string(filepath.Separator)) + } + + var results []doctorResult + for _, section := range doctorSections() { + for _, r := range section.run(env) { + r.Section = section.title + r.Message = tilde(r.Message) + r.Fix = tilde(r.Fix) + results = append(results, r) + } + } + return results +} + +func newDoctorEnv(ctx context.Context, opts doctorOptions) *doctorEnv { + if ctx == nil { + ctx = context.Background() + } + env := &doctorEnv{ + ctx: ctx, + offline: opts.offline, + baseDir: os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER), + shell: os.Getenv("SHELL"), + socketPath: model.DefaultSocketPath, + } + env.configPath, _ = model.ConfigFilePaths(env.baseDir) + env.cfg, env.cfgErr = configService.ReadConfigFile(ctx) + if env.cfgErr == nil && env.cfg.SocketPath != "" { + env.socketPath = env.cfg.SocketPath + } + if status, _, err := requestDaemonStatus(env.socketPath, doctorDaemonProbeTimeout); err == nil { + env.daemonStatus = status + } + return env +} + +// configFileHint names the config file to edit in fix messages. +func (env *doctorEnv) configFileHint() string { + if env.configPath != "" { + return env.configPath + } + return filepath.Join(env.baseDir, "config.yaml") +} + +func (env *doctorEnv) skipWithoutConfig(id string) []doctorResult { + return []doctorResult{{ID: id, Status: doctorSkip, Message: "Skipped: the config couldn't be loaded (see Configuration)."}} +} + +func doctorDaemonInstallFix(env *doctorEnv) *doctorFix { + return &doctorFix{ + Key: "daemon.install", + Label: "Install and start the daemon service (shelltime daemon install)", + Run: func(c *cli.Context) error { + if err := doctorRunDaemonInstall(c); err != nil { + return err + } + // Give the freshly started daemon a moment to open its socket before re-checking. + for deadline := time.Now().Add(doctorDaemonStartWait); time.Now().Before(deadline); time.Sleep(250 * time.Millisecond) { + if _, _, err := requestDaemonStatus(env.socketPath, 500*time.Millisecond); err == nil { + break + } + } + return nil + }, + } +} + +// --- System ------------------------------------------------------------------- + +func doctorCheckSystem(env *doctorEnv) []doctorResult { + version := doctorVersion() + results := []doctorResult{{ + ID: "system.platform", + Status: doctorInfo, + Message: fmt.Sprintf("shelltime %s on %s/%s", version, runtime.GOOS, runtime.GOARCH), + }} + + if path, err := doctorLookPath("shelltime"); err == nil { + results = append(results, doctorResult{ID: "system.path", Status: doctorOK, Message: fmt.Sprintf("shelltime is on your PATH (%s).", path)}) + } else { + results = append(results, doctorResult{ + ID: "system.path", + Status: doctorWarn, + Message: "The shelltime command isn't on your PATH, so the shell hooks can't run it.", + Fix: "Open a new terminal. If it's still missing, run `shelltime hooks install` (it adds ~/.shelltime/bin to your PATH).", + }) + } + + update := doctorResult{ID: "system.update"} + switch { + case env.offline: + update.Status, update.Message = doctorSkip, "Skipped the update check (--offline)." + case version == "dev": + update.Status, update.Message = doctorSkip, "Development build; skipped the update check." + default: + ctx, cancel := context.WithTimeout(env.ctx, doctorUpdateTimeout) + latest, err := doctorFetchLatestVersion(ctx) + cancel() + switch { + case err != nil: + update.Status, update.Message = doctorSkip, fmt.Sprintf("Couldn't check for updates: %v", err) + case model.NormalizeVersion(latest) == model.NormalizeVersion(version): + update.Status, update.Message = doctorOK, fmt.Sprintf("You're on the latest version (%s).", latest) + default: + update.Status = doctorWarn + update.Message = fmt.Sprintf("A newer version is available: %s (you have %s).", latest, version) + update.Fix = doctorUpdateHint() + } + } + return append(results, update) +} + +func doctorUpdateHint() string { + if cliPath, err := model.ResolveCLIBinaryPath(); err == nil && model.DetectInstallKind(cliPath) == model.InstallKindHomebrew { + return "Run `brew upgrade shelltime/tap/shelltime`." + } + return "Run `shelltime update`." +} + +// --- Storage ------------------------------------------------------------------ + +func doctorCheckStorage(env *doctorEnv) []doctorResult { + dir := env.baseDir + result := doctorResult{ID: "storage.dir"} + info, err := os.Stat(dir) + switch { + case os.IsNotExist(err): + result.Status = doctorFail + result.Message = fmt.Sprintf("%s doesn't exist, so shelltime has nowhere to keep its config or buffer commands.", dir) + result.Fix = "Run `shelltime init` to sign in and set everything up." + return []doctorResult{result} + case err != nil: + result.Status = doctorFail + result.Message = fmt.Sprintf("Can't access %s: %v", dir, err) + result.Fix = fmt.Sprintf("Make sure %s is owned by you and readable.", dir) + return []doctorResult{result} + case !info.IsDir(): + result.Status = doctorFail + result.Message = fmt.Sprintf("%s is a file, but shelltime needs it to be a directory.", dir) + result.Fix = fmt.Sprintf("Move the file away (`mv %s %s.bak`), then run `shelltime init`.", dir, dir) + return []doctorResult{result} + } + + if err := checkDirWritable(dir); err != nil { + result.Status = doctorFail + result.Message = fmt.Sprintf("%s isn't writable, so commands can't be recorded: %v", dir, err) + result.Fix = fmt.Sprintf("Take ownership of it: `sudo chown -R $(whoami) %s`.", dir) + } else { + result.Status = doctorOK + result.Message = fmt.Sprintf("%s exists and is writable.", dir) + } + results := []doctorResult{result} + + thresholdMB := int64(100) + if env.cfg.LogCleanup != nil && env.cfg.LogCleanup.ThresholdMB > 0 { + thresholdMB = env.cfg.LogCleanup.ThresholdMB + } + logPath := filepath.Join(dir, "log.log") + if logInfo, err := os.Stat(logPath); err == nil && !logInfo.IsDir() { + sizeMB := logInfo.Size() / (1024 * 1024) + if logInfo.Size() > thresholdMB*1024*1024 { + results = append(results, doctorResult{ + ID: "storage.log", + Status: doctorWarn, + Message: fmt.Sprintf("%s is %dMB, over the %dMB cleanup threshold.", logPath, sizeMB, thresholdMB), + Fix: "Run `shelltime gc` to clear it.", + AutoFix: &doctorFix{ + Key: "storage.log", + Label: fmt.Sprintf("Delete the oversized %s (a fresh one is started)", logPath), + Run: func(*cli.Context) error { + _, err := model.CleanLogFile(logPath, thresholdMB*1024*1024, false) + return err + }, + }, + }) + } else { + results = append(results, doctorResult{ID: "storage.log", Status: doctorOK, Message: fmt.Sprintf("Log file size is fine (%dMB).", sizeMB)}) + } + } + return results +} + +func checkDirWritable(dir string) error { + f, err := os.CreateTemp(dir, ".doctor-*") + if err != nil { + return err + } + name := f.Name() + f.Close() + return os.Remove(name) +} + +// --- Configuration ------------------------------------------------------------ + +func doctorCheckConfig(env *doctorEnv) []doctorResult { + base, local := model.ConfigFilePaths(env.baseDir) + if base == "" { + return []doctorResult{{ + ID: "config.file", + Status: doctorFail, + Message: fmt.Sprintf("No config file in %s, so nothing can be tracked or uploaded.", env.baseDir), + Fix: "Run `shelltime init` to sign in and set everything up.", + }} + } + results := []doctorResult{{ID: "config.file", Status: doctorOK, Message: fmt.Sprintf("Using %s.", base)}} + + for _, name := range []string{"config.yaml", "config.yml", "config.toml"} { + other := filepath.Join(env.baseDir, name) + if other == base { + continue + } + if _, err := os.Stat(other); err == nil { + results = append(results, doctorResult{ + ID: "config.shadowed", + Status: doctorWarn, + Message: fmt.Sprintf("%s is ignored because %s takes precedence.", other, filepath.Base(base)), + Fix: fmt.Sprintf("Move any settings you still need into %s, then delete %s.", base, other), + }) + } + } + + if env.cfgErr != nil { + return append(results, doctorResult{ + ID: "config.parse", + Status: doctorFail, + Message: fmt.Sprintf("Your config couldn't be loaded, so shelltime can't track or upload anything: %v", env.cfgErr), + Fix: fmt.Sprintf("Fix the syntax error in %s. Every option is documented in docs/CONFIG.md.", base), + }) + } + + if local != "" { + if err := model.ValidateConfigFile(local); err != nil { + results = append(results, doctorResult{ + ID: "config.local", + Status: doctorWarn, + Message: fmt.Sprintf("%s can't be parsed, so all of its overrides are silently ignored: %v", local, err), + Fix: fmt.Sprintf("Fix the syntax error in %s.", local), + }) + } else { + results = append(results, doctorResult{ID: "config.local", Status: doctorInfo, Message: fmt.Sprintf("Overrides from %s are applied.", local)}) + } + } + + cfg := env.cfg + endpointFix := fmt.Sprintf("Set `apiEndpoint: https://api.shelltime.xyz` in %s.", base) + if cfg.APIEndpoint == "" { + results = append(results, doctorResult{ + ID: "config.api_endpoint", + Status: doctorFail, + Message: "apiEndpoint isn't set, so nothing can be uploaded.", + Fix: endpointFix, + }) + } else if u, err := url.Parse(cfg.APIEndpoint); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + results = append(results, doctorResult{ + ID: "config.api_endpoint", + Status: doctorFail, + Message: fmt.Sprintf("apiEndpoint %q isn't a valid http(s) URL, so nothing can be uploaded.", cfg.APIEndpoint), + Fix: endpointFix, + }) + } else { + results = append(results, doctorResult{ID: "config.api_endpoint", Status: doctorOK, Message: fmt.Sprintf("API endpoint: %s", cfg.APIEndpoint)}) + } + + var badPatterns []string + for _, pattern := range cfg.Exclude { + if pattern == "" { + continue + } + if _, err := regexp.Compile(pattern); err != nil { + badPatterns = append(badPatterns, fmt.Sprintf("%q", pattern)) + } + } + if len(badPatterns) > 0 { + results = append(results, doctorResult{ + ID: "config.exclude", + Status: doctorWarn, + Message: fmt.Sprintf("These exclude patterns aren't valid regular expressions and are skipped, so the commands they should hide are still uploaded: %s", strings.Join(badPatterns, ", ")), + Fix: fmt.Sprintf("Fix or remove them under `exclude` in %s.", base), + }) + } else if len(cfg.Exclude) > 0 { + results = append(results, doctorResult{ID: "config.exclude", Status: doctorOK, Message: fmt.Sprintf("%d exclude pattern(s), all valid.", len(cfg.Exclude))}) + } + + if cfg.Proxy != nil && strings.TrimSpace(cfg.Proxy.URL) != "" { + if _, err := model.ParseProxyURL(cfg.Proxy.URL); err != nil { + results = append(results, doctorResult{ + ID: "config.proxy", + Status: doctorFail, + Message: fmt.Sprintf("proxy.url is invalid, so shelltime ignores it and falls back to the HTTP(S)_PROXY environment variables: %v", err), + Fix: fmt.Sprintf("Set proxy.url in %s to an http://, https://, socks5:// or socks5h:// URL.", base), + }) + } else { + results = append(results, doctorResult{ID: "config.proxy", Status: doctorInfo, Message: fmt.Sprintf("Requests go through proxy %s.", model.RedactProxyURL(cfg.Proxy.URL))}) + } + } else if proxy := doctorEnvProxy(); proxy != "" { + results = append(results, doctorResult{ID: "config.proxy", Status: doctorInfo, Message: fmt.Sprintf("Using the proxy from your environment (%s).", model.RedactProxyURL(proxy))}) + } + + if cfg.EnableMetrics != nil && *cfg.EnableMetrics { + results = append(results, doctorResult{ + ID: "config.metrics", + Status: doctorWarn, + Message: "enableMetrics is on, which adds overhead to every tracked command.", + Fix: fmt.Sprintf("Set `enableMetrics: false` in %s unless the shelltime team asked you to turn it on.", base), + }) + } + return results +} + +func doctorEnvProxy() string { + for _, key := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"} { + if v := os.Getenv(key); v != "" { + return v + } + } + return "" +} + +// --- Account ------------------------------------------------------------------ + +func doctorCheckAccount(env *doctorEnv) []doctorResult { + if env.cfgErr != nil { + return env.skipWithoutConfig("auth.token") + } + cfg := env.cfg + if strings.TrimSpace(cfg.Token) == "" { + return []doctorResult{{ + ID: "auth.token", + Status: doctorFail, + Message: "No API token is configured, so your commands and AI usage can't be uploaded.", + Fix: "Run `shelltime auth` to sign in.", + }} + } + results := []doctorResult{{ID: "auth.token", Status: doctorOK, Message: fmt.Sprintf("An API token is configured (%s).", maskDoctorToken(cfg.Token))}} + + switch { + case env.offline: + return append(results, doctorResult{ID: "auth.server", Status: doctorSkip, Message: "Skipped checking the token with the server (--offline)."}) + case cfg.APIEndpoint == "": + return append(results, doctorResult{ID: "auth.server", Status: doctorSkip, Message: "Skipped checking the token: apiEndpoint isn't set."}) + } + + ctx, cancel := context.WithTimeout(env.ctx, doctorNetworkTimeout) + defer cancel() + profile, err := model.FetchCurrentUserProfile(ctx, cfg) + + result := doctorResult{ID: "auth.server"} + var statusErr *model.HTTPStatusError + switch { + case err == nil && profile.FetchUser.Login != "": + env.login = profile.FetchUser.Login + result.Status = doctorOK + result.Message = fmt.Sprintf("Signed in as @%s on %s.", env.login, cfg.APIEndpoint) + case errors.As(err, &statusErr) && (statusErr.StatusCode == 401 || statusErr.StatusCode == 403): + result.Status = doctorFail + result.Message = fmt.Sprintf("The server rejected your token (HTTP %d): it's expired, revoked or disabled, so nothing is being uploaded.", statusErr.StatusCode) + result.Fix = "Run `shelltime auth` to sign in again." + case err == nil: + result.Status = doctorFail + result.Message = "The server didn't recognise your token, so nothing is being uploaded." + result.Fix = "Run `shelltime auth` to sign in again." + default: + result.Status = doctorWarn + result.Message = fmt.Sprintf("Couldn't reach %s to verify your token: %v", cfg.APIEndpoint, err) + result.Fix = "Check your network connection and proxy settings, then run `shelltime doctor` again." + } + return append(results, result) +} + +func maskDoctorToken(token string) string { + if len(token) <= 8 { + return "****" + } + return token[:4] + "โ€ฆ" + token[len(token)-4:] +} + +// --- Privacy ------------------------------------------------------------------ + +func doctorCheckPrivacy(env *doctorEnv) []doctorResult { + if env.cfgErr != nil { + return env.skipWithoutConfig("privacy.masking") + } + cfg := env.cfg + configFile := env.configFileHint() + + var results []doctorResult + if cfg.DataMasking != nil && !*cfg.DataMasking { + results = append(results, doctorResult{ + ID: "privacy.masking", + Status: doctorWarn, + Message: "Data masking is off: commands are uploaded exactly as typed, including any tokens in them.", + Fix: fmt.Sprintf("Set `dataMasking: true` in %s.", configFile), + }) + } else { + results = append(results, doctorResult{ + ID: "privacy.masking", + Status: doctorOK, + Message: "Data masking is on: JWT-style tokens in commands are masked before upload.", + Fix: "To hide other secrets, add regular expressions under `exclude` in your config.", + }) + } + + if cfg.Encrypted == nil || !*cfg.Encrypted { + return append(results, doctorResult{ + ID: "privacy.encryption", + Status: doctorInfo, + Message: "End-to-end encryption is off; uploads are protected by TLS only.", + Fix: fmt.Sprintf("Set `encrypted: true` in %s to encrypt commands before upload (needs the daemon).", configFile), + }) + } + + if env.daemonStatus == nil { + results = append(results, doctorResult{ + ID: "privacy.encryption", + Status: doctorWarn, + Message: "Encryption is on, but only the daemon encrypts and it isn't running, so commands are uploaded unencrypted.", + Fix: "Run `shelltime daemon install`.", + AutoFix: doctorDaemonInstallFix(env), + }) + } else { + results = append(results, doctorResult{ID: "privacy.encryption", Status: doctorOK, Message: "Encryption is on and the daemon is running to encrypt uploads."}) + } + + switch { + case env.offline: + return append(results, doctorResult{ID: "privacy.encryption_key", Status: doctorSkip, Message: "Skipped checking your token's encryption key (--offline)."}) + case strings.TrimSpace(cfg.Token) == "" || cfg.APIEndpoint == "": + return results + } + + ctx, cancel := context.WithTimeout(env.ctx, doctorNetworkTimeout) + defer cancel() + key, err := model.GetOpenTokenPublicKey(ctx, model.Endpoint{Token: cfg.Token, APIEndpoint: cfg.APIEndpoint}, 0) + switch { + case err != nil: + results = append(results, doctorResult{ID: "privacy.encryption_key", Status: doctorSkip, Message: fmt.Sprintf("Couldn't check your token's encryption key: %v", err)}) + case strings.TrimSpace(key.PublicKey) == "": + tokensPage := strings.TrimRight(cfg.WebEndpoint, "/") + " (Settings โ†’ Open Token)" + if env.login != "" { + tokensPage = fmt.Sprintf("%s/users/%s/settings/open-token", strings.TrimRight(cfg.WebEndpoint, "/"), env.login) + } + results = append(results, doctorResult{ + ID: "privacy.encryption_key", + Status: doctorWarn, + Message: "Encryption is on, but your token is an older one without an encryption key, so commands are uploaded unencrypted.", + Fix: fmt.Sprintf("Create a new token at %s, then run `shelltime auth --token `.", tokensPage), + }) + default: + results = append(results, doctorResult{ID: "privacy.encryption_key", Status: doctorOK, Message: "Your token has an encryption key."}) + } + return results +} + +// --- Daemon ------------------------------------------------------------------- + +func doctorCheckDaemon(env *doctorEnv) []doctorResult { + installFix := doctorDaemonInstallFix(env) + var results []doctorResult + + if path, err := doctorResolveDaemonBinary(); err == nil { + results = append(results, doctorResult{ID: "daemon.binary", Status: doctorOK, Message: fmt.Sprintf("Daemon binary: %s", path)}) + } else { + results = append(results, doctorResult{ + ID: "daemon.binary", + Status: doctorWarn, + Message: "The shelltime-daemon binary wasn't found.", + Fix: "Run `shelltime daemon install` (it downloads the daemon if needed).", + AutoFix: installFix, + }) + } + + switch err := doctorDaemonServiceCheck(); { + case errors.Is(err, errDoctorServiceUnsupported): + results = append(results, doctorResult{ID: "daemon.service", Status: doctorSkip, Message: fmt.Sprintf("The daemon service isn't supported on %s.", runtime.GOOS)}) + case err != nil: + results = append(results, doctorResult{ + ID: "daemon.service", + Status: doctorWarn, + Message: "The daemon service isn't installed or isn't running.", + Fix: "Run `shelltime daemon install`.", + AutoFix: installFix, + }) + default: + results = append(results, doctorResult{ID: "daemon.service", Status: doctorOK, Message: "The daemon service is registered with the system service manager."}) + } + + status := env.daemonStatus + if status == nil { + return append(results, doctorResult{ + ID: "daemon.socket", + Status: doctorWarn, + Message: fmt.Sprintf("The daemon isn't responding on %s, so syncing falls back to the slower direct path, and encryption and AI coding usage tracking don't work.", env.socketPath), + Fix: "Run `shelltime daemon install`.", + AutoFix: installFix, + }) + } + results = append(results, doctorResult{ + ID: "daemon.socket", + Status: doctorOK, + Message: fmt.Sprintf("The daemon is responding on %s (%s, up %s).", env.socketPath, status.Version, status.Uptime), + }) + + cliVersion := model.NormalizeVersion(doctorVersion()) + daemonVersion := model.NormalizeVersion(status.Version) + if cliVersion != "dev" && daemonVersion != "" && daemonVersion != "dev" && cliVersion != daemonVersion { + results = append(results, doctorResult{ + ID: "daemon.version", + Status: doctorWarn, + Message: fmt.Sprintf("The daemon is running %s but the CLI is %s, so the daemon is still on the old binary.", status.Version, doctorVersion()), + Fix: "Run `shelltime daemon reinstall`.", + AutoFix: installFix, + }) + } + return results +} + +// --- Shell hooks -------------------------------------------------------------- + +var doctorShellFiles = map[string]struct{ rc, hook string }{ + "zsh": {"~/.zshrc", "zsh.zsh"}, + "bash": {"~/.bashrc", "bash.bash"}, + "fish": {"~/.config/fish/config.fish", "fish.fish"}, +} + +func doctorCheckHooks(env *doctorEnv) []doctorResult { + if env.shell == "" { + return []doctorResult{{ + ID: "hooks.shell", + Status: doctorWarn, + Message: "$SHELL isn't set, so doctor can't tell which shell hook to check.", + Fix: "Run `shelltime doctor` from your usual terminal, or run `shelltime hooks install` to set up zsh, bash and fish.", + }} + } + + var svc model.ShellHookService + for _, candidate := range []model.ShellHookService{model.NewZshHookService(), model.NewFishHookService(), model.NewBashHookService()} { + if candidate.Match(env.shell) { + svc = candidate + break + } + } + if svc == nil { + return []doctorResult{{ + ID: "hooks.shell", + Status: doctorWarn, + Message: fmt.Sprintf("Your shell (%s) isn't supported, so its commands aren't tracked. shelltime supports zsh, bash and fish.", env.shell), + }} + } + + name := svc.ShellName() + files := doctorShellFiles[name] + fix := &doctorFix{ + Key: "hooks." + name, + Label: fmt.Sprintf("Install the %s hook (shelltime hooks install)", name), + Run: func(*cli.Context) error { return svc.Install() }, + } + fixHint := "Run `shelltime hooks install`, then open a new terminal." + results := []doctorResult{{ID: "hooks.shell", Status: doctorInfo, Message: fmt.Sprintf("Current shell: %s", env.shell)}} + + if err := svc.Check(); err != nil { + results = append(results, doctorResult{ + ID: "hooks.installed", + Status: doctorFail, + Message: fmt.Sprintf("The %s hook isn't set up in %s, so the commands you run aren't tracked.", name, files.rc), + Fix: fixHint, + AutoFix: fix, + }) + } else { + results = append(results, doctorResult{ID: "hooks.installed", Status: doctorOK, Message: fmt.Sprintf("The %s hook is set up in %s.", name, files.rc)}) + } + + hookPath := filepath.Join(model.GetHooksFolderPath(), files.hook) + if _, err := os.Stat(hookPath); err != nil { + results = append(results, doctorResult{ + ID: "hooks.script", + Status: doctorFail, + Message: fmt.Sprintf("The hook script %s is missing, so the hook in %s does nothing.", hookPath, files.rc), + Fix: fixHint, + AutoFix: fix, + }) + } + + if name == "bash" { + preexecPath := filepath.Join(model.GetHooksFolderPath(), "bash-preexec.sh") + if _, err := os.Stat(preexecPath); err != nil { + results = append(results, doctorResult{ + ID: "hooks.bash_preexec", + Status: doctorFail, + Message: fmt.Sprintf("%s is missing; bash needs it to capture commands.", preexecPath), + Fix: fixHint, + AutoFix: fix, + }) + } + } + return results +} + +// --- Claude Code -------------------------------------------------------------- + +func doctorClaudeDetected() bool { + if _, err := doctorLookPath("claude"); err == nil { + return true + } + home, _ := os.UserHomeDir() + for _, p := range []string{".claude", ".claude/local/claude", ".local/bin/claude"} { + if _, err := os.Stat(filepath.Join(home, p)); err == nil { + return true + } + } + return false +} + +func doctorCheckClaude(env *doctorEnv) []doctorResult { + if !doctorClaudeDetected() { + return []doctorResult{{ID: "claude.detected", Status: doctorSkip, Message: "Claude Code wasn't found; skipped its checks."}} + } + + svc := model.NewClaudeSettingsAICodeOtelEnvService() + fix := &doctorFix{ + Key: "claude.install", + Label: "Set up Claude Code to report usage (shelltime cc install)", + Run: doctorRunCCInstall, + } + fixHint := "Run `shelltime cc install`, then restart Claude Code." + + var results []doctorResult + if err := svc.Check(); err != nil && strings.Contains(err.Error(), "failed to parse") { + results = append(results, doctorResult{ + ID: "claude.otel", + Status: doctorFail, + Message: fmt.Sprintf("%s isn't valid JSON, so Claude Code usage isn't reported: %v", svc.SettingsPath(), errors.Unwrap(err)), + Fix: fmt.Sprintf("Fix the JSON in %s, then run `shelltime cc install`.", svc.SettingsPath()), + }) + } else if err != nil { + results = append(results, doctorResult{ + ID: "claude.otel", + Status: doctorFail, + Message: "Claude Code isn't set up to report usage to shelltime, so your Claude Code sessions won't show up.", + Fix: fixHint, + AutoFix: fix, + }) + } else { + env.claudeOtel = true + results = append(results, doctorResult{ID: "claude.otel", Status: doctorOK, Message: fmt.Sprintf("Claude Code reports usage to shelltime (%s).", svc.SettingsPath())}) + } + + var legacyShells []string + for _, legacy := range legacyAICodeOtelEnvServices() { + if legacy.Check() == nil { + legacyShells = append(legacyShells, legacy.ShellName()) + } + } + if len(legacyShells) > 0 { + results = append(results, doctorResult{ + ID: "claude.legacy_env", + Status: doctorWarn, + Message: fmt.Sprintf("Your %s config still has the old shelltime OTEL block, which can override ~/.claude/settings.json.", strings.Join(legacyShells, ", ")), + Fix: "Run `shelltime cc install` to remove it.", + AutoFix: fix, + }) + } + + statusLineFix := fmt.Sprintf(`Add "statusLine": {"type": "command", "command": "%s"} to %s.`, doctorStatusLineCommand, svc.SettingsPath()) + switch cmd, err := svc.StatusLineCommand(); { + case err != nil: + results = append(results, doctorResult{ID: "claude.statusline", Status: doctorSkip, Message: fmt.Sprintf("Couldn't read the statusline setting: %v", err)}) + case strings.Contains(cmd, doctorStatusLineCommand): + results = append(results, doctorResult{ID: "claude.statusline", Status: doctorOK, Message: "The shelltime statusline is on in Claude Code."}) + case cmd != "": + results = append(results, doctorResult{ + ID: "claude.statusline", + Status: doctorInfo, + Message: fmt.Sprintf("Claude Code uses a different statusline (%s).", cmd), + Fix: fmt.Sprintf(`To show shelltime's cost and quota instead, set statusLine.command to "%s" in %s.`, doctorStatusLineCommand, svc.SettingsPath()), + }) + default: + results = append(results, doctorResult{ + ID: "claude.statusline", + Status: doctorInfo, + Message: "The shelltime statusline isn't on (optional: shows cost and quota inside Claude Code).", + Fix: statusLineFix, + }) + } + return results +} + +// --- Codex -------------------------------------------------------------------- + +func doctorCodexDetected() bool { + if _, err := doctorLookPath("codex"); err == nil { + return true + } + home, _ := os.UserHomeDir() + _, err := os.Stat(filepath.Join(home, ".codex")) + return err == nil +} + +func doctorCheckCodex(env *doctorEnv) []doctorResult { + if !doctorCodexDetected() { + return []doctorResult{{ID: "codex.detected", Status: doctorSkip, Message: "Codex wasn't found; skipped its checks."}} + } + + fix := &doctorFix{ + Key: "codex.install", + Label: "Set up Codex to report usage (shelltime codex install)", + Run: doctorRunCodexInstall, + } + fixHint := "Run `shelltime codex install`, then restart Codex." + + result := doctorResult{ID: "codex.otel"} + switch endpoint, err := model.NewCodexOtelConfigService().Endpoint(); { + case err != nil: + result.Status = doctorFail + result.Message = fmt.Sprintf("~/.codex/config.toml can't be read, so Codex usage isn't reported: %v", err) + result.Fix = "Fix the TOML syntax in ~/.codex/config.toml, then run `shelltime codex install`." + case endpoint == "": + result.Status = doctorFail + result.Message = "Codex isn't set up to report usage to shelltime, so your Codex sessions won't show up." + result.Fix = fixHint + result.AutoFix = fix + case endpoint != model.AICodeOtelEndpoint: + result.Status = doctorFail + result.Message = fmt.Sprintf("Codex sends usage to %s instead of shelltime's receiver (%s).", endpoint, model.AICodeOtelEndpoint) + result.Fix = fixHint + result.AutoFix = fix + default: + env.codexOtel = true + result.Status = doctorOK + result.Message = "Codex reports usage to shelltime." + } + results := []doctorResult{result} + + if ok, _ := doctorCodexInstallationStatus(); !ok { + results = append(results, doctorResult{ + ID: "codex.auth", + Status: doctorInfo, + Message: "Codex isn't signed in (no ~/.codex/auth.json), so your Codex usage limits can't be synced.", + Fix: "Run `codex login`.", + }) + } + return results +} + +// --- AI usage receiver -------------------------------------------------------- + +func doctorCheckOtelReceiver(env *doctorEnv) []doctorResult { + var tools []string + if env.claudeOtel { + tools = append(tools, "Claude Code") + } + if env.codexOtel { + tools = append(tools, "Codex") + } + if len(tools) == 0 { + return []doctorResult{{ID: "otel.enabled", Status: doctorSkip, Message: "No AI coding tool is set up to report usage; skipped."}} + } + if env.cfgErr != nil { + return env.skipWithoutConfig("otel.enabled") + } + toolNames := strings.Join(tools, " and ") + sends := "sends" + if len(tools) > 1 { + sends = "send" + } + configFile := env.configFileHint() + + otelCfg := env.cfg.AICodeOtel + if otelCfg == nil || otelCfg.Enabled == nil || !*otelCfg.Enabled { + return []doctorResult{{ + ID: "otel.enabled", + Status: doctorFail, + Message: fmt.Sprintf("%s %s usage to %s, but the daemon's receiver is turned off, so that usage is dropped.", toolNames, sends, model.AICodeOtelEndpoint), + Fix: fmt.Sprintf("Set `aiCodeOtel.enabled: true` in %s, then run `shelltime daemon reinstall`.", configFile), + }} + } + results := []doctorResult{{ID: "otel.enabled", Status: doctorOK, Message: "The daemon's AI usage receiver is enabled."}} + + port := otelCfg.GRPCPort + if port == 0 { + port = model.DefaultAICodeOtelGRPCPort + } + if port != model.DefaultAICodeOtelGRPCPort { + return append(results, doctorResult{ + ID: "otel.port", + Status: doctorFail, + Message: fmt.Sprintf("The receiver listens on port %d, but %s %s to port %d, so that usage is dropped.", port, toolNames, sends, model.DefaultAICodeOtelGRPCPort), + Fix: fmt.Sprintf("Remove `aiCodeOtel.grpcPort` from %s (or set it to %d), then run `shelltime daemon reinstall`.", configFile, model.DefaultAICodeOtelGRPCPort), + }) + } + + if env.daemonStatus == nil { + return append(results, doctorResult{ID: "otel.listening", Status: doctorSkip, Message: "Skipped: the daemon isn't running (see Daemon)."}) + } + conn, err := doctorDialTCP("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second) + if err != nil { + return append(results, doctorResult{ + ID: "otel.listening", + Status: doctorFail, + Message: fmt.Sprintf("The daemon is running, but nothing is listening on port %d, so %s usage is dropped. The receiver may have been enabled after the daemon started, or another program holds the port.", port, toolNames), + Fix: fmt.Sprintf("Run `shelltime daemon reinstall`. If that doesn't help, check what uses the port with `lsof -i :%d`.", port), + AutoFix: doctorDaemonInstallFix(env), + }) + } + conn.Close() + return append(results, doctorResult{ID: "otel.listening", Status: doctorOK, Message: fmt.Sprintf("The receiver is listening on port %d.", port)}) +} + +// --- Sync --------------------------------------------------------------------- + +func doctorCheckSync(env *doctorEnv) []doctorResult { + var results []doctorResult + + pendingPath := model.GetSyncPendingFilePath() + switch pending, err := countNonEmptyLines(pendingPath); { + case err != nil && !os.IsNotExist(err): + results = append(results, doctorResult{ID: "sync.pending", Status: doctorSkip, Message: fmt.Sprintf("Couldn't read %s: %v", pendingPath, err)}) + case pending > 0: + results = append(results, doctorResult{ + ID: "sync.pending", + Status: doctorWarn, + Message: fmt.Sprintf("%d upload batch(es) failed and are queued in %s; the daemon retries them every hour.", pending, pendingPath), + Fix: "Fix any Account, Configuration or Daemon problems above; the queue uploads on its own once the server is reachable.", + }) + default: + results = append(results, doctorResult{ID: "sync.pending", Status: doctorOK, Message: "No failed uploads are waiting to be retried."}) + } + + if heartbeats, err := countNonEmptyLines(model.GetHeartbeatLogFilePath()); err == nil && heartbeats > 0 { + results = append(results, doctorResult{ + ID: "sync.heartbeats", + Status: doctorInfo, + Message: fmt.Sprintf("%d editor heartbeat(s) are waiting to be re-sent; the daemon retries them every 30 minutes.", heartbeats), + }) + } + return results +} + +// countNonEmptyLines counts lines with any non-whitespace content. It reads in chunks because +// queued sync batches can be far longer than bufio.Scanner's default line limit. +func countNonEmptyLines(path string) (int, error) { + f, err := os.Open(path) + if err != nil { + return 0, err + } + defer f.Close() + + buf := make([]byte, 64*1024) + count, inLine := 0, false + for { + n, err := f.Read(buf) + for _, b := range buf[:n] { + switch b { + case '\n': + if inLine { + count++ + } + inLine = false + case ' ', '\t', '\r': + default: + inLine = true + } + } + if err == io.EOF { + break + } + if err != nil { + return count, err + } + } + if inLine { + count++ + } + return count, nil +} diff --git a/commands/doctor_test.go b/commands/doctor_test.go new file mode 100644 index 0000000..9138f2f --- /dev/null +++ b/commands/doctor_test.go @@ -0,0 +1,790 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/malamtime/cli/daemon" + "github.com/malamtime/cli/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace/noop" +) + +// setupDoctorTest isolates HOME, installs a mock ConfigService and stubs every doctor seam that +// would touch the network, the service manager or the real daemon. +func setupDoctorTest(t *testing.T) (string, *model.MockConfigService) { + t.Helper() + otel.SetTracerProvider(noop.NewTracerProvider()) + SKIP_LOGGER_SETTINGS = true + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("SHELL", "/bin/zsh") + for _, key := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"} { + t.Setenv(key, "") + } + + mc := model.NewMockConfigService(t) + origConfig := configService + configService = mc + + origLookPath, origDial, origLatest := doctorLookPath, doctorDialTCP, doctorFetchLatestVersion + origResolve, origCodexStatus, origService := doctorResolveDaemonBinary, doctorCodexInstallationStatus, doctorDaemonServiceCheck + origDaemonInstall, origCCInstall, origCodexInstall := doctorRunDaemonInstall, doctorRunCCInstall, doctorRunCodexInstall + origWait, origStdin, origOut, origCommit := doctorDaemonStartWait, doctorStdin, doctorOut, commitID + t.Cleanup(func() { + configService = origConfig + doctorLookPath, doctorDialTCP, doctorFetchLatestVersion = origLookPath, origDial, origLatest + doctorResolveDaemonBinary, doctorCodexInstallationStatus, doctorDaemonServiceCheck = origResolve, origCodexStatus, origService + doctorRunDaemonInstall, doctorRunCCInstall, doctorRunCodexInstall = origDaemonInstall, origCCInstall, origCodexInstall + doctorDaemonStartWait, doctorStdin, doctorOut, commitID = origWait, origStdin, origOut, origCommit + }) + + doctorLookPath = func(name string) (string, error) { + if name == "shelltime" { + return "/usr/local/bin/shelltime", nil + } + return "", exec.ErrNotFound + } + doctorDialTCP = func(string, string, time.Duration) (net.Conn, error) { + return nil, errors.New("connection refused") + } + doctorFetchLatestVersion = func(context.Context) (string, error) { + return "", errors.New("no network in tests") + } + doctorResolveDaemonBinary = func() (string, error) { return "/usr/local/bin/shelltime-daemon", nil } + doctorCodexInstallationStatus = func() (bool, error) { return true, nil } + doctorDaemonServiceCheck = func() error { return nil } + doctorRunDaemonInstall = func(*cli.Context) error { return errors.New("daemon install is stubbed in tests") } + doctorDaemonStartWait = 0 + doctorStdin = strings.NewReader("") + doctorOut = io.Discard + commitID = "v0.1.91" + return home, mc +} + +// shortSocketPath returns a unix socket path short enough for every OS (t.TempDir can exceed it). +func shortSocketPath(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "stdoc") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(dir) }) + return filepath.Join(dir, "d.sock") +} + +func doctorTestConfig(t *testing.T) model.ShellTimeConfig { + t.Helper() + return model.ShellTimeConfig{ + Token: "st_v1_abcdef1234567890", + APIEndpoint: "https://api.example.test", + WebEndpoint: "https://web.example.test", + DataMasking: new(true), + SocketPath: shortSocketPath(t), + LogCleanup: &model.LogCleanup{ThresholdMB: 100}, + } +} + +func newTestDoctorEnv(cfg model.ShellTimeConfig) *doctorEnv { + return &doctorEnv{ + ctx: context.Background(), + baseDir: filepath.Join(os.Getenv("HOME"), model.COMMAND_BASE_STORAGE_FOLDER), + cfg: cfg, + shell: os.Getenv("SHELL"), + socketPath: cfg.SocketPath, + } +} + +// writeDoctorConfigFile creates ~/.shelltime/config.yaml so the config-file checks pass; the +// mocked ConfigService supplies the parsed values. +func writeDoctorConfigFile(t *testing.T, home string) string { + t.Helper() + base := filepath.Join(home, model.COMMAND_BASE_STORAGE_FOLDER) + require.NoError(t, os.MkdirAll(base, 0755)) + path := filepath.Join(base, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte("token: x\n"), 0644)) + return path +} + +func findDoctorResult(t *testing.T, results []doctorResult, id string) doctorResult { + t.Helper() + for _, r := range results { + if r.ID == id { + return r + } + } + t.Fatalf("no %q result in %+v", id, results) + return doctorResult{} +} + +func hasDoctorResult(results []doctorResult, id string) bool { + for _, r := range results { + if r.ID == id { + return true + } + } + return false +} + +func doctorTestApp() *cli.App { + return &cli.App{Name: "t", Commands: []*cli.Command{DoctorCommand}} +} + +// --- System ------------------------------------------------------------------- + +func TestDoctorCheckSystem(t *testing.T) { + setupDoctorTest(t) + env := newTestDoctorEnv(doctorTestConfig(t)) + + results := doctorCheckSystem(env) + assert.Equal(t, doctorInfo, findDoctorResult(t, results, "system.platform").Status) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "system.path").Status) + assert.Equal(t, doctorSkip, findDoctorResult(t, results, "system.update").Status, "fetch error is a skip") + + doctorFetchLatestVersion = func(context.Context) (string, error) { return "v0.1.91", nil } + assert.Equal(t, doctorOK, findDoctorResult(t, doctorCheckSystem(env), "system.update").Status) + + doctorFetchLatestVersion = func(context.Context) (string, error) { return "v0.2.0", nil } + update := findDoctorResult(t, doctorCheckSystem(env), "system.update") + assert.Equal(t, doctorWarn, update.Status) + assert.Contains(t, update.Message, "v0.2.0") + assert.Regexp(t, "shelltime update|brew upgrade", update.Fix) + + env.offline = true + assert.Contains(t, findDoctorResult(t, doctorCheckSystem(env), "system.update").Message, "--offline") + + env.offline = false + commitID = "dev" + assert.Contains(t, findDoctorResult(t, doctorCheckSystem(env), "system.update").Message, "Development build") + + doctorLookPath = func(string) (string, error) { return "", exec.ErrNotFound } + assert.Equal(t, doctorWarn, findDoctorResult(t, doctorCheckSystem(env), "system.path").Status) +} + +// --- Storage ------------------------------------------------------------------ + +func TestDoctorCheckStorage(t *testing.T) { + setupDoctorTest(t) + env := newTestDoctorEnv(doctorTestConfig(t)) + + dir := findDoctorResult(t, doctorCheckStorage(env), "storage.dir") + assert.Equal(t, doctorFail, dir.Status) + assert.Contains(t, dir.Fix, "shelltime init") + + require.NoError(t, os.WriteFile(env.baseDir, []byte("x"), 0644)) + dir = findDoctorResult(t, doctorCheckStorage(env), "storage.dir") + assert.Equal(t, doctorFail, dir.Status) + assert.Contains(t, dir.Message, "is a file") + + require.NoError(t, os.Remove(env.baseDir)) + require.NoError(t, os.MkdirAll(env.baseDir, 0755)) + results := doctorCheckStorage(env) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "storage.dir").Status) + assert.False(t, hasDoctorResult(results, "storage.log"), "no log file, no log result") + + // A log over the configured threshold is flagged and the auto-fix clears it. + env.cfg.LogCleanup = &model.LogCleanup{ThresholdMB: 1} + logPath := filepath.Join(env.baseDir, "log.log") + require.NoError(t, os.WriteFile(logPath, make([]byte, 2*1024*1024), 0644)) + logResult := findDoctorResult(t, doctorCheckStorage(env), "storage.log") + assert.Equal(t, doctorWarn, logResult.Status) + require.NotNil(t, logResult.AutoFix) + require.NoError(t, logResult.AutoFix.Run(nil)) + assert.NoFileExists(t, logPath) + + require.NoError(t, os.WriteFile(logPath, []byte("small\n"), 0644)) + assert.Equal(t, doctorOK, findDoctorResult(t, doctorCheckStorage(env), "storage.log").Status) +} + +// --- Configuration ------------------------------------------------------------ + +func TestDoctorCheckConfig_NoConfigFile(t *testing.T) { + setupDoctorTest(t) + env := newTestDoctorEnv(doctorTestConfig(t)) + require.NoError(t, os.MkdirAll(env.baseDir, 0755)) + + results := doctorCheckConfig(env) + require.Len(t, results, 1) + assert.Equal(t, "config.file", results[0].ID) + assert.Equal(t, doctorFail, results[0].Status) +} + +func TestDoctorCheckConfig_Problems(t *testing.T) { + home, _ := setupDoctorTest(t) + writeDoctorConfigFile(t, home) + env := newTestDoctorEnv(doctorTestConfig(t)) + require.NoError(t, os.WriteFile(filepath.Join(env.baseDir, "config.toml"), []byte("Token = 'x'\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(env.baseDir, "config.local.yaml"), []byte("token: [unclosed\n"), 0644)) + + env.cfg.APIEndpoint = "" + env.cfg.Exclude = []string{"(unclosed", "^ls"} + env.cfg.Proxy = &model.ProxyConfig{URL: "ftp://proxy:21"} + env.cfg.EnableMetrics = new(true) + + results := doctorCheckConfig(env) + assert.Contains(t, findDoctorResult(t, results, "config.file").Message, "config.yaml") + shadowed := findDoctorResult(t, results, "config.shadowed") + assert.Equal(t, doctorWarn, shadowed.Status) + assert.Contains(t, shadowed.Message, "config.toml") + assert.Equal(t, doctorWarn, findDoctorResult(t, results, "config.local").Status) + assert.Equal(t, doctorFail, findDoctorResult(t, results, "config.api_endpoint").Status) + exclude := findDoctorResult(t, results, "config.exclude") + assert.Equal(t, doctorWarn, exclude.Status) + assert.Contains(t, exclude.Message, "(unclosed") + assert.NotContains(t, exclude.Message, "^ls") + assert.Equal(t, doctorFail, findDoctorResult(t, results, "config.proxy").Status) + assert.Equal(t, doctorWarn, findDoctorResult(t, results, "config.metrics").Status) + + env.cfg.APIEndpoint = "api.shelltime.xyz" + assert.Equal(t, doctorFail, findDoctorResult(t, doctorCheckConfig(env), "config.api_endpoint").Status, "missing scheme") +} + +func TestDoctorCheckConfig_Healthy(t *testing.T) { + home, _ := setupDoctorTest(t) + writeDoctorConfigFile(t, home) + env := newTestDoctorEnv(doctorTestConfig(t)) + env.cfg.Exclude = []string{"^secret"} + env.cfg.Proxy = &model.ProxyConfig{URL: "http://user:hunter2@proxy:8080"} + + results := doctorCheckConfig(env) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "config.api_endpoint").Status) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "config.exclude").Status) + proxy := findDoctorResult(t, results, "config.proxy") + assert.Equal(t, doctorInfo, proxy.Status) + assert.NotContains(t, proxy.Message, "hunter2") + for _, id := range []string{"config.shadowed", "config.local", "config.parse", "config.metrics"} { + assert.False(t, hasDoctorResult(results, id), id) + } + + env.cfg.Proxy = nil + t.Setenv("HTTPS_PROXY", "http://envproxy:3128") + assert.Contains(t, findDoctorResult(t, doctorCheckConfig(env), "config.proxy").Message, "envproxy") +} + +func TestDoctorChecks_ConfigParseError(t *testing.T) { + home, _ := setupDoctorTest(t) + writeDoctorConfigFile(t, home) + env := newTestDoctorEnv(model.ShellTimeConfig{}) + env.cfgErr = errors.New("failed to parse config file: boom") + + parse := findDoctorResult(t, doctorCheckConfig(env), "config.parse") + assert.Equal(t, doctorFail, parse.Status) + assert.Contains(t, parse.Message, "boom") + assert.Equal(t, doctorSkip, findDoctorResult(t, doctorCheckAccount(env), "auth.token").Status) + assert.Equal(t, doctorSkip, findDoctorResult(t, doctorCheckPrivacy(env), "privacy.masking").Status) +} + +// --- Account ------------------------------------------------------------------ + +func TestDoctorCheckAccount(t *testing.T) { + setupDoctorTest(t) + + env := newTestDoctorEnv(doctorTestConfig(t)) + env.cfg.Token = "" + assert.Equal(t, doctorFail, findDoctorResult(t, doctorCheckAccount(env), "auth.token").Status) + + env = newTestDoctorEnv(doctorTestConfig(t)) + env.offline = true + results := doctorCheckAccount(env) + token := findDoctorResult(t, results, "auth.token") + assert.Equal(t, doctorOK, token.Status) + assert.NotContains(t, token.Message, env.cfg.Token, "token is masked") + assert.Equal(t, doctorSkip, findDoctorResult(t, results, "auth.server").Status) + + var status int + var body, authHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader = r.Header.Get("Authorization") + assert.Equal(t, "/api/v2/graphql", r.URL.Path) + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + env = newTestDoctorEnv(doctorTestConfig(t)) + env.cfg.APIEndpoint = srv.URL + status, body = http.StatusOK, `{"data":{"fetchUser":{"id":1,"login":"annatar"}}}` + server := findDoctorResult(t, doctorCheckAccount(env), "auth.server") + assert.Equal(t, doctorOK, server.Status) + assert.Contains(t, server.Message, "@annatar") + assert.Equal(t, "annatar", env.login) + assert.Equal(t, "CLI "+env.cfg.Token, authHeader) + + status, body = http.StatusUnauthorized, `{"code":401,"error":"token expired"}` + server = findDoctorResult(t, doctorCheckAccount(env), "auth.server") + assert.Equal(t, doctorFail, server.Status) + assert.Contains(t, server.Fix, "shelltime auth") + + status, body = http.StatusOK, `{"data":{"fetchUser":{"id":0,"login":""}}}` + assert.Equal(t, doctorFail, findDoctorResult(t, doctorCheckAccount(env), "auth.server").Status) + + srv.Close() + assert.Equal(t, doctorWarn, findDoctorResult(t, doctorCheckAccount(env), "auth.server").Status, "unreachable server") +} + +// --- Privacy ------------------------------------------------------------------ + +func TestDoctorCheckPrivacy(t *testing.T) { + setupDoctorTest(t) + + env := newTestDoctorEnv(doctorTestConfig(t)) + env.cfg.DataMasking = new(false) + results := doctorCheckPrivacy(env) + assert.Equal(t, doctorWarn, findDoctorResult(t, results, "privacy.masking").Status) + assert.Equal(t, doctorInfo, findDoctorResult(t, results, "privacy.encryption").Status) + + // Encryption without a daemon uploads plaintext. + env = newTestDoctorEnv(doctorTestConfig(t)) + env.cfg.Encrypted = new(true) + env.offline = true + results = doctorCheckPrivacy(env) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "privacy.masking").Status) + encryption := findDoctorResult(t, results, "privacy.encryption") + assert.Equal(t, doctorWarn, encryption.Status) + require.NotNil(t, encryption.AutoFix) + assert.Equal(t, "daemon.install", encryption.AutoFix.Key) + assert.Equal(t, doctorSkip, findDoctorResult(t, results, "privacy.encryption_key").Status) + + status, publicKey := http.StatusOK, "" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/opentoken/publickey", r.URL.Path) + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": 1, "publicKey": publicKey}}) + })) + defer srv.Close() + + env.offline = false + env.cfg.APIEndpoint = srv.URL + env.daemonStatus = &daemon.StatusResponse{Version: "v0.1.91"} + env.login = "annatar" + results = doctorCheckPrivacy(env) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "privacy.encryption").Status) + key := findDoctorResult(t, results, "privacy.encryption_key") + assert.Equal(t, doctorWarn, key.Status, "legacy token without a key") + assert.Contains(t, key.Fix, "https://web.example.test/users/annatar/settings/open-token") + + publicKey = "-----BEGIN PUBLIC KEY-----" + assert.Equal(t, doctorOK, findDoctorResult(t, doctorCheckPrivacy(env), "privacy.encryption_key").Status) + + status = http.StatusInternalServerError + assert.Equal(t, doctorSkip, findDoctorResult(t, doctorCheckPrivacy(env), "privacy.encryption_key").Status) +} + +// --- Daemon ------------------------------------------------------------------- + +func TestDoctorCheckDaemon_NotRunning(t *testing.T) { + setupDoctorTest(t) + env := newTestDoctorEnv(doctorTestConfig(t)) + + results := doctorCheckDaemon(env) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "daemon.binary").Status) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "daemon.service").Status) + socket := findDoctorResult(t, results, "daemon.socket") + assert.Equal(t, doctorWarn, socket.Status) + require.NotNil(t, socket.AutoFix) + assert.Equal(t, "daemon.install", socket.AutoFix.Key) + + doctorResolveDaemonBinary = func() (string, error) { return "", errors.New("not found") } + doctorDaemonServiceCheck = func() error { return errors.New("inactive") } + results = doctorCheckDaemon(env) + assert.Equal(t, doctorWarn, findDoctorResult(t, results, "daemon.binary").Status) + assert.Equal(t, doctorWarn, findDoctorResult(t, results, "daemon.service").Status) + + doctorDaemonServiceCheck = func() error { return errDoctorServiceUnsupported } + assert.Equal(t, doctorSkip, findDoctorResult(t, doctorCheckDaemon(env), "daemon.service").Status) +} + +func TestDoctorCheckDaemon_VersionMismatch(t *testing.T) { + _, mc := setupDoctorTest(t) + cfg := doctorTestConfig(t) + mc.On("ReadConfigFile", mock.Anything).Return(cfg, nil) + + ln := startFakeStatusDaemon(t, cfg.SocketPath, daemon.StatusResponse{Version: "v0.1.80", Uptime: "1h"}) + defer ln.Close() + env := newDoctorEnv(context.Background(), doctorOptions{}) + require.NotNil(t, env.daemonStatus, "doctor probes the daemon socket from the config") + + results := doctorCheckDaemon(env) + socket := findDoctorResult(t, results, "daemon.socket") + assert.Equal(t, doctorOK, socket.Status) + assert.Contains(t, socket.Message, "v0.1.80") + version := findDoctorResult(t, results, "daemon.version") + assert.Equal(t, doctorWarn, version.Status) + assert.Contains(t, version.Fix, "daemon reinstall") + + env.daemonStatus = &daemon.StatusResponse{Version: "0.1.91"} + assert.False(t, hasDoctorResult(doctorCheckDaemon(env), "daemon.version"), "v-prefix is normalized") +} + +// --- Shell hooks -------------------------------------------------------------- + +func TestDoctorCheckHooks_ShellDetection(t *testing.T) { + setupDoctorTest(t) + env := newTestDoctorEnv(doctorTestConfig(t)) + + env.shell = "" + assert.Equal(t, doctorWarn, findDoctorResult(t, doctorCheckHooks(env), "hooks.shell").Status) + + env.shell = "/bin/tcsh" + shell := findDoctorResult(t, doctorCheckHooks(env), "hooks.shell") + assert.Equal(t, doctorWarn, shell.Status) + assert.Contains(t, shell.Message, "isn't supported") +} + +func TestDoctorCheckHooks_ZshMissingThenFixed(t *testing.T) { + home, _ := setupDoctorTest(t) + require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), nil, 0644)) + env := newTestDoctorEnv(doctorTestConfig(t)) + + results := doctorCheckHooks(env) + installed := findDoctorResult(t, results, "hooks.installed") + assert.Equal(t, doctorFail, installed.Status) + require.NotNil(t, installed.AutoFix) + assert.Equal(t, "hooks.zsh", installed.AutoFix.Key) + assert.Equal(t, doctorFail, findDoctorResult(t, results, "hooks.script").Status) + + require.NoError(t, installed.AutoFix.Run(nil)) + results = doctorCheckHooks(env) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "hooks.installed").Status) + assert.False(t, hasDoctorResult(results, "hooks.script")) +} + +func TestDoctorCheckHooks_BashNeedsPreexec(t *testing.T) { + home, _ := setupDoctorTest(t) + t.Setenv("SHELL", "/bin/bash") + hooksDir := filepath.Join(home, model.COMMAND_BASE_STORAGE_FOLDER, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "bash.bash"), []byte("# hook\n"), 0644)) + bashrc := "# Added by shelltime CLI\n" + + "export PATH=\"$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER + "/bin:$PATH\"\n" + + "source " + filepath.Join(hooksDir, "bash.bash") + "\n" + require.NoError(t, os.WriteFile(filepath.Join(home, ".bashrc"), []byte(bashrc), 0644)) + env := newTestDoctorEnv(doctorTestConfig(t)) + + results := doctorCheckHooks(env) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "hooks.installed").Status) + assert.Equal(t, doctorFail, findDoctorResult(t, results, "hooks.bash_preexec").Status) + + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "bash-preexec.sh"), []byte("# preexec\n"), 0644)) + assert.False(t, hasDoctorResult(doctorCheckHooks(env), "hooks.bash_preexec")) +} + +// --- Claude Code -------------------------------------------------------------- + +func TestDoctorCheckClaude(t *testing.T) { + home, _ := setupDoctorTest(t) + env := newTestDoctorEnv(doctorTestConfig(t)) + settingsPath := filepath.Join(home, ".claude", "settings.json") + + assert.Equal(t, doctorSkip, findDoctorResult(t, doctorCheckClaude(env), "claude.detected").Status) + + require.NoError(t, os.MkdirAll(filepath.Dir(settingsPath), 0755)) + results := doctorCheckClaude(env) + otelResult := findDoctorResult(t, results, "claude.otel") + assert.Equal(t, doctorFail, otelResult.Status) + require.NotNil(t, otelResult.AutoFix) + assert.Equal(t, "claude.install", otelResult.AutoFix.Key) + statusLine := findDoctorResult(t, results, "claude.statusline") + assert.Equal(t, doctorInfo, statusLine.Status) + assert.Contains(t, statusLine.Fix, doctorStatusLineCommand) + assert.False(t, env.claudeOtel) + + require.NoError(t, os.WriteFile(settingsPath, []byte(`{"env": `), 0644)) + otelResult = findDoctorResult(t, doctorCheckClaude(env), "claude.otel") + assert.Equal(t, doctorFail, otelResult.Status) + assert.Nil(t, otelResult.AutoFix, "cc install can't fix invalid JSON") + + settings := `{"env": {"CLAUDE_CODE_ENABLE_TELEMETRY": "1", "OTEL_EXPORTER_OTLP_ENDPOINT": "` + model.AICodeOtelEndpoint + `"}, + "statusLine": {"type": "command", "command": "shelltime cc statusline"}}` + require.NoError(t, os.WriteFile(settingsPath, []byte(settings), 0644)) + results = doctorCheckClaude(env) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "claude.otel").Status) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "claude.statusline").Status) + assert.True(t, env.claudeOtel) + assert.False(t, hasDoctorResult(results, "claude.legacy_env")) + + // An OTEL block written to ~/.zshrc by older versions is flagged for migration. + require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), nil, 0644)) + require.NoError(t, model.NewZshAICodeOtelEnvService().Install()) + legacy := findDoctorResult(t, doctorCheckClaude(env), "claude.legacy_env") + assert.Equal(t, doctorWarn, legacy.Status) + require.NotNil(t, legacy.AutoFix) + assert.Equal(t, "claude.install", legacy.AutoFix.Key) + + require.NoError(t, os.WriteFile(settingsPath, []byte(`{"statusLine": {"command": "~/bin/my-statusline"}}`), 0644)) + statusLine = findDoctorResult(t, doctorCheckClaude(env), "claude.statusline") + assert.Equal(t, doctorInfo, statusLine.Status) + assert.Contains(t, statusLine.Message, "my-statusline") +} + +// --- Codex -------------------------------------------------------------------- + +func TestDoctorCheckCodex(t *testing.T) { + home, _ := setupDoctorTest(t) + env := newTestDoctorEnv(doctorTestConfig(t)) + configPath := filepath.Join(home, ".codex", "config.toml") + + assert.Equal(t, doctorSkip, findDoctorResult(t, doctorCheckCodex(env), "codex.detected").Status) + + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) + otelResult := findDoctorResult(t, doctorCheckCodex(env), "codex.otel") + assert.Equal(t, doctorFail, otelResult.Status) + require.NotNil(t, otelResult.AutoFix) + assert.Equal(t, "codex.install", otelResult.AutoFix.Key) + + require.NoError(t, os.WriteFile(configPath, []byte("[otel.exporter.otlp-grpc]\nendpoint = \"http://localhost:4317\"\n"), 0644)) + otelResult = findDoctorResult(t, doctorCheckCodex(env), "codex.otel") + assert.Equal(t, doctorFail, otelResult.Status) + assert.Contains(t, otelResult.Message, "4317") + assert.NotNil(t, otelResult.AutoFix) + + require.NoError(t, os.WriteFile(configPath, []byte("[otel\n"), 0644)) + otelResult = findDoctorResult(t, doctorCheckCodex(env), "codex.otel") + assert.Equal(t, doctorFail, otelResult.Status) + assert.Nil(t, otelResult.AutoFix, "codex install can't fix invalid TOML") + + require.NoError(t, os.Remove(configPath)) + require.NoError(t, model.NewCodexOtelConfigService().Install()) + results := doctorCheckCodex(env) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "codex.otel").Status) + assert.True(t, env.codexOtel) + assert.False(t, hasDoctorResult(results, "codex.auth")) + + doctorCodexInstallationStatus = daemon.CodexInstallationStatus + assert.Equal(t, doctorInfo, findDoctorResult(t, doctorCheckCodex(env), "codex.auth").Status, "no auth.json") +} + +// --- AI usage receiver -------------------------------------------------------- + +func TestDoctorCheckOtelReceiver(t *testing.T) { + setupDoctorTest(t) + env := newTestDoctorEnv(doctorTestConfig(t)) + + assert.Equal(t, doctorSkip, findDoctorResult(t, doctorCheckOtelReceiver(env), "otel.enabled").Status) + + env.claudeOtel = true + enabled := findDoctorResult(t, doctorCheckOtelReceiver(env), "otel.enabled") + assert.Equal(t, doctorFail, enabled.Status) + assert.Contains(t, enabled.Fix, "aiCodeOtel.enabled: true") + + env.cfg.AICodeOtel = &model.AICodeOtel{Enabled: new(true), GRPCPort: 4317} + assert.Equal(t, doctorFail, findDoctorResult(t, doctorCheckOtelReceiver(env), "otel.port").Status) + + env.cfg.AICodeOtel.GRPCPort = 0 + assert.Equal(t, doctorSkip, findDoctorResult(t, doctorCheckOtelReceiver(env), "otel.listening").Status, "daemon not running") + + env.daemonStatus = &daemon.StatusResponse{Version: "v0.1.91"} + listening := findDoctorResult(t, doctorCheckOtelReceiver(env), "otel.listening") + assert.Equal(t, doctorFail, listening.Status) + require.NotNil(t, listening.AutoFix) + assert.Equal(t, "daemon.install", listening.AutoFix.Key) + + var dialed string + doctorDialTCP = func(_, address string, _ time.Duration) (net.Conn, error) { + dialed = address + client, server := net.Pipe() + server.Close() + return client, nil + } + assert.Equal(t, doctorOK, findDoctorResult(t, doctorCheckOtelReceiver(env), "otel.listening").Status) + assert.Equal(t, "127.0.0.1:54027", dialed) +} + +// --- Sync --------------------------------------------------------------------- + +func TestDoctorCheckSync(t *testing.T) { + home, _ := setupDoctorTest(t) + env := newTestDoctorEnv(doctorTestConfig(t)) + + results := doctorCheckSync(env) + assert.Equal(t, doctorOK, findDoctorResult(t, results, "sync.pending").Status) + assert.False(t, hasDoctorResult(results, "sync.heartbeats")) + + base := filepath.Join(home, model.COMMAND_BASE_STORAGE_FOLDER) + require.NoError(t, os.MkdirAll(base, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(base, "sync-pending.jsonl"), []byte("{}\n\n \n{}\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(base, "coding-heartbeat.data.log"), []byte("a\nb\nc"), 0644)) + + results = doctorCheckSync(env) + pending := findDoctorResult(t, results, "sync.pending") + assert.Equal(t, doctorWarn, pending.Status) + assert.Contains(t, pending.Message, "2 upload") + assert.Contains(t, findDoctorResult(t, results, "sync.heartbeats").Message, "3 editor") +} + +func TestCountNonEmptyLines(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "f") + + _, err := countNonEmptyLines(path) + assert.True(t, os.IsNotExist(err)) + + // A line longer than bufio.Scanner's limit, CRLF endings, a blank line and no final newline. + content := strings.Repeat("x", 200*1024) + "\r\n\r\n \t\nsecond\r\nthird" + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + count, err := countNonEmptyLines(path) + require.NoError(t, err) + assert.Equal(t, 3, count) +} + +// --- Rendering ---------------------------------------------------------------- + +func TestGroupDoctorActionsAndFixes(t *testing.T) { + daemonFix := &doctorFix{Key: "daemon.install"} + hookFix := &doctorFix{Key: "hooks.zsh"} + results := []doctorResult{ + {ID: "a", Status: doctorWarn, Fix: "Run `shelltime daemon install` (downloads it).", AutoFix: daemonFix}, + {ID: "b", Status: doctorOK}, + {ID: "c", Status: doctorFail, Fix: "Run `shelltime hooks install`.", AutoFix: hookFix}, + {ID: "d", Status: doctorWarn, Fix: "Run `shelltime daemon install`.", AutoFix: daemonFix}, + {ID: "e", Status: doctorFail, Fix: "Run `shelltime init`."}, + {ID: "f", Status: doctorFail, Fix: "Run `shelltime init`."}, + {ID: "g", Status: doctorWarn}, + {ID: "h", Status: doctorInfo, Fix: "tip", AutoFix: &doctorFix{Key: "info.only"}}, + } + + actions := groupDoctorActions(results) + require.Len(t, actions, 4) + assert.Equal(t, []string{"c"}, doctorActionIDs(actions[0]), "failures come first") + assert.Equal(t, []string{"e", "f"}, doctorActionIDs(actions[1]), "same fix text is grouped") + assert.Equal(t, []string{"a", "d"}, doctorActionIDs(actions[2]), "same auto-fix key is grouped") + assert.Equal(t, []string{"g"}, doctorActionIDs(actions[3])) + + fixes := collectDoctorFixes(results) + require.Len(t, fixes, 2, "info results never contribute fixes") + assert.Same(t, hookFix, fixes[0]) + assert.Same(t, daemonFix, fixes[1]) + + assert.NotPanics(t, func() { + printDoctorReport(results) + printDoctorSummary(nil, false) + }) +} + +func doctorActionIDs(action *doctorAction) []string { + var ids []string + for _, r := range action.problems { + ids = append(ids, r.ID) + } + return ids +} + +// --- End to end --------------------------------------------------------------- + +func TestCommandDoctor_HealthySetupExitsCleanly(t *testing.T) { + home, mc := setupDoctorTest(t) + writeDoctorConfigFile(t, home) + mc.On("ReadConfigFile", mock.Anything).Return(doctorTestConfig(t), nil) + require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), nil, 0644)) + require.NoError(t, model.NewZshHookService().Install()) + + // The daemon isn't running, but that is only a warning. + require.NoError(t, doctorTestApp().Run([]string{"t", "doctor", "--offline"})) +} + +func TestCommandDoctor_JSONReport(t *testing.T) { + home, mc := setupDoctorTest(t) + writeDoctorConfigFile(t, home) + mc.On("ReadConfigFile", mock.Anything).Return(doctorTestConfig(t), nil) + require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), nil, 0644)) + out := &bytes.Buffer{} + doctorOut = out + + err := doctorTestApp().Run([]string{"t", "doctor", "--offline", "--format", "json"}) + require.ErrorIs(t, err, ErrDoctorFoundProblems) + + var report doctorReport + require.NoError(t, json.Unmarshal(out.Bytes(), &report), out.String()) + assert.Equal(t, "v0.1.91", report.Version) + assert.Equal(t, 2, report.Summary.Fail, "hook missing and hook script missing") + installed := findDoctorResult(t, report.Checks, "hooks.installed") + assert.Equal(t, "Shell Hooks", installed.Section) + require.NotNil(t, installed.AutoFix) + assert.Equal(t, "hooks.zsh", installed.AutoFix.Key) + assert.NotContains(t, out.String(), home, "paths under HOME are shown as ~") +} + +func TestCommandDoctor_ConfigErrorKeepsChecking(t *testing.T) { + home, mc := setupDoctorTest(t) + writeDoctorConfigFile(t, home) + mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{}, assert.AnError) + out := &bytes.Buffer{} + doctorOut = out + + err := doctorTestApp().Run([]string{"t", "doctor", "--offline", "--format", "json"}) + require.ErrorIs(t, err, ErrDoctorFoundProblems) + + var report doctorReport + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + assert.Equal(t, doctorFail, findDoctorResult(t, report.Checks, "config.parse").Status) + assert.True(t, hasDoctorResult(report.Checks, "hooks.installed"), "checks after the config still run") +} + +func TestCommandDoctor_FlagValidation(t *testing.T) { + setupDoctorTest(t) + + err := doctorTestApp().Run([]string{"t", "doctor", "--format", "xml"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported format") + + err = doctorTestApp().Run([]string{"t", "doctor", "--fix", "--format", "json"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--fix") +} + +func TestCommandDoctor_FixAppliesAutomaticFixes(t *testing.T) { + home, mc := setupDoctorTest(t) + writeDoctorConfigFile(t, home) + cfg := doctorTestConfig(t) + cfg.AICodeOtel = &model.AICodeOtel{Enabled: new(true)} + mc.On("ReadConfigFile", mock.Anything).Return(cfg, nil) + require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), nil, 0644)) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0755)) + + daemonInstalls := 0 + doctorRunDaemonInstall = func(*cli.Context) error { + daemonInstalls++ + return nil + } + doctorDaemonServiceCheck = func() error { return errors.New("inactive") } + + // Problems left after the fixes are only daemon warnings, so doctor exits cleanly. + require.NoError(t, doctorTestApp().Run([]string{"t", "doctor", "--offline", "--fix", "--yes"})) + + assert.Equal(t, 1, daemonInstalls, "one daemon install for every daemon problem") + zshrc, err := os.ReadFile(filepath.Join(home, ".zshrc")) + require.NoError(t, err) + assert.Contains(t, string(zshrc), "# Added by shelltime CLI") + settings, err := os.ReadFile(filepath.Join(home, ".claude", "settings.json")) + require.NoError(t, err) + assert.Contains(t, string(settings), model.AICodeOtelEndpoint) +} + +func TestCommandDoctor_FixDeclined(t *testing.T) { + home, mc := setupDoctorTest(t) + writeDoctorConfigFile(t, home) + mc.On("ReadConfigFile", mock.Anything).Return(doctorTestConfig(t), nil) + require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), nil, 0644)) + doctorStdin = strings.NewReader("n\n") + + err := doctorTestApp().Run([]string{"t", "doctor", "--offline", "--fix"}) + require.ErrorIs(t, err, ErrDoctorFoundProblems) + + zshrc, err := os.ReadFile(filepath.Join(home, ".zshrc")) + require.NoError(t, err) + assert.Empty(t, zshrc, "nothing is changed when the prompt is declined") +} diff --git a/commands/misc_commands_test.go b/commands/misc_commands_test.go index ba8d3b1..15c5888 100644 --- a/commands/misc_commands_test.go +++ b/commands/misc_commands_test.go @@ -1,13 +1,10 @@ package commands import ( - "os" - "path/filepath" "testing" "github.com/malamtime/cli/model" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" "go.opentelemetry.io/otel" @@ -36,69 +33,6 @@ func TestInjectVarAndAIService(t *testing.T) { assert.Equal(t, model.AIService(ai), aiService) } -// --- doctor command ----------------------------------------------------------- - -func setupMiscTest(t *testing.T) *model.MockConfigService { - t.Helper() - otel.SetTracerProvider(noop.NewTracerProvider()) - SKIP_LOGGER_SETTINGS = true - orig := configService - mc := model.NewMockConfigService(t) - configService = mc - t.Cleanup(func() { configService = orig }) - return mc -} - -func TestCommandDoctor_Success(t *testing.T) { - mc := setupMiscTest(t) - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("SHELL", "/bin/bash") - // Create the .shelltime dir so the directory check reports success. - require.NoError(t, os.MkdirAll(filepath.Join(home, ".shelltime"), 0755)) - - enabled := true - mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{ - DataMasking: &enabled, - Encrypted: &enabled, - EnableMetrics: &enabled, - }, nil) - - app := &cli.App{Name: "t", Commands: []*cli.Command{DoctorCommand}} - // commandDoctor ignores daemon Check() failures and returns nil on a valid - // config. - err := app.Run([]string{"t", "doctor"}) - require.NoError(t, err) -} - -func TestCommandDoctor_ConfigError(t *testing.T) { - mc := setupMiscTest(t) - home := t.TempDir() - t.Setenv("HOME", home) - require.NoError(t, os.MkdirAll(filepath.Join(home, ".shelltime"), 0755)) - - mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{}, assert.AnError) - - app := &cli.App{Name: "t", Commands: []*cli.Command{DoctorCommand}} - err := app.Run([]string{"t", "doctor"}) - require.Error(t, err) - assert.Equal(t, assert.AnError, err) -} - -func TestCommandDoctor_NoShelltimeDir(t *testing.T) { - mc := setupMiscTest(t) - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("SHELL", "") - // .shelltime dir intentionally missing -> "does not exist" branch; the action - // still proceeds to read config and returns nil. - mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{}, nil) - - app := &cli.App{Name: "t", Commands: []*cli.Command{DoctorCommand}} - err := app.Run([]string{"t", "doctor"}) - require.NoError(t, err) -} - // --- hooks install / uninstall ------------------------------------------------ func TestCommandHooksInstall_BinaryNotFound(t *testing.T) { diff --git a/docs/CONFIG.md b/docs/CONFIG.md index b3fbdbc..4ab91fb 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -494,16 +494,31 @@ All ShellTime data lives in `~/.shelltime/`: โ”œโ”€โ”€ config.yaml # Main configuration โ”œโ”€โ”€ config.local.yaml # Local overrides (add to .gitignore) โ”œโ”€โ”€ log.log # CLI logs -โ”œโ”€โ”€ sync-pending.txt # Pending sync data +โ”œโ”€โ”€ sync-pending.jsonl # Uploads queued for retry โ””โ”€โ”€ logs/ # Daemon logs (macOS) ``` ### How do I check my current configuration? ```bash -shelltime doctor +shelltime config view # the merged configuration +shelltime doctor # a health check of the whole setup ``` +`shelltime doctor` checks the config files, your token (against the server), data masking and +encryption, the daemon, the shell hook for your current shell, the Claude Code and Codex +integrations, the AI usage receiver (`aiCodeOtel`) and queued uploads. Every problem comes with +the command or config change that fixes it. + +```bash +shelltime doctor --fix # apply the safe fixes (hooks, cc/codex install, daemon) after confirming +shelltime doctor --fix --yes # same, without the prompt +shelltime doctor --offline # skip the network checks (token, encryption key, latest version) +shelltime doctor --format json # machine-readable report +``` + +It exits with status 1 when any check fails, so scripts can gate on it. + ### Why isn't my local config being applied? 1. Ensure file is named exactly `config.local.yaml` (or `config.local.toml`) diff --git a/model/aicode_otel_claude_settings.go b/model/aicode_otel_claude_settings.go index f2c73a4..f430014 100644 --- a/model/aicode_otel_claude_settings.go +++ b/model/aicode_otel_claude_settings.go @@ -44,7 +44,7 @@ func claudeSettingsOtelEnvVars() []claudeSettingsEnvVar { {"OTEL_METRICS_EXPORTER", "otlp"}, {"OTEL_LOGS_EXPORTER", "otlp"}, {"OTEL_EXPORTER_OTLP_PROTOCOL", "grpc"}, - {"OTEL_EXPORTER_OTLP_ENDPOINT", aiCodeOtelEndpoint}, + {"OTEL_EXPORTER_OTLP_ENDPOINT", AICodeOtelEndpoint}, {"OTEL_METRIC_EXPORT_INTERVAL", "10000"}, {"OTEL_LOGS_EXPORT_INTERVAL", "5000"}, {"OTEL_LOG_USER_PROMPTS", "1"}, @@ -163,12 +163,34 @@ func (s *ClaudeSettingsAICodeOtelEnvService) Check() error { return fmt.Errorf("failed to parse %s: %w", s.settingsPath, err) } - if _, ok := settings.Env["CLAUDE_CODE_ENABLE_TELEMETRY"]; !ok || settings.Env["OTEL_EXPORTER_OTLP_ENDPOINT"] != aiCodeOtelEndpoint { + if _, ok := settings.Env["CLAUDE_CODE_ENABLE_TELEMETRY"]; !ok || settings.Env["OTEL_EXPORTER_OTLP_ENDPOINT"] != AICodeOtelEndpoint { return fmt.Errorf("Claude Code OTEL config not found in %s", s.settingsPath) } return nil } +// StatusLineCommand returns the statusLine.command configured in settings.json. +// A missing file or key yields an empty string and no error. +func (s *ClaudeSettingsAICodeOtelEnvService) StatusLineCommand() (string, error) { + data, _, err := s.readSettings() + if err != nil || data == nil { + return "", err + } + + var settings struct { + StatusLine *struct { + Command string `json:"command"` + } `json:"statusLine"` + } + if err := json.Unmarshal(data, &settings); err != nil { + return "", fmt.Errorf("failed to parse %s: %w", s.settingsPath, err) + } + if settings.StatusLine == nil { + return "", nil + } + return settings.StatusLine.Command, nil +} + // readSettings returns the settings file content and mode. A missing file yields nil data. func (s *ClaudeSettingsAICodeOtelEnvService) readSettings() ([]byte, os.FileMode, error) { info, err := os.Stat(s.settingsPath) diff --git a/model/aicode_otel_claude_settings_test.go b/model/aicode_otel_claude_settings_test.go index 6aca284..de422e8 100644 --- a/model/aicode_otel_claude_settings_test.go +++ b/model/aicode_otel_claude_settings_test.go @@ -72,7 +72,7 @@ func TestClaudeSettingsOtel_InstallCreatesMissingFile(t *testing.T) { for _, v := range claudeSettingsOtelEnvVars() { assert.Equal(t, v.Value, env[v.Key], "env var %s", v.Key) } - assert.Equal(t, aiCodeOtelEndpoint, env["OTEL_EXPORTER_OTLP_ENDPOINT"]) + assert.Equal(t, AICodeOtelEndpoint, env["OTEL_EXPORTER_OTLP_ENDPOINT"]) attrs, _ := env["OTEL_RESOURCE_ATTRIBUTES"].(string) assert.Contains(t, attrs, "team.id=shelltime") @@ -146,7 +146,7 @@ func TestClaudeSettingsOtel_InstallReplacesStaleValueInPlace(t *testing.T) { assert.Equal(t, []string{"OTEL_EXPORTER_OTLP_ENDPOINT", "FOO"}, envKeys[:2], "existing keys keep their position") env := readClaudeSettingsEnv(t, path) - assert.Equal(t, aiCodeOtelEndpoint, env["OTEL_EXPORTER_OTLP_ENDPOINT"]) + assert.Equal(t, AICodeOtelEndpoint, env["OTEL_EXPORTER_OTLP_ENDPOINT"]) assert.Equal(t, "bar", env["FOO"]) } @@ -247,3 +247,25 @@ func TestClaudeSettingsOtel_InstallKeepsFileMode(t *testing.T) { require.NoError(t, err) assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) } + +func TestClaudeSettings_StatusLineCommand(t *testing.T) { + svc, path := setupClaudeSettingsTest(t) + + cmd, err := svc.StatusLineCommand() + require.NoError(t, err, "missing settings.json is not an error") + assert.Empty(t, cmd) + + writeClaudeSettings(t, path, realisticClaudeSettings) + cmd, err = svc.StatusLineCommand() + require.NoError(t, err) + assert.Empty(t, cmd, "no statusLine key") + + writeClaudeSettings(t, path, `{"statusLine": {"type": "command", "command": "shelltime cc statusline"}}`) + cmd, err = svc.StatusLineCommand() + require.NoError(t, err) + assert.Equal(t, "shelltime cc statusline", cmd) + + writeClaudeSettings(t, path, `{"statusLine": `) + _, err = svc.StatusLineCommand() + assert.Error(t, err) +} diff --git a/model/aicode_otel_env.go b/model/aicode_otel_env.go index 8ffb796..9845ea2 100644 --- a/model/aicode_otel_env.go +++ b/model/aicode_otel_env.go @@ -12,7 +12,12 @@ import ( const ( aiCodeOtelMarkerStart = "# >>> shelltime cc otel >>>" aiCodeOtelMarkerEnd = "# <<< shelltime cc otel <<<" - aiCodeOtelEndpoint = "http://localhost:54027" + + // DefaultAICodeOtelGRPCPort is the port the daemon's OTEL receiver listens on by default. + DefaultAICodeOtelGRPCPort = 54027 + // AICodeOtelEndpoint is the OTLP endpoint `cc install` and `codex install` point Claude Code + // and Codex at. It is fixed to the default port, so a custom aiCodeOtel.grpcPort breaks them. + AICodeOtelEndpoint = "http://localhost:54027" ) // AICodeOtelEnvService interface for shell-specific env var setup @@ -114,7 +119,7 @@ func NewBashAICodeOtelEnvService() AICodeOtelEnvService { "export OTEL_METRICS_EXPORTER=otlp", "export OTEL_LOGS_EXPORTER=otlp", "export OTEL_EXPORTER_OTLP_PROTOCOL=grpc", - "export OTEL_EXPORTER_OTLP_ENDPOINT=" + aiCodeOtelEndpoint, + "export OTEL_EXPORTER_OTLP_ENDPOINT=" + AICodeOtelEndpoint, "export OTEL_METRIC_EXPORT_INTERVAL=10000", "export OTEL_LOGS_EXPORT_INTERVAL=5000", "export OTEL_LOG_USER_PROMPTS=1", @@ -206,7 +211,7 @@ func NewZshAICodeOtelEnvService() AICodeOtelEnvService { "export OTEL_METRICS_EXPORTER=otlp", "export OTEL_LOGS_EXPORTER=otlp", "export OTEL_EXPORTER_OTLP_PROTOCOL=grpc", - "export OTEL_EXPORTER_OTLP_ENDPOINT=" + aiCodeOtelEndpoint, + "export OTEL_EXPORTER_OTLP_ENDPOINT=" + AICodeOtelEndpoint, "export OTEL_METRIC_EXPORT_INTERVAL=10000", "export OTEL_LOGS_EXPORT_INTERVAL=5000", "export OTEL_LOG_USER_PROMPTS=1", @@ -295,7 +300,7 @@ func NewFishAICodeOtelEnvService() AICodeOtelEnvService { "set -gx OTEL_METRICS_EXPORTER otlp", "set -gx OTEL_LOGS_EXPORTER otlp", "set -gx OTEL_EXPORTER_OTLP_PROTOCOL grpc", - "set -gx OTEL_EXPORTER_OTLP_ENDPOINT " + aiCodeOtelEndpoint, + "set -gx OTEL_EXPORTER_OTLP_ENDPOINT " + AICodeOtelEndpoint, "set -gx OTEL_METRIC_EXPORT_INTERVAL 10000", "set -gx OTEL_LOGS_EXPORT_INTERVAL 5000", "set -gx OTEL_LOG_USER_PROMPTS 1", diff --git a/model/aicode_otel_env_test.go b/model/aicode_otel_env_test.go index 442b25e..d59f1e8 100644 --- a/model/aicode_otel_env_test.go +++ b/model/aicode_otel_env_test.go @@ -71,7 +71,7 @@ func TestBashAICodeOtelEnv_InstallCreatesFileAndMarkers(t *testing.T) { assert.Contains(t, s, aiCodeOtelMarkerStart) assert.Contains(t, s, aiCodeOtelMarkerEnd) assert.Contains(t, s, "export CLAUDE_CODE_ENABLE_TELEMETRY=1") - assert.Contains(t, s, "export OTEL_EXPORTER_OTLP_ENDPOINT="+aiCodeOtelEndpoint) + assert.Contains(t, s, "export OTEL_EXPORTER_OTLP_ENDPOINT="+AICodeOtelEndpoint) assert.Equal(t, 1, countMarkers(t, bashrc)) // Installing twice must not duplicate the marker block (remove-then-add). @@ -165,7 +165,7 @@ func TestFishAICodeOtelEnv_InstallRequiresExistingFile(t *testing.T) { content, err := os.ReadFile(fishConfig) require.NoError(t, err) assert.Contains(t, string(content), "set -gx CLAUDE_CODE_ENABLE_TELEMETRY 1") - assert.Contains(t, string(content), "set -gx OTEL_EXPORTER_OTLP_ENDPOINT "+aiCodeOtelEndpoint) + assert.Contains(t, string(content), "set -gx OTEL_EXPORTER_OTLP_ENDPOINT "+AICodeOtelEndpoint) require.NoError(t, svc.Uninstall()) assert.Equal(t, 0, countMarkers(t, fishConfig)) diff --git a/model/codex_otel_config.go b/model/codex_otel_config.go index 052a29a..9de0a1a 100644 --- a/model/codex_otel_config.go +++ b/model/codex_otel_config.go @@ -18,6 +18,8 @@ type CodexOtelConfigService interface { Install() error Uninstall() error Check() (bool, error) + // Endpoint returns otel.exporter.otlp-grpc.endpoint, or "" when it is not set. + Endpoint() (string, error) } type codexOtelConfigService struct { @@ -55,7 +57,7 @@ func (s *codexOtelConfigService) Install() error { "log_user_prompt": true, "exporter": map[string]interface{}{ "otlp-grpc": map[string]interface{}{ - "endpoint": aiCodeOtelEndpoint, + "endpoint": AICodeOtelEndpoint, }, }, } @@ -130,3 +132,29 @@ func (s *codexOtelConfigService) Check() (bool, error) { _, exists := config["otel"] return exists, nil } + +func (s *codexOtelConfigService) Endpoint() (string, error) { + data, err := os.ReadFile(s.configPath) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("failed to read config file: %w", err) + } + + // Walk a generic map: `exporter` may also be a plain string such as "none". + config := make(map[string]interface{}) + if err := toml.Unmarshal(data, &config); err != nil { + return "", fmt.Errorf("failed to parse config: %w", err) + } + var node interface{} = config + for _, key := range []string{"otel", "exporter", "otlp-grpc", "endpoint"} { + table, ok := node.(map[string]interface{}) + if !ok { + return "", nil + } + node = table[key] + } + endpoint, _ := node.(string) + return endpoint, nil +} diff --git a/model/codex_otel_config_test.go b/model/codex_otel_config_test.go index 4abfbd6..c1599d9 100644 --- a/model/codex_otel_config_test.go +++ b/model/codex_otel_config_test.go @@ -38,7 +38,7 @@ func TestCodexOtelConfig_InstallCreatesConfig(t *testing.T) { require.True(t, ok) grpc, ok := exporter["otlp-grpc"].(map[string]interface{}) require.True(t, ok) - assert.Equal(t, aiCodeOtelEndpoint, grpc["endpoint"]) + assert.Equal(t, AICodeOtelEndpoint, grpc["endpoint"]) // Now Check reports installed. ok, err = svc.Check() @@ -108,3 +108,29 @@ func TestCodexOtelConfig_CheckMalformedConfig(t *testing.T) { assert.False(t, ok) assert.Contains(t, err.Error(), "failed to parse config") } + +func TestCodexOtelConfig_Endpoint(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + svc := NewCodexOtelConfigService() + configPath := filepath.Join(home, ".codex", "config.toml") + + endpoint, err := svc.Endpoint() + require.NoError(t, err, "missing config is not an error") + assert.Empty(t, endpoint) + + require.NoError(t, svc.Install()) + endpoint, err = svc.Endpoint() + require.NoError(t, err) + assert.Equal(t, AICodeOtelEndpoint, endpoint) + + // `exporter` may be a plain string in a valid Codex config. + require.NoError(t, os.WriteFile(configPath, []byte("[otel]\nexporter = \"none\"\n"), 0644)) + endpoint, err = svc.Endpoint() + require.NoError(t, err) + assert.Empty(t, endpoint) + + require.NoError(t, os.WriteFile(configPath, []byte("[otel\n"), 0644)) + _, err = svc.Endpoint() + assert.Error(t, err) +} diff --git a/model/config.go b/model/config.go index 959aacc..d0df7fa 100644 --- a/model/config.go +++ b/model/config.go @@ -50,6 +50,24 @@ type configFiles struct { localFormat configFormat } +// ConfigFilePaths returns the base and local config files ReadConfigFile would load from +// configDir. Either is empty when no such file exists. +func ConfigFilePaths(configDir string) (base, local string) { + files := findConfigFiles(configDir) + return files.baseFile, files.localFile +} + +// ValidateConfigFile reports whether the config file at path parses, using the format implied +// by its extension. ReadConfigFile silently skips a local override that fails this check. +func ValidateConfigFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + var config ShellTimeConfig + return unmarshalConfig(data, detectFormat(path), &config) +} + // findConfigFiles discovers config files in priority order // Priority: config.local.yaml > config.local.yml > config.yaml > config.yml > config.local.toml > config.toml func findConfigFiles(configDir string) configFiles { @@ -259,7 +277,7 @@ func (cs *configService) ReadConfigFile(ctx context.Context, opts ...ReadConfigO // Initialize AICodeOtel config with default port if enabled but port not set if config.AICodeOtel != nil && config.AICodeOtel.GRPCPort == 0 { - config.AICodeOtel.GRPCPort = 54027 // default OTEL gRPC port + config.AICodeOtel.GRPCPort = DefaultAICodeOtelGRPCPort } if config.AICodeOtel != nil && config.AICodeOtel.Debug != nil && *config.AICodeOtel.Debug { diff --git a/model/config_test.go b/model/config_test.go index b08a93d..98ebccd 100644 --- a/model/config_test.go +++ b/model/config_test.go @@ -625,3 +625,27 @@ noProxy = ['localhost', '10.0.0.0/8']` assert.Nil(t, config.Proxy) }) } + +func TestConfigFilePathsAndValidate(t *testing.T) { + dir := t.TempDir() + + base, local := ConfigFilePaths(dir) + assert.Empty(t, base) + assert.Empty(t, local) + + basePath := filepath.Join(dir, "config.yaml") + localPath := filepath.Join(dir, "config.local.toml") + require.NoError(t, os.WriteFile(basePath, []byte("token: abc\n"), 0644)) + require.NoError(t, os.WriteFile(localPath, []byte("Token = \"x\"\n"), 0644)) + + base, local = ConfigFilePaths(dir) + assert.Equal(t, basePath, base) + assert.Equal(t, localPath, local) + + assert.NoError(t, ValidateConfigFile(basePath)) + assert.NoError(t, ValidateConfigFile(localPath)) + + require.NoError(t, os.WriteFile(localPath, []byte("Token = \n"), 0644)) + assert.Error(t, ValidateConfigFile(localPath)) + assert.Error(t, ValidateConfigFile(filepath.Join(dir, "missing.yaml"))) +} diff --git a/model/types.go b/model/types.go index 3eab384..f1afc60 100644 --- a/model/types.go +++ b/model/types.go @@ -142,7 +142,7 @@ var DefaultConfig = ShellTimeConfig{ Exclude: []string{}, AICodeOtel: new(AICodeOtel{ Enabled: new(true), - GRPCPort: 54027, + GRPCPort: DefaultAICodeOtelGRPCPort, Debug: new(false), }), CodeTracking: new(CodeTracking{