// Package cli holds per-subcommand handlers. Each returns the process // exit code (0=ok, 64=usage, 65=runtime error, 66=degraded — a // degraded-mode run is NOT a hard failure but operators may want to // gate CI on it). package cli import ( "context" "encoding/json" "flag" "fmt" "os" "path/filepath" "time" "local-review-harness/internal/config" "local-review-harness/internal/llm" ) // commonFlags wires the three flags every subcommand accepts. type commonFlags struct { reviewProfilePath string modelProfilePath string outputDir string } func bindCommonFlags(fs *flag.FlagSet, cf *commonFlags) { fs.StringVar(&cf.reviewProfilePath, "review-profile", "", "review profile YAML (defaults applied if empty)") fs.StringVar(&cf.modelProfilePath, "model-profile", "", "model profile YAML (defaults applied if empty)") fs.StringVar(&cf.outputDir, "output-dir", "", "override review profile output dir") } // resolveOutputDir picks the output dir from flag > review profile > // hardcoded fallback. Always relative to the target repo, NOT the // harness's own cwd — operators pointing at a remote checkout want // reports landing inside that checkout. func resolveOutputDir(cf *commonFlags, rp config.ReviewProfile, repoPath string) string { dir := cf.outputDir if dir == "" { dir = rp.Reports.OutputDir } if dir == "" { dir = "reports/latest" } if filepath.IsAbs(dir) { return dir } return filepath.Join(repoPath, dir) } // writeJSON marshals v to path with indent, creating the dir. func writeJSON(path string, v any) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } bs, err := json.MarshalIndent(v, "", " ") if err != nil { return err } bs = append(bs, '\n') return os.WriteFile(path, bs, 0o644) } // nowUTC returns ISO-8601 UTC for receipt timestamps. func nowUTC() string { return time.Now().UTC().Format(time.RFC3339Nano) } // Stub-only sentinel — Phase C replaces this with real Ollama provider. // Phase A keeps the pipeline runnable end-to-end with a degraded-status // model-doctor JSON. func nilProvider() llm.Provider { return nil } // Repo runs Phase 0 (intake) + Phase 1 (static scan) + Phase 4 // (report gen). Phase B implements the analyzers + scanner; Phase // A leaves repoCmd as a stub until B lands. func Repo(args []string) int { fs := flag.NewFlagSet("repo", flag.ContinueOnError) var cf commonFlags bindCommonFlags(fs, &cf) if err := fs.Parse(args); err != nil { return 64 } if fs.NArg() < 1 { fmt.Fprintln(os.Stderr, "repo: missing target path") return 64 } repoPath := fs.Arg(0) return runRepo(context.Background(), repoPath, cf) } // Scrum runs the same pipeline as Repo but emits the full Scrum // report bundle. In Phase B both subcommands share the pipeline; // scrum just toggles the markdown report set on. func Scrum(args []string) int { fs := flag.NewFlagSet("scrum", flag.ContinueOnError) var cf commonFlags bindCommonFlags(fs, &cf) if err := fs.Parse(args); err != nil { return 64 } if fs.NArg() < 1 { fmt.Fprintln(os.Stderr, "scrum: missing target path") return 64 } repoPath := fs.Arg(0) return runScrum(context.Background(), repoPath, cf) } // ModelDoctor probes the configured model provider and writes // reports/latest/model-doctor.json. Phase A returns degraded status // (no real probe yet); Phase C wires the Ollama HealthCheck call. func ModelDoctor(args []string) int { fs := flag.NewFlagSet("model doctor", flag.ContinueOnError) var cf commonFlags bindCommonFlags(fs, &cf) if err := fs.Parse(args); err != nil { return 64 } rp, err := config.LoadReviewProfile(cf.reviewProfilePath) if err != nil { fmt.Fprintln(os.Stderr, "config:", err) return 65 } mp, err := config.LoadModelProfile(cf.modelProfilePath) if err != nil { fmt.Fprintln(os.Stderr, "config:", err) return 65 } // Output dir is local cwd for `model doctor` since it's not // repo-bound (no positional path argument). outDir := cf.outputDir if outDir == "" { outDir = rp.Reports.OutputDir } // Phase A: stub. Phase C swaps in a real probe. doc := map[string]any{ "provider": mp.Provider, "base_url": mp.BaseURL, "primary_model": mp.Model, "fallback_model": mp.FallbackModel, "server_available": false, "primary_model_available": false, "fallback_model_available": false, "basic_prompt_ok": false, "json_mode_ok": false, "timeout_seconds": mp.TimeoutSeconds, "status": "degraded", "errors": []string{"phase A stub: real Ollama probe lands in Phase C"}, "generated_at": nowUTC(), } out := filepath.Join(outDir, "model-doctor.json") if err := writeJSON(out, doc); err != nil { fmt.Fprintln(os.Stderr, "write:", err) return 65 } fmt.Println(out) return 66 // degraded exit code }