Przeglądaj źródła

cli: configurable date formats (chatto + CLI), ISO for JSON/MCP; tests & docs

Paul Klumpp 2 dni temu
rodzic
commit
f86423a9b9
10 zmienionych plików z 139 dodań i 13 usunięć
  1. 37 0
      .env.example
  2. 9 0
      AGENTS.md
  3. 5 0
      README.md
  4. 9 4
      cmd/cheapo/chatto.go
  5. 35 0
      cmd/cheapo/chatto_test.go
  6. 14 3
      cmd/cheapo/main.go
  7. 23 0
      cmd/cheapo/tz.go
  8. 2 1
      go.mod
  9. 4 4
      go.sum
  10. 1 1
      internal/mcpserver/mcpserver.go

+ 37 - 0
.env.example

@@ -0,0 +1,37 @@
+# .env in the project root (godotenv). Never commit the real .env file.
+CHATTO_TOKEN="<token>"               # set by the operator
+# CHATTO_BASE_URL=https://chat.chatto.run   # optional, is the default
+
+# Date format for chatto-send output (Go time layout). Default: 02.01.2006 (unambiguous).
+# Go uses a reference time (Mon Jan 2 15:04:05 MST 2006) to describe the layout.
+# Examples (left = layout, right = rendered output for 2026-12-31 23:59:59 UTC):
+#   02.01.2006                  -> 31.12.2026
+#   2006-01-02                  -> 2026-12-31
+#   02.01.2006 15:04            -> 31.12.2026 23:59
+#   02.01.2006 15:04:05        -> 31.12.2026 23:59:59
+#   January 2, 2006             -> December 31, 2026
+#   Mon, 02 Jan 2006 15:04:05 MST -> Wed, 31 Dec 2026 23:59:59 UTC
+#   2006-01-02T15:04:05Z07:00  -> 2026-12-31T23:59:59Z
+# Reference: https://pkg.go.dev/time#Time.Format
+# CHEAPO_CHATTO_OUTPUT_DATE_FORMAT=2006-01-02
+CHEAPO_CHATTO_OUTPUT_DATE_FORMAT=02.01.2006
+# Timezone for chatto-send dates (default: system local time). Examples: Europe/Berlin, UTC, America/New_York
+# CHEAPO_CHATTO_OUTPUT_TIMEZONE=Europe/Berlin
+
+# Date format for CLI 'Free until:' output (Go time layout). Default: RFC3339 (ISO, unambiguous).
+# JSON output and MCP always use ISO-8601 regardless of this setting.
+# Go uses a reference time (Mon Jan 2 15:04:05 MST 2006) to describe the layout.
+# Examples (left = layout, right = rendered output for 2026-12-31 23:59:59 UTC):
+#   2006-01-02                      -> 2026-12-31
+#   02.01.2006                      -> 31.12.2026
+#   2006-01-02 15:04                -> 2026-12-31 23:59
+#   2006-01-02 15:04:05            -> 2026-12-31 23:59:59
+#   02.01.2006 15:04:05            -> 31.12.2026 23:59:59
+#   January 2, 2006                 -> December 31, 2026
+#   Mon, 02 Jan 2006 15:04:05 MST  -> Wed, 31 Dec 2026 23:59:59 UTC
+#   2006-01-02T15:04:05Z07:00     -> 2026-12-31T23:59:59Z
+# Default (ISO-8601 / RFC3339): 2006-01-02T15:04:05Z07:00
+# CHEAPO_CLI_OUTPUT_DATE_FORMAT=2006-01-02
+# CHEAPO_CLI_OUTPUT_DATE_FORMAT=2006-01-02T15:04:05Z07:00
+# Timezone for CLI 'Free until:' (default: UTC). Examples: Europe/Berlin, UTC, America/New_York
+# CHEAPO_CLI_OUTPUT_TIMEZONE=Europe/Berlin

+ 9 - 0
AGENTS.md

@@ -96,6 +96,15 @@ How it gets the info:
   `internal/mcpserver`.
 - User-facing strings in English (CLI output, help texts, error messages),
   code comments in English.
+- **Unambiguous date outputs:** prefer ISO-8601 (RFC-3339 / `2006-01-02`)
+  for machine-readable/structured surfaces (CLI `--json`, MCP `offer` wire type);
+  compact locale-style formats (e.g. `02.01.`) are only for human-facing chat
+  messages and must be overridable via `CHEAPO_CHATTO_OUTPUT_DATE_FORMAT`. The CLI's human-
+  readable "Free until:" line honors `CHEAPO_CLI_OUTPUT_DATE_FORMAT` (default
+  RFC-3339). Avoid ambiguous `DD.MM.`/`MM/DD` outputs without a year.
+  Both `CHEAPO_CLI_OUTPUT_DATE_FORMAT` and `CHEAPO_CHATTO_OUTPUT_DATE_FORMAT`
+  are Go time layouts (reference time `Mon Jan 2 15:04:05 MST 2006`); see
+  `.env.example` for many examples.
 - Wrap errors with context: `fmt.Errorf("epic: ...: %w", err)`.
 - New store platforms (Prime, GOG, …) follow the provider pattern (above).
 - **Chatto client:** `internal/chatto` is a hand-built Connect-JSON client

+ 5 - 0
README.md

@@ -161,6 +161,11 @@ CLI commands (credentials deliberately provided by the operator, never fetched a
 # .env in the project root (godotenv), never commit it — it is in .gitignore:
 CHATTO_TOKEN="<token>"               # set by the operator
 # CHATTO_BASE_URL=https://chat.chatto.run   # optional, is the default
+#
+# Date format for CLI 'Free until:' / chatto-send output (optional, Go time layout):
+#   CHEAPO_CLI_OUTPUT_DATE_FORMAT=02.01.2006
+#   CHEAPO_CHATTO_OUTPUT_DATE_FORMAT=02.01.2006
+# See .env.example for many layout examples.
 
 cheapo chatto-rooms                     # list rooms (client verification)
 cheapo chatto-send --room <room_id>     # post current free games

+ 9 - 4
cmd/cheapo/chatto.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"os"
 	"strings"
+	"time"
 
 	"github.com/joho/godotenv"
 	"github.com/spf13/cobra"
@@ -63,20 +64,24 @@ func formatGames(games []game.Game, mode string) string {
 	// One table per store keeps columns uniform (dates only for epic).
 	b.WriteString("| Game | Store | Price | Period |\n")
 	b.WriteString("|---|---|---|---|\n")
+	loc := outputLocation("CHEAPO_CHATTO_OUTPUT_TIMEZONE", time.Local)
 	for _, g := range games {
+		dateFmt := os.Getenv("CHEAPO_CHATTO_OUTPUT_DATE_FORMAT")
+		if dateFmt == "" {
+			dateFmt = "02.01.2006"
+		}
 		period := "—"
 		if g.Kind == "upcoming" && !g.StartDate.IsZero() && !g.EndDate.IsZero() {
 			period = fmt.Sprintf("from %s, until %s",
-				g.StartDate.Local().Format("02.01."),
-				g.EndDate.Local().Format("02.01."))
+				g.StartDate.In(loc).Format(dateFmt),
+				g.EndDate.In(loc).Format(dateFmt))
 		} else if !g.EndDate.IsZero() {
-			period = "until " + g.EndDate.Local().Format("02.01.")
+			period = "until " + g.EndDate.In(loc).Format(dateFmt)
 		}
 		fmt.Fprintf(&b, "| **%s** | [%s](%s) | %s | %s |\n",
 			escapePipe(g.Title), g.Store, g.URL, priceCell(g), period)
 	}
 
-	b.WriteString("\n_As of now — free from/until 17:00 Europe/Berlin each._")
 	return b.String()
 }
 

+ 35 - 0
cmd/cheapo/chatto_test.go

@@ -0,0 +1,35 @@
+package main
+
+import (
+	"strings"
+	"testing"
+	"time"
+
+	"gogs.netdome.biz/paul/cheapo/internal/game"
+)
+
+func mustParse(s string) time.Time {
+	tt, err := time.Parse(time.RFC3339, s)
+	if err != nil {
+		panic(err)
+	}
+	return tt
+}
+
+func TestFormatGamesDefaultDate(t *testing.T) {
+	t.Setenv("CHEAPO_CHATTO_OUTPUT_DATE_FORMAT", "")
+	games := []game.Game{{Title: "G", Store: "epic", URL: "u", Kind: "free-now", EndDate: mustParse("2026-11-02T00:00:00Z")}}
+	out := formatGames(games, "")
+	if !strings.Contains(out, "until 02.11.2026") {
+		t.Fatalf("expected compact date, got:\n%s", out)
+	}
+}
+
+func TestFormatGamesCustomDate(t *testing.T) {
+	t.Setenv("CHEAPO_CHATTO_OUTPUT_DATE_FORMAT", "2006-01-02")
+	games := []game.Game{{Title: "G", Store: "epic", URL: "u", Kind: "free-now", EndDate: mustParse("2026-11-02T00:00:00Z")}}
+	out := formatGames(games, "")
+	if !strings.Contains(out, "until 2026-11-02") {
+		t.Fatalf("expected ISO date, got:\n%s", out)
+	}
+}

+ 14 - 3
cmd/cheapo/main.go

@@ -30,6 +30,11 @@ var (
 )
 
 func main() {
+	// Load .env (godotenv) so values like CHEAPO_CLI_OUTPUT_DATE_FORMAT and
+	// CHEAPO_MCP_TOKEN apply to every command, not just mcp/chatto-*.
+	// Real environment variables take precedence over .env values.
+	loadEnv()
+
 	root := &cobra.Command{
 		Use:   "cheapo",
 		Short: "cheapo — free games lister (Go edition)",
@@ -41,8 +46,8 @@ References: https://github.com/vogler/free-games-claimer
 		SilenceUsage: true,
 	}
 
-	root.PersistentFlags().StringVar(&flagCountry, "country", "DE", "ISO country code for store region (epic)")
-	root.PersistentFlags().StringVar(&flagLocale, "locale", "de", "store locale (e.g. de, en-US)")
+	root.PersistentFlags().StringVar(&flagCountry, "country", "DE", "ISO country code for store region; determines currency/availability (epic only)")
+	root.PersistentFlags().StringVar(&flagLocale, "locale", "de", "display locale for Epic (formatting only, e.g. de -> '22,39 €', en-US -> '€22.39'; currency comes from --country)")
 	root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON")
 	root.PersistentFlags().StringVar(&flagStore, "store", "all", "which store(s): epic, steam, gog, all")
 
@@ -113,7 +118,13 @@ func printGames(cmd *cobra.Command, games []game.Game) error {
 				g.StartDate.Local().Format("02.01.2006 15:04"),
 				g.EndDate.Local().Format("02.01.2006 15:04"))
 		} else if !g.EndDate.IsZero() {
-			fmt.Fprintf(out, "   Free until: %s\n", g.EndDate.Local().Format("02.01.2006 15:04 (MST)"))
+			fmtStr := os.Getenv("CHEAPO_CLI_OUTPUT_DATE_FORMAT")
+			if fmtStr == "" {
+				fmtStr = time.RFC3339
+			}
+			loc := outputLocation("CHEAPO_CLI_OUTPUT_TIMEZONE", time.UTC)
+			s := g.EndDate.In(loc).Format(fmtStr)
+			fmt.Fprintf(out, "   Free until: %s\n", s)
 		}
 		fmt.Fprintf(out, "   %s\n", g.URL)
 		fmt.Fprintln(out)

+ 23 - 0
cmd/cheapo/tz.go

@@ -0,0 +1,23 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"time"
+)
+
+// outputLocation resolves the timezone for date formatting from an environment
+// variable. An empty or invalid value falls back to fallback (so defaults stay
+// unchanged and a bad value never breaks output).
+func outputLocation(envVar string, fallback *time.Location) *time.Location {
+	tz := os.Getenv(envVar)
+	if tz == "" {
+		return fallback
+	}
+	loc, err := time.LoadLocation(tz)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "⚠ invalid timezone %q (%s): using default\n", tz, envVar)
+		return fallback
+	}
+	return loc
+}

+ 2 - 1
go.mod

@@ -17,7 +17,8 @@ require (
 	github.com/spf13/pflag v1.0.9 // indirect
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
 	golang.org/x/oauth2 v0.35.0 // indirect
-	golang.org/x/sync v0.20.0 // indirect
+	golang.org/x/sync v0.24.0 // indirect
 	golang.org/x/sys v0.49.0 // indirect
 	golang.org/x/time v0.15.0 // indirect
+	golang.org/x/tools v0.51.0 // indirect
 )

+ 4 - 4
go.sum

@@ -27,12 +27,12 @@ golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
 golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
 golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
 golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
+golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
 golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
 golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
 golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
 golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
-golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
-golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
+golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

+ 1 - 1
internal/mcpserver/mcpserver.go

@@ -217,7 +217,7 @@ type listArgs struct {
 	Mode    string `json:"mode,omitempty" jsonschema:"what to list: free (current giveaways, default), upcoming (announced future Epic giveaways) or deals (discounted games)"`
 	Store   string `json:"store,omitempty" jsonschema:"which store: epic, steam, gog or all (default all)"`
 	Country string `json:"country,omitempty" jsonschema:"ISO country code for the Epic store region, e.g. DE (default DE)"`
-	Locale  string `json:"locale,omitempty" jsonschema:"store locale, e.g. de or en-US (default de)"`
+	Locale  string `json:"locale,omitempty" jsonschema:"Epic display locale (formatting only, e.g. de -> '22,39 €', en-US -> '€22.39'; currency comes from country)"`
 }
 
 // offer is the wire form of game.Game. It exists because game.Game marshals