initial half-vibe coded commit

This commit is contained in:
Henner Flick 2025-08-30 13:17:19 +02:00
commit 50fe6a6af4
6 changed files with 402 additions and 0 deletions

46
.gitignore vendored Normal file
View file

@ -0,0 +1,46 @@
# Ignore the Go binary executables
/bin/
/obj/
# Go workspace file
go.work
# Go Modules and Dependency files
/vendor/
.golangci.yml
.golangci-lint-cache/
# IDE/Editor settings
.vscode/
.idea/
*.iml
# OS generated files
.DS_Store
Thumbs.db
# Test binary and coverage files
*.test
*.coverprofile
# Debugging files
*.log
*.out
# Go test binaries and profiles
profile/
# User-specific config files
.env
# Go vendor directory (if you use a vendoring strategy)
# /vendor
# Go build cache
/go-build/
# Other ignored files (e.g., CI/CD)
*.pid
*.swp

7
LICENSE Normal file
View file

@ -0,0 +1,7 @@
Copyright 2025 Hendrik Flick
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

92
README.md Normal file
View file

@ -0,0 +1,92 @@
# klog
`klog` is a small command-line tool to log coffee brewing events to the Kaffeelogger-API via HTTP POST requests.
I have half-vibe-coded it with the help of ChatGPT.
It is built with Go and uses a simple configuration file to store your API settings and token.
---
## Features
- **Commands**
- `klog config token <token>` store the access token
- `klog config url <url>` set API endpoint
- `klog config insecure <true|false>` ignore self-signed SSL certs
- `klog config show` show current configuration
- `klog c` (aliases: `klog k`, `klog coffee`, `klog kaffee`) log a coffee event
- `klog brew <waterIn> [--notify=<true|false>] [--plan=<true|false>] [--planTime=<minutes>]` log a brew with options
- **Formatted output**
Responses from the server (usually JSON) are displayed as a table for easier reading.
---
## Installation
### Build from source
Make sure you have [Go installed](https://go.dev/dl/).
```bash
git clone https://dev.hflabs.de/henner/kaffeecli.git
cd klog
go build -o bin/klog main.go
```
### Prebuilt binaries
On tagged releases, prebuilt binaries for Linux and Windows are available under the [Releases](../../../releases) section.
---
## Usage
### Configure the tool
```bash
# Set API token
klog config token my-secret-token
# Set Kaffeelogger URL
klog config url https://example.com/
# Show current configuration
klog config show
```
### Log events
```bash
# Quick coffee log
klog c
# Brew with parameters
klog brew 1250 # plans a brew with 1250mL and notifies in 8 min.
klog brew 250 --notify=true --plan=true --planTime=5 # plans a brew with 250mL and notifies in 5 min.
```
---
## Development
Run locally:
```bash
go run main.go
```
Compile for Linux:
```bash
GOOS=linux GOARCH=amd64 go build -o bin/klog main.go
```
Compile for Windows:
```bash
GOOS=windows GOARCH=amd64 go build -o bin/klog.exe main.go
```
---
## License
MIT License. See [LICENSE](LICENSE) for details.

9
go.mod Normal file
View file

@ -0,0 +1,9 @@
module dev.hflabs.de/henner/kaffeecli
go 1.23.3
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
)

10
go.sum Normal file
View file

@ -0,0 +1,10 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

238
main.go Normal file
View file

@ -0,0 +1,238 @@
package main
import (
"crypto/tls"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
)
type Config struct {
AccessToken string `json:"access_token"`
APIUrl string `json:"api_url"`
InsecureSkipVerify bool `json:"insecure_skip_verify"`
}
var configPath = filepath.Join(os.Getenv("HOME"), ".klog", "config.json")
func saveConfig(cfg Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
os.MkdirAll(filepath.Dir(configPath), 0700)
return ioutil.WriteFile(configPath, data, 0600)
}
func loadConfig() (Config, error) {
var cfg Config
data, err := ioutil.ReadFile(configPath)
if err != nil {
return cfg, err
}
err = json.Unmarshal(data, &cfg)
return cfg, err
}
func buildAPIEndpoint(baseURL, method string) string {
// ensure /api.php?method=... is appended correctly
if strings.HasSuffix(baseURL, "/") {
return fmt.Sprintf("%sapi.php?method=%s", baseURL, method)
}
return fmt.Sprintf("%s/api.php?method=%s", baseURL, method)
}
func postFormRequest(method string, formValues url.Values) error {
cfg, err := loadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
endpoint := buildAPIEndpoint(cfg.APIUrl, method)
req, err := http.NewRequest("POST", endpoint, strings.NewReader(formValues.Encode()))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+cfg.AccessToken)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify},
},
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := ioutil.ReadAll(resp.Body)
// Try to parse as JSON array first
var arr []map[string]interface{}
if err := json.Unmarshal(respBody, &arr); err == nil {
if len(arr) == 0 {
fmt.Println("(no data)")
return nil
}
printTable(arr)
return nil
}
// Try to parse as single object
var obj map[string]interface{}
if err := json.Unmarshal(respBody, &obj); err == nil {
printTable([]map[string]interface{}{obj})
return nil
}
// fallback: print raw
fmt.Println(string(respBody))
return nil
}
func printTable(data []map[string]interface{}) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
// print headers
for k := range data[0] {
fmt.Fprintf(w, "%s\t", k)
}
fmt.Fprintln(w)
// print separator
for range data[0] {
fmt.Fprintf(w, "--------\t")
}
fmt.Fprintln(w)
// print rows
for _, row := range data {
for _, v := range row {
fmt.Fprintf(w, "%v\t", v)
}
fmt.Fprintln(w)
}
w.Flush()
}
func main() {
var rootCmd = &cobra.Command{Use: "klog"}
// config command
var configCmd = &cobra.Command{Use: "config", Short: "Configure klog settings"}
var configTokenCmd = &cobra.Command{
Use: "token [token]",
Short: "Save access token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, _ := loadConfig()
cfg.AccessToken = args[0]
return saveConfig(cfg)
},
}
var configURLCmd = &cobra.Command{
Use: "url [url]",
Short: "Save API base URL",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, _ := loadConfig()
cfg.APIUrl = args[0]
return saveConfig(cfg)
},
}
var configInsecureCmd = &cobra.Command{
Use: "insecure [true|false]",
Short: "Ignore self-signed certificates",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, _ := loadConfig()
val := strings.ToLower(args[0])
cfg.InsecureSkipVerify = val == "true" || val == "1" || val == "yes"
return saveConfig(cfg)
},
}
var configShowCmd = &cobra.Command{
Use: "show",
Short: "Show current configuration",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
fmt.Printf("Access Token: %s\nAPI URL: %s\nIgnore Self-Signed Certs: %v\n", cfg.AccessToken, cfg.APIUrl, cfg.InsecureSkipVerify)
return nil
},
}
configCmd.AddCommand(configTokenCmd, configURLCmd, configInsecureCmd, configShowCmd)
// coffee command
var cCmd = &cobra.Command{
Use: "c",
Aliases: []string{"k", "coffee", "kaffee"},
Short: "Log a coffee",
RunE: func(cmd *cobra.Command, args []string) error {
return postFormRequest("coffee", nil)
},
}
var balanceCmd = &cobra.Command{
Use: "balance",
Short: "Show my balance",
RunE: func(cmd *cobra.Command, args []string) error {
return postFormRequest("balance", nil)
},
}
// brew command
var planTime int
var notify bool
var plan bool
var brewCmd = &cobra.Command{
Use: "brew [waterIn]",
Short: "Log a brew with parameters",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
cmd.Help()
return nil
}
form := url.Values{}
form.Set("waterIn", args[0])
form.Set("notify", strconv.FormatBool(notify))
form.Set("plan", strconv.FormatBool(plan))
if plan {
form.Set("planTime", strconv.Itoa(planTime))
}
return postFormRequest("brew", form)
},
}
brewCmd.Flags().IntVar(&planTime, "planTime", 8, "Planning time in minutes (e.g. 4, 5, 6, 7, 8) (relevant only if plan=true)")
brewCmd.Flags().BoolVar(&notify, "notify", true, "Enable/disable notifications")
brewCmd.Flags().BoolVar(&plan, "plan", true, "Enable/disable planning")
rootCmd.AddCommand(configCmd, balanceCmd, cCmd, brewCmd)
rootCmd.Execute()
}