feat: init command, README, fix config creation, move cicd

- 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
This commit is contained in:
2026-07-03 09:52:44 +02:00
parent 442ac9f580
commit d1cc207ce4
5 changed files with 466 additions and 2 deletions
+200
View File
@@ -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
+263
View File
@@ -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
}
+2 -1
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -70,7 +71,7 @@ func LoadOrCreate(path string) (*Config, error) {
return cfg, nil return cfg, nil
} }
if !os.IsNotExist(err) { if !errors.Is(err, os.ErrNotExist) {
return nil, err return nil, err
} }
+1 -1
View File
@@ -319,7 +319,7 @@ func init() {
runCmd := &cobra.Command{ runCmd := &cobra.Command{
Use: "run [duration]", Use: "run [duration]",
Short: "Run timer in CLI mode (no TUI)", Short: "Run timer in CLI mode",
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cliMode = true cliMode = true