Files
weatherreporter/internal/cli/root.go

440 lines
14 KiB
Go
Raw Normal View History

package cli
import (
"context"
"flag"
"fmt"
"io"
"os"
"path/filepath"
2026-05-29 16:58:18 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
2026-07-31 19:13:45 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo"
2026-08-02 05:56:31 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
2026-05-29 16:58:18 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
2026-05-29 16:58:18 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const helpText = `weatherreporter prepares weather reports from normalized forecast data.
Usage:
weatherreporter --help
2026-07-31 19:13:45 +00:00
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]
2026-08-02 06:02:24 +00:00
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:
2026-05-29 16:58:18 +00:00
-h, --help Show this help message.
2026-07-31 19:13:45 +00:00
--version Show the Weatherreporter version.
2026-05-29 16:58:18 +00:00
--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.
2026-08-13 13:52:16 +00:00
--llm-debug-dir PATH Write sensitive prompt debug artifacts under PATH (Unix only).
2026-08-02 06:02:24 +00:00
--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.
2026-08-02 06:02:24 +00:00
--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.
`
2026-05-29 16:58:18 +00:00
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)
2026-08-02 05:56:31 +00:00
compareDetailed func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error)
2026-05-29 16:58:18 +00:00
}
func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
2026-05-29 16:58:18 +00:00
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
}
2026-07-31 19:13:45 +00:00
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
}
2026-05-29 16:58:18 +00:00
switch args[0] {
case "generate":
2026-06-20 22:55:59 +00:00
req, opts, err := r.resolveGenerateAction(args[1:])
2026-05-29 16:58:18 +00:00
if err != nil {
return err
}
generateDetailed := r.generateDetailed
if generateDetailed == nil {
generateDetailed = app.GenerateDetailed
}
result, err := generateDetailed(ctx, req)
2026-06-20 22:55:59 +00:00
if result != nil {
summary := newGenerateSummary(result, err)
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, nil); encodeErr != nil {
return encodeErr
}
}
return err
2026-05-29 16:58:18 +00:00
case "run":
2026-06-20 22:55:59 +00:00
req, opts, err := r.resolveRunAction(args[1:])
2026-05-29 16:58:18 +00:00
if err != nil {
return err
}
runBatchDetailed := r.runBatchDetailed
if runBatchDetailed == nil {
runBatchDetailed = app.RunBatchDetailed
}
result, err := runBatchDetailed(ctx, req)
2026-05-29 18:20:39 +00:00
if result != nil {
2026-08-13 03:02:59 +00:00
summary := newBatchSummary(result, err)
2026-06-20 22:55:59 +00:00
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) {
2026-06-20 22:49:51 +00:00
writeBatchStatus(w, result)
}); encodeErr != nil {
2026-05-29 18:20:39 +00:00
return encodeErr
}
2026-08-13 03:02:59 +00:00
if err != nil {
return err
}
2026-06-20 22:55:59 +00:00
if summary.Status == summaryStatusFailed {
2026-05-29 18:20:39 +00:00
return app.BatchError{Result: result}
}
}
return err
2026-08-02 06:02:24 +00:00
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
2026-05-29 16:58:18 +00:00
default:
return fmt.Errorf("unknown command %q", args[0])
}
}
2026-05-29 16:58:18 +00:00
type commonOptions struct {
ConfigPath string
Units string
Timezone string
Output string
OutputDir string
LLMDebugDir string
Quiet bool
2026-05-29 16:58:18 +00:00
}
2026-05-29 16:58:18 +00:00
type generateOptions struct {
commonOptions
Date string
2026-05-29 16:58:18 +00:00
}
2026-08-02 05:56:31 +00:00
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
}
2026-05-29 16:58:18 +00:00
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
2026-06-20 22:55:59 +00:00
req, _, err := r.resolveGenerateAction(args)
return req, err
}
func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commonOptions, error) {
2026-05-29 18:00:27 +00:00
if r.Clock == nil {
r.Clock = timeutil.SystemClock{}
}
2026-05-29 16:58:18 +00:00
if len(args) == 0 {
2026-06-20 22:55:59 +00:00
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate requires a report name")
2026-05-29 16:58:18 +00:00
}
2026-06-15 12:33:55 +00:00
if _, err := report.IDForCommandName(args[0]); err != nil {
2026-06-20 22:55:59 +00:00
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("unknown generate report %q", args[0])
}
2026-06-15 12:33:55 +00:00
reportKind := app.ReportKind(args[0])
opts, err := parseGenerateFlags(reportKind, args[1:])
2026-05-29 16:58:18 +00:00
if err != nil {
2026-06-20 22:55:59 +00:00
return app.GenerateRequest{}, commonOptions{}, err
2026-05-29 16:58:18 +00:00
}
2026-08-13 04:06:37 +00:00
if err := reportDateRequiredError("generate", reportKind, opts.Date); err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
2026-05-29 16:58:18 +00:00
cfg, err := config.Load(config.LoadOptions{
Path: opts.ConfigPath,
Units: opts.Units,
Timezone: opts.Timezone,
})
if err != nil {
2026-06-20 22:55:59 +00:00
return app.GenerateRequest{}, commonOptions{}, err
2026-05-29 16:58:18 +00:00
}
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil {
2026-06-20 22:55:59 +00:00
return app.GenerateRequest{}, commonOptions{}, err
2026-05-29 16:58:18 +00:00
}
now := r.Clock.Now()
2026-05-29 16:58:18 +00:00
req := app.GenerateRequest{
Config: cfg,
Report: reportKind,
LLMDebugDir: opts.LLMDebugDir,
Now: now,
2026-05-29 16:58:18 +00:00
}
2026-08-13 04:06:37 +00:00
req.Date, err = resolveActionReportDate("generate", reportKind, opts.Date, location, now)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
2026-05-29 16:58:18 +00:00
}
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
2026-06-20 22:55:59 +00:00
return req, opts.commonOptions, nil
2026-05-29 16:58:18 +00:00
}
2026-05-29 18:00:27 +00:00
func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
2026-06-20 22:55:59 +00:00
req, _, err := r.resolveRunAction(args)
return req, err
}
2026-08-02 05:56:31 +00:00
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,
}
2026-08-13 04:06:37 +00:00
req.Date, err = resolveActionReportDate("compare", reportKind, opts.Date, location, r.Clock.Now())
2026-08-02 05:56:31 +00:00
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
}
2026-06-20 22:55:59 +00:00
func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions, error) {
2026-05-29 18:00:27 +00:00
if r.Clock == nil {
r.Clock = timeutil.SystemClock{}
}
2026-05-29 16:58:18 +00:00
if len(args) == 0 {
2026-06-20 22:55:59 +00:00
return app.BatchRequest{}, commonOptions{}, fmt.Errorf("run requires a batch name")
2026-05-29 16:58:18 +00:00
}
2026-06-15 12:33:55 +00:00
if _, err := report.BatchForCommandName(args[0]); err != nil {
2026-06-20 22:55:59 +00:00
return app.BatchRequest{}, commonOptions{}, fmt.Errorf("unknown run batch %q", args[0])
}
2026-06-15 12:33:55 +00:00
batch := app.BatchKind(args[0])
2026-05-29 16:58:18 +00:00
opts, err := parseRunFlags(args[1:])
if err != nil {
2026-06-20 22:55:59 +00:00
return app.BatchRequest{}, commonOptions{}, err
2026-05-29 16:58:18 +00:00
}
cfg, err := config.Load(config.LoadOptions{
Path: opts.ConfigPath,
Units: opts.Units,
Timezone: opts.Timezone,
})
if err != nil {
2026-06-20 22:55:59 +00:00
return app.BatchRequest{}, commonOptions{}, err
2026-05-29 16:58:18 +00:00
}
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
2026-05-29 18:00:27 +00:00
}
func resolveRun(args []string) (app.BatchRequest, error) {
return Runner{Clock: timeutil.SystemClock{}}.resolveRun(args)
2026-05-29 16:58:18 +00:00
}
2026-05-29 16:58:18 +00:00
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")
2026-08-13 04:06:37 +00:00
addReportDateFlag(fs, report, &opts.Date)
2026-05-29 16:58:18 +00:00
if err := fs.Parse(args); err != nil {
return generateOptions{}, err
}
if fs.NArg() > 0 {
2026-05-29 16:58:18 +00:00
return generateOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0))
}
2026-05-29 16:58:18 +00:00
return opts, nil
}
2026-05-29 16:58:18 +00:00
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")
2026-05-29 16:58:18 +00:00
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
}
2026-08-02 05:56:31 +00:00
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")
2026-08-02 05:56:31 +00:00
fs.Var(&opts.ProfileIDs, "profile", "prompt profile ID")
2026-08-13 04:06:37 +00:00
addReportDateFlag(fs, report, &opts.Date)
2026-08-02 05:56:31 +00:00
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
}
2026-05-29 16:58:18 +00:00
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")
2026-08-13 13:52:16 +00:00
fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH (Unix only)")
2026-05-29 16:58:18 +00:00
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)
2026-05-29 16:58:18 +00:00
}
return filepath.Clean(value), nil
2026-05-29 16:58:18 +00:00
}