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/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] 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. --out-dir PATH Write generated Markdown reports beneath PATH for run commands. --quiet Suppress successful generate and run output. ` type Runner struct { Clock timeutil.Clock ExecutorFactory ExecutorFactory Version string WorkingDir string } 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 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 } result, err := app.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 } result, err := app.RunBatchDetailed(ctx, req) if result != nil { summary := newBatchSummary(result) if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) { writeBatchStatus(w, result) }); encodeErr != nil { return encodeErr } if summary.Status == summaryStatusFailed { return app.BatchError{Result: result} } } 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 } 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 } cfg, err := config.Load(config.LoadOptions{ Path: opts.ConfigPath, Units: opts.Units, Timezone: opts.Timezone, }) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } executor, err := r.promptExecutor(cfg.Promptkit) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone) 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 := app.GenerateRequest{ Config: cfg, Report: reportKind, WorkingDir: workingDir, OutputPath: outputPath, LLMDebugDir: opts.LLMDebugDir, Now: r.Clock.Now(), Executor: executor, } switch reportKind { case app.ReportDaily: if opts.Date == "" { return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD") } req.Date, err = timeutil.ParseLocalDate(opts.Date, location) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } case app.ReportToday: if opts.Date == "" { req.Date = timeutil.LocalDate(r.Clock.Now(), location) } else { req.Date, err = timeutil.ParseLocalDate(opts.Date, location) if err != nil { return app.GenerateRequest{}, commonOptions{}, err } } } 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) 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 successful action output") if report == app.ReportDaily || report == app.ReportToday { fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD") } 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 successful action 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 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") 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 }