From d1cc207ce4ef4d14ec4bef8508f6eac1eeb10f5a Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 3 Jul 2026 09:52:44 +0200 Subject: [PATCH] feat: init command, README, fix config creation, move cicd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmd_init.go: interactive setup wizard (gosleep-timer init) - README.md: comprehensive documentation - config/config.go: fix os.IsNotExist → errors.Is for wrapped err - main.go: remove '(no TUI)' from run help - cicd.yaml moved to .github/workflows/cicd.yaml --- cicd.yaml => .github/workflows/cicd.yaml | 0 README.md | 200 +++++++++++++++++ cmd_init.go | 263 +++++++++++++++++++++++ config/config.go | 3 +- main.go | 2 +- 5 files changed, 466 insertions(+), 2 deletions(-) rename cicd.yaml => .github/workflows/cicd.yaml (100%) create mode 100644 README.md create mode 100644 cmd_init.go diff --git a/cicd.yaml b/.github/workflows/cicd.yaml similarity index 100% rename from cicd.yaml rename to .github/workflows/cicd.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..c19e441 --- /dev/null +++ b/README.md @@ -0,0 +1,200 @@ +# ⏱ gosleep-timer + +TUI sleep timer with modular WM/DE support. Go rewrite of `timer.py`. + +## Quick start + +```bash +# Run TUI +gosleep-timer + +# First time? Interactive setup wizard +gosleep-timer init +``` + +## Installation + +```bash +go install github.com/forust/gosleep-timer@latest +# or +git clone https://github.com/forust/gosleep-timer +cd gosleep-timer +go build -o gosleep-timer . +sudo cp gosleep-timer /usr/local/bin/ +``` + +## Usage + +### TUI mode (default) + +```bash +gosleep-timer +``` + +Keys: +| Key | Action | +|-----|--------| +| `s` | Start timer | +| `S` | Stop timer | +| `Tab` | Next field | +| `↑`/`↓` | Previous/next profile | +| `1`-`0` | Toggle modules | +| `h` | History | +| `?` | Help | +| `q`/`Esc` | Quit | + +### CLI mode + +```bash +# Run with defaults (25m) +gosleep-timer run + +# Custom duration +gosleep-timer run 45m +gosleep-timer run 1h30m + +# With profile +gosleep-timer run 20m --profile work +``` + +### Other commands + +```bash +# Interactive setup +gosleep-timer init + +# List profiles +gosleep-timer list-profiles + +# Export/import config +gosleep-timer export > config-backup.yaml +gosleep-timer import < config-backup.yaml + +# History and stats +gosleep-timer history +gosleep-timer stats +``` + +## Configuration + +File: `~/.config/gosleep-timer/config.yaml` + +### Profiles + +```yaml +profiles: + default: + modules: + workspace: + enabled: true + backend: auto # auto, niri, hyprland, kde + workspace: 12 + media: + enabled: true + action: stop # stop, pause, play-pause, next, previous + lock: + enabled: true + command: "" # custom command or auto-detect + kill: + enabled: false + processes: [firefox, telegram-desktop] + notify: + enabled: true + sound: "" # path or empty + sound: + enabled: true + file: /usr/share/sounds/freedesktop/stereo/complete.oga + brightness: + enabled: false + value: 0 + mute: + enabled: false + custom: + enabled: false + pre: [] + post: [] + script: + enabled: false + path: "" + + work: + extends: default + modules: + workspace: + workspace: 5 +``` + +### Profile inheritance + +Profiles can extend others. Modules from the base profile are merged and overlaid. + +## Modules + +| Module | Stage | Description | +|--------|-------|-------------| +| **workspace** | pre | Switch WM workspace (niri/Hyprland/KDE) | +| **media** | pre | Control music player via playerctl | +| **lock** | post | Lock screen | +| **kill** | pre | Kill processes by name | +| **notify** | post | Desktop notification | +| **sound** | post | Play audio file | +| **brightness** | pre/post | Dim/restore brightness | +| **mute** | pre/post | Mute/unmute audio | +| **custom** | pre/post | User-defined shell commands | +| **script** | pre/post | Run external script with args | + +### Workspace backends + +| Backend | Command | +|---------|---------| +| niri | `niri msg action focus-workspace {n}` | +| Hyprland | `hyprctl dispatch workspace {n}` | +| KDE | `qdbus org.kde.KWin /KWin setCurrentDesktop {n}` | + +Auto-detection checks `$XDG_CURRENT_DESKTOP`, then tries `hyprctl` and `niri msg`. + +## Project structure + +``` +gosleep-timer/ +├── main.go # CLI entrypoint +├── cmd_init.go # Interactive setup wizard +├── config/ # YAML config load/save/types +├── engine/ # Timer core, executor, stages +├── modules/ # Module interface + 10 implementations +├── profiles/ # Profile manager +├── history/ # JSONL log + statistics +├── tui/ # Bubbletea TUI app +├── cicd.yaml # CI/CD pipeline +└── Dockerfile # Multi-stage build +``` + +## Development + +```bash +# Build +go build -o gosleep-timer . + +# Test +go test ./... + +# Lint +golangci-lint run --config .golangci.yml + +# Static analysis +staticcheck ./... +gosec ./... +revive -config .revive.toml ./... +``` + +## CI/CD + +Pipeline (`.github/workflows/cicd.yaml`): +- **lint-go**: golangci-lint, staticcheck, gosec, revive +- **lint-yaml**: yamllint +- **build**: go build + test with race detector +- **publish**: Docker image to registry (main/dev branches) + +## License + +MIT diff --git a/cmd_init.go b/cmd_init.go new file mode 100644 index 0000000..271672f --- /dev/null +++ b/cmd_init.go @@ -0,0 +1,263 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/forust/gosleep-timer/config" +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Interactive config setup wizard", + Long: `Creates a new config.yaml with interactive prompts. +Walks through all modules and profiles step by step.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfgPath, err := getConfigPath() + if err != nil { + return err + } + + // Check if config already exists + if _, err := os.Stat(cfgPath); err == nil { + fmt.Printf("Config already exists at %s\n", cfgPath) + fmt.Print("Overwrite? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Println("Aborted.") + return nil + } + } + + cfg := runWizard() + if cfg == nil { + fmt.Println("\nSetup aborted.") + return nil + } + + if err := config.Save(cfgPath, cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + + fmt.Printf("\n✅ Config saved to %s\n", cfgPath) + fmt.Println("Run 'gosleep-timer' to start the TUI.") + return nil + }, +} + +func init() { + rootCmd.AddCommand(initCmd) +} + +func runWizard() *config.Config { + reader := bufio.NewReader(os.Stdin) + cfg := config.DefaultConfig() + p := &config.Profile{} + + fmt.Println(strings.Repeat("=", 50)) + fmt.Println(" ⏱ gosleep-timer Setup Wizard") + fmt.Println(strings.Repeat("=", 50)) + + // Profile name + fmt.Print("\nProfile name [default]: ") + name := readLine(reader, "default") + + // Duration + fmt.Print("Default duration (e.g. 25m, 1h, 90s) [25m]: ") + dur := readLine(reader, "25m") + + // Workspace module + fmt.Println(strings.Repeat("-", 50)) + fmt.Println("Workspace — switch to a workspace on timer start") + wsEnabled := askYesNo(reader, "Enable workspace switch?", true) + ws := &config.WorkspaceConfig{Enabled: wsEnabled} + if wsEnabled { + fmt.Print("Workspace number [12]: ") + wsStr := readLine(reader, "12") + ws.Workspace, _ = strconv.Atoi(wsStr) + if ws.Workspace == 0 { + ws.Workspace = 12 + } + + fmt.Println("Backend (auto-detect / niri / hyprland / kde):") + fmt.Print(" [auto]: ") + ws.Backend = readLine(reader, "auto") + if ws.Backend == "auto" { + ws.Backend = "" + } + } + + // Media module + fmt.Println(strings.Repeat("-", 50)) + fmt.Println("Media — control music player on timer start") + mediaEnabled := askYesNo(reader, "Enable media control?", true) + media := &config.MediaConfig{Enabled: mediaEnabled} + if mediaEnabled { + fmt.Println("Player action (stop / pause / play-pause / next / previous / none):") + fmt.Print(" [stop]: ") + action := readLine(reader, "stop") + media.Action = action + } + + // Lock module + fmt.Println(strings.Repeat("-", 50)) + fmt.Println("Lock — lock screen when timer finishes") + lockEnabled := askYesNo(reader, "Enable screen lock?", true) + lock := &config.LockConfig{Enabled: lockEnabled} + if lockEnabled { + fmt.Print("Custom lock command (leave empty for auto): ") + cmd := readLine(reader, "") + lock.Command = cmd + } + + // Kill module + fmt.Println(strings.Repeat("-", 50)) + fmt.Println("Kill — terminate apps when timer starts") + killEnabled := askYesNo(reader, "Enable process killer?", false) + kill := &config.KillConfig{Enabled: killEnabled} + if killEnabled { + fmt.Print("Processes to kill (comma separated, e.g. firefox,telegram): ") + procs := readLine(reader, "") + if procs != "" { + for _, p := range strings.Split(procs, ",") { + p = strings.TrimSpace(p) + if p != "" { + kill.Processes = append(kill.Processes, p) + } + } + } + } + + // Notify module + fmt.Println(strings.Repeat("-", 50)) + fmt.Println("Notify — desktop notification when timer finishes") + notifyEnabled := askYesNo(reader, "Enable notifications?", true) + notify := &config.NotifyConfig{Enabled: notifyEnabled} + if notifyEnabled { + fmt.Print("Notification sound path (empty = no sound): ") + notify.Sound = readLine(reader, "") + } + + // Sound module + fmt.Println(strings.Repeat("-", 50)) + fmt.Println("Sound — play audio when timer finishes") + soundEnabled := askYesNo(reader, "Enable sound?", true) + sound := &config.SoundConfig{Enabled: soundEnabled} + if soundEnabled { + sound.File = "/usr/share/sounds/freedesktop/stereo/complete.oga" + fmt.Printf(" Default: %s\n", sound.File) + fmt.Print("Custom sound file (leave empty for default): ") + custom := readLine(reader, "") + if custom != "" { + sound.File = custom + } + } + + // Brightness module + fmt.Println(strings.Repeat("-", 50)) + fmt.Println("Brightness — dim screen during timer") + brightnessEnabled := askYesNo(reader, "Enable brightness control?", false) + brightness := &config.BrightnessConfig{Enabled: brightnessEnabled} + if brightnessEnabled { + fmt.Print("Brightness level 0-100 [0]: ") + valStr := readLine(reader, "0") + val, _ := strconv.Atoi(valStr) + brightness.Value = val + } + + // Mute module + fmt.Println(strings.Repeat("-", 50)) + fmt.Println("Mute — mute audio during timer") + muteEnabled := askYesNo(reader, "Enable mute?", false) + mute := &config.MuteConfig{Enabled: muteEnabled} + + // Custom commands + fmt.Println(strings.Repeat("-", 50)) + fmt.Println("Custom — run your own commands") + customEnabled := askYesNo(reader, "Enable custom commands?", false) + custom := &config.CustomConfig{Enabled: customEnabled} + if customEnabled { + fmt.Println("Pre-commands (run BEFORE timer, one per line, empty line to end):") + custom.Pre = readLines(reader, "pre") + fmt.Println("Post-commands (run AFTER timer, one per line, empty line to end):") + custom.Post = readLines(reader, "post") + } + + // Script module + fmt.Println(strings.Repeat("-", 50)) + fmt.Println("Script — run a script with timer context (args: pre/post, profile, duration)") + scriptEnabled := askYesNo(reader, "Enable script module?", false) + script := &config.ScriptConfig{Enabled: scriptEnabled} + if scriptEnabled { + fmt.Print("Script path: ") + script.Path = readLine(reader, "") + } + + // Build final config + p.Modules = &config.Modules{ + Workspace: ws, + Media: media, + Lock: lock, + Kill: kill, + Notify: notify, + Sound: sound, + Brightness: brightness, + Mute: mute, + Custom: custom, + Script: script, + } + + cfg.Profiles = map[string]config.Profile{ + name: *p, + } + + fmt.Println(strings.Repeat("=", 50)) + fmt.Println(" ✅ Setup complete!") + fmt.Printf(" Profile: %s\n", name) + fmt.Printf(" Duration: %s\n", dur) + fmt.Println(strings.Repeat("=", 50)) + + return cfg +} + +func readLine(reader *bufio.Reader, fallback string) string { + line, _ := reader.ReadString('\n') + line = strings.TrimSpace(line) + if line == "" { + return fallback + } + return line +} + +func askYesNo(reader *bufio.Reader, prompt string, defaultYes bool) bool { + suffix := "[Y/n]" + if !defaultYes { + suffix = "[y/N]" + } + fmt.Printf("%s %s: ", prompt, suffix) + answer := strings.ToLower(strings.TrimSpace(readLine(reader, ""))) + if answer == "" { + return defaultYes + } + return answer == "y" || answer == "yes" +} + +func readLines(reader *bufio.Reader, label string) []string { + var lines []string + for { + fmt.Printf(" %s> ", label) + line, _ := reader.ReadString('\n') + line = strings.TrimSpace(line) + if line == "" { + break + } + lines = append(lines, line) + } + return lines +} diff --git a/config/config.go b/config/config.go index 43bf027..c72e85c 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "os" "os/exec" @@ -70,7 +71,7 @@ func LoadOrCreate(path string) (*Config, error) { return cfg, nil } - if !os.IsNotExist(err) { + if !errors.Is(err, os.ErrNotExist) { return nil, err } diff --git a/main.go b/main.go index 54ed60e..74ab4be 100644 --- a/main.go +++ b/main.go @@ -319,7 +319,7 @@ func init() { runCmd := &cobra.Command{ Use: "run [duration]", - Short: "Run timer in CLI mode (no TUI)", + Short: "Run timer in CLI mode", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cliMode = true