From 8490252bb554fc1e47c03d31793cef981d9a0c5f Mon Sep 17 00:00:00 2001 From: sstent Date: Thu, 18 Sep 2025 13:37:07 -0700 Subject: [PATCH] Feat: Implement 1A.2 CLI Framework Setup with Cobra --- cmd/garth/cmd/activities.go | 64 ++++++++++++ cmd/garth/cmd/data.go | 102 +++++++++++++++++++ cmd/garth/cmd/login.go | 53 ++++++++++ cmd/garth/cmd/root.go | 38 +++++++ cmd/garth/cmd/stats.go | 87 ++++++++++++++++ cmd/garth/cmd/tokens.go | 55 +++++++++++ cmd/garth/main.go | 192 +----------------------------------- go.mod | 10 +- go.sum | 8 ++ 9 files changed, 418 insertions(+), 191 deletions(-) create mode 100644 cmd/garth/cmd/activities.go create mode 100644 cmd/garth/cmd/data.go create mode 100644 cmd/garth/cmd/login.go create mode 100644 cmd/garth/cmd/root.go create mode 100644 cmd/garth/cmd/stats.go create mode 100644 cmd/garth/cmd/tokens.go diff --git a/cmd/garth/cmd/activities.go b/cmd/garth/cmd/activities.go new file mode 100644 index 0000000..6c6d349 --- /dev/null +++ b/cmd/garth/cmd/activities.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "fmt" + "log" + "time" + + "garmin-connect/internal/auth/credentials" + "garmin-connect/pkg/garmin" + + "github.com/spf13/cobra" +) + +var activitiesCmd = &cobra.Command{ + Use: "activities", + Short: "Display recent Garmin Connect activities", + Long: `Fetches and displays a list of recent activities from Garmin Connect.`, + Run: func(cmd *cobra.Command, args []string) { + // Load credentials from .env file + _, _, domain, err := credentials.LoadEnvCredentials() + if err != nil { + log.Fatalf("Failed to load credentials: %v", err) + } + + // Create client + garminClient, err := garmin.NewClient(domain) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + + // Try to load existing session first + sessionFile := "garmin_session.json" + if err := garminClient.LoadSession(sessionFile); err != nil { + log.Fatalf("No existing session found. Please run 'garth login' first.") + } + + activities, err := garminClient.GetActivities(5) + if err != nil { + log.Fatalf("Failed to get activities: %v", err) + } + displayActivities(activities) + }, +} + +func init() { + rootCmd.AddCommand(activitiesCmd) +} + +func displayActivities(activities []garmin.Activity) { + fmt.Printf("\n=== Recent Activities ===\n") + for i, activity := range activities { + fmt.Printf("%d. %s\n", i+1, activity.ActivityName) + fmt.Printf(" Type: %s\n", activity.ActivityType.TypeKey) + fmt.Printf(" Date: %s\n", activity.StartTimeLocal) + if activity.Distance > 0 { + fmt.Printf(" Distance: %.2f km\n", activity.Distance/1000) + } + if activity.Duration > 0 { + duration := time.Duration(activity.Duration) * time.Second + fmt.Printf(" Duration: %v\n", duration.Round(time.Second)) + } + fmt.Println() + } +} diff --git a/cmd/garth/cmd/data.go b/cmd/garth/cmd/data.go new file mode 100644 index 0000000..9642e25 --- /dev/null +++ b/cmd/garth/cmd/data.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "log" + "os" + "time" + + "garmin-connect/internal/auth/credentials" + "garmin-connect/pkg/garmin" + + "github.com/spf13/cobra" +) + +var ( + dataDateStr string + dataDays int + dataOutputFile string +) + +var dataCmd = &cobra.Command{ + Use: "data [type]", + Short: "Fetch various data types from Garmin Connect", + Long: `Fetch data such as bodybattery, sleep, HRV, and weight from Garmin Connect.`, + Args: cobra.ExactArgs(1), // Expects one argument: the data type + Run: func(cmd *cobra.Command, args []string) { + dataType := args[0] + + // Load credentials from .env file + _, _, domain, err := credentials.LoadEnvCredentials() + if err != nil { + log.Fatalf("Failed to load credentials: %v", err) + } + + // Create client + garminClient, err := garmin.NewClient(domain) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + + // Try to load existing session first + sessionFile := "garmin_session.json" + if err := garminClient.LoadSession(sessionFile); err != nil { + log.Fatalf("No existing session found. Please run 'garth login' first.") + } + + endDate := time.Now().AddDate(0, 0, -1) // default to yesterday + if dataDateStr != "" { + parsedDate, err := time.Parse("2006-01-02", dataDateStr) + if err != nil { + log.Fatalf("Invalid date format: %v", err) + } + endDate = parsedDate + } + + var result interface{} + + switch dataType { + case "bodybattery": + result, err = garminClient.GetBodyBattery(endDate) + case "sleep": + result, err = garminClient.GetSleep(endDate) + case "hrv": + result, err = garminClient.GetHRV(endDate) + case "weight": + result, err = garminClient.GetWeight(endDate) + default: + log.Fatalf("Unknown data type: %s", dataType) + } + + if err != nil { + log.Fatalf("Failed to get %s data: %v", dataType, err) + } + + outputResult(result, dataOutputFile) + }, +} + +func init() { + rootCmd.AddCommand(dataCmd) + + dataCmd.Flags().StringVar(&dataDateStr, "date", "", "Date in YYYY-MM-DD format (default: yesterday)") + dataCmd.Flags().StringVar(&dataOutputFile, "output", "", "Output file for JSON results") + // dataCmd.Flags().IntVar(&dataDays, "days", 1, "Number of days to fetch") // Not used for single day data types +} + +func outputResult(data interface{}, outputFile string) { + jsonBytes, err := json.MarshalIndent(data, "", " ") + if err != nil { + log.Fatalf("Failed to marshal result: %v", err) + } + + if outputFile != "" { + if err := os.WriteFile(outputFile, jsonBytes, 0644); err != nil { + log.Fatalf("Failed to write output file: %v", err) + } + fmt.Printf("Results saved to %s\n", outputFile) + } else { + fmt.Println(string(jsonBytes)) + } +} diff --git a/cmd/garth/cmd/login.go b/cmd/garth/cmd/login.go new file mode 100644 index 0000000..cb98dac --- /dev/null +++ b/cmd/garth/cmd/login.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "fmt" + "log" + + "garmin-connect/internal/auth/credentials" + "garmin-connect/pkg/garmin" + + "github.com/spf13/cobra" +) + +var loginCmd = &cobra.Command{ + Use: "login", + Short: "Login to Garmin Connect", + Long: `Login to Garmin Connect using credentials from .env file and save the session.`, + Run: func(cmd *cobra.Command, args []string) { + // Load credentials from .env file + email, password, domain, err := credentials.LoadEnvCredentials() + if err != nil { + log.Fatalf("Failed to load credentials: %v", err) + } + + // Create client + garminClient, err := garmin.NewClient(domain) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + + // Try to load existing session first + sessionFile := "garmin_session.json" + if err := garminClient.LoadSession(sessionFile); err != nil { + fmt.Println("No existing session found, logging in with credentials from .env...") + + if err := garminClient.Login(email, password); err != nil { + log.Fatalf("Login failed: %v", err) + } + + // Save session for future use + if err := garminClient.SaveSession(sessionFile); err != nil { + fmt.Printf("Failed to save session: %v\n", err) + } + } else { + fmt.Println("Loaded existing session") + } + + fmt.Println("Login successful!") + }, +} + +func init() { + rootCmd.AddCommand(loginCmd) +} diff --git a/cmd/garth/cmd/root.go b/cmd/garth/cmd/root.go new file mode 100644 index 0000000..ecc2f15 --- /dev/null +++ b/cmd/garth/cmd/root.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "garth", + Short: "garth is a CLI for interacting with Garmin Connect", + Long: `A command-line interface for Garmin Connect that allows you to +interact with your health and fitness data.`, + // Uncomment the following line if your bare application + // has an action associated with it: + // Run: func(cmd *cobra.Command, args []string) { }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func init() { + // Here you will define your flags and configuration settings. + // Cobra supports persistent flags, which, if defined here, will be global for your application. + + // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.garth.yaml)") + + // Cobra also supports local flags, which will only run when this action is called directly. + // rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +} + diff --git a/cmd/garth/cmd/stats.go b/cmd/garth/cmd/stats.go new file mode 100644 index 0000000..06e32a9 --- /dev/null +++ b/cmd/garth/cmd/stats.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "log" + "time" + + "garmin-connect/internal/auth/credentials" + "garmin-connect/pkg/garmin" + + "github.com/spf13/cobra" +) + +var ( + statsDateStr string + statsDays int + statsOutputFile string +) + +var statsCmd = &cobra.Command{ + Use: "stats [type]", + Short: "Fetch various stats types from Garmin Connect", + Long: `Fetch stats such as steps, stress, hydration, intensity, sleep, and HRV from Garmin Connect.`, + Args: cobra.ExactArgs(1), // Expects one argument: the stats type + Run: func(cmd *cobra.Command, args []string) { + statsType := args[0] + + // Load credentials from .env file + _, _, domain, err := credentials.LoadEnvCredentials() + if err != nil { + log.Fatalf("Failed to load credentials: %v", err) + } + + // Create client + garminClient, err := garmin.NewClient(domain) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + + // Try to load existing session first + sessionFile := "garmin_session.json" + if err := garminClient.LoadSession(sessionFile); err != nil { + log.Fatalf("No existing session found. Please run 'garth login' first.") + } + + endDate := time.Now().AddDate(0, 0, -1) // default to yesterday + if statsDateStr != "" { + parsedDate, err := time.Parse("2006-01-02", statsDateStr) + if err != nil { + log.Fatalf("Invalid date format: %v", err) + } + endDate = parsedDate + } + + var stats garmin.Stats + switch statsType { + case "steps": + stats = garmin.NewDailySteps() + case "stress": + stats = garmin.NewDailyStress() + case "hydration": + stats = garmin.NewDailyHydration() + case "intensity": + stats = garmin.NewDailyIntensityMinutes() + case "sleep": + stats = garmin.NewDailySleep() + case "hrv": + stats = garmin.NewDailyHRV() + default: + log.Fatalf("Unknown stats type: %s", statsType) + } + + result, err := stats.List(endDate, statsDays, garminClient.Client) + if err != nil { + log.Fatalf("Failed to get %s stats: %v", statsType, err) + } + + outputResult(result, statsOutputFile) + }, +} + +func init() { + rootCmd.AddCommand(statsCmd) + + statsCmd.Flags().StringVar(&statsDateStr, "date", "", "Date in YYYY-MM-DD format (default: yesterday)") + statsCmd.Flags().IntVar(&statsDays, "days", 1, "Number of days to fetch") + statsCmd.Flags().StringVar(&statsOutputFile, "output", "", "Output file for JSON results") +} diff --git a/cmd/garth/cmd/tokens.go b/cmd/garth/cmd/tokens.go new file mode 100644 index 0000000..025fedc --- /dev/null +++ b/cmd/garth/cmd/tokens.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "log" + + "garmin-connect/internal/auth/credentials" + "garmin-connect/pkg/garmin" + + "github.com/spf13/cobra" +) + +var tokensCmd = &cobra.Command{ + Use: "tokens", + Short: "Output OAuth tokens in JSON format", + Long: `Output the OAuth1 and OAuth2 tokens in JSON format after a successful login.`, + Run: func(cmd *cobra.Command, args []string) { + // Load credentials from .env file + _, _, domain, err := credentials.LoadEnvCredentials() + if err != nil { + log.Fatalf("Failed to load credentials: %v", err) + } + + // Create client + garminClient, err := garmin.NewClient(domain) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + + // Try to load existing session first + sessionFile := "garmin_session.json" + if err := garminClient.LoadSession(sessionFile); err != nil { + log.Fatalf("No existing session found. Please run 'garth login' first.") + } + + tokens := struct { + OAuth1 *garmin.OAuth1Token `json:"oauth1"` + OAuth2 *garmin.OAuth2Token `json:"oauth2"` + }{ + OAuth1: garminClient.OAuth1Token(), + OAuth2: garminClient.OAuth2Token(), + } + + jsonBytes, err := json.MarshalIndent(tokens, "", " ") + if err != nil { + log.Fatalf("Failed to marshal tokens: %v", err) + } + fmt.Println(string(jsonBytes)) + }, +} + +func init() { + rootCmd.AddCommand(tokensCmd) +} diff --git a/cmd/garth/main.go b/cmd/garth/main.go index 8ff8379..b8f60fd 100644 --- a/cmd/garth/main.go +++ b/cmd/garth/main.go @@ -1,197 +1,9 @@ package main import ( - "encoding/json" - "flag" - "fmt" - "log" - "os" - "time" - - "garmin-connect/pkg/garmin" - "garmin-connect/internal/auth/credentials" + "garmin-connect/cmd/garth/cmd" ) func main() { - // Parse command line flags - var outputTokens = flag.Bool("tokens", false, "Output OAuth tokens in JSON format") - var dataType = flag.String("data", "", "Data type to fetch (bodybattery, sleep, hrv, weight)") - var statsType = flag.String("stats", "", "Stats type to fetch (steps, stress, hydration, intensity, sleep, hrv)") - - var dateStr = flag.String("date", "", "Date in YYYY-MM-DD format (default: yesterday)") - var days = flag.Int("days", 1, "Number of days to fetch") - var outputFile = flag.String("output", "", "Output file for JSON results") - flag.Parse() - - // Load credentials from .env file - email, password, domain, err := credentials.LoadEnvCredentials() - if err != nil { - log.Fatalf("Failed to load credentials: %v", err) - } - - // Create client - garminClient, err := garmin.NewClient(domain) - if err != nil { - log.Fatalf("Failed to create client: %v", err) - } - - // Try to load existing session first - sessionFile := "garmin_session.json" - if err := garminClient.LoadSession(sessionFile); err != nil { - fmt.Println("No existing session found, logging in with credentials from .env...") - - if err := garminClient.Login(email, password); err != nil { - log.Fatalf("Login failed: %v", err) - } - - // Save session for future use - if err := garminClient.SaveSession(sessionFile); err != nil { - fmt.Printf("Failed to save session: %v\n", err) - } - } else { - fmt.Println("Loaded existing session") - } - - // If tokens flag is set, output tokens and exit - if *outputTokens { - outputTokensJSON(garminClient) - return - } - - // Handle data requests - if *dataType != "" { - handleDataRequest(garminClient, *dataType, *dateStr, *days, *outputFile) - return - } - - // Handle stats requests - if *statsType != "" { - handleStatsRequest(garminClient, *statsType, *dateStr, *days, *outputFile) - return - } - - // Default: show recent activities - activities, err := garminClient.GetActivities(5) - if err != nil { - log.Fatalf("Failed to get activities: %v", err) - } - displayActivities(activities) -} - -func outputTokensJSON(c *garmin.Client) { - tokens := struct { - OAuth1 *garmin.OAuth1Token `json:"oauth1"` - OAuth2 *garmin.OAuth2Token `json:"oauth2"` - }{ - OAuth1: c.OAuth1Token(), - OAuth2: c.OAuth2Token(), - } - - jsonBytes, err := json.MarshalIndent(tokens, "", " ") - if err != nil { - log.Fatalf("Failed to marshal tokens: %v", err) - } - fmt.Println(string(jsonBytes)) -} - -func handleDataRequest(c *garmin.Client, dataType, dateStr string, days int, outputFile string) { - endDate := time.Now().AddDate(0, 0, -1) // default to yesterday - if dateStr != "" { - parsedDate, err := time.Parse("2006-01-02", dateStr) - if err != nil { - log.Fatalf("Invalid date format: %v", err) - } - endDate = parsedDate - } - - var result interface{} - var err error - - switch dataType { - case "bodybattery": - result, err = c.GetBodyBattery(endDate) - case "sleep": - result, err = c.GetSleep(endDate) - case "hrv": - result, err = c.GetHRV(endDate) - case "weight": - result, err = c.GetWeight(endDate) - default: - log.Fatalf("Unknown data type: %s", dataType) - } - - if err != nil { - log.Fatalf("Failed to get %s data: %v", dataType, err) - } - - outputResult(result, outputFile) -} - -func handleStatsRequest(c *garmin.Client, statsType, dateStr string, days int, outputFile string) { - endDate := time.Now().AddDate(0, 0, -1) // default to yesterday - if dateStr != "" { - parsedDate, err := time.Parse("2006-01-02", dateStr) - if err != nil { - log.Fatalf("Invalid date format: %v", err) - } - endDate = parsedDate - } - - var stats garmin.Stats - switch statsType { - case "steps": - stats = garmin.NewDailySteps() - case "stress": - stats = garmin.NewDailyStress() - case "hydration": - stats = garmin.NewDailyHydration() - case "intensity": - stats = garmin.NewDailyIntensityMinutes() - case "sleep": - stats = garmin.NewDailySleep() - case "hrv": - stats = garmin.NewDailyHRV() - default: - log.Fatalf("Unknown stats type: %s", statsType) - } - - result, err := stats.List(endDate, days, c.Client) - if err != nil { - log.Fatalf("Failed to get %s stats: %v", statsType, err) - } - - outputResult(result, outputFile) -} - -func outputResult(data interface{}, outputFile string) { - jsonBytes, err := json.MarshalIndent(data, "", " ") - if err != nil { - log.Fatalf("Failed to marshal result: %v", err) - } - - if outputFile != "" { - if err := os.WriteFile(outputFile, jsonBytes, 0644); err != nil { - log.Fatalf("Failed to write output file: %v", err) - } - fmt.Printf("Results saved to %s\n", outputFile) - } else { - fmt.Println(string(jsonBytes)) - } -} - -func displayActivities(activities []garmin.Activity) { - fmt.Printf("\n=== Recent Activities ===\n") - for i, activity := range activities { - fmt.Printf("%d. %s\n", i+1, activity.ActivityName) - fmt.Printf(" Type: %s\n", activity.ActivityType.TypeKey) - fmt.Printf(" Date: %s\n", activity.StartTimeLocal) - if activity.Distance > 0 { - fmt.Printf(" Distance: %.2f km\n", activity.Distance/1000) - } - if activity.Duration > 0 { - duration := time.Duration(activity.Duration) * time.Second - fmt.Printf(" Duration: %v\n", duration.Round(time.Second)) - } - fmt.Println() - } + cmd.Execute() } \ No newline at end of file diff --git a/go.mod b/go.mod index 7803f9e..6a63811 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,15 @@ module garmin-connect go 1.24.2 -require github.com/joho/godotenv v1.5.1 +require ( + github.com/joho/godotenv v1.5.1 + github.com/spf13/cobra v1.10.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index ae38d6f..f25ae29 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,17 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=