diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index a931d69..5d67363 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -7,11 +7,22 @@ import ( "runtime" "github.com/CoreyRDean/intent/internal/config" + "github.com/CoreyRDean/intent/internal/daemon" intentruntime "github.com/CoreyRDean/intent/internal/runtime" "github.com/CoreyRDean/intent/internal/state" "github.com/CoreyRDean/intent/internal/version" ) +type daemonStatusCaller interface { + Call(req daemon.Request) (*daemon.Response, error) +} + +var newDaemonStatusClient = func(socket string) daemonStatusCaller { + return daemon.NewClient(socket) +} + +var daemonServiceInstalled = daemon.IsInstalled + func cmdDoctor(_ context.Context, _ []string) int { ok := true check := func(name, status string, good bool) { @@ -36,18 +47,23 @@ func cmdDoctor(_ context.Context, _ []string) int { check("cache directory", dirs.Cache, true) } - cfg, _ := config.Load(dirs.ConfigPath()) - if cfg != nil { - check("config", fmt.Sprintf("backend=%s model=%s", cfg.Backend, cfg.Model), true) - } + if err == nil { + cfg, _ := config.Load(dirs.ConfigPath()) + if cfg != nil { + check("config", fmt.Sprintf("backend=%s model=%s", cfg.Backend, cfg.Model), true) + } - rt := intentruntime.New(dirs.Cache) - check("llamafile runtime", - fmt.Sprintf("expected at %s", rt.LlamafilePath()), - rt.HaveLlamafile()) + rt := intentruntime.New(dirs.Cache) + check("llamafile runtime", + fmt.Sprintf("expected at %s", rt.LlamafilePath()), + rt.HaveLlamafile()) - modelFile, modelStatus := resolveModelCheck(cfg) - check("model", fmt.Sprintf("%s — %s", modelStatus, rt.ModelPath(modelFile)), rt.HaveModel(modelFile)) + modelFile, modelStatus := resolveModelCheck(cfg) + check("model", fmt.Sprintf("%s — %s", modelStatus, rt.ModelPath(modelFile)), rt.HaveModel(modelFile)) + + daemonStatus, daemonOK := doctorDaemonStatus(dirs) + check("daemon", daemonStatus, daemonOK) + } // Sandbox tooling. switch runtime.GOOS { @@ -91,3 +107,26 @@ func okStr(err error) string { } return "missing" } + +func doctorDaemonStatus(dirs state.Dirs) (string, bool) { + installed := daemonServiceInstalled(daemonLabel) + resp, err := newDaemonStatusClient(dirs.SocketPath()).Call(daemon.Request{Op: daemon.OpStatus}) + if err != nil { + if installed { + return "installed but not responding", false + } + return "not running (optional)", true + } + if !resp.OK { + return "unhealthy: " + resp.Error, false + } + + serviceState := "no" + if installed { + serviceState = "yes" + } + if endpoint, _ := resp.Data["llamafile_endpoint"].(string); endpoint != "" { + return fmt.Sprintf("running (service installed: %s, endpoint: %s)", serviceState, endpoint), true + } + return fmt.Sprintf("running (service installed: %s)", serviceState), true +} diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index c70dc46..80010f1 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -1,10 +1,15 @@ package cli import ( + "context" + "errors" + "strings" "testing" "github.com/CoreyRDean/intent/internal/config" + "github.com/CoreyRDean/intent/internal/daemon" intentruntime "github.com/CoreyRDean/intent/internal/runtime" + "github.com/CoreyRDean/intent/internal/state" ) func TestResolveModelCheck(t *testing.T) { @@ -52,3 +57,101 @@ func TestResolveModelCheck(t *testing.T) { }) } } + +type stubDaemonStatusClient struct { + resp *daemon.Response + err error +} + +func (s stubDaemonStatusClient) Call(_ daemon.Request) (*daemon.Response, error) { + return s.resp, s.err +} + +func TestDoctorDaemonStatus(t *testing.T) { + origNewClient := newDaemonStatusClient + origInstalled := daemonServiceInstalled + t.Cleanup(func() { + newDaemonStatusClient = origNewClient + daemonServiceInstalled = origInstalled + }) + + dirs := state.Dirs{State: t.TempDir()} + + tests := []struct { + name string + installed bool + client stubDaemonStatusClient + want string + wantOK bool + }{ + { + name: "missing optional daemon is informational", + installed: false, + client: stubDaemonStatusClient{err: errors.New("dial unix: no such file or directory")}, + want: "not running (optional)", + wantOK: true, + }, + { + name: "installed daemon that does not respond is unhealthy", + installed: true, + client: stubDaemonStatusClient{err: errors.New("connection refused")}, + want: "installed but not responding", + wantOK: false, + }, + { + name: "running daemon reports endpoint", + installed: false, + client: stubDaemonStatusClient{resp: &daemon.Response{ + OK: true, + Data: map[string]any{ + "llamafile_endpoint": "http://127.0.0.1:18080", + }, + }}, + want: "running (service installed: no, endpoint: http://127.0.0.1:18080)", + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newDaemonStatusClient = func(string) daemonStatusCaller { return tt.client } + daemonServiceInstalled = func(string) bool { return tt.installed } + + got, gotOK := doctorDaemonStatus(dirs) + if got != tt.want { + t.Fatalf("status = %q, want %q", got, tt.want) + } + if gotOK != tt.wantOK { + t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK) + } + }) + } +} + +func TestDoctorPrintsDaemonStatus(t *testing.T) { + origNewClient := newDaemonStatusClient + origInstalled := daemonServiceInstalled + t.Cleanup(func() { + newDaemonStatusClient = origNewClient + daemonServiceInstalled = origInstalled + }) + + t.Setenv("HOME", t.TempDir()) + t.Setenv("INTENT_STATE_DIR", t.TempDir()) + t.Setenv("INTENT_CACHE_DIR", t.TempDir()) + + newDaemonStatusClient = func(string) daemonStatusCaller { + return stubDaemonStatusClient{err: errors.New("dial unix: no such file or directory")} + } + daemonServiceInstalled = func(string) bool { return false } + + out := captureStdout(func() { + _ = cmdDoctor(context.Background(), nil) + }) + if !strings.Contains(out, "daemon") { + t.Fatalf("doctor output missing daemon line: %q", out) + } + if !strings.Contains(out, "not running (optional)") { + t.Fatalf("doctor output missing optional daemon status: %q", out) + } +}