diff --git a/cmd/gen/doctor.go b/cmd/gen/doctor.go new file mode 100644 index 0000000..68dfdca --- /dev/null +++ b/cmd/gen/doctor.go @@ -0,0 +1,27 @@ +// Code generated by creed-codegen; DO NOT EDIT. + +package gen + +import ( + "github.com/spf13/cobra" + + opsgen "github.com/techgodhq/creed/internal/ops/gen" + "github.com/techgodhq/creed/internal/service" +) + +// DoctorCommandSpec describes the generated CLI wrapper for service.Service.Doctor. +type DoctorCommandSpec struct { + Operation opsgen.OperationDescriptor + ParamNames []string +} + +// DoctorSpec is metadata extracted from service.Service.Doctor. +var DoctorSpec = DoctorCommandSpec{ + Operation: mustOperation("Doctor"), + ParamNames: []string{"ctx"}, +} + +// NewDoctorCommand returns the generated Cobra command wrapper for service.Service.Doctor. +func NewDoctorCommand(s service.Service) *cobra.Command { + return newGeneratedCommand(s, DoctorSpec.Operation, runDoctor) +} diff --git a/cmd/gen/handlers.go b/cmd/gen/handlers.go index 877acee..baa2bba 100644 --- a/cmd/gen/handlers.go +++ b/cmd/gen/handlers.go @@ -203,3 +203,36 @@ func runWatch(cmd *cobra.Command, s service.Service, args []string) error { } return runWatchCommand(cmd, s, target, quiet, force, debounce) } + +func runDoctor(cmd *cobra.Command, s service.Service, args []string) error { + result, err := s.Doctor(cmd.Context()) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Project root: %s\n", result.Root) + fmt.Fprintf(cmd.OutOrStdout(), "Source: %s, Manifest: %s\n", boolMark(result.SourceDirOK), boolMark(result.ManifestOK)) + for _, target := range result.Targets { + status := "disabled" + if target.Enabled { + status = "enabled" + } + fmt.Fprintf(cmd.OutOrStdout(), "Target %s: %s\n", target.Name, status) + } + for _, check := range result.Checks { + if check.Kind == "error" { + fmt.Fprintf(cmd.OutOrStdout(), "ERROR %s: %s\n", check.Code, check.Message) + } + } + if result.HasErrors() { + return fmt.Errorf("doctor found issues") + } + fmt.Fprintln(cmd.OutOrStdout(), "All checks passed") + return nil +} + +func boolMark(b bool) string { + if b { + return "present" + } + return "missing" +} diff --git a/cmd/gen/registry.go b/cmd/gen/registry.go index b397c04..61ccc0d 100644 --- a/cmd/gen/registry.go +++ b/cmd/gen/registry.go @@ -26,5 +26,6 @@ func Commands(s service.Service) []*cobra.Command { NewPullCommand(s), NewPushCommand(s), NewWatchCommand(s), + NewDoctorCommand(s), } } diff --git a/internal/codegen/main.go b/internal/codegen/main.go index 7c2264b..56ded2e 100644 --- a/internal/codegen/main.go +++ b/internal/codegen/main.go @@ -856,7 +856,14 @@ import ( "github.com/techgodhq/creed/internal/usecase" ) -%s`, handlers.String())) +%s + +func boolMark(b bool) string { + if b { + return "present" + } + return "missing" +}`, handlers.String())) } func cliHandlerFunction(method serviceMethod, inputs []methodParam) (string, error) { @@ -901,6 +908,14 @@ func cliHandlerFunction(method serviceMethod, inputs []methodParam) (string, err fmt.Fprintf(&b, " for _, diagnostic := range result.Errors {\n fmt.Fprintf(cmd.OutOrStdout(), \"ERROR %%s: %%s\\n\", diagnostic.Code, diagnostic.Message)\n }\n") fmt.Fprintf(&b, " for _, diagnostic := range result.Warnings {\n fmt.Fprintf(cmd.OutOrStdout(), \"WARNING %%s: %%s\\n\", diagnostic.Code, diagnostic.Message)\n }\n") fmt.Fprintf(&b, " if !result.Valid {\n return fmt.Errorf(\"validation failed\")\n }\n fmt.Fprintln(cmd.OutOrStdout(), \"Validation passed\")\n return nil\n}\n\n") + case "Doctor": + fmt.Fprintf(&b, " result, err := s.Doctor(%s)\n", callArgs) + fmt.Fprintf(&b, " if err != nil {\n return err\n }\n") + fmt.Fprintf(&b, " fmt.Fprintf(cmd.OutOrStdout(), \"Project root: %%s\\n\", result.Root)\n") + fmt.Fprintf(&b, " fmt.Fprintf(cmd.OutOrStdout(), \"Source: %%s, Manifest: %%s\\n\", boolMark(result.SourceDirOK), boolMark(result.ManifestOK))\n") + fmt.Fprintf(&b, " for _, target := range result.Targets {\n status := \"disabled\"\n if target.Enabled {\n status = \"enabled\"\n }\n fmt.Fprintf(cmd.OutOrStdout(), \"Target %%s: %%s\\n\", target.Name, status)\n }\n") + fmt.Fprintf(&b, " for _, check := range result.Checks {\n if check.Kind == \"error\" {\n fmt.Fprintf(cmd.OutOrStdout(), \"ERROR %%s: %%s\\n\", check.Code, check.Message)\n }\n }\n") + fmt.Fprintf(&b, " if result.HasErrors() {\n return fmt.Errorf(\"doctor found issues\")\n }\n fmt.Fprintln(cmd.OutOrStdout(), \"All checks passed\")\n return nil\n}\n\n") case "Watch": fmt.Fprintf(&b, "\treturn runWatchCommand(cmd, s, target, quiet, force, debounce)\n}\n\n") case "AddSkill", "AddConfig": diff --git a/internal/httpapi/gen/handlers.go b/internal/httpapi/gen/handlers.go index b64e65f..da97544 100644 --- a/internal/httpapi/gen/handlers.go +++ b/internal/httpapi/gen/handlers.go @@ -39,6 +39,7 @@ func GeneratedOperations(s service.Service) []GeneratedOperation { {Descriptor: mustOperation("DisableTarget"), Handler: DisableTargetHTTPHandler(s)}, {Descriptor: mustOperation("Pull"), Handler: PullHTTPHandler(s)}, {Descriptor: mustOperation("Push"), Handler: PushHTTPHandler(s)}, + {Descriptor: mustOperation("Doctor"), Handler: DoctorHTTPHandler(s)}, } } @@ -304,6 +305,20 @@ func PushHTTPHandler(s service.Service) OperationHandler { } } +// DoctorHTTPHandler returns the generated HTTP handler for service.Service.Doctor. +func DoctorHTTPHandler(s service.Service) OperationHandler { + return func(ctx context.Context, payload json.RawMessage) (any, error) { + if err := decodePayload(payload, &struct{}{}); err != nil { + return nil, err + } + result, err := s.Doctor(ctx) + if err != nil { + return nil, err + } + return result, nil + } +} + func mustOperation(methodName string) opsgen.OperationDescriptor { operation, ok := opsgen.ByMethodName(methodName) if !ok { diff --git a/internal/mcp/gen/doctor.go b/internal/mcp/gen/doctor.go new file mode 100644 index 0000000..140a891 --- /dev/null +++ b/internal/mcp/gen/doctor.go @@ -0,0 +1,12 @@ +// Code generated by creed-codegen; DO NOT EDIT. + +package gen + +// DoctorToolName is the generated MCP tool name for service.Service.Doctor. +const DoctorToolName = "doctor" + +// DoctorToolDescription is the generated MCP tool description for service.Service.Doctor. +const DoctorToolDescription = "Doctor produces a diagnostic report covering the project root,\nmanifest and source presence, validation summary, configured\ntargets, and git availability. It is non-mutating and never\nexposes sensitive values. Generated CLI, MCP, and HTTP callers\nreceive the same structured report." + +// DoctorToolParams are parameter names extracted from service.Service.Doctor. +var DoctorToolParams = []string{"ctx"} diff --git a/internal/mcp/gen/handlers.go b/internal/mcp/gen/handlers.go index b48c261..95a8046 100644 --- a/internal/mcp/gen/handlers.go +++ b/internal/mcp/gen/handlers.go @@ -41,6 +41,7 @@ func GeneratedTools(s service.Service) []GeneratedTool { {Spec: DisableTargetToolSpec(), Tool: DisableTargetMCPTool(), Handler: DisableTargetMCPHandler(s)}, {Spec: PullToolSpec(), Tool: PullMCPTool(), Handler: PullMCPHandler(s)}, {Spec: PushToolSpec(), Tool: PushMCPTool(), Handler: PushMCPHandler(s)}, + {Spec: DoctorToolSpec(), Tool: DoctorMCPTool(), Handler: DoctorMCPHandler(s)}, } } @@ -474,6 +475,31 @@ func PushMCPHandler(s service.Service) ToolHandler { } } +// DoctorToolSpec returns generated MCP metadata for service.Service.Doctor. +func DoctorToolSpec() ToolSpec { + return ToolSpec{MethodName: "Doctor", Name: DoctorToolName, Description: DoctorToolDescription, ParamNames: []string{}} +} + +// DoctorMCPTool returns the generated MCP tool definition for service.Service.Doctor. +func DoctorMCPTool() mcplib.Tool { + options := []mcplib.ToolOption{mcplib.WithDescription(DoctorToolDescription)} + return mcplib.NewTool(DoctorToolName, options...) +} + +// DoctorMCPHandler returns the generated MCP handler for service.Service.Doctor. +func DoctorMCPHandler(s service.Service) ToolHandler { + return func(ctx context.Context, payload json.RawMessage) (any, error) { + if err := decodePayload(payload, &struct{}{}); err != nil { + return nil, err + } + result, err := s.Doctor(ctx) + if err != nil { + return nil, err + } + return result, nil + } +} + type okResponse struct { OK bool `json:"ok"` } diff --git a/internal/mcp/gen/tool_specs.go b/internal/mcp/gen/tool_specs.go index 1333b05..fde4964 100644 --- a/internal/mcp/gen/tool_specs.go +++ b/internal/mcp/gen/tool_specs.go @@ -26,4 +26,5 @@ var ToolSpecs = []ToolSpec{ {MethodName: "DisableTarget", Name: DisableTargetToolName, Description: DisableTargetToolDescription, ParamNames: DisableTargetToolParams}, {MethodName: "Pull", Name: PullToolName, Description: PullToolDescription, ParamNames: PullToolParams}, {MethodName: "Push", Name: PushToolName, Description: PushToolDescription, ParamNames: PushToolParams}, + {MethodName: "Doctor", Name: DoctorToolName, Description: DoctorToolDescription, ParamNames: DoctorToolParams}, } diff --git a/internal/ops/gen/operations.go b/internal/ops/gen/operations.go index e40aaab..8080d1d 100644 --- a/internal/ops/gen/operations.go +++ b/internal/ops/gen/operations.go @@ -184,6 +184,16 @@ var Operations = []OperationDescriptor{ Inputs: []InputDescriptor{{Name: "target", ExternalName: "target", Type: "string", Kind: "primitive", Required: false, CLIKind: "flag", Help: "Sync only the named target on each change."}, {Name: "quiet", ExternalName: "quiet", Type: "bool", Kind: "primitive", Required: false, CLIKind: "flag", Help: "Suppress per-sync output; report only errors."}, {Name: "force", ExternalName: "force", Type: "bool", Kind: "primitive", Required: false, CLIKind: "flag", Help: "Rewrite files on each sync even when unchanged."}, {Name: "debounce", ExternalName: "debounce", Type: "string", Kind: "primitive", Required: false, CLIKind: "flag", Help: "Debounce window (e.g. 500ms, 1s). Defaults to 500ms."}}, Outputs: []OutputDescriptor{{Name: "result1", Type: "error"}}, }, + { + MethodName: "Doctor", + OperationName: "doctor", + Description: "Doctor produces a diagnostic report covering the project root,\nmanifest and source presence, validation summary, configured\ntargets, and git availability. It is non-mutating and never\nexposes sensitive values. Generated CLI, MCP, and HTTP callers\nreceive the same structured report.", + CLIName: "doctor", + MCPName: "doctor", + HTTPRoute: "/v1/operations/doctor", + Inputs: []InputDescriptor{}, + Outputs: []OutputDescriptor{{Name: "result1", Type: "DoctorReport"}, {Name: "result2", Type: "error"}}, + }, } // ByOperationName returns the descriptor for operationName, if generated. diff --git a/internal/service/doctor.go b/internal/service/doctor.go new file mode 100644 index 0000000..8db613f --- /dev/null +++ b/internal/service/doctor.go @@ -0,0 +1,239 @@ +package service + +import ( + "context" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// DoctorCheck is a single diagnostic finding in a DoctorReport. CheckKind +// distinguishes actionable errors from informational status so the CLI can +// format them differently. +type DoctorCheck struct { + Kind string `json:"kind"` // "error" or "info" + Code string `json:"code"` // machine-readable diagnostic code + Message string `json:"message"` // human-readable description + Detail string `json:"detail,omitempty"` // optional extra context +} + +// DoctorTargetSummary describes one configured target's state for the report. +type DoctorTargetSummary struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Enabled bool `json:"enabled"` + OutputDir string `json:"output_dir"` +} + +// DoctorReport is the structured result of a non-mutating environment and +// source-health diagnostic run. It reuses the canonical ValidationResult +// and enriches it with project-level context that helps resolve setup +// failures. +type DoctorReport struct { + Root string `json:"root"` + ManifestOK bool `json:"manifest_ok"` + SourceDirOK bool `json:"source_dir_ok"` + SourceType string `json:"source_type,omitempty"` + SourceRemote string `json:"source_remote,omitempty"` + GitAvailable bool `json:"git_available"` + GitPath string `json:"git_path,omitempty"` + Validation ValidationResult `json:"validation"` + Targets []DoctorTargetSummary `json:"targets"` + Checks []DoctorCheck `json:"checks"` +} + +// Doctor produces a diagnostic report covering the project root, manifest and +// source presence, validation summary, configured targets, and git +// availability. It is non-mutating and never exposes tokens or sensitive +// remote credentials. A returned error is reserved for an unexpected failure +// that prevents any diagnosis; structured findings are always in the report. +func (s *Implementation) Doctor(ctx context.Context) (DoctorReport, error) { + if err := ctx.Err(); err != nil { + return DoctorReport{}, err + } + + report := DoctorReport{Root: s.resolveRoot()} + + // --- .creed/ presence --- + creedDir := s.creedDir() + if info, err := os.Stat(creedDir); err != nil { + if os.IsNotExist(err) { + report.Checks = append(report.Checks, DoctorCheck{ + Kind: "error", + Code: "missing_source_dir", + Message: ".creed source directory does not exist", + Detail: "Run 'creed init' to scaffold the project", + }) + } else { + report.Checks = append(report.Checks, DoctorCheck{ + Kind: "error", + Code: "unreadable_source_dir", + Message: ".creed source directory cannot be inspected", + Detail: err.Error(), + }) + } + } else if !info.IsDir() { + report.Checks = append(report.Checks, DoctorCheck{ + Kind: "error", + Code: "source_not_directory", + Message: ".creed path exists but is not a directory", + }) + } else { + report.SourceDirOK = true + report.Checks = append(report.Checks, DoctorCheck{ + Kind: "info", + Code: "source_dir", + Message: ".creed source directory present", + }) + } + + // --- Manifest presence --- + if _, err := os.Stat(s.manifestPath()); err != nil { + if os.IsNotExist(err) { + report.Checks = append(report.Checks, DoctorCheck{ + Kind: "error", + Code: "missing_manifest", + Message: "manifest.yaml does not exist", + Detail: "Run 'creed init' to create the project manifest", + }) + } else { + report.Checks = append(report.Checks, DoctorCheck{ + Kind: "error", + Code: "unreadable_manifest", + Message: "manifest.yaml cannot be inspected", + Detail: err.Error(), + }) + } + } else { + report.ManifestOK = true + report.Checks = append(report.Checks, DoctorCheck{ + Kind: "info", + Code: "manifest", + Message: "manifest.yaml present", + }) + } + + // --- Source type / remote (from manifest, best-effort) --- + if manifest, err := s.readManifest(); err == nil { + report.SourceType = manifest.Source.Type + report.SourceRemote = redactRemoteURL(manifest.Source.Remote) + } + + // --- Canonical validation --- + validation, _ := s.Validate(ctx) + report.Validation = validation + if validation.Valid { + report.Checks = append(report.Checks, DoctorCheck{ + Kind: "info", + Code: "validation", + Message: "manifest and sources validate cleanly", + }) + } else { + // Convert each validation error into a doctor check for unified display. + for _, diag := range validation.Errors { + report.Checks = append(report.Checks, DoctorCheck{ + Kind: "error", + Code: diag.Code, + Message: diag.Message, + Detail: diag.Path, + }) + } + } + for _, diag := range validation.Warnings { + report.Checks = append(report.Checks, DoctorCheck{ + Kind: "info", + Code: diag.Code, + Message: diag.Message, + Detail: diag.Path, + }) + } + + // --- Configured targets --- + if targets, err := s.ListTargets(ctx); err == nil { + for _, t := range targets { + report.Targets = append(report.Targets, DoctorTargetSummary{ + Name: t.Name, + DisplayName: t.DisplayName, + Enabled: t.Enabled, + OutputDir: t.OutputDir, + }) + } + } + + // --- Git availability (remote prerequisite, never a sync failure) --- + if gitPath, err := exec.LookPath("git"); err == nil { + report.GitAvailable = true + report.GitPath = gitPath + report.Checks = append(report.Checks, DoctorCheck{ + Kind: "info", + Code: "git_available", + Message: "git executable found", + Detail: gitPath, + }) + } else { + if report.SourceType == "git" { + report.Checks = append(report.Checks, DoctorCheck{ + Kind: "error", + Code: "git_missing_for_remote_source", + Message: "git source configured but git executable not found — pull/push operations will fail", + Detail: "Install git or switch source.type to 'local'", + }) + } else { + report.Checks = append(report.Checks, DoctorCheck{ + Kind: "info", + Code: "git_not_required", + Message: "git not found — only needed for remote pull/push, not for local sync", + }) + } + } + + return report, nil +} + +// HasErrors returns true when the report contains any error-level check or +// validation errors. It is used by the CLI to set an appropriate exit code. +func (r DoctorReport) HasErrors() bool { + if !r.Validation.Valid { + return true + } + for _, c := range r.Checks { + if c.Kind == "error" { + return true + } + } + return false +} + +// resolveRoot returns the absolute project root for display purposes. +func (s *Implementation) resolveRoot() string { + abs, err := filepath.Abs(s.root) + if err != nil { + return s.root + } + return abs +} + +// redactRemoteURL strips embedded credentials from a git remote URL so the +// doctor report never exposes tokens or passwords. For URLs that cannot be +// parsed, it falls back to a conservative split-based approach that removes +// anything between the scheme and the last @ before the host. +func redactRemoteURL(remote string) string { + if remote == "" { + return "" + } + // SSH-style URLs (git@host:path) have no URL userinfo; safe to return. + if !strings.HasPrefix(remote, "https://") && !strings.HasPrefix(remote, "http://") { + return remote + } + parsed, err := url.Parse(remote) + if err != nil || parsed.User == nil { + return remote + } + // Preserve the username (safe to display), drop the password. + if _, hasPassword := parsed.User.Password(); hasPassword { + parsed.User = url.User(parsed.User.Username()) + } + return parsed.String() +} diff --git a/internal/service/impl_test.go b/internal/service/impl_test.go index 76b9bdd..048cc9d 100644 --- a/internal/service/impl_test.go +++ b/internal/service/impl_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "os" "os/exec" "path/filepath" @@ -583,3 +584,213 @@ func mustRead(t *testing.T, path string) string { } return string(data) } + +func TestDoctorHealthyProject(t *testing.T) { + root := t.TempDir() + svc := New(root) + ctx := context.Background() + + if err := svc.Init(ctx, "demo"); err != nil { + t.Fatalf("Init() error = %v", err) + } + + report, err := svc.Doctor(ctx) + if err != nil { + t.Fatalf("Doctor() error = %v", err) + } + + if !report.ManifestOK { + t.Errorf("ManifestOK = false, want true") + } + if !report.SourceDirOK { + t.Errorf("SourceDirOK = false, want true") + } + if !report.Validation.Valid { + t.Errorf("Validation.Valid = false, want true; errors = %#v", report.Validation.Errors) + } + if report.HasErrors() { + t.Errorf("HasErrors() = true, want false; checks = %#v", report.Checks) + } + if !filepath.IsAbs(report.Root) { + t.Errorf("Root = %q, want absolute path", report.Root) + } + if len(report.Targets) == 0 { + t.Errorf("Targets is empty, want configured targets") + } + + enabled := map[string]bool{} + for _, target := range report.Targets { + enabled[target.Name] = target.Enabled + } + for _, name := range []string{"claude", "codex", "cursor"} { + if !enabled[name] { + t.Errorf("target %s should be enabled by default", name) + } + } + + if report.GitAvailable && report.GitPath == "" { + t.Errorf("GitAvailable = true but GitPath is empty") + } +} + +func TestDoctorMissingCreedDir(t *testing.T) { + root := t.TempDir() + svc := New(root) + ctx := context.Background() + + report, err := svc.Doctor(ctx) + if err != nil { + t.Fatalf("Doctor() error = %v", err) + } + + if report.SourceDirOK { + t.Errorf("SourceDirOK = true, want false for missing .creed") + } + if report.ManifestOK { + t.Errorf("ManifestOK = true, want false for missing manifest") + } + if !report.HasErrors() { + t.Errorf("HasErrors() = false, want true for missing setup; checks = %#v", report.Checks) + } + + if !hasDoctorCheck(report.Checks, "error", "missing_source_dir") { + t.Errorf("missing_source_dir error not found; checks = %#v", report.Checks) + } + if !hasDoctorCheck(report.Checks, "error", "missing_manifest") { + t.Errorf("missing_manifest error not found; checks = %#v", report.Checks) + } +} + +func TestDoctorInvalidManifestReportsValidationErrors(t *testing.T) { + root := t.TempDir() + svc := New(root) + ctx := context.Background() + + if err := svc.Init(ctx, "demo"); err != nil { + t.Fatalf("Init() error = %v", err) + } + + badManifest := `version: 1 +source: + type: local + path: .creed +targets: + - name: nonexistent + enabled: true + output_dir: . +skills: + - name: review + path: skills/review.md +config: + - name: project + path: config/project.md +` + if err := os.WriteFile(filepath.Join(root, ".creed", "manifest.yaml"), []byte(badManifest), 0644); err != nil { + t.Fatal(err) + } + + report, err := svc.Doctor(ctx) + if err != nil { + t.Fatalf("Doctor() error = %v", err) + } + + if report.Validation.Valid { + t.Errorf("Validation.Valid = true, want false for bad manifest") + } + if !report.HasErrors() { + t.Errorf("HasErrors() = false, want true") + } +} + +func TestDoctorNeverExposesSensitiveRemote(t *testing.T) { + root := t.TempDir() + svc := New(root, WithGitToken("secret-token-do-not-leak")) + ctx := context.Background() + + if err := svc.Init(ctx, "demo"); err != nil { + t.Fatalf("Init() error = %v", err) + } + + // Configure git source with a remote containing credentials in the URL. + remote := "https://ci-user:hunter2@git.example.com/repo.git" + manifest := fmt.Sprintf(`version: 1 +source: + type: git + path: .creed + remote: %s +targets: + - name: claude + enabled: true + output_dir: . +skills: + - name: review + path: skills/review.md +config: + - name: project + path: config/project.md +`, remote) + if err := os.WriteFile(filepath.Join(root, ".creed", "manifest.yaml"), []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + report, err := svc.Doctor(ctx) + if err != nil { + t.Fatalf("Doctor() error = %v", err) + } + + // The token passed via WithGitToken must never appear anywhere. + serialized := fmt.Sprintf("%#v", report) + if strings.Contains(serialized, "secret-token-do-not-leak") { + t.Errorf("report serialization leaked git token") + } + + // The password embedded in the remote URL must be redacted. + if strings.Contains(report.SourceRemote, "hunter2") { + t.Errorf("SourceRemote leaked URL password: %s", report.SourceRemote) + } + // The username should be preserved (safe to display). + if !strings.Contains(report.SourceRemote, "ci-user") { + t.Errorf("SourceRemote should preserve username; got: %s", report.SourceRemote) + } + // The host and path should be intact. + if !strings.Contains(report.SourceRemote, "git.example.com/repo.git") { + t.Errorf("SourceRemote should preserve host/path; got: %s", report.SourceRemote) + } + + for _, check := range report.Checks { + if strings.Contains(check.Message, "secret-token-do-not-leak") || strings.Contains(check.Detail, "secret-token-do-not-leak") { + t.Errorf("check leaked git token: %+v", check) + } + if strings.Contains(check.Message, "hunter2") || strings.Contains(check.Detail, "hunter2") { + t.Errorf("check leaked URL password: %+v", check) + } + } +} + +func TestDoctorCreedPathIsFileNotDir(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".creed"), []byte("not a dir"), 0644); err != nil { + t.Fatal(err) + } + svc := New(root) + + report, err := svc.Doctor(context.Background()) + if err != nil { + t.Fatalf("Doctor() error = %v", err) + } + if report.SourceDirOK { + t.Errorf("SourceDirOK = true, want false when .creed is not a directory") + } + if !hasDoctorCheck(report.Checks, "error", "source_not_directory") { + t.Errorf("source_not_directory error not found; checks = %#v", report.Checks) + } +} + +func hasDoctorCheck(checks []DoctorCheck, kind, code string) bool { + for _, c := range checks { + if c.Kind == kind && c.Code == code { + return true + } + } + return false +} diff --git a/internal/service/service.go b/internal/service/service.go index 7f3ce7b..d61d49b 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -50,4 +50,10 @@ type Service interface { // ctx is cancelled. This is a blocking operation; callers must // provide a cancellable context. Watch(ctx context.Context, opts usecase.WatchOptions, sink usecase.WatchSink) error + // Doctor produces a diagnostic report covering the project root, + // manifest and source presence, validation summary, configured + // targets, and git availability. It is non-mutating and never + // exposes sensitive values. Generated CLI, MCP, and HTTP callers + // receive the same structured report. + Doctor(ctx context.Context) (DoctorReport, error) }