package cli import ( "context" "errors" "fmt" "io" "os" "strings" "forge.lclr.dev/AI/mcp-framework/config" "forge.lclr.dev/AI/mcp-framework/manifest" "forge.lclr.dev/AI/mcp-framework/secretstore" ) type DoctorStatus string const ( DoctorStatusOK DoctorStatus = "ok" DoctorStatusWarn DoctorStatus = "warn" DoctorStatusFail DoctorStatus = "fail" ) type DoctorResult struct { Name string Status DoctorStatus Summary string Detail string } type DoctorCheck func(context.Context) DoctorResult type DoctorReport struct { Results []DoctorResult } type DoctorSummary struct { OK int Warn int Fail int Total int } type DoctorSecret struct { Name string Label string } type DoctorManifestValidator func(manifest.File, string) []string type DoctorOptions struct { ConfigCheck DoctorCheck SecretStoreCheck DoctorCheck SecretBackendPolicy secretstore.BackendPolicy RequiredSecrets []DoctorSecret SecretStoreFactory func() (secretstore.Store, error) ManifestDir string ManifestValidator DoctorManifestValidator ManifestCheck DoctorCheck ConnectivityCheck DoctorCheck BitwardenOptions BitwardenDoctorOptions DisableAutoBitwardenCheck bool ExtraChecks []DoctorCheck } type BitwardenDoctorOptions struct { Command string Debug bool Shell string LookupEnv func(string) (string, bool) } var checkBitwardenReady = secretstore.EnsureBitwardenReady func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport { checks := make([]DoctorCheck, 0, 5+len(options.ExtraChecks)) if options.ConfigCheck != nil { checks = append(checks, options.ConfigCheck) } if options.SecretStoreCheck != nil { checks = append(checks, options.SecretStoreCheck) } if len(options.RequiredSecrets) > 0 && options.SecretStoreFactory != nil { checks = append(checks, RequiredSecretsCheck(options.SecretStoreFactory, options.RequiredSecrets)) } if options.ManifestCheck != nil { checks = append(checks, options.ManifestCheck) } else if strings.TrimSpace(options.ManifestDir) != "" { checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator)) } if options.ConnectivityCheck != nil { checks = append(checks, options.ConnectivityCheck) } if shouldAutoIncludeBitwardenCheck(options) { checks = append(checks, BitwardenReadyCheck(options.BitwardenOptions)) } checks = append(checks, options.ExtraChecks...) results := make([]DoctorResult, 0, len(checks)) for _, check := range checks { if check == nil { continue } results = append(results, normalizeDoctorResult(check(ctx))) } return DoctorReport{Results: results} } func (r DoctorReport) Summary() DoctorSummary { summary := DoctorSummary{Total: len(r.Results)} for _, result := range r.Results { switch result.Status { case DoctorStatusOK: summary.OK++ case DoctorStatusWarn: summary.Warn++ case DoctorStatusFail: summary.Fail++ default: summary.Fail++ } } return summary } func (r DoctorReport) HasFailures() bool { return r.Summary().Fail > 0 } func RenderDoctorReport(w io.Writer, report DoctorReport) error { for _, result := range report.Results { if _, err := fmt.Fprintf(w, "[%s] %s: %s\n", doctorLabel(result.Status), result.Name, result.Summary); err != nil { return err } if detail := strings.TrimSpace(result.Detail); detail != "" { if _, err := fmt.Fprintf(w, " %s\n", detail); err != nil { return err } } } summary := report.Summary() _, err := fmt.Fprintf( w, "Summary: %d ok, %d warning(s), %d failure(s), %d total\n", summary.OK, summary.Warn, summary.Fail, summary.Total, ) return err } func NewConfigCheck[T any](store config.Store[T]) DoctorCheck { return func(context.Context) DoctorResult { path, err := store.ConfigPath() if err != nil { return DoctorResult{ Name: "config", Status: DoctorStatusFail, Summary: "cannot resolve config path", Detail: err.Error(), } } info, err := os.Stat(path) switch { case err == nil && info.IsDir(): return DoctorResult{ Name: "config", Status: DoctorStatusFail, Summary: "config path is a directory", Detail: path, } case err == nil: if _, err := store.Load(path); err != nil { return DoctorResult{ Name: "config", Status: DoctorStatusFail, Summary: "config file is unreadable or invalid", Detail: err.Error(), } } return DoctorResult{ Name: "config", Status: DoctorStatusOK, Summary: "config file is readable", Detail: path, } case errors.Is(err, os.ErrNotExist): return DoctorResult{ Name: "config", Status: DoctorStatusWarn, Summary: "config file is missing", Detail: path, } default: return DoctorResult{ Name: "config", Status: DoctorStatusFail, Summary: "cannot access config file", Detail: err.Error(), } } } } func SecretStoreAvailabilityCheck(factory func() (secretstore.Store, error)) DoctorCheck { return func(context.Context) DoctorResult { if factory == nil { return DoctorResult{ Name: "secret-store", Status: DoctorStatusWarn, Summary: "secret store check is not configured", } } _, err := factory() if err != nil { return DoctorResult{ Name: "secret-store", Status: DoctorStatusFail, Summary: "secret backend is unavailable", Detail: err.Error(), } } return DoctorResult{ Name: "secret-store", Status: DoctorStatusOK, Summary: "secret backend is available", } } } func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck { return func(context.Context) DoctorResult { err := checkBitwardenReady(secretstore.Options{ BitwardenCommand: strings.TrimSpace(options.Command), BitwardenDebug: options.Debug, Shell: strings.TrimSpace(options.Shell), LookupEnv: options.LookupEnv, }) if err == nil { return DoctorResult{ Name: "bitwarden", Status: DoctorStatusOK, Summary: "bitwarden CLI is ready", } } switch { case errors.Is(err, secretstore.ErrBWNotLoggedIn): return DoctorResult{ Name: "bitwarden", Status: DoctorStatusFail, Summary: "bitwarden login is required", Detail: err.Error(), } case errors.Is(err, secretstore.ErrBWLocked): return DoctorResult{ Name: "bitwarden", Status: DoctorStatusFail, Summary: "bitwarden vault is locked or BW_SESSION is missing", Detail: err.Error(), } case errors.Is(err, secretstore.ErrBWUnavailable): return DoctorResult{ Name: "bitwarden", Status: DoctorStatusFail, Summary: "bitwarden CLI is unavailable", Detail: err.Error(), } default: return DoctorResult{ Name: "bitwarden", Status: DoctorStatusFail, Summary: "bitwarden readiness check failed", Detail: err.Error(), } } } } func shouldAutoIncludeBitwardenCheck(options DoctorOptions) bool { if options.DisableAutoBitwardenCheck { return false } if options.SecretBackendPolicy == secretstore.BackendBitwardenCLI { return true } if options.SecretStoreFactory == nil { return false } store, err := options.SecretStoreFactory() if err == nil { return secretstore.EffectiveBackendPolicy(store) == secretstore.BackendBitwardenCLI } if errors.Is(err, secretstore.ErrBWNotLoggedIn) || errors.Is(err, secretstore.ErrBWLocked) || errors.Is(err, secretstore.ErrBWUnavailable) { return true } return strings.Contains(strings.ToLower(strings.TrimSpace(err.Error())), "bitwarden") } func RequiredSecretsCheck(factory func() (secretstore.Store, error), required []DoctorSecret) DoctorCheck { return func(context.Context) DoctorResult { store, err := factory() if err != nil { return DoctorResult{ Name: "required-secrets", Status: DoctorStatusFail, Summary: "cannot inspect required secrets", Detail: err.Error(), } } var missing []string for _, secret := range required { name := strings.TrimSpace(secret.Name) if name == "" { continue } _, err := store.GetSecret(name) switch { case err == nil: case errors.Is(err, secretstore.ErrNotFound): if label := strings.TrimSpace(secret.Label); label != "" { missing = append(missing, fmt.Sprintf("%s (%s)", name, label)) } else { missing = append(missing, name) } default: return DoctorResult{ Name: "required-secrets", Status: DoctorStatusFail, Summary: fmt.Sprintf("cannot read secret %q", name), Detail: err.Error(), } } } if len(missing) > 0 { return DoctorResult{ Name: "required-secrets", Status: DoctorStatusFail, Summary: "required secrets are missing", Detail: strings.Join(missing, ", "), } } return DoctorResult{ Name: "required-secrets", Status: DoctorStatusOK, Summary: "all required secrets are present", } } } func RequiredResolvedFieldsCheck(options ResolveOptions) DoctorCheck { required := requiredFieldNames(options.Fields) return func(context.Context) DoctorResult { if len(required) == 0 { return DoctorResult{ Name: "required-profile-fields", Status: DoctorStatusWarn, Summary: "no required profile field is configured", } } resolution, err := ResolveFields(options) if err != nil { return DoctorResult{ Name: "required-profile-fields", Status: DoctorStatusFail, Summary: resolveFieldsErrorSummary(err), Detail: FormatResolveFieldsError(err), } } sources := make([]string, 0, len(required)) for _, name := range required { field, ok := resolution.Get(name) if !ok || !field.Found { return DoctorResult{ Name: "required-profile-fields", Status: DoctorStatusFail, Summary: "required profile values are missing", Detail: name, } } sources = append(sources, fmt.Sprintf("%s=%s", name, field.Source)) } return DoctorResult{ Name: "required-profile-fields", Status: DoctorStatusOK, Summary: "required profile values are resolved", Detail: strings.Join(sources, ", "), } } } func FormatResolveFieldsError(err error) string { if err == nil { return "" } var missing *MissingRequiredValuesError if errors.As(err, &missing) { if len(missing.Fields) == 0 { return "missing required configuration values" } return strings.Join(missing.Fields, ", ") } var lookupErr *SourceLookupError if errors.As(err, &lookupErr) { key := strings.TrimSpace(lookupErr.Key) if key == "" { key = lookupErr.Field } return fmt.Sprintf( "field %q via source %q (key %q): %v", lookupErr.Field, lookupErr.Source, key, lookupErr.Err, ) } return err.Error() } func ManifestCheck(startDir string, validator DoctorManifestValidator) DoctorCheck { return func(context.Context) DoctorResult { file, path, err := manifest.LoadDefault(startDir) if err != nil { return DoctorResult{ Name: "manifest", Status: DoctorStatusFail, Summary: "manifest is missing or invalid", Detail: err.Error(), } } if validator != nil { issues := validator(file, path) if len(issues) > 0 { return DoctorResult{ Name: "manifest", Status: DoctorStatusFail, Summary: "manifest validation failed", Detail: strings.Join(issues, "; "), } } } return DoctorResult{ Name: "manifest", Status: DoctorStatusOK, Summary: "manifest is valid", Detail: path, } } } func normalizeDoctorResult(result DoctorResult) DoctorResult { result.Name = strings.TrimSpace(result.Name) if result.Name == "" { result.Name = "unnamed-check" } result.Summary = strings.TrimSpace(result.Summary) if result.Summary == "" { result.Summary = "no details provided" } result.Detail = strings.TrimSpace(result.Detail) switch result.Status { case DoctorStatusOK, DoctorStatusWarn, DoctorStatusFail: default: result.Status = DoctorStatusFail } return result } func doctorLabel(status DoctorStatus) string { switch status { case DoctorStatusOK: return "OK" case DoctorStatusWarn: return "WARN" default: return "FAIL" } } func requiredFieldNames(specs []FieldSpec) []string { required := make([]string, 0, len(specs)) seen := make(map[string]struct{}, len(specs)) for _, spec := range specs { if !spec.Required { continue } name := strings.TrimSpace(spec.Name) if name == "" { continue } if _, ok := seen[name]; ok { continue } seen[name] = struct{}{} required = append(required, name) } return required } func resolveFieldsErrorSummary(err error) string { var missing *MissingRequiredValuesError if errors.As(err, &missing) { return "required profile values are missing" } var lookupErr *SourceLookupError if errors.As(err, &lookupErr) { return fmt.Sprintf("cannot resolve profile value %q", lookupErr.Field) } return "profile resolution failed" }