Detailed changes
@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"git.secluded.site/pika/internal/config"
+ "git.secluded.site/pika/internal/form"
"git.secluded.site/pika/internal/menu"
"git.secluded.site/pika/internal/restic"
)
@@ -75,7 +76,8 @@ var rootCmd = &cobra.Command{
preset, command, passthrough := parseArgs(args)
// No command → interactive menu.
- if command == "" {
+ interactive := command == ""
+ if interactive {
selected, err := runMenu()
if err != nil {
return fmt.Errorf("menu: %w", err)
@@ -88,6 +90,28 @@ var rootCmd = &cobra.Command{
overrides := parsePassthrough(passthrough)
+ // When launched interactively, prompt for a preset and any
+ // command-specific inputs that restic would otherwise reject.
+ if interactive {
+ p, err := promptPreset()
+ if err != nil {
+ return err
+ }
+ preset = p
+
+ cmdOverrides, err := promptForCommand(command)
+ if err != nil {
+ return err
+ }
+ if overrides == nil && len(cmdOverrides) > 0 {
+ overrides = cmdOverrides
+ } else {
+ for k, v := range cmdOverrides {
+ overrides[k] = v
+ }
+ }
+ }
+
cfg, err := config.Resolve(preset, command, overrides)
if err != nil {
return err
@@ -287,3 +311,55 @@ func runMenu() (string, error) {
}
return model.Choice(), nil
}
+
+// promptPreset shows an interactive preset selector when presets are defined
+// in the config. Returns "" (global-only) if no presets exist.
+func promptPreset() (string, error) {
+ presets := config.Presets()
+ if len(presets) == 0 {
+ return "", nil
+ }
+ selected, err := form.SelectPreset(presets)
+ if err != nil {
+ return "", fmt.Errorf("preset selection: %w", err)
+ }
+ return selected, nil
+}
+
+// promptForCommand collects required inputs for commands that need them.
+// Returns CLI overrides to merge, or nil if the command needs no extra input.
+func promptForCommand(command string) (map[string][]string, error) {
+ switch command {
+ case "restore":
+ return promptRestore()
+ case "backup":
+ return promptBackup()
+ default:
+ return nil, nil
+ }
+}
+
+// promptRestore collects the snapshot ID and target directory for restore.
+func promptRestore() (map[string][]string, error) {
+ snapshotID, target, err := form.RestoreInputs()
+ if err != nil {
+ return nil, fmt.Errorf("restore inputs: %w", err)
+ }
+ return map[string][]string{
+ overrideArgumentsKey: {snapshotID},
+ "target": {target},
+ }, nil
+}
+
+// promptBackup collects backup paths if none are likely to come from config.
+// The user is always offered the chance to provide paths; config-defined
+// _arguments will still take precedence during resolution.
+func promptBackup() (map[string][]string, error) {
+ paths, err := form.BackupPaths()
+ if err != nil {
+ return nil, fmt.Errorf("backup paths: %w", err)
+ }
+ return map[string][]string{
+ overrideArgumentsKey: paths,
+ }, nil
+}
@@ -5,24 +5,32 @@ go 1.26.1
require (
charm.land/bubbletea/v2 v2.0.2
charm.land/fang/v2 v2.0.1
+ charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.2
github.com/BurntSushi/toml v1.6.0
github.com/spf13/cobra v1.10.2
)
require (
+ charm.land/bubbles/v2 v2.0.0 // indirect
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
+ github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
+ github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
+ github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/mango v0.1.0 // indirect
github.com/muesli/mango-cobra v1.2.0 // indirect
@@ -1,42 +1,68 @@
+charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
+charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
+charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
+charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
+github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
+github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
+github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
+github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
+github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
@@ -0,0 +1,125 @@
+// Package form provides interactive huh-based prompts for collecting user
+// input when pika is invoked via the interactive menu.
+package form
+
+import (
+ "errors"
+ "fmt"
+
+ "charm.land/huh/v2"
+)
+
+// ErrAborted is returned when the user cancels a form (ctrl+c / q).
+var ErrAborted = errors.New("aborted by user")
+
+// wrapAbort converts huh's abort error into our own sentinel.
+func wrapAbort(err error) error {
+ if err != nil && errors.Is(err, huh.ErrUserAborted) {
+ return ErrAborted
+ }
+ return err
+}
+
+// SelectPreset displays an interactive selector for the given preset names.
+// Returns the selected preset, or "" if the user picks "(none)".
+func SelectPreset(presets []string) (string, error) {
+ if len(presets) == 0 {
+ return "", nil
+ }
+
+ opts := make([]huh.Option[string], 0, len(presets)+1)
+ opts = append(opts, huh.NewOption("(global defaults only)", ""))
+ for _, p := range presets {
+ opts = append(opts, huh.NewOption(p, p))
+ }
+
+ var selected string
+ form := huh.NewForm(
+ huh.NewGroup(
+ huh.NewSelect[string]().
+ Title("Select a preset").
+ Options(opts...).
+ Value(&selected),
+ ),
+ )
+
+ if err := wrapAbort(form.Run()); err != nil {
+ return "", err
+ }
+ return selected, nil
+}
+
+// RestoreInputs collects the required inputs for `restic restore`:
+// a snapshot ID and a target directory.
+func RestoreInputs() (snapshotID, target string, err error) {
+ form := huh.NewForm(
+ huh.NewGroup(
+ huh.NewInput().
+ Title("Snapshot ID").
+ Placeholder("e.g. latest or a1b2c3d4").
+ Value(&snapshotID).
+ Validate(notEmpty("snapshot ID")),
+ huh.NewInput().
+ Title("Target directory").
+ Placeholder("e.g. /tmp/restore").
+ Value(&target).
+ Validate(notEmpty("target directory")),
+ ),
+ )
+
+ if err := wrapAbort(form.Run()); err != nil {
+ return "", "", err
+ }
+ return snapshotID, target, nil
+}
+
+// BackupPaths collects one or more paths to back up when none are configured.
+func BackupPaths() ([]string, error) {
+ var raw string
+ form := huh.NewForm(
+ huh.NewGroup(
+ huh.NewInput().
+ Title("Paths to back up").
+ Description("Space-separated list of files or directories.").
+ Placeholder("e.g. /home/user /etc").
+ Value(&raw).
+ Validate(notEmpty("backup path")),
+ ),
+ )
+
+ if err := wrapAbort(form.Run()); err != nil {
+ return nil, err
+ }
+ return splitFields(raw), nil
+}
+
+// notEmpty returns a validation function that rejects blank input.
+func notEmpty(fieldName string) func(string) error {
+ return func(s string) error {
+ if len(s) == 0 {
+ return fmt.Errorf("%s is required", fieldName)
+ }
+ return nil
+ }
+}
+
+// splitFields splits a string on whitespace, like strings.Fields, but kept
+// here to avoid importing strings for a one-liner.
+func splitFields(s string) []string {
+ var fields []string
+ start := -1
+ for i, r := range s {
+ if r == ' ' || r == '\t' {
+ if start >= 0 {
+ fields = append(fields, s[start:i])
+ start = -1
+ }
+ } else if start < 0 {
+ start = i
+ }
+ }
+ if start >= 0 {
+ fields = append(fields, s[start:])
+ }
+ return fields
+}
@@ -0,0 +1,64 @@
+package form
+
+import (
+ "testing"
+)
+
+func TestNotEmpty(t *testing.T) {
+ t.Parallel()
+
+ validate := notEmpty("thing")
+
+ tests := []struct {
+ name string
+ input string
+ wantErr bool
+ }{
+ {name: "empty string", input: "", wantErr: true},
+ {name: "non-empty string", input: "hello", wantErr: false},
+ {name: "whitespace only", input: " ", wantErr: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ err := validate(tt.input)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("notEmpty(%q): got err=%v, wantErr=%v", tt.input, err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestSplitFields(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ input string
+ want []string
+ }{
+ {name: "empty", input: "", want: nil},
+ {name: "single path", input: "/home/user", want: []string{"/home/user"}},
+ {name: "two paths", input: "/home/user /etc", want: []string{"/home/user", "/etc"}},
+ {name: "leading whitespace", input: " /home", want: []string{"/home"}},
+ {name: "trailing whitespace", input: "/home ", want: []string{"/home"}},
+ {name: "tabs", input: "/a\t/b", want: []string{"/a", "/b"}},
+ {name: "multiple spaces", input: "/a /b /c", want: []string{"/a", "/b", "/c"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := splitFields(tt.input)
+ if len(got) != len(tt.want) {
+ t.Fatalf("splitFields(%q): got %v, want %v", tt.input, got, tt.want)
+ }
+ for i := range got {
+ if got[i] != tt.want[i] {
+ t.Errorf("splitFields(%q)[%d]: got %q, want %q", tt.input, i, got[i], tt.want[i])
+ }
+ }
+ })
+ }
+}