feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
package cmd
import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"time"
"github.com/charmbracelet/huh"
"github.com/fatih/color"
"github.com/spf13/cobra"
2026-03-30 13:06:07 +02:00
"github.com/torrentclaw/unarr/internal/config"
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
)
2026-04-10 16:35:12 +02:00
var configCategories = [ ] string { "downloads" , "organization" , "library" , "notifications" , "device" , "region" , "connection" , "advanced" }
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
func newConfigCmd ( ) * cobra . Command {
cmd := & cobra . Command {
2026-03-31 00:29:16 +02:00
Use : "config [category]" ,
Short : "Edit settings interactively" ,
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
Long : ` Edit unarr settings interactively with a category - based menu .
Categories :
downloads Download directory , method , speed limits , concurrency
organization Auto - sort into Movies / TV Shows folders
2026-04-10 16:35:12 +02:00
library Library scan settings and file deletion permissions
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
notifications Desktop notifications
device Agent name
region Country and language
connection API URL , API key
advanced Daemon poll & heartbeat intervals
Run without arguments to see the full menu , or specify a category
to jump directly to it .
Config file : ~ / . config / unarr / config . toml
Environment variables override config file values :
UNARR_API_KEY API key
UNARR_API_URL API URL
UNARR_COUNTRY Default country code
UNARR_DOWNLOAD_DIR Download directory ` ,
Example : ` unarr config # Interactive menu
unarr config downloads # Jump to downloads settings
unarr config region # Jump to region settings ` ,
Args : cobra . MaximumNArgs ( 1 ) ,
ValidArgs : configCategories ,
RunE : func ( cmd * cobra . Command , args [ ] string ) error {
category := ""
if len ( args ) == 1 {
category = args [ 0 ]
}
return runConfigMenu ( category )
} ,
}
return cmd
}
func runConfigMenu ( category string ) error {
if ! isTerminal ( ) {
return fmt . Errorf ( "interactive config requires a terminal (use UNARR_* env vars instead)" )
}
bold := color . New ( color . Bold )
green := color . New ( color . FgGreen )
dim := color . New ( color . FgHiBlack )
cfg := loadConfig ( )
original := cfg // snapshot for change detection
fmt . Println ( )
bold . Println ( " unarr config" )
fmt . Println ( )
// Direct category access
if category != "" {
if err := runCategory ( & cfg , category ) ; err != nil {
if errors . Is ( err , huh . ErrUserAborted ) {
fmt . Println ( "\n Cancelled." )
return nil
}
return err
}
return saveIfChanged ( cfg , original , green , dim )
}
// Menu loop
for {
var choice string
err := huh . NewForm (
huh . NewGroup (
huh . NewSelect [ string ] ( ) .
Title ( "Settings" ) .
Options (
huh . NewOption ( "Downloads — directory, method, speed limits" , "downloads" ) ,
huh . NewOption ( "Organization — auto-sort Movies & TV Shows" , "organization" ) ,
2026-04-10 16:35:12 +02:00
huh . NewOption ( "Library — scan settings & file deletion" , "library" ) ,
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
huh . NewOption ( "Notifications — desktop notifications" , "notifications" ) ,
huh . NewOption ( "Device — agent name" , "device" ) ,
huh . NewOption ( "Region — country & language" , "region" ) ,
huh . NewOption ( "Connection — API URL & key" , "connection" ) ,
huh . NewOption ( "Advanced — daemon intervals" , "advanced" ) ,
huh . NewOption ( "Done — save & exit" , "done" ) ,
) .
Value ( & choice ) ,
) ,
) . Run ( )
if err != nil {
if errors . Is ( err , huh . ErrUserAborted ) {
return saveIfChanged ( cfg , original , green , dim )
}
return err
}
if choice == "done" {
return saveIfChanged ( cfg , original , green , dim )
}
if err := runCategory ( & cfg , choice ) ; err != nil {
if errors . Is ( err , huh . ErrUserAborted ) {
continue // back to menu
}
return err
}
}
}
func runCategory ( cfg * config . Config , category string ) error {
switch category {
case "downloads" :
return configDownloads ( cfg )
case "organization" :
return configOrganization ( cfg )
2026-04-10 16:35:12 +02:00
case "library" :
return configLibrary ( cfg )
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
case "notifications" :
return configNotifications ( cfg )
case "device" :
return configDevice ( cfg )
case "region" :
return configRegion ( cfg )
case "connection" :
return configConnection ( cfg )
case "advanced" :
return configAdvanced ( cfg )
default :
return fmt . Errorf ( "unknown category %q — valid: %s" , category , strings . Join ( configCategories , ", " ) )
}
}
func configDownloads ( cfg * config . Config ) error {
concurrent := strconv . Itoa ( cfg . Download . MaxConcurrent )
validConcurrent := map [ string ] bool { "1" : true , "2" : true , "3" : true , "4" : true , "5" : true , "6" : true , "8" : true , "10" : true }
if ! validConcurrent [ concurrent ] {
concurrent = "3"
}
validMethods := map [ string ] bool { "auto" : true , "torrent" : true , "debrid" : true , "usenet" : true }
if ! validMethods [ cfg . Download . PreferredMethod ] {
cfg . Download . PreferredMethod = "auto"
}
2026-03-29 16:54:32 +02:00
validQualities := map [ string ] bool { "" : true , "720p" : true , "1080p" : true , "2160p" : true }
if ! validQualities [ cfg . Download . PreferredQuality ] {
cfg . Download . PreferredQuality = ""
}
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
err := huh . NewForm (
huh . NewGroup (
huh . NewInput ( ) .
Title ( "Download directory" ) .
Value ( & cfg . Download . Dir ) ,
huh . NewSelect [ string ] ( ) .
Title ( "Preferred method" ) .
Options (
huh . NewOption ( "Auto (torrent + debrid when available)" , "auto" ) ,
huh . NewOption ( "Torrent only (BitTorrent P2P)" , "torrent" ) ,
huh . NewOption ( "Debrid only (Real-Debrid, AllDebrid...)" , "debrid" ) ,
huh . NewOption ( "Usenet only (requires Pro)" , "usenet" ) ,
) .
Value ( & cfg . Download . PreferredMethod ) ,
2026-03-29 16:54:32 +02:00
huh . NewSelect [ string ] ( ) .
Title ( "Preferred quality" ) .
Description ( "Hint for automatic torrent selection" ) .
Options (
huh . NewOption ( "Any (best available)" , "" ) ,
huh . NewOption ( "720p" , "720p" ) ,
huh . NewOption ( "1080p" , "1080p" ) ,
huh . NewOption ( "2160p (4K)" , "2160p" ) ,
) .
Value ( & cfg . Download . PreferredQuality ) ,
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
huh . NewSelect [ string ] ( ) .
Title ( "Max concurrent downloads" ) .
Options (
huh . NewOption ( "1" , "1" ) ,
huh . NewOption ( "2" , "2" ) ,
huh . NewOption ( "3 (default)" , "3" ) ,
huh . NewOption ( "4" , "4" ) ,
huh . NewOption ( "5" , "5" ) ,
huh . NewOption ( "6" , "6" ) ,
huh . NewOption ( "8" , "8" ) ,
huh . NewOption ( "10" , "10" ) ,
) .
Value ( & concurrent ) ,
huh . NewInput ( ) .
Title ( "Max download speed" ) .
Description ( "0 = unlimited. Examples: 10MB, 500KB" ) .
Value ( & cfg . Download . MaxDownloadSpeed ) .
Validate ( validateSpeed ) ,
huh . NewInput ( ) .
Title ( "Max upload speed" ) .
Description ( "0 = unlimited. Examples: 1MB, 500KB" ) .
Value ( & cfg . Download . MaxUploadSpeed ) .
Validate ( validateSpeed ) ,
) ,
) . Run ( )
if err != nil {
return err
}
cfg . Download . Dir = expandHome ( strings . TrimSpace ( cfg . Download . Dir ) )
n , _ := strconv . Atoi ( concurrent )
if n > 0 {
cfg . Download . MaxConcurrent = n
}
return nil
}
func configOrganization ( cfg * config . Config ) error {
err := huh . NewForm (
huh . NewGroup (
huh . NewConfirm ( ) .
Title ( "Auto-organize downloads?" ) .
Description ( "Sort files into Movies and TV Shows subdirectories" ) .
Value ( & cfg . Organize . Enabled ) ,
huh . NewInput ( ) .
Title ( "Movies directory" ) .
Value ( & cfg . Organize . MoviesDir ) ,
huh . NewInput ( ) .
Title ( "TV Shows directory" ) .
Value ( & cfg . Organize . TVShowsDir ) ,
) ,
) . Run ( )
if err != nil {
return err
}
cfg . Organize . MoviesDir = expandHome ( strings . TrimSpace ( cfg . Organize . MoviesDir ) )
cfg . Organize . TVShowsDir = expandHome ( strings . TrimSpace ( cfg . Organize . TVShowsDir ) )
return nil
}
func configNotifications ( cfg * config . Config ) error {
return huh . NewForm (
huh . NewGroup (
huh . NewConfirm ( ) .
Title ( "Desktop notifications?" ) .
Description ( "Show a notification when a download completes" ) .
Value ( & cfg . Notifications . Enabled ) ,
) ,
) . Run ( )
}
func configDevice ( cfg * config . Config ) error {
dim := color . New ( color . FgHiBlack )
if cfg . Agent . ID != "" {
dim . Printf ( " Agent ID: %s\n\n" , cfg . Agent . ID )
}
return huh . NewForm (
huh . NewGroup (
huh . NewInput ( ) .
Title ( "Agent name" ) .
Description ( "Shown in the web dashboard" ) .
Value ( & cfg . Agent . Name ) ,
) ,
) . Run ( )
}
func configRegion ( cfg * config . Config ) error {
return huh . NewForm (
huh . NewGroup (
huh . NewInput ( ) .
Title ( "Country" ) .
Description ( "ISO code for streaming providers (US, ES, DE, GB...)" ) .
Placeholder ( "US" ) .
Value ( & cfg . General . Country ) ,
huh . NewInput ( ) .
Title ( "Locale" ) .
Description ( "Language for content metadata (en, es, de, fr...)" ) .
Placeholder ( "en" ) .
Value ( & cfg . General . Locale ) ,
) ,
) . Run ( )
}
func configConnection ( cfg * config . Config ) error {
keyDesc := "Current: (none)"
if k := cfg . Auth . APIKey ; len ( k ) > 8 {
keyDesc = "Current: " + k [ : 8 ] + "..."
}
return huh . NewForm (
huh . NewGroup (
huh . NewInput ( ) .
Title ( "API URL" ) .
Value ( & cfg . Auth . APIURL ) ,
huh . NewInput ( ) .
Title ( "API Key" ) .
Description ( keyDesc ) .
EchoMode ( huh . EchoModePassword ) .
Value ( & cfg . Auth . APIKey ) ,
) ,
) . Run ( )
}
2026-04-10 16:35:12 +02:00
func configLibrary ( cfg * config . Config ) error {
return huh . NewForm (
huh . NewGroup (
huh . NewConfirm ( ) .
Title ( "Allow file deletion from web UI?" ) .
Description ( "When enabled, the web library's Delete button can permanently remove files from disk.\nOnly activate this if you understand that deleted files cannot be recovered." ) .
Value ( & cfg . Library . AllowDelete ) ,
) ,
) . Run ( )
}
2026-04-08 18:50:59 +02:00
func configAdvanced ( _ * config . Config ) error {
// Sync intervals are adaptive (3s watching, 60s idle) — no user-facing config needed.
fmt . Println ( "No advanced settings to configure. Sync intervals are automatic." )
return nil
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
}
// ── Validators ──────────────────────────────────────────────────────
func validateSpeed ( s string ) error {
s = strings . TrimSpace ( s )
if s == "" || s == "0" {
return nil
}
if _ , err := config . ParseSpeed ( s ) ; err != nil {
return fmt . Errorf ( "invalid speed: %s (e.g. 10MB, 500KB, 0)" , s )
}
return nil
}
func validateDuration ( s string ) error {
s = strings . TrimSpace ( s )
if s == "" {
return nil
}
if _ , err := time . ParseDuration ( s ) ; err != nil {
return fmt . Errorf ( "invalid duration: %s (e.g. 30s, 1m, 5m)" , s )
}
return nil
}
// ── Save logic ──────────────────────────────────────────────────────
func saveIfChanged ( cfg , original config . Config , green , dim * color . Color ) error {
if reflect . DeepEqual ( cfg , original ) {
dim . Println ( " No changes made." )
fmt . Println ( )
return nil
}
if err := cfg . ValidatePaths ( ) ; err != nil {
return fmt . Errorf ( "unsafe configuration: %w" , err )
}
configPath := config . FilePath ( )
if cfgFile != "" {
configPath = cfgFile
}
if err := config . Save ( cfg , configPath ) ; err != nil {
return fmt . Errorf ( "save config: %w" , err )
}
appCfg = cfg // update cached config so subsequent calls see the new values
fmt . Println ( )
green . Printf ( " ✓ Configuration saved to %s\n" , configPath )
fmt . Println ( )
return nil
}