Add preset selector and command input forms

Amolith created

Change summary

cmd/root.go                |  78 ++++++++++++++++++++++++
go.mod                     |   8 ++
go.sum                     |  26 ++++++++
internal/form/form.go      | 125 ++++++++++++++++++++++++++++++++++++++++
internal/form/form_test.go |  64 ++++++++++++++++++++
5 files changed, 300 insertions(+), 1 deletion(-)

Detailed changes

cmd/root.go 🔗

@@ -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
+}

go.mod 🔗

@@ -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

go.sum 🔗

@@ -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=

internal/form/form.go 🔗

@@ -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
+}

internal/form/form_test.go 🔗

@@ -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])
+				}
+			}
+		})
+	}
+}