436 lines
14 KiB
Go
436 lines
14 KiB
Go
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]
|
|
weatherreporter inspect reports [--config PATH] [--limit N]
|
|
weatherreporter inspect metadata [--config PATH] RUN_ID
|
|
weatherreporter inspect modules [--config PATH] RUN_ID
|
|
weatherreporter inspect data-package [--config PATH] RUN_ID
|
|
weatherreporter inspect prior [--config PATH] RUN_ID
|
|
weatherreporter inspect sources [--config PATH] RUN_ID
|
|
|
|
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 outside the managed workspace.
|
|
--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
|
|
case "inspect":
|
|
return r.runInspect(ctx, args[1:], stdout)
|
|
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 inspectOptions struct {
|
|
ConfigPath string
|
|
Limit int
|
|
RunID string
|
|
}
|
|
|
|
type inspectRunCommand struct {
|
|
Name string
|
|
Inspect func(context.Context, app.InspectRunRequest) (any, error)
|
|
}
|
|
|
|
var inspectRunCommands = []inspectRunCommand{
|
|
{Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
|
return app.InspectMetadata(ctx, req)
|
|
}},
|
|
{Name: "modules", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
|
return app.InspectModules(ctx, req)
|
|
}},
|
|
{Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
|
return app.InspectDataPackage(ctx, req)
|
|
}},
|
|
{Name: "prior", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
|
return app.InspectPriorSnapshot(ctx, req)
|
|
}},
|
|
{Name: "sources", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
|
return app.InspectSources(ctx, req)
|
|
}},
|
|
}
|
|
|
|
func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer) error {
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("inspect requires a command")
|
|
}
|
|
command := args[0]
|
|
switch command {
|
|
case "reports":
|
|
opts, err := parseInspectReportsFlags(args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
records, err := app.InspectReports(ctx, app.InspectReportsRequest{Config: cfg, Limit: opts.Limit})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(stdout, records)
|
|
default:
|
|
for _, candidate := range inspectRunCommands {
|
|
if candidate.Name == command {
|
|
return runInspectRunCommand(ctx, stdout, candidate, args[1:])
|
|
}
|
|
}
|
|
return fmt.Errorf("unknown inspect command %q", command)
|
|
}
|
|
}
|
|
|
|
func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspectRunCommand, args []string) error {
|
|
opts, err := parseInspectRunFlags(command.Name, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
value, err := command.Inspect(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(stdout, value)
|
|
}
|
|
|
|
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 parseInspectReportsFlags(args []string) (inspectOptions, error) {
|
|
fs := flag.NewFlagSet("inspect reports", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
opts := inspectOptions{Limit: 20}
|
|
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
|
|
fs.IntVar(&opts.Limit, "limit", 20, "maximum reports to list")
|
|
if err := fs.Parse(args); err != nil {
|
|
return inspectOptions{}, err
|
|
}
|
|
if fs.NArg() > 0 {
|
|
return inspectOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0))
|
|
}
|
|
if opts.Limit < 0 {
|
|
return inspectOptions{}, fmt.Errorf("limit must be zero or greater")
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
func parseInspectRunFlags(command string, args []string) (inspectOptions, error) {
|
|
fs := flag.NewFlagSet("inspect "+command, flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
opts := inspectOptions{}
|
|
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
|
|
if err := fs.Parse(args); err != nil {
|
|
return inspectOptions{}, err
|
|
}
|
|
if fs.NArg() != 1 {
|
|
return inspectOptions{}, fmt.Errorf("inspect %s requires a run id", command)
|
|
}
|
|
opts.RunID = 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
|
|
}
|