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:
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user