package cli import ( "context" "flag" "fmt" "io" "os" "path/filepath" "gitea.maximumdirect.net/eric/weatherreporter/internal/app" "gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo" "gitea.maximumdirect.net/eric/weatherreporter/internal/comparison" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) const helpText = `weatherreporter prepares weather reports from normalized forecast data. Usage: weatherreporter --help weatherreporter --version weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet] weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--llm-debug-dir PATH] [--quiet] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet] weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet] weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet] weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet] weatherreporter compare REPORT --profile PROFILE --profile PROFILE [--config PATH] [--units VALUE] [--tz NAME] [--date YYYY-MM-DD] [--out-dir PATH] [--replace] [--llm-debug-dir PATH] [--quiet] Options: -h, --help Show this help message. --version Show the Weatherreporter version. --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml. --units VALUE Override weather API units. --tz NAME Override weather API timezone. --out PATH Write the generated Markdown report to PATH. --llm-debug-dir PATH Write sensitive prompt debug artifacts under PATH (Unix only). --profile PROFILE Select a prompt profile for compare; repeat for every profile. --date YYYY-MM-DD Required for generate/compare daily; optional for generate/compare today. --out-dir PATH Write generated Markdown reports beneath PATH for run commands, or select the exact comparison directory. --replace Authorize replacement of a recognized comparison bundle. --quiet Suppress action summaries and routine batch status output. ` type Runner struct { Clock timeutil.Clock ExecutorFactory ExecutorFactory Version string WorkingDir string generateDetailed func(context.Context, app.GenerateRequest) (*app.ReportResult, error) runBatchDetailed func(context.Context, app.BatchRequest) (*app.BatchResult, error) compareDetailed func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) } func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { return Runner{Clock: timeutil.SystemClock{}}.Run(ctx, args, stdout, stderr) } func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { if r.Clock == nil { r.Clock = timeutil.SystemClock{} } if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { _, err := fmt.Fprint(stdout, helpText) return err } if len(args) == 2 && args[0] == "compare" && (args[1] == "--help" || args[1] == "-h") { _, err := fmt.Fprint(stdout, helpText) return err } if args[0] == "--version" { if len(args) != 1 { return fmt.Errorf("--version does not accept arguments") } version := r.Version if version == "" { version = buildinfo.Version } _, err := fmt.Fprintf(stdout, "weatherreporter %s\n", version) return err } switch args[0] { case "generate": req, opts, err := r.resolveGenerateAction(args[1:]) if err != nil { return err } generateDetailed := r.generateDetailed if generateDetailed == nil { generateDetailed = app.GenerateDetailed } result, err := generateDetailed(ctx, req) if result != nil { summary := newGenerateSummary(result, err) if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, nil); encodeErr != nil { return encodeErr } } return err case "run": req, opts, err := r.resolveRunAction(args[1:]) if err != nil { return err } runBatchDetailed := r.runBatchDetailed if runBatchDetailed == nil { runBatchDetailed = app.RunBatchDetailed } result, err := runBatchDetailed(ctx, req) if result != nil { summary := newBatchSummary(result, err) if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) { writeBatchStatus(w, result) }); encodeErr != nil { return encodeErr } if err != nil { return err } if summary.Status == summaryStatusFailed { return app.BatchError{Result: result} } } return err case "compare": req, opts, err := r.resolveComparisonAction(args[1:]) if err != nil { return err } compareDetailed := r.compareDetailed if compareDetailed == nil { compareDetailed = app.CompareDetailed } result, err := compareDetailed(ctx, req) if result != nil { summary := newComparisonSummary(result, err) if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, nil); encodeErr != nil { return encodeErr } } return err default: return fmt.Errorf("unknown command %q", args[0]) } } type commonOptions struct { ConfigPath string Units string Timezone string Output string OutputDir string LLMDebugDir string Quiet bool } type generateOptions struct { commonOptions Date string } type comparisonOptions struct { commonOptions Date string ProfileIDs profileValues Replace bool } type profileValues []string func (values *profileValues) String() string { return "" } func (values *profileValues) Set(value string) error { *values = append(*values, value) return nil } func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { req, _, err := r.resolveGenerateAction(args) return req, err } func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commonOptions, error) { if r.Clock == nil { r.Clock = timeutil.SystemClock{} } if len(args) == 0 { return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate requires a report name") } if _, err := report.IDForCommandName(args[0]); err != nil { return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("unknown generate report %q", args[0]) } reportKind := app.ReportKind(args[0]) opts, err := parseGenerateFlags(reportKind, args[1:]) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } if err := reportDateRequiredError("generate", reportKind, opts.Date); err != nil { return app.GenerateRequest{}, commonOptions{}, err } cfg, err := config.Load(config.LoadOptions{ Path: opts.ConfigPath, Units: opts.Units, Timezone: opts.Timezone, }) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } now := r.Clock.Now() req := app.GenerateRequest{ Config: cfg, Report: reportKind, LLMDebugDir: opts.LLMDebugDir, Now: now, } req.Date, err = resolveActionReportDate("generate", reportKind, opts.Date, location, now) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } workingDir, err := r.workingDir() if err != nil { return app.GenerateRequest{}, commonOptions{}, err } outputPath, err := resolveOutputOverride(workingDir, opts.Output) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } req.WorkingDir, req.OutputPath = workingDir, outputPath executor, err := r.promptExecutor(cfg.Promptkit) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } req.Executor = executor return req, opts.commonOptions, nil } func (r Runner) resolveRun(args []string) (app.BatchRequest, error) { req, _, err := r.resolveRunAction(args) return req, err } func (r Runner) resolveComparisonAction(args []string) (app.ComparisonRequest, commonOptions, error) { if r.Clock == nil { r.Clock = timeutil.SystemClock{} } if len(args) == 0 { return app.ComparisonRequest{}, commonOptions{}, fmt.Errorf("compare requires a report name") } if _, err := report.IDForCommandName(args[0]); err != nil { return app.ComparisonRequest{}, commonOptions{}, fmt.Errorf("unknown compare report %q", args[0]) } reportKind := app.ReportKind(args[0]) opts, err := parseComparisonFlags(reportKind, args[1:]) if err != nil { return app.ComparisonRequest{}, commonOptions{}, err } if err := comparison.ValidateProfileIDs(opts.ProfileIDs); err != nil { return app.ComparisonRequest{}, commonOptions{}, err } cfg, err := config.Load(config.LoadOptions{ Path: opts.ConfigPath, Units: opts.Units, Timezone: opts.Timezone, }) if err != nil { return app.ComparisonRequest{}, commonOptions{}, err } location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone) if err != nil { return app.ComparisonRequest{}, commonOptions{}, err } workingDir, err := r.workingDir() if err != nil { return app.ComparisonRequest{}, commonOptions{}, err } outputDir, err := resolveOutputOverride(workingDir, opts.OutputDir) if err != nil { return app.ComparisonRequest{}, commonOptions{}, err } req := app.ComparisonRequest{ Config: cfg, Report: reportKind, ProfileIDs: append([]string(nil), opts.ProfileIDs...), WorkingDir: workingDir, OutputDir: outputDir, Replace: opts.Replace, LLMDebugDir: opts.LLMDebugDir, Clock: r.Clock, } req.Date, err = resolveActionReportDate("compare", reportKind, opts.Date, location, r.Clock.Now()) if err != nil { return app.ComparisonRequest{}, commonOptions{}, err } promptkitConfig := cfg.Promptkit promptkitConfig.Profile = "" executor, err := r.promptExecutor(promptkitConfig) if err != nil { return app.ComparisonRequest{}, commonOptions{}, err } req.Executor = executor return req, opts.commonOptions, nil } func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions, error) { if r.Clock == nil { r.Clock = timeutil.SystemClock{} } if len(args) == 0 { return app.BatchRequest{}, commonOptions{}, fmt.Errorf("run requires a batch name") } if _, err := report.BatchForCommandName(args[0]); err != nil { return app.BatchRequest{}, commonOptions{}, fmt.Errorf("unknown run batch %q", args[0]) } batch := app.BatchKind(args[0]) opts, err := parseRunFlags(args[1:]) if err != nil { return app.BatchRequest{}, commonOptions{}, err } cfg, err := config.Load(config.LoadOptions{ Path: opts.ConfigPath, Units: opts.Units, Timezone: opts.Timezone, }) if err != nil { return app.BatchRequest{}, commonOptions{}, err } executor, err := r.promptExecutor(cfg.Promptkit) if err != nil { return app.BatchRequest{}, commonOptions{}, err } workingDir, err := r.workingDir() if err != nil { return app.BatchRequest{}, commonOptions{}, err } outputDir, err := resolveOutputOverride(workingDir, opts.OutputDir) if err != nil { return app.BatchRequest{}, commonOptions{}, err } return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), WorkingDir: workingDir, OutputDir: outputDir, LLMDebugDir: opts.LLMDebugDir, Executor: executor}, opts, nil } func resolveRun(args []string) (app.BatchRequest, error) { return Runner{Clock: timeutil.SystemClock{}}.resolveRun(args) } func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions, error) { fs := flag.NewFlagSet("generate "+string(report), flag.ContinueOnError) fs.SetOutput(io.Discard) opts := generateOptions{} addCommonFlags(fs, &opts.commonOptions, true) fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output") addReportDateFlag(fs, report, &opts.Date) if err := fs.Parse(args); err != nil { return generateOptions{}, err } if fs.NArg() > 0 { return generateOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0)) } return opts, nil } func parseRunFlags(args []string) (commonOptions, error) { fs := flag.NewFlagSet("run", flag.ContinueOnError) fs.SetOutput(io.Discard) opts := commonOptions{} addCommonFlags(fs, &opts, false) fs.StringVar(&opts.OutputDir, "out-dir", "", "generated Markdown report directory") fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output") if err := fs.Parse(args); err != nil { return commonOptions{}, err } if fs.NArg() > 0 { return commonOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0)) } return opts, nil } func parseComparisonFlags(report app.ReportKind, args []string) (comparisonOptions, error) { fs := flag.NewFlagSet("compare "+string(report), flag.ContinueOnError) fs.SetOutput(io.Discard) opts := comparisonOptions{} addCommonFlags(fs, &opts.commonOptions, false) fs.StringVar(&opts.OutputDir, "out-dir", "", "comparison bundle directory") fs.BoolVar(&opts.Replace, "replace", false, "replace a recognized comparison bundle") fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output") fs.Var(&opts.ProfileIDs, "profile", "prompt profile ID") addReportDateFlag(fs, report, &opts.Date) if err := fs.Parse(args); err != nil { return comparisonOptions{}, err } if fs.NArg() > 0 { return comparisonOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0)) } return opts, nil } func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) { fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path") fs.StringVar(&opts.Units, "units", "", "weather API units") fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone") fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH (Unix only)") if includeOutput { fs.StringVar(&opts.Output, "out", "", "generated Markdown report path") } } func (r Runner) workingDir() (string, error) { workingDir := r.WorkingDir if workingDir == "" { var err error workingDir, err = os.Getwd() if err != nil { return "", fmt.Errorf("get working directory: %w", err) } } if !filepath.IsAbs(workingDir) { return "", fmt.Errorf("working directory %q must be absolute", workingDir) } return filepath.Clean(workingDir), nil } func resolveOutputOverride(workingDir, value string) (string, error) { if value == "" { return "", nil } if !filepath.IsAbs(value) { value = filepath.Join(workingDir, value) } return filepath.Clean(value), nil }