Initial commit

Amolith created

Change summary

AGENTS.md                       |  154 +++++
cmd/completions.go              |  147 ++++
cmd/completions_test.go         |  190 ++++++
cmd/root.go                     |  289 +++++++++
cmd/root_test.go                |  223 +++++++
example/config.toml             |   56 +
go.mod                          |   37 +
go.sum                          |   74 ++
internal/config/config.go       |  359 +++++++++++
internal/config/config_test.go  |  273 +++++++++
internal/config/files.go        |   78 ++
internal/config/files_test.go   |   87 ++
internal/config/presets.go      |   31 +
internal/config/presets_test.go |   83 ++
internal/menu/menu.go           |  177 +++++
internal/restic/exec.go         |   97 +++
internal/restic/exec_test.go    |  132 ++++
main.go                         |    7 
mise.toml                       |   39 +
restic-cli-catalogue.md         | 1055 +++++++++++++++++++++++++++++++++++
20 files changed, 3,588 insertions(+)

Detailed changes

AGENTS.md πŸ”—

@@ -0,0 +1,154 @@
+# AGENTS.md - Working with Pika
+
+## Project Overview
+
+**Pika** is a friendly TOML-configured wrapper around
+[restic](https://restic.net/) (a backup tool). It provides:
+
+- Layered configuration with presets and command-specific overrides
+- Interactive menu when invoked without arguments (BubbleTea v2)
+- Split preset syntax (`home@cloud`) for composable configurations
+- Passthrough of arbitrary flags to restic
+
+This project uses jujutsu for version control. Before starting work, check `jj
+st`. If there's existing work in progress, run `jj new -m "..."` to create a new
+working copy with a good, imperative, kernel-commit-style description. DO NOT
+follow or read ANY skills or use ANY tools related to Conventional Commits.
+
+## Essential Commands
+
+All tasks are managed via **mise** (see `mise.toml`):
+
+```bash
+mise run vuln
+mise run vet
+mise run install
+
+# Check formatting without modifying
+mise run fmt:check
+# Run full check suite (fmt:check, vet, lint, vuln, build, test)
+mise run check
+```
+
+## Current Architecture & Data Flow
+
+Suggest updating this if implementation changes.
+
+```text
+main.go
+  └── cmd.Execute()
+        └── rootCmd.RunE
+              β”œβ”€β”€ extractOwnFlags()      # --dry-run, --config
+              β”œβ”€β”€ parseArgs()            # Split into preset, command,
+              β”‚                          # passthrough
+              β”œβ”€β”€ runMenu()              # BubbleTea if no command
+              β”œβ”€β”€ config.Resolve()       # Merge TOML sections + CLI overrides
+              β”‚     β”œβ”€β”€ DiscoverFiles()  # Find config files
+              β”‚     β”œβ”€β”€ loadFiles()      # Parse and merge TOML
+              β”‚     β”œβ”€β”€ buildSectionOrder()  # Determine merge precedence
+              β”‚     β”œβ”€β”€ interpolate()      # Resolve ${section.key} refs
+              β”‚     └── assemble()       # Build ResolvedConfig
+              β”œβ”€β”€ config.IsDryRun()
+              β”œβ”€β”€ restic.DryRun()        # Print what would execute
+              └── restic.Run()           # syscall.Exec to restic
+```
+
+## Config System
+
+### Section Merge Order
+
+Config sections are merged in ascending priority order. For
+`pika home@cloud backup`:
+
+```text
+[global] β†’ [global.backup] β†’ [@cloud] β†’ [@cloud.backup] β†’
+[home@] β†’ [home@.backup] β†’ [home@cloud] β†’ [home@cloud.backup] β†’
+CLI overrides
+```
+
+### Config File Discovery
+
+1. Default dirs: `/usr/share/pika`, `/etc/pika`, `~/.config/pika`
+2. In each dir: `config.toml` then sorted `conf.d/*.toml`
+3. `PIKA_CONFIG_PATHS` env var (colon-separated, supports globs)
+4. `PIKA_CONFIG_FILE` replaces all above if set
+
+### Special Config Keys
+
+| Key          | Purpose                                                            |
+| ------------ | ------------------------------------------------------------------ |
+| `_arguments` | Positional args passed to restic (array or space-separated string) |
+| `_workdir`   | Directory to chdir before exec                                     |
+| `_command`   | Restic subcommand (allows aliasing)                                |
+| `*.environ`  | Section suffix for environment variables                           |
+
+### Interpolation
+
+Values can reference other sections: `cache-dir = "${vars.cache-root}/cache"`
+
+### Split Presets
+
+Presets with `@` are split: `home@cloud` β†’ applies `[@cloud]`, `[home@]`,
+then `[home@cloud]`
+
+See `example/config.toml` for comprehensive examples.
+
+## Code Patterns & Conventions
+
+### Test Patterns
+
+- **Table-driven tests** with `t.Parallel()`
+- Testdata embedded as string constants (see `resolveFixtureTOML`)
+- `tt := tt` copy before parallel subtest
+- Use `t.TempDir()` and `t.Setenv()` for isolation
+
+## Important Gotchas
+
+### Config Merge Behavior
+
+- Later files override earlier at **section granularity**
+- Within a file, later sections override earlier
+- Nested tables (sub-commands) are **not** merged across sectionsβ€”only leaf keys
+- Multi-line strings become repeated flags (split on `\n`)
+
+### syscall.Exec
+
+`restic.Run()` uses `syscall.Exec` which **replaces the current process**.
+This means:
+
+- No Go code runs after successful exec
+- No deferred functions execute
+- Dry-run mode exists specifically to show what would run
+
+### Test Isolation
+
+Tests modify `DefaultConfigDirs` global. Always restore with `t.Cleanup()`:
+
+```go
+original := DefaultConfigDirs
+DefaultConfigDirs = []string{tmpDir}
+t.Cleanup(func() { DefaultConfigDirs = original })
+```
+
+### BubbleTea v2
+
+Using v2 API (not v1). Key differences:
+
+- `tea.NewProgram(m)` instead of `tea.NewProgram(m, opts...)`
+- `tea.RequestBackgroundColor` for theme detection
+- `tea.NewView()` instead of returning strings
+
+## Environment Variables
+
+| Variable            | Purpose                                 |
+| ------------------- | --------------------------------------- |
+| `PIKA_CONFIG_FILE`  | Single config file (highest priority)   |
+| `PIKA_CONFIG_PATHS` | Colon-separated additional config paths |
+| `PIKA_DRYRUN`       | Set to enable dry-run mode              |
+| `PIKA_EXECUTABLE`   | Override restic binary path             |
+
+## Development Workflow
+
+1. Make changes
+2. `mise run check` - full validation
+3. Test manually: `./pika --dry-run <preset> <command>`

cmd/completions.go πŸ”—

@@ -0,0 +1,147 @@
+package cmd
+
+import (
+	"slices"
+
+	"github.com/spf13/cobra"
+
+	"git.secluded.site/pika/internal/config"
+)
+
+// knownCommands lists the restic subcommands pika knows about. This is the
+// same set shown in the interactive menu (minus "quit").
+var knownCommands = []string{
+	"backup",
+	"check",
+	"forget",
+	"init",
+	"restore",
+	"snapshots",
+}
+
+// pikaFlags lists pika's own flags (the ones extracted in extractOwnFlags).
+// Cobra can't advertise these automatically because DisableFlagParsing is on.
+var pikaFlags = []string{
+	"--config",
+	"--dry-run",
+	"--help",
+}
+
+func init() {
+	rootCmd.ValidArgsFunction = completeArgs
+}
+
+// completeArgs provides dynamic shell completions for pika.
+//
+// It examines how many positional (non-flag) arguments have already been
+// provided and offers:
+//
+//   - 0 positionals so far β†’ presets (from TOML config) + known commands
+//   - 1 positional so far  β†’ if it's a preset, offer commands; if it's
+//     already a command, no more positional completions
+//   - 2+ positionals       β†’ no further positional completions
+//
+// When the current word starts with "-", pika's own flags are offered instead.
+func completeArgs(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+	// When the user is typing a flag, offer pika's own flags.
+	if len(toComplete) > 0 && toComplete[0] == '-' {
+		return pikaFlags, cobra.ShellCompDirectiveNoFileComp
+	}
+
+	// Count how many positional args have already been accepted (i.e. are
+	// in args, not toComplete). We need to skip flag tokens and their
+	// values, just like parseArgs does.
+	positionals := countPositionals(args)
+
+	switch positionals {
+	case 0:
+		// Nothing committed yet β€” offer presets and commands.
+		return presetsAndCommands(), cobra.ShellCompDirectiveNoFileComp
+	case 1:
+		// One positional committed. If it's a known command, there's
+		// nothing more to complete positionally (the rest is restic
+		// flags, which we don't complete).
+		if isKnownCommand(args) {
+			return nil, cobra.ShellCompDirectiveNoFileComp
+		}
+		// Otherwise the first positional was a preset; offer commands.
+		return knownCommands, cobra.ShellCompDirectiveNoFileComp
+	default:
+		return nil, cobra.ShellCompDirectiveNoFileComp
+	}
+}
+
+// presetsAndCommands merges config presets with the known command list,
+// deduplicating any overlap.
+func presetsAndCommands() []string {
+	presets := config.Presets()
+	seen := make(map[string]struct{}, len(presets)+len(knownCommands))
+
+	out := make([]string, 0, len(presets)+len(knownCommands))
+	for _, p := range presets {
+		if _, ok := seen[p]; ok {
+			continue
+		}
+		seen[p] = struct{}{}
+		out = append(out, p)
+	}
+	for _, c := range knownCommands {
+		if _, ok := seen[c]; ok {
+			continue
+		}
+		seen[c] = struct{}{}
+		out = append(out, c)
+	}
+	return out
+}
+
+// countPositionals returns the number of positional (non-flag) tokens in args.
+func countPositionals(args []string) int {
+	n := 0
+	for i := 0; i < len(args); i++ {
+		arg := args[i]
+		switch {
+		case arg == "--":
+			// Everything after -- is positional.
+			n += len(args) - i - 1
+			return n
+		case isFlagToken(arg):
+			// Skip the flag's value if it has one.
+			if !hasEqualSign(arg) && i+1 < len(args) && !isFlagToken(args[i+1]) && args[i+1] != "--" {
+				i++
+			}
+		default:
+			n++
+		}
+	}
+	return n
+}
+
+// isKnownCommand reports whether the first positional arg in the already-
+// accepted args is a known restic command.
+func isKnownCommand(args []string) bool {
+	for i := 0; i < len(args); i++ {
+		arg := args[i]
+		switch {
+		case arg == "--":
+			return false
+		case isFlagToken(arg):
+			if !hasEqualSign(arg) && i+1 < len(args) && !isFlagToken(args[i+1]) && args[i+1] != "--" {
+				i++
+			}
+		default:
+			// First positional β€” check if it's a command.
+			return slices.Contains(knownCommands, arg)
+		}
+	}
+	return false
+}
+
+func hasEqualSign(arg string) bool {
+	for _, c := range arg {
+		if c == '=' {
+			return true
+		}
+	}
+	return false
+}

cmd/completions_test.go πŸ”—

@@ -0,0 +1,190 @@
+package cmd
+
+import (
+	"os"
+	"path/filepath"
+	"reflect"
+	"sort"
+	"testing"
+
+	"github.com/spf13/cobra"
+)
+
+// setupCompletionConfig writes a small TOML fixture and points PIKA_CONFIG_FILE
+// at it, so config.Presets() returns deterministic results.
+func setupCompletionConfig(t *testing.T) {
+	t.Helper()
+
+	dir := t.TempDir()
+	cfg := filepath.Join(dir, "config.toml")
+	err := os.WriteFile(cfg, []byte(`
+[global]
+verbose = true
+
+[home]
+repo = "/repos/home"
+
+["home@cloud"]
+repo = "/repos/cloud"
+
+["@nas"]
+repo = "/repos/nas"
+`), 0o600)
+	if err != nil {
+		t.Fatalf("writing fixture config: %v", err)
+	}
+	t.Setenv("PIKA_CONFIG_FILE", cfg)
+	t.Setenv("HOME", dir)
+}
+
+func TestCompleteArgsNoArgs(t *testing.T) {
+	setupCompletionConfig(t)
+
+	completions, directive := completeArgs(nil, nil, "")
+	if directive != cobra.ShellCompDirectiveNoFileComp {
+		t.Fatalf("expected NoFileComp directive, got %v", directive)
+	}
+
+	// Should contain both presets and commands.
+	has := make(map[string]bool)
+	for _, c := range completions {
+		has[c] = true
+	}
+
+	for _, want := range []string{"home", "home@cloud", "@nas", "backup", "restore", "snapshots"} {
+		if !has[want] {
+			t.Errorf("missing expected completion %q in %v", want, completions)
+		}
+	}
+	// "global" must not appear.
+	if has["global"] {
+		t.Errorf("completions should not include 'global', got %v", completions)
+	}
+}
+
+func TestCompleteArgsAfterPreset(t *testing.T) {
+	setupCompletionConfig(t)
+
+	completions, directive := completeArgs(nil, []string{"home"}, "")
+	if directive != cobra.ShellCompDirectiveNoFileComp {
+		t.Fatalf("expected NoFileComp, got %v", directive)
+	}
+	sort.Strings(completions)
+	if !reflect.DeepEqual(completions, knownCommands) {
+		t.Fatalf("after preset, expected commands %v, got %v", knownCommands, completions)
+	}
+}
+
+func TestCompleteArgsAfterCommand(t *testing.T) {
+	setupCompletionConfig(t)
+
+	completions, directive := completeArgs(nil, []string{"backup"}, "")
+	if directive != cobra.ShellCompDirectiveNoFileComp {
+		t.Fatalf("expected NoFileComp, got %v", directive)
+	}
+	if len(completions) != 0 {
+		t.Fatalf("after command, expected no completions, got %v", completions)
+	}
+}
+
+func TestCompleteArgsAfterPresetAndCommand(t *testing.T) {
+	setupCompletionConfig(t)
+
+	completions, directive := completeArgs(nil, []string{"home", "backup"}, "")
+	if directive != cobra.ShellCompDirectiveNoFileComp {
+		t.Fatalf("expected NoFileComp, got %v", directive)
+	}
+	if len(completions) != 0 {
+		t.Fatalf("after preset+command, expected no completions, got %v", completions)
+	}
+}
+
+func TestCompleteArgsFlagPrefix(t *testing.T) {
+	t.Parallel()
+
+	// No config needed β€” flag completions are static.
+	completions, directive := completeArgs(nil, nil, "--")
+	if directive != cobra.ShellCompDirectiveNoFileComp {
+		t.Fatalf("expected NoFileComp, got %v", directive)
+	}
+	sort.Strings(completions)
+	want := []string{"--config", "--dry-run", "--help"}
+	if !reflect.DeepEqual(completions, want) {
+		t.Fatalf("flag completions mismatch: got %v, want %v", completions, want)
+	}
+}
+
+func TestCompleteArgsSkipsFlags(t *testing.T) {
+	setupCompletionConfig(t)
+
+	// Simulate: pika --config ./pika.toml <tab>
+	// The flag and its value should not count as positionals, so we
+	// should still be at 0 positionals β†’ presets + commands.
+	completions, directive := completeArgs(nil, []string{"--config", "./pika.toml"}, "")
+	if directive != cobra.ShellCompDirectiveNoFileComp {
+		t.Fatalf("expected NoFileComp, got %v", directive)
+	}
+
+	has := make(map[string]bool)
+	for _, c := range completions {
+		has[c] = true
+	}
+	if !has["backup"] {
+		t.Errorf("expected commands after flag+value, got %v", completions)
+	}
+	if !has["home"] {
+		t.Errorf("expected presets after flag+value, got %v", completions)
+	}
+}
+
+func TestCountPositionals(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		args []string
+		want int
+	}{
+		{name: "empty", args: nil, want: 0},
+		{name: "one positional", args: []string{"backup"}, want: 1},
+		{name: "two positionals", args: []string{"home", "backup"}, want: 2},
+		{name: "flag with value", args: []string{"--repo", "/repo"}, want: 0},
+		{name: "flag=value", args: []string{"--repo=/repo"}, want: 0},
+		{name: "mixed", args: []string{"--repo", "/repo", "home", "backup"}, want: 2},
+		{name: "double dash", args: []string{"home", "--", "a", "b"}, want: 3},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			got := countPositionals(tt.args)
+			if got != tt.want {
+				t.Fatalf("countPositionals(%#v) = %d, want %d", tt.args, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestIsKnownCommand(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		args []string
+		want bool
+	}{
+		{name: "empty", args: nil, want: false},
+		{name: "command first", args: []string{"backup"}, want: true},
+		{name: "preset first", args: []string{"home"}, want: false},
+		{name: "flag then command", args: []string{"--repo", "/repo", "backup"}, want: true},
+		{name: "flag then preset", args: []string{"--repo", "/repo", "home"}, want: false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			got := isKnownCommand(tt.args)
+			if got != tt.want {
+				t.Fatalf("isKnownCommand(%#v) = %v, want %v", tt.args, got, tt.want)
+			}
+		})
+	}
+}

cmd/root.go πŸ”—

@@ -0,0 +1,289 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/fang/v2"
+	"github.com/spf13/cobra"
+
+	"git.secluded.site/pika/internal/config"
+	"git.secluded.site/pika/internal/menu"
+	"git.secluded.site/pika/internal/restic"
+)
+
+var (
+	flagDryRun     bool
+	flagConfigFile string
+)
+
+const overrideArgumentsKey = "_arguments"
+
+// menuItems defines the interactive command picker shown when pika is invoked
+// with no arguments.
+var menuItems = []menu.Item{
+	{Label: "backup", Hotkey: 'b'},
+	{Label: "restore", Hotkey: 'r'},
+	{Label: "snapshots", Hotkey: 's'},
+	{Label: "forget", Hotkey: 'f'},
+	{Label: "check", Hotkey: 'c'},
+	{Label: "init", Hotkey: 'i'},
+	{Label: "quit", Hotkey: 'q'},
+}
+
+var rootCmd = &cobra.Command{
+	Use:   "pika [preset] <command> [restic flags...]",
+	Short: "A friendly wrapper around restic",
+	Long:  "pika resolves layered TOML config presets and executes restic with the merged result.",
+	Example: `  pika backup
+  pika home backup
+  pika home@nas backup --tag daily --verbose
+  pika --config ./pika.toml home backup
+  pika --dry-run home backup
+  pika --help`,
+
+	// Accept arbitrary args β€” we parse preset/command ourselves.
+	Args:               cobra.ArbitraryArgs,
+	DisableFlagParsing: true,
+	SilenceUsage:       true,
+	SilenceErrors:      true,
+
+	RunE: func(cmd *cobra.Command, args []string) error {
+		// Re-parse our own flags out of the raw args since cobra flag
+		// parsing is disabled (we need to pass unknown flags through to
+		// restic).
+		args = extractOwnFlags(args)
+
+		if hasHelpFlag(args) {
+			return cmd.Help()
+		}
+
+		if flagConfigFile != "" {
+			if err := os.Setenv("PIKA_CONFIG_FILE", flagConfigFile); err != nil {
+				return fmt.Errorf("setting PIKA_CONFIG_FILE: %w", err)
+			}
+		}
+		if flagDryRun {
+			if err := os.Setenv("PIKA_DRYRUN", "1"); err != nil {
+				return fmt.Errorf("setting PIKA_DRYRUN: %w", err)
+			}
+		}
+
+		preset, command, passthrough := parseArgs(args)
+
+		// No command β†’ interactive menu.
+		if command == "" {
+			selected, err := runMenu()
+			if err != nil {
+				return fmt.Errorf("menu: %w", err)
+			}
+			if selected == "" || selected == "quit" {
+				return nil
+			}
+			command = selected
+		}
+
+		overrides := parsePassthrough(passthrough)
+
+		cfg, err := config.Resolve(preset, command, overrides)
+		if err != nil {
+			return err
+		}
+
+		if config.IsDryRun() {
+			fmt.Print(restic.DryRun(cfg))
+			return nil
+		}
+
+		return restic.Run(cfg)
+	},
+}
+
+// Execute is the main entry point, called from main.go.
+func Execute() {
+	if err := fang.Execute(context.Background(), rootCmd); err != nil {
+		os.Exit(1)
+	}
+}
+
+// extractOwnFlags pulls --dry-run and --config out of the raw arg list,
+// setting package-level flags, and returns the remaining args.
+func extractOwnFlags(args []string) []string {
+	var rest []string
+	for i := 0; i < len(args); i++ {
+		switch args[i] {
+		case "--dry-run":
+			flagDryRun = true
+		case "--config":
+			if i+1 < len(args) {
+				i++
+				flagConfigFile = args[i]
+			}
+		case "--help", "-h":
+			// Keep help flags in the raw list so RunE can dispatch help.
+			rest = append(rest, args[i])
+		default:
+			rest = append(rest, args[i])
+		}
+	}
+	return rest
+}
+
+func hasHelpFlag(args []string) bool {
+	for _, arg := range args {
+		if arg == "--help" || arg == "-h" {
+			return true
+		}
+	}
+	return false
+}
+
+// parseArgs splits args into (preset, command, remaining) using crestic-style
+// positional arity.
+//
+// Rules:
+//   - 0 positional args: no preset, no command.
+//   - 1 positional arg: command only.
+//   - 2+ positional args: first is preset, second is command.
+//
+// All unconsumed args are returned as passthrough for parsePassthrough.
+func parseArgs(args []string) (preset, command string, rest []string) {
+	positionals := positionalIndices(args)
+	if len(positionals) == 0 {
+		if len(args) == 0 {
+			return "", "", nil
+		}
+		return "", "", append([]string(nil), args...)
+	}
+
+	consumed := make(map[int]struct{}, 2)
+	if len(positionals) == 1 {
+		idx := positionals[0]
+		command = args[idx]
+		consumed[idx] = struct{}{}
+	} else {
+		presetIdx := positionals[0]
+		commandIdx := positionals[1]
+		preset = args[presetIdx]
+		command = args[commandIdx]
+		consumed[presetIdx] = struct{}{}
+		consumed[commandIdx] = struct{}{}
+	}
+
+	rest = make([]string, 0, len(args)-len(consumed))
+	for i, arg := range args {
+		if _, ok := consumed[i]; ok {
+			continue
+		}
+		rest = append(rest, arg)
+	}
+
+	return preset, command, rest
+}
+
+// parsePassthrough converts restic-style CLI flags into a map suitable for
+// config.Resolve's cliOverrides parameter.
+func parsePassthrough(args []string) map[string][]string {
+	if len(args) == 0 {
+		return nil
+	}
+
+	overrides := make(map[string][]string)
+	var positional []string
+	for i := 0; i < len(args); i++ {
+		arg := args[i]
+		if arg == "--" {
+			if i+1 < len(args) {
+				positional = append(positional, args[i+1:]...)
+			}
+			break
+		}
+
+		if !isFlagToken(arg) {
+			positional = append(positional, arg)
+			continue
+		}
+
+		name := strings.TrimLeft(arg, "-")
+
+		// --flag=value form
+		if eqIdx := strings.IndexByte(name, '='); eqIdx >= 0 {
+			overrides[name[:eqIdx]] = append(overrides[name[:eqIdx]], name[eqIdx+1:])
+			continue
+		}
+
+		// Boolean flag (no next arg, or next arg is also a flag)
+		if i+1 >= len(args) || args[i+1] == "--" || isFlagToken(args[i+1]) {
+			if _, ok := overrides[name]; !ok {
+				overrides[name] = nil
+			}
+			continue
+		}
+
+		// --flag value form
+		i++
+		overrides[name] = append(overrides[name], args[i])
+	}
+
+	if len(positional) > 0 {
+		overrides[overrideArgumentsKey] = append([]string(nil), positional...)
+	}
+
+	if len(overrides) == 0 {
+		return nil
+	}
+
+	return overrides
+}
+
+// positionalIndices returns indices of positional arguments in args. Flag
+// values are treated as part of their flag, not positional args.
+func positionalIndices(args []string) []int {
+	var idx []int
+
+	for i := 0; i < len(args); i++ {
+		arg := args[i]
+
+		switch {
+		case arg == "--":
+			for j := i + 1; j < len(args); j++ {
+				idx = append(idx, j)
+			}
+			return idx
+		case isFlagToken(arg):
+			if strings.Contains(arg, "=") {
+				continue
+			}
+			if i+1 < len(args) && args[i+1] != "--" && !isFlagToken(args[i+1]) {
+				i++
+			}
+		default:
+			idx = append(idx, i)
+		}
+	}
+
+	return idx
+}
+
+func isFlagToken(arg string) bool {
+	return strings.HasPrefix(arg, "-") && arg != "-" && arg != "--"
+}
+
+// runMenu launches the interactive BubbleTea menu and returns the chosen
+// command, or "" if the user quit.
+func runMenu() (string, error) {
+	m := menu.New(menuItems)
+	p := tea.NewProgram(m)
+	result, err := p.Run()
+	if err != nil {
+		return "", err
+	}
+	model, ok := result.(menu.Model)
+	if !ok {
+		return "", fmt.Errorf("unexpected menu model type %T", result)
+	}
+	return model.Choice(), nil
+}

cmd/root_test.go πŸ”—

@@ -0,0 +1,223 @@
+package cmd
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestParseArgs(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name        string
+		args        []string
+		wantPreset  string
+		wantCommand string
+		wantRest    []string
+	}{
+		{
+			name:        "no args",
+			args:        nil,
+			wantPreset:  "",
+			wantCommand: "",
+			wantRest:    nil,
+		},
+		{
+			name:        "single positional is command",
+			args:        []string{"backup"},
+			wantPreset:  "",
+			wantCommand: "backup",
+			wantRest:    nil,
+		},
+		{
+			name:        "single split-like token is still command",
+			args:        []string{"home@nas"},
+			wantPreset:  "",
+			wantCommand: "home@nas",
+			wantRest:    nil,
+		},
+		{
+			name:        "two positionals are preset and command",
+			args:        []string{"home", "backup"},
+			wantPreset:  "home",
+			wantCommand: "backup",
+			wantRest:    nil,
+		},
+		{
+			name:        "two positionals plus passthrough",
+			args:        []string{"home@nas", "backup", "--tag", "daily", "/src"},
+			wantPreset:  "home@nas",
+			wantCommand: "backup",
+			wantRest:    []string{"--tag", "daily", "/src"},
+		},
+		{
+			name:        "single command with flags",
+			args:        []string{"backup", "--repo", "/repo", "--json"},
+			wantPreset:  "",
+			wantCommand: "backup",
+			wantRest:    []string{"--repo", "/repo", "--json"},
+		},
+		{
+			name:        "flags before command",
+			args:        []string{"--repo", "/repo", "backup", "--json"},
+			wantPreset:  "",
+			wantCommand: "backup",
+			wantRest:    []string{"--repo", "/repo", "--json"},
+		},
+		{
+			name:        "no positional command when only flags provided",
+			args:        []string{"--repo", "/repo", "--json"},
+			wantPreset:  "",
+			wantCommand: "",
+			wantRest:    []string{"--repo", "/repo", "--json"},
+		},
+		{
+			name:        "double-dash keeps following literals in passthrough",
+			args:        []string{"home", "backup", "--", "--literal", "path"},
+			wantPreset:  "home",
+			wantCommand: "backup",
+			wantRest:    []string{"--", "--literal", "path"},
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			gotPreset, gotCommand, gotRest := parseArgs(tt.args)
+			if gotPreset != tt.wantPreset {
+				t.Fatalf("preset mismatch: got %q, want %q", gotPreset, tt.wantPreset)
+			}
+			if gotCommand != tt.wantCommand {
+				t.Fatalf("command mismatch: got %q, want %q", gotCommand, tt.wantCommand)
+			}
+			if !equalStringSlices(gotRest, tt.wantRest) {
+				t.Fatalf("rest mismatch: got %#v, want %#v", gotRest, tt.wantRest)
+			}
+		})
+	}
+}
+
+func TestParsePassthrough(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		args []string
+		want map[string][]string
+	}{
+		{
+			name: "no passthrough",
+			args: nil,
+			want: nil,
+		},
+		{
+			name: "flag with value",
+			args: []string{"--repo", "/repo"},
+			want: map[string][]string{
+				"repo": {"/repo"},
+			},
+		},
+		{
+			name: "repeated and equals flags",
+			args: []string{"--tag=daily", "--tag", "weekly"},
+			want: map[string][]string{
+				"tag": {"daily", "weekly"},
+			},
+		},
+		{
+			name: "boolean flags",
+			args: []string{"-v", "--json"},
+			want: map[string][]string{
+				"v":    nil,
+				"json": nil,
+			},
+		},
+		{
+			name: "positional arguments become _arguments",
+			args: []string{"/src", "/dst"},
+			want: map[string][]string{
+				overrideArgumentsKey: {"/src", "/dst"},
+			},
+		},
+		{
+			name: "mixed flags and positional arguments",
+			args: []string{"--repo", "/repo", "/src", "/dst"},
+			want: map[string][]string{
+				"repo":               {"/repo"},
+				overrideArgumentsKey: {"/src", "/dst"},
+			},
+		},
+		{
+			name: "double-dash preserves literal arguments",
+			args: []string{"--repo", "/repo", "--", "--literal", "path"},
+			want: map[string][]string{
+				"repo":               {"/repo"},
+				overrideArgumentsKey: {"--literal", "path"},
+			},
+		},
+		{
+			name: "single dash is positional argument",
+			args: []string{"-"},
+			want: map[string][]string{
+				overrideArgumentsKey: {"-"},
+			},
+		},
+		{
+			name: "flag without value before another flag is boolean",
+			args: []string{"--host", "--json"},
+			want: map[string][]string{
+				"host": nil,
+				"json": nil,
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			got := parsePassthrough(tt.args)
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Fatalf("overrides mismatch: got %#v, want %#v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestHasHelpFlag(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		args []string
+		want bool
+	}{
+		{name: "no args", args: nil, want: false},
+		{name: "no help token", args: []string{"backup", "--repo", "/repo"}, want: false},
+		{name: "short help", args: []string{"-h"}, want: true},
+		{name: "long help", args: []string{"--help"}, want: true},
+		{name: "help mixed with args", args: []string{"home", "backup", "--help"}, want: true},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			got := hasHelpFlag(tt.args)
+			if got != tt.want {
+				t.Fatalf("hasHelpFlag(%#v) = %v, want %v", tt.args, got, tt.want)
+			}
+		})
+	}
+}
+
+func equalStringSlices(a, b []string) bool {
+	if len(a) == 0 && len(b) == 0 {
+		return true
+	}
+	return reflect.DeepEqual(a, b)
+}

example/config.toml πŸ”—

@@ -0,0 +1,56 @@
+# pika config merge order (lowest to highest precedence):
+#   [global] -> [global.<command>] -> split preset parts -> full preset -> CLI overrides
+#
+# For a split preset like `home@cloud` and command `backup`, pika checks:
+#   [global] -> [global.backup] -> [@cloud] -> [@cloud.backup] ->
+#   [home@] -> [home@.backup] -> [home@cloud] -> [home@cloud.backup]
+
+[global]
+# Shared flags for every command and preset.
+password-file = "~/.config/restic/password.txt"
+repository = "sftp:restic@example.org:/srv/restic"
+
+[global.backup]
+# Command-specific defaults.
+exclude-file = "~/.config/restic/excludes.txt"
+exclude-if-present = ".nobackup"
+
+[home.backup]
+# Simple preset: `pika home backup`
+_arguments = ["/home/amolith"]
+tag = ["home"]
+
+["@nas"]
+# Split preset suffix: applies to `* @nas` style presets.
+repository = "sftp:nas@example.org:/mnt/backups/restic"
+tag = ["nas"]
+
+["@cloud"]
+# Split preset suffix for cloud backups.
+repository = "s3:s3.us-east-1.amazonaws.com/my-restic-bucket"
+tag = ["cloud"]
+
+["@cloud".environ]
+# Environment variables are loaded from `.environ` sections.
+AWS_PROFILE = "restic-cloud"
+RESTIC_PASSWORD_COMMAND = "pass show backups/restic-cloud"
+
+["home@"]
+# Split preset prefix: applies to `home@*` presets.
+host = "amolith-laptop"
+tag = ["home"]
+
+["home@".backup]
+# Prefix + command section.
+_arguments = ["/home/amolith", "/etc"]
+exclude-if-present = ".pika-skip"
+
+["home@".forget]
+# Prefix + different command section.
+keep-daily = 7
+keep-weekly = 5
+keep-monthly = 12
+
+["home@cloud"]
+# Full split preset section can override prefix/suffix values.
+repository = "s3:s3.us-east-1.amazonaws.com/my-restic-home-cloud"

go.mod πŸ”—

@@ -0,0 +1,37 @@
+module git.secluded.site/pika
+
+go 1.26.1
+
+require (
+	charm.land/bubbletea/v2 v2.0.2
+	charm.land/fang/v2 v2.0.1
+	charm.land/lipgloss/v2 v2.0.2
+	github.com/BurntSushi/toml v1.6.0
+	github.com/spf13/cobra v1.10.2
+)
+
+require (
+	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/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/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/muesli/cancelreader v0.2.2 // indirect
+	github.com/muesli/mango v0.1.0 // indirect
+	github.com/muesli/mango-cobra v1.2.0 // indirect
+	github.com/muesli/mango-pflag v0.1.0 // indirect
+	github.com/muesli/roff v0.1.0 // indirect
+	github.com/rivo/uniseg v0.4.7 // indirect
+	github.com/spf13/pflag v1.0.9 // indirect
+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	golang.org/x/sync v0.19.0 // indirect
+	golang.org/x/sys v0.42.0 // indirect
+	golang.org/x/text v0.24.0 // indirect
+)

go.sum πŸ”—

@@ -0,0 +1,74 @@
+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/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/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
+github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
+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/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/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/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/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/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/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=
+github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
+github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
+github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
+github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
+github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
+github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
+github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/config/config.go πŸ”—

@@ -0,0 +1,359 @@
+package config
+
+import (
+	"fmt"
+	"os"
+	"regexp"
+	"strings"
+
+	"github.com/BurntSushi/toml"
+)
+
+// Special keys recognised during option assembly.
+const (
+	keyArguments = "_arguments"
+	keyWorkdir   = "_workdir"
+	keyCommand   = "_command"
+)
+
+// environSuffix marks a section as containing environment variables.
+const environSuffix = ".environ"
+
+// interpolateRe matches ${section.key} references for cross-section interpolation.
+var interpolateRe = regexp.MustCompile(`\$\{([^.}]+)\.([^}]+)\}`)
+
+// ResolvedConfig holds the fully-merged result of config resolution, ready for
+// the restic exec layer to consume.
+type ResolvedConfig struct {
+	// Command is the restic subcommand to run (may be aliased via _command).
+	Command string
+
+	// Flags maps flag names to their values. Multi-value flags have multiple
+	// entries. Boolean flags (true) are represented as a nil slice.
+	Flags []Flag
+
+	// Arguments are positional args passed after the flags.
+	Arguments []string
+
+	// Workdir is the directory to chdir into before exec, or "" for cwd.
+	Workdir string
+
+	// Environ holds additional environment variables for the restic process.
+	Environ map[string]string
+
+	// SectionsRead lists which config sections contributed to this resolution.
+	SectionsRead []string
+}
+
+// Flag is a single CLI flag to pass to restic.
+type Flag struct {
+	Name  string
+	Value string // empty for boolean switches
+}
+
+// rawConfig is the entire parsed TOML file as nested string-keyed maps.
+type rawConfig map[string]any
+
+// Resolve loads all discovered config files, merges sections according to
+// preset/command rules, and returns a ResolvedConfig.
+//
+// cliOverrides are applied last and should use flag names without leading
+// dashes (e.g. "exclude" not "--exclude"). Each key maps to one or more values;
+// boolean switches use a nil/empty slice.
+func Resolve(preset, command string, cliOverrides map[string][]string) (*ResolvedConfig, error) {
+	files := DiscoverFiles()
+	return resolveFrom(files, preset, command, cliOverrides)
+}
+
+func resolveFrom(files []string, preset, command string, cliOverrides map[string][]string) (*ResolvedConfig, error) {
+	raw, err := loadFiles(files)
+	if err != nil {
+		return nil, err
+	}
+
+	sections := buildSectionOrder(preset, command)
+	envSections := make([]string, len(sections))
+	for i, s := range sections {
+		envSections[i] = s + environSuffix
+	}
+
+	// Merge option sections in order.
+	merged := make(map[string]any)
+	var sectionsRead []string
+	for _, sect := range sections {
+		tbl, ok := lookupSection(raw, sect)
+		if !ok {
+			continue
+		}
+		// Skip sub-tables (nested commands / environ) β€” only merge leaf keys.
+		for k, v := range tbl {
+			if _, isMap := v.(map[string]any); isMap {
+				continue
+			}
+			merged[k] = v
+		}
+		sectionsRead = append(sectionsRead, sect)
+	}
+
+	// Merge environ sections.
+	environ := make(map[string]string)
+	for _, sect := range envSections {
+		tbl, ok := lookupSection(raw, sect)
+		if !ok {
+			continue
+		}
+		for k, v := range tbl {
+			environ[k] = ExpandPath(fmt.Sprint(v))
+		}
+	}
+
+	// Perform cross-section interpolation on merged values.
+	interpolate(merged, raw)
+
+	// Apply CLI overrides last.
+	for k, vals := range cliOverrides {
+		if len(vals) == 0 {
+			merged[k] = true
+		} else if len(vals) == 1 {
+			merged[k] = vals[0]
+		} else {
+			iface := make([]any, len(vals))
+			for i, v := range vals {
+				iface[i] = v
+			}
+			merged[k] = iface
+		}
+	}
+
+	return assemble(merged, command, environ, sectionsRead), nil
+}
+
+// loadFiles reads and merges all TOML config files. Later files override
+// earlier ones at the top-level section granularity.
+func loadFiles(files []string) (rawConfig, error) {
+	combined := make(rawConfig)
+	for _, f := range files {
+		data, err := os.ReadFile(f)
+		if err != nil {
+			if os.IsNotExist(err) {
+				continue
+			}
+			return nil, fmt.Errorf("reading config %s: %w", f, err)
+		}
+		var parsed rawConfig
+		if err := toml.Unmarshal(data, &parsed); err != nil {
+			return nil, fmt.Errorf("parsing config %s: %w", f, err)
+		}
+		// Merge top-level keys; later files win.
+		for k, v := range parsed {
+			if existing, ok := combined[k]; ok {
+				if eMap, eOk := existing.(map[string]any); eOk {
+					if vMap, vOk := v.(map[string]any); vOk {
+						for mk, mv := range vMap {
+							eMap[mk] = mv
+						}
+						continue
+					}
+				}
+			}
+			combined[k] = v
+		}
+	}
+	return combined, nil
+}
+
+// buildSectionOrder returns the list of config sections to read, in ascending
+// priority order, for the given preset and command.
+//
+// For a plain preset "foo" with command "backup":
+//
+//	[global] -> [global.backup] -> [foo] -> [foo.backup]
+//
+// For a split preset "home@nas" with command "backup":
+//
+//	[global] -> [global.backup] -> [@nas] -> [@nas.backup] ->
+//	[home@] -> [home@.backup] -> [home@nas] -> [home@nas.backup]
+func buildSectionOrder(preset, command string) []string {
+	sections := []string{"global"}
+	if command != "" {
+		sections = append(sections, "global."+command)
+	}
+
+	if preset == "" {
+		return sections
+	}
+
+	if idx := strings.Index(preset, "@"); idx >= 0 {
+		// Split preset: "prefix@suffix"
+		// Parts in reverse order of the split, then the full preset.
+		parts := splitPreset(preset)
+		for _, part := range parts {
+			sections = append(sections, part)
+			if command != "" {
+				sections = append(sections, part+"."+command)
+			}
+		}
+		// Full preset (only if not already the sole part).
+		if len(parts) != 1 || parts[0] != preset {
+			sections = append(sections, preset)
+			if command != "" {
+				sections = append(sections, preset+"."+command)
+			}
+		}
+	} else {
+		sections = append(sections, preset)
+		if command != "" {
+			sections = append(sections, preset+"."+command)
+		}
+	}
+
+	return sections
+}
+
+// splitPreset splits "prefix@suffix" into ["@suffix", "prefix@"] β€” the two
+// halves that get their own section lookups before the full preset.
+func splitPreset(preset string) []string {
+	idx := strings.Index(preset, "@")
+	if idx < 0 {
+		return []string{preset}
+	}
+	suffix := preset[idx:]   // "@nas"
+	prefix := preset[:idx+1] // "home@"
+
+	return []string{suffix, prefix}
+}
+
+// lookupSection finds a dotted section name in the raw config tree.
+// "global.backup" looks up raw["global"]["backup"].
+func lookupSection(raw rawConfig, section string) (map[string]any, bool) {
+	parts := strings.Split(section, ".")
+	var current any = (map[string]any)(raw)
+	for _, p := range parts {
+		m, ok := current.(map[string]any)
+		if !ok {
+			return nil, false
+		}
+		current, ok = m[p]
+		if !ok {
+			return nil, false
+		}
+	}
+	m, ok := current.(map[string]any)
+	if !ok {
+		return nil, false
+	}
+	return m, true
+}
+
+// interpolate resolves ${section.key} references in merged values.
+func interpolate(merged map[string]any, raw rawConfig) {
+	for k, v := range merged {
+		s, ok := v.(string)
+		if !ok {
+			continue
+		}
+		merged[k] = interpolateRe.ReplaceAllStringFunc(s, func(match string) string {
+			sub := interpolateRe.FindStringSubmatch(match)
+			if len(sub) != 3 {
+				return match
+			}
+			sect, key := sub[1], sub[2]
+			tbl, ok := lookupSection(raw, sect)
+			if !ok {
+				return match
+			}
+			val, ok := tbl[key]
+			if !ok {
+				return match
+			}
+			return fmt.Sprint(val)
+		})
+	}
+}
+
+// assemble converts the merged key-value map into a ResolvedConfig.
+func assemble(merged map[string]any, command string, environ map[string]string, sectionsRead []string) *ResolvedConfig {
+	rc := &ResolvedConfig{
+		Command:      command,
+		Environ:      environ,
+		SectionsRead: sectionsRead,
+	}
+
+	// Extract special keys.
+	if args, ok := merged[keyArguments]; ok {
+		rc.Arguments = toStringSlice(args)
+		delete(merged, keyArguments)
+	}
+	if wd, ok := merged[keyWorkdir]; ok {
+		rc.Workdir = ExpandPath(fmt.Sprint(wd))
+		delete(merged, keyWorkdir)
+	}
+	if cmd, ok := merged[keyCommand]; ok {
+		rc.Command = fmt.Sprint(cmd)
+		delete(merged, keyCommand)
+	}
+
+	// Build flags.
+	for k, v := range merged {
+		switch val := v.(type) {
+		case bool:
+			if val {
+				rc.Flags = append(rc.Flags, Flag{Name: flagName(k)})
+			}
+		case []any:
+			for _, elem := range val {
+				rc.Flags = append(rc.Flags, Flag{
+					Name:  flagName(k),
+					Value: fmt.Sprint(elem),
+				})
+			}
+		default:
+			s := fmt.Sprint(val)
+			// Multi-line string values (newline-separated) become repeated flags.
+			lines := strings.Split(s, "\n")
+			for _, line := range lines {
+				rc.Flags = append(rc.Flags, Flag{
+					Name:  flagName(k),
+					Value: ExpandPath(line),
+				})
+			}
+		}
+	}
+
+	// Expand path references in arguments.
+	for i, a := range rc.Arguments {
+		rc.Arguments[i] = ExpandPath(a)
+	}
+
+	return rc
+}
+
+// flagName returns the CLI flag form of a key: single-char keys get "-k",
+// longer keys get "--key".
+func flagName(key string) string {
+	if len(key) == 1 {
+		return "-" + key
+	}
+	return "--" + key
+}
+
+// toStringSlice coerces a value (string or []any) into []string.
+func toStringSlice(v any) []string {
+	switch val := v.(type) {
+	case string:
+		return strings.Fields(val)
+	case []any:
+		out := make([]string, 0, len(val))
+		for _, elem := range val {
+			out = append(out, fmt.Sprint(elem))
+		}
+		return out
+	default:
+		return []string{fmt.Sprint(v)}
+	}
+}
+
+// IsDryRun reports whether the PIKA_DRYRUN environment variable is set.
+func IsDryRun() bool {
+	return os.Getenv("PIKA_DRYRUN") != ""
+}

internal/config/config_test.go πŸ”—

@@ -0,0 +1,273 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"reflect"
+	"strings"
+	"testing"
+)
+
+func TestResolve(t *testing.T) {
+	tmpDir := t.TempDir()
+	homeDir := filepath.Join(tmpDir, "home")
+	if err := os.MkdirAll(homeDir, 0o755); err != nil {
+		t.Fatalf("creating temp HOME: %v", err)
+	}
+
+	configPath := filepath.Join(tmpDir, "config.toml")
+	if err := os.WriteFile(configPath, []byte(resolveFixtureTOML), 0o600); err != nil {
+		t.Fatalf("writing fixture config: %v", err)
+	}
+
+	t.Setenv("HOME", homeDir)
+	t.Setenv("PIKA_CONFIG_FILE", configPath)
+	t.Setenv("PIKA_CONFIG_PATHS", "")
+
+	tests := []struct {
+		name         string
+		preset       string
+		command      string
+		overrides    map[string][]string
+		wantCommand  string
+		wantWorkdir  string
+		wantArgs     []string
+		wantSections []string
+		wantEnviron  map[string]string
+		wantFlags    map[string][]string
+	}{
+		{
+			name:         "global command only",
+			preset:       "",
+			command:      "backup",
+			wantCommand:  "backup",
+			wantWorkdir:  "",
+			wantArgs:     nil,
+			wantSections: []string{"global", "global.backup"},
+			wantEnviron: map[string]string{
+				"RESTIC_REPOSITORY":       "/repos/global",
+				"RESTIC_PASSWORD_COMMAND": "pass global",
+			},
+			wantFlags: map[string][]string{
+				"--password-file":      {"/secrets/global"},
+				"--verbose":            {""},
+				"--tag":                {"global-one", "global-two"},
+				"--exclude-file":       {"/etc/restic/global-excludes"},
+				"--exclude-if-present": {".nobackup"},
+				"--cache-dir":          {"/srv/cache"},
+			},
+		},
+		{
+			name:         "preset command merge with special keys",
+			preset:       "home",
+			command:      "backup",
+			wantCommand:  "backup",
+			wantWorkdir:  filepath.Join(homeDir, "work", "home"),
+			wantArgs:     []string{"/home/alice", "/home/shared"},
+			wantSections: []string{"global", "global.backup", "home", "home.backup"},
+			wantEnviron: map[string]string{
+				"RESTIC_REPOSITORY":       "/repos/global",
+				"RESTIC_PASSWORD_COMMAND": "pass home",
+			},
+			wantFlags: map[string][]string{
+				"--password-file":      {"/secrets/home"},
+				"--verbose":            {""},
+				"--tag":                {"home-tag"},
+				"--exclude-file":       {"/etc/restic/global-excludes"},
+				"--exclude-if-present": {".nobackup"},
+				"--cache-dir":          {"/srv/cache"},
+				"--repo":               {"/repos/home"},
+			},
+		},
+		{
+			name:         "split preset sections",
+			preset:       "home@cloud",
+			command:      "backup",
+			wantCommand:  "backup",
+			wantWorkdir:  "",
+			wantArgs:     []string{"/data/cloud"},
+			wantSections: []string{"global", "global.backup", "@cloud", "home@", "home@.backup", "home@cloud", "home@cloud.backup"},
+			wantEnviron: map[string]string{
+				"RESTIC_REPOSITORY":       "/repos/cloud",
+				"RESTIC_PASSWORD_COMMAND": "pass cloud backup",
+			},
+			wantFlags: map[string][]string{
+				"--password-file":      {"/secrets/global"},
+				"--verbose":            {""},
+				"--tag":                {"cloud-tag"},
+				"--exclude-file":       {"/etc/restic/home-split-excludes"},
+				"--exclude-if-present": {".nobackup"},
+				"--cache-dir":          {"/srv/cache"},
+				"--repo":               {"/repos/home-cloud"},
+			},
+		},
+		{
+			name:         "command alias",
+			preset:       "archive",
+			command:      "backup",
+			wantCommand:  "snapshots",
+			wantWorkdir:  "",
+			wantArgs:     []string{"latest"},
+			wantSections: []string{"global", "global.backup", "archive", "archive.backup"},
+			wantEnviron: map[string]string{
+				"RESTIC_REPOSITORY":       "/repos/global",
+				"RESTIC_PASSWORD_COMMAND": "pass global",
+			},
+			wantFlags: map[string][]string{
+				"--password-file":      {"/secrets/global"},
+				"--verbose":            {""},
+				"--tag":                {"global-one", "global-two"},
+				"--exclude-file":       {"/etc/restic/global-excludes"},
+				"--exclude-if-present": {".nobackup"},
+				"--cache-dir":          {"/srv/cache"},
+				"--json":               {""},
+			},
+		},
+		{
+			name:    "cli overrides take precedence",
+			preset:  "home",
+			command: "backup",
+			overrides: map[string][]string{
+				"tag":                {"cli-a", "cli-b"},
+				"password-file":      {"/secrets/cli"},
+				"repo":               {"/repos/cli"},
+				"json":               nil,
+				"_arguments":         {"/cli/path"},
+				"_workdir":           {"~/work/cli"},
+				"_command":           {"backup"},
+				"exclude-if-present": {".override-marker"},
+			},
+			wantCommand:  "backup",
+			wantWorkdir:  filepath.Join(homeDir, "work", "cli"),
+			wantArgs:     []string{"/cli/path"},
+			wantSections: []string{"global", "global.backup", "home", "home.backup"},
+			wantEnviron: map[string]string{
+				"RESTIC_REPOSITORY":       "/repos/global",
+				"RESTIC_PASSWORD_COMMAND": "pass home",
+			},
+			wantFlags: map[string][]string{
+				"--password-file":      {"/secrets/cli"},
+				"--verbose":            {""},
+				"--tag":                {"cli-a", "cli-b"},
+				"--exclude-file":       {"/etc/restic/global-excludes"},
+				"--exclude-if-present": {".override-marker"},
+				"--cache-dir":          {"/srv/cache"},
+				"--repo":               {"/repos/cli"},
+				"--json":               {""},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			cfg, err := Resolve(tt.preset, tt.command, tt.overrides)
+			if err != nil {
+				t.Fatalf("Resolve() error: %v", err)
+			}
+
+			if cfg.Command != tt.wantCommand {
+				t.Fatalf("command mismatch: got %q, want %q", cfg.Command, tt.wantCommand)
+			}
+			if cfg.Workdir != tt.wantWorkdir {
+				t.Fatalf("workdir mismatch: got %q, want %q", cfg.Workdir, tt.wantWorkdir)
+			}
+			if !equalStrings(cfg.Arguments, tt.wantArgs) {
+				t.Fatalf("arguments mismatch: got %#v, want %#v", cfg.Arguments, tt.wantArgs)
+			}
+			if !reflect.DeepEqual(cfg.SectionsRead, tt.wantSections) {
+				t.Fatalf("sections mismatch: got %#v, want %#v", cfg.SectionsRead, tt.wantSections)
+			}
+			if !reflect.DeepEqual(cfg.Environ, tt.wantEnviron) {
+				t.Fatalf("environ mismatch: got %#v, want %#v", cfg.Environ, tt.wantEnviron)
+			}
+
+			for _, f := range cfg.Flags {
+				if !strings.HasPrefix(f.Name, "-") {
+					t.Fatalf("flag name %q is missing CLI prefix", f.Name)
+				}
+			}
+
+			gotFlags := collectFlags(cfg.Flags)
+			if !reflect.DeepEqual(gotFlags, tt.wantFlags) {
+				t.Fatalf("flags mismatch: got %#v, want %#v", gotFlags, tt.wantFlags)
+			}
+		})
+	}
+}
+
+func collectFlags(flags []Flag) map[string][]string {
+	out := make(map[string][]string)
+	for _, flag := range flags {
+		out[flag.Name] = append(out[flag.Name], flag.Value)
+	}
+	return out
+}
+
+func equalStrings(a, b []string) bool {
+	if len(a) == 0 && len(b) == 0 {
+		return true
+	}
+	return reflect.DeepEqual(a, b)
+}
+
+const resolveFixtureTOML = `
+[vars]
+cache-root = "/srv"
+
+[global]
+password-file = "/secrets/global"
+verbose = true
+tag = ["global-one", "global-two"]
+
+[global.backup]
+exclude-file = "/etc/restic/global-excludes"
+exclude-if-present = ".nobackup"
+cache-dir = "${vars.cache-root}/cache"
+
+[global.backup.environ]
+RESTIC_REPOSITORY = "/repos/global"
+RESTIC_PASSWORD_COMMAND = "pass global"
+
+[home]
+repo = "/repos/home"
+_workdir = "~/work/home"
+tag = ["home-tag"]
+
+[home.backup]
+_arguments = ["/home/alice", "/home/shared"]
+password-file = "/secrets/home"
+
+[home.backup.environ]
+RESTIC_PASSWORD_COMMAND = "pass home"
+
+["@cloud"]
+repo = "/repos/cloud-base"
+tag = ["cloud-tag"]
+
+["@cloud".environ]
+RESTIC_REPOSITORY = "/repos/cloud"
+
+["home@"]
+repo = "/repos/home-prefix"
+
+["home@".backup]
+exclude-file = "/etc/restic/home-split-excludes"
+
+["home@".forget]
+keep-last = 7
+
+["home@cloud"]
+repo = "/repos/home-cloud"
+
+["home@cloud".backup]
+_arguments = ["/data/cloud"]
+
+["home@cloud".backup.environ]
+RESTIC_PASSWORD_COMMAND = "pass cloud backup"
+
+[archive.backup]
+_command = "snapshots"
+_arguments = ["latest"]
+json = true
+`

internal/config/files.go πŸ”—

@@ -0,0 +1,78 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+)
+
+// DefaultConfigDirs lists the base directories searched for config files,
+// in ascending priority order.
+var DefaultConfigDirs = []string{
+	"/usr/share/pika",
+	"/etc/pika",
+	"~/.config/pika",
+}
+
+// DiscoverFiles returns config file paths in ascending priority order.
+//
+// The search order is:
+//  1. For each directory in DefaultConfigDirs: config.toml, then sorted conf.d/*.toml
+//  2. Paths/globs from the PIKA_CONFIG_PATHS env var (colon-separated)
+//  3. If PIKA_CONFIG_FILE is set, it replaces ALL of the above
+//
+// All paths support ~ (home dir) and $VAR expansion.
+func DiscoverFiles() []string {
+	return discoverFiles(os.Getenv)
+}
+
+// discoverFiles is the testable core; getenv abstracts os.Getenv.
+func discoverFiles(getenv func(string) string) []string {
+	if single := getenv("PIKA_CONFIG_FILE"); single != "" {
+		return []string{ExpandPath(single)}
+	}
+
+	var paths []string
+
+	for _, dir := range DefaultConfigDirs {
+		dir = ExpandPath(dir)
+
+		paths = append(paths, filepath.Join(dir, "config.toml"))
+		paths = append(paths, sortedGlob(filepath.Join(dir, "conf.d", "*.toml"))...)
+	}
+
+	if extra := getenv("PIKA_CONFIG_PATHS"); extra != "" {
+		for _, entry := range strings.Split(extra, ":") {
+			entry = ExpandPath(strings.TrimSpace(entry))
+			if strings.ContainsAny(entry, "*?[") {
+				paths = append(paths, sortedGlob(entry)...)
+			} else {
+				paths = append(paths, entry)
+			}
+		}
+	}
+
+	return paths
+}
+
+// ExpandPath expands ~ to the user's home directory and $VAR references.
+func ExpandPath(p string) string {
+	if strings.HasPrefix(p, "~/") || p == "~" {
+		home, err := os.UserHomeDir()
+		if err == nil {
+			p = home + p[1:]
+		}
+	}
+	return os.ExpandEnv(p)
+}
+
+// sortedGlob returns glob matches in sorted order, or nil on error.
+func sortedGlob(pattern string) []string {
+	matches, err := filepath.Glob(pattern)
+	if err != nil || len(matches) == 0 {
+		return nil
+	}
+	sort.Strings(matches)
+	return matches
+}

internal/config/files_test.go πŸ”—

@@ -0,0 +1,87 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"reflect"
+	"testing"
+)
+
+func TestDiscoverFiles_ConfigFileOverride(t *testing.T) {
+	tmpDir := t.TempDir()
+	homeDir := filepath.Join(tmpDir, "home")
+	if err := os.MkdirAll(homeDir, 0o755); err != nil {
+		t.Fatalf("creating temp HOME: %v", err)
+	}
+
+	t.Setenv("HOME", homeDir)
+	t.Setenv("PIKA_CONFIG_FILE", "~/only.toml")
+	t.Setenv("PIKA_CONFIG_PATHS", filepath.Join(tmpDir, "extras", "*.toml"))
+
+	got := DiscoverFiles()
+	want := []string{filepath.Join(homeDir, "only.toml")}
+
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("DiscoverFiles() mismatch: got %#v, want %#v", got, want)
+	}
+}
+
+func TestDiscoverFiles_Order(t *testing.T) {
+	tmpDir := t.TempDir()
+	dirA := filepath.Join(tmpDir, "a")
+	dirB := filepath.Join(tmpDir, "b")
+	extraDir := filepath.Join(tmpDir, "extra")
+	if err := os.MkdirAll(filepath.Join(dirA, "conf.d"), 0o755); err != nil {
+		t.Fatalf("mkdir dirA conf.d: %v", err)
+	}
+	if err := os.MkdirAll(filepath.Join(dirB, "conf.d"), 0o755); err != nil {
+		t.Fatalf("mkdir dirB conf.d: %v", err)
+	}
+	if err := os.MkdirAll(extraDir, 0o755); err != nil {
+		t.Fatalf("mkdir extraDir: %v", err)
+	}
+
+	for _, path := range []string{
+		filepath.Join(dirA, "conf.d", "z.toml"),
+		filepath.Join(dirA, "conf.d", "a.toml"),
+		filepath.Join(dirB, "conf.d", "c.toml"),
+		filepath.Join(dirB, "conf.d", "b.toml"),
+		filepath.Join(extraDir, "later.toml"),
+		filepath.Join(extraDir, "earlier.toml"),
+	} {
+		if err := os.WriteFile(path, []byte("# fixture"), 0o600); err != nil {
+			t.Fatalf("writing %s: %v", path, err)
+		}
+	}
+
+	explicit := filepath.Join(tmpDir, "explicit.toml")
+	if err := os.WriteFile(explicit, []byte("# explicit"), 0o600); err != nil {
+		t.Fatalf("writing explicit file: %v", err)
+	}
+
+	originalDirs := DefaultConfigDirs
+	DefaultConfigDirs = []string{dirA, dirB}
+	t.Cleanup(func() {
+		DefaultConfigDirs = originalDirs
+	})
+
+	t.Setenv("PIKA_CONFIG_FILE", "")
+	t.Setenv("PIKA_CONFIG_PATHS", filepath.Join(extraDir, "*.toml")+":"+explicit)
+
+	got := DiscoverFiles()
+	want := []string{
+		filepath.Join(dirA, "config.toml"),
+		filepath.Join(dirA, "conf.d", "a.toml"),
+		filepath.Join(dirA, "conf.d", "z.toml"),
+		filepath.Join(dirB, "config.toml"),
+		filepath.Join(dirB, "conf.d", "b.toml"),
+		filepath.Join(dirB, "conf.d", "c.toml"),
+		filepath.Join(extraDir, "earlier.toml"),
+		filepath.Join(extraDir, "later.toml"),
+		explicit,
+	}
+
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("DiscoverFiles() ordering mismatch: got %#v, want %#v", got, want)
+	}
+}

internal/config/presets.go πŸ”—

@@ -0,0 +1,31 @@
+package config
+
+import (
+	"sort"
+)
+
+// Presets loads all discovered config files and returns the distinct preset
+// names found as top-level TOML sections. The "global" section is excluded
+// since it provides shared defaults, not a user-selectable preset.
+func Presets() []string {
+	files := DiscoverFiles()
+	return presetsFrom(files)
+}
+
+// presetsFrom is the testable core of Presets.
+func presetsFrom(files []string) []string {
+	raw, err := loadFiles(files)
+	if err != nil {
+		return nil
+	}
+
+	presets := make([]string, 0, len(raw))
+	for key := range raw {
+		if key == "global" {
+			continue
+		}
+		presets = append(presets, key)
+	}
+	sort.Strings(presets)
+	return presets
+}

internal/config/presets_test.go πŸ”—

@@ -0,0 +1,83 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"reflect"
+	"testing"
+)
+
+const presetsFixtureTOML = `
+[global]
+verbose = true
+
+[global.backup]
+exclude-file = "/etc/excludes"
+
+[home]
+repo = "/repos/home"
+
+[home.backup]
+_arguments = ["/home/alice"]
+
+["@cloud"]
+repo = "/repos/cloud"
+
+["@cloud".environ]
+AWS_PROFILE = "restic"
+
+["home@"]
+host = "laptop"
+
+["home@cloud"]
+repo = "/repos/home-cloud"
+
+[archive]
+json = true
+
+[vars]
+cache-root = "/srv"
+`
+
+func TestPresetsFrom(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	path := filepath.Join(dir, "config.toml")
+	if err := os.WriteFile(path, []byte(presetsFixtureTOML), 0o600); err != nil {
+		t.Fatalf("writing fixture: %v", err)
+	}
+
+	got := presetsFrom([]string{path})
+
+	// We expect every top-level section except "global", sorted.
+	want := []string{"@cloud", "archive", "home", "home@", "home@cloud", "vars"}
+
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("presets mismatch:\n  got:  %#v\n  want: %#v", got, want)
+	}
+}
+
+func TestPresetsFromEmpty(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	path := filepath.Join(dir, "config.toml")
+	if err := os.WriteFile(path, []byte("[global]\nverbose = true\n"), 0o600); err != nil {
+		t.Fatalf("writing fixture: %v", err)
+	}
+
+	got := presetsFrom([]string{path})
+	if len(got) != 0 {
+		t.Fatalf("expected no presets, got %#v", got)
+	}
+}
+
+func TestPresetsFromMissing(t *testing.T) {
+	t.Parallel()
+
+	got := presetsFrom([]string{"/nonexistent/path.toml"})
+	if len(got) != 0 {
+		t.Fatalf("expected nil for missing file, got %#v", got)
+	}
+}

internal/menu/menu.go πŸ”—

@@ -0,0 +1,177 @@
+package menu
+
+import (
+	"fmt"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+)
+
+// Item represents a single menu entry.
+type Item struct {
+	// Label is the full display text (e.g. "backup").
+	Label string
+	// Hotkey is the single character that instantly selects this item (e.g. 'b').
+	Hotkey rune
+	// Value is the string returned by Choice() when this item is selected.
+	// If empty, Label is used.
+	Value string
+}
+
+// Model is a hand-rolled BubbleTea v2 model for an interactive hotkey menu.
+type Model struct {
+	items    []Item
+	cursor   int
+	choice   string
+	quitting bool
+
+	hasDarkBG bool
+	lightDark lipgloss.LightDarkFunc
+}
+
+// New creates a menu Model with the given items.
+func New(items []Item) Model {
+	return Model{
+		items:     items,
+		hasDarkBG: true, // sensible default until we hear from the terminal
+		lightDark: lipgloss.LightDark(true),
+	}
+}
+
+// Choice returns the selected item's value, or "" if nothing was chosen.
+func (m Model) Choice() string {
+	return m.choice
+}
+
+// Init requests the terminal background color so we can adapt styling.
+func (m Model) Init() tea.Cmd {
+	return tea.RequestBackgroundColor
+}
+
+// Update handles key presses and background color detection.
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.BackgroundColorMsg:
+		m.hasDarkBG = msg.IsDark()
+		m.lightDark = lipgloss.LightDark(m.hasDarkBG)
+		return m, nil
+
+	case tea.KeyPressMsg:
+		switch msg.String() {
+		case "ctrl+c", "q":
+			m.quitting = true
+			return m, tea.Quit
+
+		case "up", "k":
+			if m.cursor > 0 {
+				m.cursor--
+			}
+			return m, nil
+
+		case "down", "j":
+			if m.cursor < len(m.items)-1 {
+				m.cursor++
+			}
+			return m, nil
+
+		case "enter":
+			m.choice = m.itemValue(m.cursor)
+			return m, tea.Quit
+
+		default:
+			// Check if the keypress matches any item's hotkey.
+			if len(msg.Text) == 1 {
+				r := rune(msg.Text[0])
+				for i, item := range m.items {
+					if item.Hotkey == r {
+						m.cursor = i
+						m.choice = m.itemValue(i)
+						return m, tea.Quit
+					}
+				}
+			}
+		}
+	}
+
+	return m, nil
+}
+
+// View renders the menu as an inline vertical list.
+func (m Model) View() tea.View {
+	if m.quitting || m.choice != "" {
+		return tea.NewView("")
+	}
+
+	accentColor := m.lightDark(
+		lipgloss.Color("#7D56F4"),
+		lipgloss.Color("#AD8AFF"),
+	)
+	normalColor := m.lightDark(
+		lipgloss.Color("#333333"),
+		lipgloss.Color("#DDDDDD"),
+	)
+	cursorColor := m.lightDark(
+		lipgloss.Color("#7D56F4"),
+		lipgloss.Color("#AD8AFF"),
+	)
+
+	hotStyle := lipgloss.NewStyle().
+		Bold(true).
+		Foreground(accentColor)
+	labelStyle := lipgloss.NewStyle().
+		Foreground(normalColor)
+	cursorStyle := lipgloss.NewStyle().
+		Foreground(cursorColor).
+		Bold(true)
+
+	var b strings.Builder
+	for i, item := range m.items {
+		cursor := "  "
+		if i == m.cursor {
+			cursor = cursorStyle.Render("β–Έ ")
+		}
+
+		line := renderItem(item, hotStyle, labelStyle)
+		fmt.Fprintf(&b, "%s%s\n", cursor, line)
+	}
+
+	b.WriteString("\n")
+	b.WriteString(labelStyle.Render("↑/↓ navigate β€’ hotkey or enter to select β€’ q to quit"))
+	b.WriteString("\n")
+
+	return tea.NewView(b.String())
+}
+
+// renderItem formats a single menu item with the hotkey character styled
+// differently from the rest of the label. For example, with hotkey 'b' and
+// label "backup", it renders "[b]ackup" where [b] is in the accent style.
+func renderItem(item Item, hotStyle, labelStyle lipgloss.Style) string {
+	label := item.Label
+	hk := string(item.Hotkey)
+	idx := strings.Index(strings.ToLower(label), strings.ToLower(hk))
+
+	if idx < 0 {
+		// Hotkey not in label β€” show it as a prefix.
+		return hotStyle.Render("["+hk+"]") + " " + labelStyle.Render(label)
+	}
+
+	before := label[:idx]
+	match := label[idx : idx+len(hk)]
+	after := label[idx+len(hk):]
+
+	return labelStyle.Render(before) +
+		hotStyle.Render("["+match+"]") +
+		labelStyle.Render(after)
+}
+
+// itemValue returns the value for the item at index i.
+func (m Model) itemValue(i int) string {
+	if i < 0 || i >= len(m.items) {
+		return ""
+	}
+	if m.items[i].Value != "" {
+		return m.items[i].Value
+	}
+	return m.items[i].Label
+}

internal/restic/exec.go πŸ”—

@@ -0,0 +1,97 @@
+package restic
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+	"syscall"
+
+	"git.secluded.site/pika/internal/config"
+)
+
+// DefaultExecutable is the restic binary name used when PIKA_EXECUTABLE is
+// unset.
+const DefaultExecutable = "restic"
+
+// Run replaces the current process with restic, configured according to cfg.
+func Run(cfg *config.ResolvedConfig) error {
+	exe := executable()
+
+	path, err := exec.LookPath(exe)
+	if err != nil {
+		return fmt.Errorf("finding %s: %w", exe, err)
+	}
+
+	if cfg.Workdir != "" {
+		if err := os.Chdir(cfg.Workdir); err != nil {
+			return fmt.Errorf("chdir %s: %w", cfg.Workdir, err)
+		}
+	}
+
+	argv := buildArgv(exe, cfg)
+	env := buildEnv(cfg.Environ)
+
+	return syscall.Exec(path, argv, env)
+}
+
+// DryRun formats a human-readable summary of what Run would execute.
+func DryRun(cfg *config.ResolvedConfig) string {
+	var b strings.Builder
+
+	if len(cfg.SectionsRead) > 0 {
+		fmt.Fprintf(&b, "config sections: %s\n", strings.Join(cfg.SectionsRead, " β†’ "))
+	}
+
+	if cfg.Workdir != "" {
+		fmt.Fprintf(&b, "workdir: %s\n", cfg.Workdir)
+	}
+
+	if len(cfg.Environ) > 0 {
+		fmt.Fprintln(&b, "environ:")
+		for k, v := range cfg.Environ {
+			fmt.Fprintf(&b, "  %s=%s\n", k, v)
+		}
+	}
+
+	argv := buildArgv(executable(), cfg)
+	fmt.Fprintf(&b, "command: %s\n", strings.Join(argv, " "))
+
+	return b.String()
+}
+
+// executable returns the restic binary name, respecting PIKA_EXECUTABLE.
+func executable() string {
+	if e := os.Getenv("PIKA_EXECUTABLE"); e != "" {
+		return config.ExpandPath(e)
+	}
+	return DefaultExecutable
+}
+
+// buildArgv assembles the full argument vector for the restic process.
+func buildArgv(exe string, cfg *config.ResolvedConfig) []string {
+	argv := []string{exe}
+
+	if cfg.Command != "" {
+		argv = append(argv, cfg.Command)
+	}
+
+	for _, f := range cfg.Flags {
+		argv = append(argv, f.Name)
+		if f.Value != "" {
+			argv = append(argv, f.Value)
+		}
+	}
+
+	argv = append(argv, cfg.Arguments...)
+	return argv
+}
+
+// buildEnv merges extra environment variables into the current process env.
+func buildEnv(extra map[string]string) []string {
+	env := os.Environ()
+	for k, v := range extra {
+		env = append(env, k+"="+v)
+	}
+	return env
+}

internal/restic/exec_test.go πŸ”—

@@ -0,0 +1,132 @@
+package restic
+
+import (
+	"os"
+	"path/filepath"
+	"reflect"
+	"strings"
+	"testing"
+
+	"git.secluded.site/pika/internal/config"
+)
+
+func TestBuildArgv(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		exe  string
+		cfg  *config.ResolvedConfig
+		want []string
+	}{
+		{
+			name: "command flags and args",
+			exe:  "restic",
+			cfg: &config.ResolvedConfig{
+				Command: "backup",
+				Flags: []config.Flag{
+					{Name: "--repo", Value: "/repo"},
+					{Name: "--json"},
+					{Name: "-o", Value: "s3.connections=5"},
+				},
+				Arguments: []string{"/home/alice", "/var/lib"},
+			},
+			want: []string{"restic", "backup", "--repo", "/repo", "--json", "-o", "s3.connections=5", "/home/alice", "/var/lib"},
+		},
+		{
+			name: "no command still includes executable",
+			exe:  "restic",
+			cfg: &config.ResolvedConfig{
+				Flags: []config.Flag{{Name: "--json"}},
+			},
+			want: []string{"restic", "--json"},
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			got := buildArgv(tt.exe, tt.cfg)
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Fatalf("buildArgv() mismatch: got %#v, want %#v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestBuildEnv(t *testing.T) {
+	t.Parallel()
+
+	const key = "PIKA_TEST_BUILD_ENV"
+	const val = "set-by-test"
+
+	env := buildEnv(map[string]string{key: val})
+
+	if !containsEntry(env, key+"="+val) {
+		t.Fatalf("buildEnv() missing %s=%s in %#v", key, val, env)
+	}
+}
+
+func TestDryRunOutput(t *testing.T) {
+	t.Parallel()
+
+	cfg := &config.ResolvedConfig{
+		Command:      "backup",
+		SectionsRead: []string{"global", "global.backup", "home", "home.backup"},
+		Workdir:      "/tmp/work",
+		Environ: map[string]string{
+			"RESTIC_REPOSITORY": "/repos/home",
+			"RESTIC_PASSWORD":   "secret",
+		},
+		Flags: []config.Flag{
+			{Name: "--repo", Value: "/repos/home"},
+			{Name: "--json"},
+		},
+		Arguments: []string{"/home/alice"},
+	}
+
+	output := DryRun(cfg)
+
+	for _, fragment := range []string{
+		"config sections: global β†’ global.backup β†’ home β†’ home.backup",
+		"workdir: /tmp/work",
+		"environ:",
+		"RESTIC_REPOSITORY=/repos/home",
+		"RESTIC_PASSWORD=secret",
+		"command: restic backup --repo /repos/home --json /home/alice",
+	} {
+		if !strings.Contains(output, fragment) {
+			t.Fatalf("DryRun() missing fragment %q in output:\n%s", fragment, output)
+		}
+	}
+}
+
+func TestDryRunExecutableOverride(t *testing.T) {
+	tmpDir := t.TempDir()
+	homeDir := filepath.Join(tmpDir, "home")
+	if err := os.MkdirAll(homeDir, 0o755); err != nil {
+		t.Fatalf("creating temp HOME: %v", err)
+	}
+
+	t.Setenv("HOME", homeDir)
+	t.Setenv("PIKA_EXECUTABLE", "~/bin/restic-alt")
+
+	cfg := &config.ResolvedConfig{Command: "snapshots"}
+	output := DryRun(cfg)
+
+	expectedPrefix := "command: " + filepath.Join(homeDir, "bin", "restic-alt") + " snapshots"
+	if !strings.Contains(output, expectedPrefix) {
+		t.Fatalf("DryRun() command mismatch: want fragment %q in output %q", expectedPrefix, output)
+	}
+}
+
+func containsEntry(values []string, needle string) bool {
+	for _, value := range values {
+		if value == needle {
+			return true
+		}
+	}
+	return false
+}

main.go πŸ”—

@@ -0,0 +1,7 @@
+package main
+
+import "git.secluded.site/pika/cmd"
+
+func main() {
+	cmd.Execute()
+}

mise.toml πŸ”—

@@ -0,0 +1,39 @@
+[tools]
+go = "latest"
+"go:golang.org/x/vuln/cmd/govulncheck" = "latest"
+"go:mvdan.cc/gofumpt" = "latest"
+golangci-lint = "latest"
+
+[tasks.build]
+run = "go build -o pika ."
+
+[tasks.install]
+run = "go install ."
+
+[tasks.test]
+run = "go test -v ./..."
+
+[tasks.fmt]
+run = "gofumpt -w ."
+
+[tasks."fmt:check"]
+run = """
+output=$(gofumpt -d .)
+if [ -n "$output" ]; then
+  echo "$output"
+  echo "Files unformatted; execute 'mise run fmt'"
+  exit 1
+fi
+"""
+
+[tasks.lint]
+run = "golangci-lint run"
+
+[tasks.vuln]
+run = "govulncheck ./..."
+
+[tasks.vet]
+run = "go vet ./..."
+
+[tasks.check]
+depends = ["fmt:check", "vet", "lint", "vuln", "build", "test"]

restic-cli-catalogue.md πŸ”—

@@ -0,0 +1,1055 @@
+# Restic CLI Catalogue
+
+**Version:** 0.18.1 (compiled with go1.25.6 on linux/amd64)
+
+Generated from `restic --help` and `restic <command> --help` output on 2026-03-13.
+
+---
+
+## Table of Contents
+
+- [Global Flags](#global-flags)
+- [Commands](#commands)
+  - [backup](#backup)
+  - [cache](#cache)
+  - [cat](#cat)
+  - [check](#check)
+  - [copy](#copy)
+  - [diff](#diff)
+  - [dump](#dump)
+  - [find](#find)
+  - [forget](#forget)
+  - [init](#init)
+  - [key](#key)
+    - [key add](#key-add)
+    - [key list](#key-list)
+    - [key passwd](#key-passwd)
+    - [key remove](#key-remove)
+  - [list](#list)
+  - [ls](#ls)
+  - [migrate](#migrate)
+  - [mount](#mount)
+  - [prune](#prune)
+  - [recover](#recover)
+  - [repair](#repair)
+    - [repair index](#repair-index)
+    - [repair packs](#repair-packs)
+    - [repair snapshots](#repair-snapshots)
+  - [restore](#restore)
+  - [rewrite](#rewrite)
+  - [snapshots](#snapshots)
+  - [stats](#stats)
+  - [tag](#tag)
+  - [unlock](#unlock)
+- [Advanced / Additional Commands](#advanced--additional-commands)
+  - [features](#features)
+  - [options](#options)
+  - [generate](#generate)
+  - [version](#version)
+
+---
+
+## Global Flags
+
+These flags apply to **all** commands. They are inherited by every subcommand. None are strictly required β€” the repository and password can be supplied via environment variables instead of flags.
+
+| Long | Short | Type | Default | Description |
+|------|-------|------|---------|-------------|
+| `--cacert` | | file (string) | system certificates or `$RESTIC_CACERT` | File to load root certificates from |
+| `--cache-dir` | | directory (string) | system default cache directory | Set the cache directory |
+| `--cleanup-cache` | | bool | `false` | Auto remove old cache directories |
+| `--compression` | | mode (string) | `auto` (or `$RESTIC_COMPRESSION`) | Compression mode (only for repo format v2), one of `auto\|off\|max` |
+| `--help` | `-h` | bool | `false` | Help for restic |
+| `--http-user-agent` | | string | `""` | Set a http user agent for outgoing http requests |
+| `--insecure-no-password` | | bool | `false` | Use an empty password for the repository (insecure) |
+| `--insecure-tls` | | bool | `false` | Skip TLS certificate verification when connecting to the repository (insecure) |
+| `--json` | | bool | `false` | Set output mode to JSON for commands that support it |
+| `--key-hint` | | key (string) | `$RESTIC_KEY_HINT` | Key ID of key to try decrypting first |
+| `--limit-download` | | rate (int, KiB/s) | unlimited | Limits downloads to a maximum rate in KiB/s |
+| `--limit-upload` | | rate (int, KiB/s) | unlimited | Limits uploads to a maximum rate in KiB/s |
+| `--no-cache` | | bool | `false` | Do not use a local cache |
+| `--no-extra-verify` | | bool | `false` | Skip additional verification of data before upload (see documentation) |
+| `--no-lock` | | bool | `false` | Do not lock the repository, this allows some operations on read-only repositories |
+| `--option` | `-o` | key=value (string, repeatable) | β€” | Set extended option (can be specified multiple times) |
+| `--pack-size` | | size (int, MiB) | `$RESTIC_PACK_SIZE` | Set target pack size in MiB, created pack files may be larger |
+| `--password-command` | | command (string) | `$RESTIC_PASSWORD_COMMAND` | Shell command to obtain the repository password from |
+| `--password-file` | `-p` | file (string) | `$RESTIC_PASSWORD_FILE` | File to read the repository password from |
+| `--quiet` | `-q` | bool | `false` | Do not output comprehensive progress report |
+| `--repo` | `-r` | repository (string) | `$RESTIC_REPOSITORY` | Repository to backup to or restore from |
+| `--repository-file` | | file (string) | `$RESTIC_REPOSITORY_FILE` | File to read the repository location from |
+| `--retry-lock` | | duration | no retries | Retry to lock the repository if it is already locked, takes a value like `5m` or `2h` |
+| `--stuck-request-timeout` | | duration | `5m0s` | Duration after which to retry stuck requests |
+| `--tls-client-cert` | | file (string) | `$RESTIC_TLS_CLIENT_CERT` | Path to a file containing PEM encoded TLS client certificate and private key |
+| `--verbose` | `-v` | int (repeatable) | `0` | Be verbose (specify multiple times or a level using `--verbose=n`, max level/times is 2) |
+
+---
+
+## Commands
+
+---
+
+### backup
+
+**Description:** Create a new backup of files and/or directories.
+
+**Usage:** `restic backup [flags] [FILE/DIR] ...`
+
+**Exit codes:** 0 = success, 1 = fatal error (no snapshot created), 3 = some source data could not be read (incomplete snapshot created), 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `FILE/DIR ...` | optional (but at least one source is needed β€” can come from `--files-from*` or `--stdin` instead) | Files and/or directories to back up |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--dry-run` | `-n` | bool | `false` | optional | Do not upload or write any data, just show what would be done |
+| `--exclude` | `-e` | pattern (string, repeatable) | β€” | optional | Exclude a pattern (can be specified multiple times) |
+| `--exclude-caches` | | bool | `false` | optional | Excludes cache directories that are marked with a CACHEDIR.TAG file |
+| `--exclude-file` | | file (string, repeatable) | β€” | optional | Read exclude patterns from a file (can be specified multiple times) |
+| `--exclude-if-present` | | filename[:header] (string, repeatable) | β€” | optional | Exclude contents of directories containing filename (except filename itself) if header matches (can be specified multiple times) |
+| `--exclude-larger-than` | | size (string) | β€” | optional | Max size of the files to be backed up (allowed suffixes: k/K, m/M, g/G, t/T) |
+| `--files-from` | | file (string, repeatable) | β€” | optional | Read the files to backup from file (can be combined with file args; can be specified multiple times) |
+| `--files-from-raw` | | file (string, repeatable) | β€” | optional | Read the files to backup from file (can be combined with file args; can be specified multiple times) |
+| `--files-from-verbatim` | | file (string, repeatable) | β€” | optional | Read the files to backup from file (can be combined with file args; can be specified multiple times) |
+| `--force` | `-f` | bool | `false` | optional | Force re-reading the source files/directories (overrides the "parent" flag) |
+| `--group-by` | `-g` | group (string) | `host,paths` | optional | Group snapshots by host, paths and/or tags, separated by comma (disable grouping with '') |
+| `--help` | `-h` | bool | `false` | optional | Help for backup |
+| `--host` | `-H` | hostname (string) | `$RESTIC_HOST` | optional | Set the hostname for the snapshot manually. To prevent an expensive rescan use the "parent" flag |
+| `--iexclude` | | pattern (string, repeatable) | β€” | optional | Same as --exclude but ignores the casing of filenames |
+| `--iexclude-file` | | file (string, repeatable) | β€” | optional | Same as --exclude-file but ignores casing of filenames in patterns |
+| `--ignore-ctime` | | bool | `false` | optional | Ignore ctime changes when checking for modified files |
+| `--ignore-inode` | | bool | `false` | optional | Ignore inode number and ctime changes when checking for modified files |
+| `--no-scan` | | bool | `false` | optional | Do not run scanner to estimate size of backup |
+| `--one-file-system` | `-x` | bool | `false` | optional | Exclude other file systems, don't cross filesystem boundaries and subvolumes |
+| `--parent` | | snapshot (string) | latest snapshot in group | optional | Use this parent snapshot |
+| `--read-concurrency` | | int | `$RESTIC_READ_CONCURRENCY` or `2` | optional | Read n files concurrently |
+| `--skip-if-unchanged` | | bool | `false` | optional | Skip snapshot creation if identical to parent snapshot |
+| `--stdin` | | bool | `false` | optional | Read backup from stdin |
+| `--stdin-filename` | | filename (string) | `"stdin"` | optional | Filename to use when reading from stdin |
+| `--stdin-from-command` | | bool | `false` | optional | Interpret arguments as command to execute and store its stdout |
+| `--tag` | | tags (string-list, repeatable) | `[]` | optional | Add tags for the new snapshot in the format `tag[,tag,...]` (can be specified multiple times) |
+| `--time` | | time (string) | now | optional | Time of the backup (ex. '2012-11-01 22:08:41') |
+| `--with-atime` | | bool | `false` | optional | Store the atime for all files and directories |
+
+---
+
+### cache
+
+**Description:** Operate on local cache directories.
+
+**Usage:** `restic cache [flags]`
+
+**Exit codes:** 0 = success, 1 = error.
+
+#### Positional Arguments
+
+None.
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--cleanup` | | bool | `false` | optional | Remove old cache directories |
+| `--help` | `-h` | bool | `false` | optional | Help for cache |
+| `--max-age` | | int (days) | `30` | optional | Max age in days for cache directories to be considered old |
+| `--no-size` | | bool | `false` | optional | Do not output the size of the cache directories |
+
+---
+
+### cat
+
+**Description:** Print internal objects to stdout.
+
+**Usage:** `restic cat [flags] [masterkey|config|pack ID|blob ID|snapshot ID|index ID|key ID|lock ID|tree snapshot:subfolder]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| object type + ID | **required** | One of: `masterkey`, `config`, `pack ID`, `blob ID`, `snapshot ID`, `index ID`, `key ID`, `lock ID`, `tree snapshot:subfolder` |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for cat |
+
+---
+
+### check
+
+**Description:** Test the repository for errors and report any errors found. Can also read all data to simulate a restore.
+
+**Usage:** `restic check [flags]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+**Note:** By default, the check command will always load all data directly from the repository and not use a local cache.
+
+#### Positional Arguments
+
+None.
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for check |
+| `--read-data` | | bool | `false` | optional | Read all data blobs |
+| `--read-data-subset` | | subset (string) | β€” | optional | Read a subset of data packs, specified as 'n/t' for specific part, or either 'x%' or 'x.y%' or a size in bytes with suffixes k/K, m/M, g/G, t/T for a random subset |
+| `--with-cache` | | bool | `false` | optional | Use existing cache, only read uncached data from repository |
+
+---
+
+### copy
+
+**Description:** Copy snapshots from one repository to another.
+
+**Usage:** `restic copy [flags] [snapshotID ...]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `snapshotID ...` | optional | Snapshot IDs to copy (if omitted, copies all matching snapshots) |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--from-insecure-no-password` | | bool | `false` | optional | Use an empty password for the source repository (insecure) |
+| `--from-key-hint` | | string | `$RESTIC_FROM_KEY_HINT` | optional | Key ID of key to try decrypting the source repository first |
+| `--from-password-command` | | command (string) | `$RESTIC_FROM_PASSWORD_COMMAND` | optional | Shell command to obtain the source repository password from |
+| `--from-password-file` | | file (string) | `$RESTIC_FROM_PASSWORD_FILE` | optional | File to read the source repository password from |
+| `--from-repo` | | repository (string) | `$RESTIC_FROM_REPOSITORY` | **required** (or env) | Source repository to copy snapshots from |
+| `--from-repository-file` | | file (string) | `$RESTIC_FROM_REPOSITORY_FILE` | optional | File from which to read the source repository location |
+| `--help` | `-h` | bool | `false` | optional | Help for copy |
+| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) |
+| `--path` | | path (string, repeatable) | β€” | optional | Only consider snapshots including this (absolute) path (can be specified multiple times) |
+| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) |
+
+---
+
+### diff
+
+**Description:** Show differences between two snapshots.
+
+**Usage:** `restic diff [flags] snapshotID snapshotID`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+**Output key:** `+` = added, `-` = removed, `U` = metadata updated, `M` = content modified, `T` = type changed, `?` = bitrot detected.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `snapshotID` (first) | **required** | First snapshot (supports `snapshotID:subfolder` syntax) |
+| `snapshotID` (second) | **required** | Second snapshot (supports `snapshotID:subfolder` syntax) |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for diff |
+| `--metadata` | | bool | `false` | optional | Print changes in metadata |
+
+---
+
+### dump
+
+**Description:** Print a backed-up file to stdout. Folders are output as tar (default) or zip.
+
+**Usage:** `restic dump [flags] snapshotID file`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `snapshotID` | **required** | Snapshot ID to dump from (special value `latest` supported; supports `snapshotID:subfolder` syntax) |
+| `file` | **required** | File path within the snapshot to dump (pass `/` for whole snapshot as archive) |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--archive` | `-a` | format (string) | `"tar"` | optional | Set archive format as "tar" or "zip" |
+| `--help` | `-h` | bool | `false` | optional | Help for dump |
+| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host, when snapshot ID "latest" is given (can be specified multiple times) |
+| `--path` | | path (string, repeatable) | β€” | optional | Only consider snapshots including this (absolute) path, when snapshot ID "latest" is given |
+| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...], when snapshot ID "latest" is given |
+| `--target` | `-t` | path (string) | β€” | optional | Write the output to target path |
+
+---
+
+### find
+
+**Description:** Find a file, a directory or restic IDs.
+
+**Usage:** `restic find [flags] PATTERN...`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `PATTERN ...` | **required** | One or more search patterns (file/dir names, or IDs when `--blob`/`--tree`/`--pack` is used) |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--blob` | | bool | `false` | optional | Pattern is a blob-ID |
+| `--help` | `-h` | bool | `false` | optional | Help for find |
+| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) |
+| `--human-readable` | | bool | `false` | optional | Print sizes in human readable format |
+| `--ignore-case` | `-i` | bool | `false` | optional | Ignore case for pattern |
+| `--long` | `-l` | bool | `false` | optional | Use a long listing format showing size and mode |
+| `--newest` | `-N` | string | β€” | optional | Newest modification date/time |
+| `--oldest` | `-O` | string | β€” | optional | Oldest modification date/time |
+| `--pack` | | bool | `false` | optional | Pattern is a pack-ID |
+| `--path` | | path (string, repeatable) | β€” | optional | Only consider snapshots including this (absolute) path |
+| `--reverse` | `-R` | bool | `false` | optional | Reverse sort order oldest to newest |
+| `--show-pack-id` | | bool | `false` | optional | Display the pack-ID the blobs belong to (with --blob or --tree) |
+| `--snapshot` | `-s` | id (string, repeatable) | β€” | optional | Snapshot id to search in (can be given multiple times) |
+| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) |
+| `--tree` | | bool | `false` | optional | Pattern is a tree-ID |
+
+---
+
+### forget
+
+**Description:** Remove snapshots from the repository according to a policy.
+
+**Usage:** `restic forget [flags] [snapshot ID] [...]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+**Note:** This only deletes snapshot objects (references). Use `prune` to remove unreferenced data afterwards.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `snapshot ID ...` | optional | Specific snapshot IDs to forget (if omitted, uses policy flags to select) |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--compact` | `-c` | bool | `false` | optional | Use compact output format |
+| `--dry-run` | `-n` | bool | `false` | optional | Do not delete anything, just print what would be done |
+| `--group-by` | `-g` | group (string) | `host,paths` | optional | Group snapshots by host, paths and/or tags, separated by comma (disable grouping with '') |
+| `--help` | `-h` | bool | `false` | optional | Help for forget |
+| `--host` | | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) |
+| `--keep-daily` | `-d` | int or `unlimited` | β€” | optional | Keep the last n daily snapshots |
+| `--keep-hourly` | `-H` | int or `unlimited` | β€” | optional | Keep the last n hourly snapshots |
+| `--keep-last` | `-l` | int or `unlimited` | β€” | optional | Keep the last n snapshots |
+| `--keep-monthly` | `-m` | int or `unlimited` | β€” | optional | Keep the last n monthly snapshots |
+| `--keep-tag` | | taglist (string-list, repeatable) | `[]` | optional | Keep snapshots with this taglist (can be specified multiple times) |
+| `--keep-weekly` | `-w` | int or `unlimited` | β€” | optional | Keep the last n weekly snapshots |
+| `--keep-within` | | duration (string) | β€” | optional | Keep snapshots newer than duration (eg. 1y5m7d2h) relative to the latest snapshot |
+| `--keep-within-daily` | | duration (string) | β€” | optional | Keep daily snapshots newer than duration relative to the latest snapshot |
+| `--keep-within-hourly` | | duration (string) | β€” | optional | Keep hourly snapshots newer than duration relative to the latest snapshot |
+| `--keep-within-monthly` | | duration (string) | β€” | optional | Keep monthly snapshots newer than duration relative to the latest snapshot |
+| `--keep-within-weekly` | | duration (string) | β€” | optional | Keep weekly snapshots newer than duration relative to the latest snapshot |
+| `--keep-within-yearly` | | duration (string) | β€” | optional | Keep yearly snapshots newer than duration relative to the latest snapshot |
+| `--keep-yearly` | `-y` | int or `unlimited` | β€” | optional | Keep the last n yearly snapshots |
+| `--max-repack-size` | | size (string) | β€” | optional | Stop after repacking this much data in total (allowed suffixes: k/K, m/M, g/G, t/T) |
+| `--max-unused` | | limit (string) | `"5%"` | optional | Tolerate given limit of unused data (absolute value with suffixes, a value in %, or 'unlimited') |
+| `--path` | | path (string, repeatable) | β€” | optional | Only consider snapshots including this (absolute) path |
+| `--prune` | | bool | `false` | optional | Automatically run the 'prune' command if snapshots have been removed |
+| `--repack-cacheable-only` | | bool | `false` | optional | Only repack packs which are cacheable |
+| `--repack-small` | | bool | `false` | optional | Repack pack files below 80% of target pack size |
+| `--repack-smaller-than` | | below-limit (string) | β€” | optional | Pack below-limit packfiles (allowed suffixes: k/K, m/M) |
+| `--repack-uncompressed` | | bool | `false` | optional | Repack all uncompressed data |
+| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) |
+| `--unsafe-allow-remove-all` | | bool | `false` | optional | Allow deleting all snapshots of a snapshot group |
+
+---
+
+### init
+
+**Description:** Initialize a new repository.
+
+**Usage:** `restic init [flags]`
+
+**Exit codes:** 0 = success, 1 = error.
+
+#### Positional Arguments
+
+None.
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--copy-chunker-params` | | bool | `false` | optional | Copy chunker parameters from the secondary repository (useful with the copy command) |
+| `--from-insecure-no-password` | | bool | `false` | optional | Use an empty password for the source repository (insecure) |
+| `--from-key-hint` | | string | `$RESTIC_FROM_KEY_HINT` | optional | Key ID of key to try decrypting the source repository first |
+| `--from-password-command` | | command (string) | `$RESTIC_FROM_PASSWORD_COMMAND` | optional | Shell command to obtain the source repository password from |
+| `--from-password-file` | | file (string) | `$RESTIC_FROM_PASSWORD_FILE` | optional | File to read the source repository password from |
+| `--from-repo` | | repository (string) | `$RESTIC_FROM_REPOSITORY` | optional | Source repository to copy chunker parameters from |
+| `--from-repository-file` | | file (string) | `$RESTIC_FROM_REPOSITORY_FILE` | optional | File from which to read the source repository location |
+| `--help` | `-h` | bool | `false` | optional | Help for init |
+| `--repository-version` | | string | `"stable"` | optional | Repository format version to use, allowed values are a format version, 'latest' and 'stable' |
+
+---
+
+### key
+
+**Description:** Manage keys (passwords). This is a parent command with subcommands.
+
+**Usage:** `restic key [command]`
+
+**Subcommands:** `add`, `list`, `passwd`, `remove`
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for key |
+
+---
+
+### key add
+
+**Description:** Add a new key (password) to the repository; returns the new key ID.
+
+**Usage:** `restic key add [flags]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+None.
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for add |
+| `--host` | | string | β€” | optional | The hostname for new key |
+| `--new-insecure-no-password` | | bool | `false` | optional | Add an empty password for the repository (insecure) |
+| `--new-password-file` | | file (string) | β€” | optional | File from which to read the new password |
+| `--user` | | string | β€” | optional | The username for new key |
+
+---
+
+### key list
+
+**Description:** List keys (passwords) associated with the repository.
+
+**Usage:** `restic key list [flags]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+None.
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for list |
+
+---
+
+### key passwd
+
+**Description:** Change key (password); creates a new key ID and removes the old key ID, returns new key ID.
+
+**Usage:** `restic key passwd [flags]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+None.
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for passwd |
+| `--host` | | string | β€” | optional | The hostname for new key |
+| `--new-insecure-no-password` | | bool | `false` | optional | Add an empty password for the repository (insecure) |
+| `--new-password-file` | | file (string) | β€” | optional | File from which to read the new password |
+| `--user` | | string | β€” | optional | The username for new key |
+
+---
+
+### key remove
+
+**Description:** Remove key ID (password) from the repository. Cannot remove the current key being used to access the repository.
+
+**Usage:** `restic key remove [ID] [flags]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `ID` | **required** | Key ID to remove |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for remove |
+
+---
+
+### list
+
+**Description:** List objects in the repository based on type.
+
+**Usage:** `restic list [flags] [blobs|packs|index|snapshots|keys|locks]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| object type | **required** | One of: `blobs`, `packs`, `index`, `snapshots`, `keys`, `locks` |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for list |
+
+---
+
+### ls
+
+**Description:** List files in a snapshot.
+
+**Usage:** `restic ls [flags] snapshotID [dir...]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `snapshotID` | **required** | Snapshot ID to list (special value `latest` supported) |
+| `dir ...` | optional | Absolute directory paths to filter listing (must start with `/`) |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for ls |
+| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host, when snapshot ID "latest" is given |
+| `--human-readable` | | bool | `false` | optional | Print sizes in human readable format |
+| `--long` | `-l` | bool | `false` | optional | Use a long listing format showing size and mode |
+| `--ncdu` | | bool | `false` | optional | Output NCDU export format (pipe into 'ncdu -f -') |
+| `--path` | | path (string, repeatable) | β€” | optional | Only consider snapshots including this (absolute) path, when snapshot ID "latest" is given |
+| `--recursive` | | bool | `false` | optional | Include files in subfolders of the listed directories |
+| `--reverse` | | bool | `false` | optional | Reverse sorted output |
+| `--sort` | `-s` | mode (string) | `name` | optional | Sort output by (name\|size\|time=mtime\|atime\|ctime\|extension) |
+| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...], when snapshot ID "latest" is given |
+
+---
+
+### migrate
+
+**Description:** Apply migrations to a repository. Lists available migrations if none specified.
+
+**Usage:** `restic migrate [flags] [migration name] [...]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `migration name ...` | optional | Names of migrations to apply (if omitted, lists available migrations) |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--force` | `-f` | bool | `false` | optional | Apply a migration a second time |
+| `--help` | `-h` | bool | `false` | optional | Help for migrate |
+
+---
+
+### mount
+
+**Description:** Mount the repository via FUSE to a directory (read-only).
+
+**Usage:** `restic mount [flags] mountpoint`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `mountpoint` | **required** | Directory to mount the repository on |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--allow-other` | | bool | `false` | optional | Allow other users to access the data in the mounted directory |
+| `--help` | `-h` | bool | `false` | optional | Help for mount |
+| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) |
+| `--no-default-permissions` | | bool | `false` | optional | For 'allow-other', ignore Unix permissions and allow users to read all snapshot files |
+| `--owner-root` | | bool | `false` | optional | Use 'root' as the owner of files and dirs |
+| `--path` | | path (string, repeatable) | β€” | optional | Only consider snapshots including this (absolute) path |
+| `--path-template` | | template (string, repeatable) | see note | optional | Set template for path names (can be specified multiple times). Default templates: `ids/%i`, `snapshots/%T`, `hosts/%h/%T`, `tags/%t/%T`. Patterns: `%i` short ID, `%I` long ID, `%u` username, `%h` hostname, `%t` tags, `%T` timestamp |
+| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] |
+| `--time-template` | | template (string) | `"2006-01-02T15:04:05Z07:00"` | optional | Set template to use for times (Go time format) |
+
+---
+
+### prune
+
+**Description:** Remove unneeded data from the repository.
+
+**Usage:** `restic prune [flags]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+None.
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--dry-run` | `-n` | bool | `false` | optional | Do not modify the repository, just print what would be done |
+| `--help` | `-h` | bool | `false` | optional | Help for prune |
+| `--max-repack-size` | | size (string) | β€” | optional | Stop after repacking this much data in total (allowed suffixes: k/K, m/M, g/G, t/T) |
+| `--max-unused` | | limit (string) | `"5%"` | optional | Tolerate given limit of unused data (absolute value with suffixes, a value in %, or 'unlimited') |
+| `--repack-cacheable-only` | | bool | `false` | optional | Only repack packs which are cacheable |
+| `--repack-small` | | bool | `false` | optional | Repack pack files below 80% of target pack size |
+| `--repack-smaller-than` | | below-limit (string) | β€” | optional | Pack below-limit packfiles (allowed suffixes: k/K, m/M) |
+| `--repack-uncompressed` | | bool | `false` | optional | Repack all uncompressed data |
+| `--unsafe-recover-no-free-space` | | string | β€” | optional | UNSAFE, READ THE DOCUMENTATION BEFORE USING! Try to recover a repository stuck with no free space. Do not use without trying out 'prune --max-repack-size 0' first. |
+
+---
+
+### recover
+
+**Description:** Recover data from the repository not referenced by snapshots. Builds a new snapshot from all directories found in the raw data that are not referenced in an existing snapshot.
+
+**Usage:** `restic recover [flags]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+None.
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for recover |
+
+---
+
+### repair
+
+**Description:** Repair the repository. This is a parent command with subcommands.
+
+**Usage:** `restic repair [command]`
+
+**Subcommands:** `index`, `packs`, `snapshots`
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for repair |
+
+---
+
+### repair index
+
+**Description:** Build a new index based on the pack files in the repository.
+
+**Usage:** `restic repair index [flags]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+None.
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for index |
+| `--read-all-packs` | | bool | `false` | optional | Read all pack files to generate new index from scratch |
+
+---
+
+### repair packs
+
+**Description:** Salvage damaged pack files. Extracts intact blobs from specified pack files, rebuilds the index, and removes the pack files from the repository.
+
+**Usage:** `restic repair packs [packIDs...] [flags]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `packIDs ...` | **required** | Pack IDs to repair |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for packs |
+
+---
+
+### repair snapshots
+
+**Description:** Repair broken snapshots. Scans given snapshots and generates new ones with damaged directories and file contents removed.
+
+**Usage:** `restic repair snapshots [flags] [snapshot ID] [...]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+**Warning:** Repairing and deleting broken snapshots causes data loss! It will remove broken directories and modify broken files. Depends on a correct index β€” run `repair index` first!
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `snapshot ID ...` | optional | Specific snapshot IDs to repair (if omitted, uses filter flags to select) |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--dry-run` | `-n` | bool | `false` | optional | Do not do anything, just print what would be done |
+| `--forget` | | bool | `false` | optional | Remove original snapshots after creating new ones |
+| `--help` | `-h` | bool | `false` | optional | Help for snapshots |
+| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) |
+| `--path` | | path (string, repeatable) | β€” | optional | Only consider snapshots including this (absolute) path |
+| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) |
+
+---
+
+### restore
+
+**Description:** Extract the data from a snapshot.
+
+**Usage:** `restic restore [flags] snapshotID`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `snapshotID` | **required** | Snapshot ID to restore (special value `latest` supported; supports `snapshotID:subfolder` syntax) |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--delete` | | bool | `false` | optional | Delete files from target directory if they do not exist in snapshot. Use '--dry-run -vv' to check what would be deleted |
+| `--dry-run` | | bool | `false` | optional | Do not write any data, just show what would be done |
+| `--exclude` | `-e` | pattern (string, repeatable) | β€” | optional | Exclude a pattern (can be specified multiple times) |
+| `--exclude-file` | | file (string, repeatable) | β€” | optional | Read exclude patterns from a file (can be specified multiple times) |
+| `--exclude-xattr` | | pattern (string, repeatable) | β€” | optional | Exclude xattr by pattern (can be specified multiple times) |
+| `--help` | `-h` | bool | `false` | optional | Help for restore |
+| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host, when snapshot ID "latest" is given |
+| `--iexclude` | | pattern (string, repeatable) | β€” | optional | Same as --exclude but ignores the casing of filenames |
+| `--iexclude-file` | | file (string, repeatable) | β€” | optional | Same as --exclude-file but ignores casing of filenames in patterns |
+| `--iinclude` | | pattern (string, repeatable) | β€” | optional | Same as --include but ignores the casing of filenames |
+| `--iinclude-file` | | file (string, repeatable) | β€” | optional | Same as --include-file but ignores casing of filenames in patterns |
+| `--include` | `-i` | pattern (string, repeatable) | β€” | optional | Include a pattern (can be specified multiple times) |
+| `--include-file` | | file (string, repeatable) | β€” | optional | Read include patterns from a file (can be specified multiple times) |
+| `--include-xattr` | | pattern (string, repeatable) | β€” | optional | Include xattr by pattern (can be specified multiple times) |
+| `--overwrite` | | behavior (string) | `always` | optional | Overwrite behavior, one of (always\|if-changed\|if-newer\|never) |
+| `--path` | | path (string, repeatable) | β€” | optional | Only consider snapshots including this (absolute) path, when snapshot ID "latest" is given |
+| `--sparse` | | bool | `false` | optional | Restore files as sparse |
+| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...], when snapshot ID "latest" is given |
+| `--target` | `-t` | string | β€” | **required** | Directory to extract data to |
+| `--verify` | | bool | `false` | optional | Verify restored files content |
+
+---
+
+### rewrite
+
+**Description:** Rewrite snapshots to exclude unwanted files. Creates new snapshots with the excluded files removed.
+
+**Usage:** `restic rewrite [flags] [snapshotID ...]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+**Note:** The special tag 'rewrite' will be added to new snapshots unless `--forget` is used.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `snapshotID ...` | optional | Specific snapshot IDs to rewrite (if omitted, rewrites all matching snapshots) |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--dry-run` | `-n` | bool | `false` | optional | Do not do anything, just print what would be done |
+| `--exclude` | `-e` | pattern (string, repeatable) | β€” | optional | Exclude a pattern (can be specified multiple times) |
+| `--exclude-file` | | file (string, repeatable) | β€” | optional | Read exclude patterns from a file (can be specified multiple times) |
+| `--forget` | | bool | `false` | optional | Remove original snapshots after creating new ones |
+| `--help` | `-h` | bool | `false` | optional | Help for rewrite |
+| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) |
+| `--iexclude` | | pattern (string, repeatable) | β€” | optional | Same as --exclude but ignores the casing of filenames |
+| `--iexclude-file` | | file (string, repeatable) | β€” | optional | Same as --exclude-file but ignores casing of filenames in patterns |
+| `--new-host` | | string | β€” | optional | Replace hostname |
+| `--new-time` | | string | β€” | optional | Replace time of the backup |
+| `--path` | | path (string, repeatable) | β€” | optional | Only consider snapshots including this (absolute) path |
+| `--snapshot-summary` | `-s` | bool | `false` | optional | Create snapshot summary record if it does not exist |
+| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) |
+
+---
+
+### snapshots
+
+**Description:** List all snapshots stored in the repository.
+
+**Usage:** `restic snapshots [flags] [snapshotID ...]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `snapshotID ...` | optional | Specific snapshot IDs to list (if omitted, lists all matching snapshots) |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--compact` | `-c` | bool | `false` | optional | Use compact output format |
+| `--group-by` | `-g` | group (string) | β€” | optional | Group snapshots by host, paths and/or tags, separated by comma |
+| `--help` | `-h` | bool | `false` | optional | Help for snapshots |
+| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) |
+| `--latest` | | int | β€” | optional | Only show the last n snapshots for each host and path |
+| `--path` | | path (string, repeatable) | β€” | optional | Only consider snapshots including this (absolute) path |
+| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) |
+
+---
+
+### stats
+
+**Description:** Scan the repository and show basic statistics.
+
+**Usage:** `restic stats [flags] [snapshot ID] [...]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+**Modes:**
+- `restore-size` (default): Counts the size of the restored files
+- `files-by-contents`: Counts total size of unique files (unique by contents)
+- `raw-data`: Counts the size of blobs in the repository
+- `blobs-per-file`: Combination of files-by-contents and raw-data
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `snapshot ID ...` | optional | Specific snapshot IDs to compute stats for (if omitted, uses all matching snapshots; `latest` supported) |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for stats |
+| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) |
+| `--mode` | | string | `"restore-size"` | optional | Counting mode: restore-size, files-by-contents, blobs-per-file or raw-data |
+| `--path` | | path (string, repeatable) | β€” | optional | Only consider snapshots including this (absolute) path |
+| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) |
+
+---
+
+### tag
+
+**Description:** Modify tags on existing snapshots. Can set/replace, add to, or remove from the tag set.
+
+**Usage:** `restic tag [flags] [snapshotID ...]`
+
+**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password.
+
+#### Positional Arguments
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `snapshotID ...` | optional | Specific snapshot IDs to modify (if omitted, modifies all matching snapshots) |
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--add` | | tags (string-list, repeatable) | `[]` | optional | Tags to add to existing tags in the format `tag[,tag,...]` (can be given multiple times) |
+| `--help` | `-h` | bool | `false` | optional | Help for tag |
+| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) |
+| `--path` | | path (string, repeatable) | β€” | optional | Only consider snapshots including this (absolute) path |
+| `--remove` | | tags (string-list, repeatable) | `[]` | optional | Tags to remove from existing tags in the format `tag[,tag,...]` (can be given multiple times) |
+| `--set` | | tags (string-list, repeatable) | `[]` | optional | Tags to replace existing tags with in the format `tag[,tag,...]` (can be given multiple times) |
+| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) |
+
+---
+
+### unlock
+
+**Description:** Remove locks other processes created. By default only removes stale locks.
+
+**Usage:** `restic unlock [flags]`
+
+**Exit codes:** 0 = success, 1 = error.
+
+#### Positional Arguments
+
+None.
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for unlock |
+| `--remove-all` | | bool | `false` | optional | Remove all locks, even non-stale ones |
+
+---
+
+## Advanced / Additional Commands
+
+---
+
+### features
+
+**Description:** Print list of supported feature flags.
+
+**Usage:** `restic features [flags]`
+
+**Exit codes:** 0 = success, 1 = error.
+
+**Note:** Feature flags are controlled via the `RESTIC_FEATURES` environment variable (e.g. `featureA=true,featureB=false`). Feature states: alpha (disabled by default), beta (enabled by default), stable (always enabled), deprecated (always disabled).
+
+#### Positional Arguments
+
+None.
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for features |
+
+---
+
+### options
+
+**Description:** Print list of extended options.
+
+**Usage:** `restic options [flags]`
+
+**Exit codes:** 0 = success, 1 = error.
+
+#### Positional Arguments
+
+None.
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--help` | `-h` | bool | `false` | optional | Help for options |
+
+---
+
+### generate
+
+**Description:** Generate manual pages and auto-completion files (bash, fish, zsh, powershell).
+
+**Usage:** `restic generate [flags]`
+
+**Exit codes:** 0 = success, 1 = error.
+
+#### Positional Arguments
+
+None.
+
+#### Command-Specific Flags
+
+| Long | Short | Type | Default | Required | Description |
+|------|-------|------|---------|----------|-------------|
+| `--bash-completion` | | file (string) | β€” | optional | Write bash completion file (`-` for stdout) |
+| `--fish-completion` | | file (string) | β€” | optional | Write fish completion file (`-` for stdout) |
+| `--help` | `-h` | bool | `false` | optional | Help for generate |
+| `--man` | | directory (string) | β€” | optional | Write man pages to directory |
+| `--powershell-completion` | | file (string) | β€” | optional | Write powershell completion file (`-` for stdout) |
+| `--zsh-completion` | | file (string) | β€” | optional | Write zsh completion file (`-` for stdout) |
+
+---
+
+### version
+
+**Description:** Print version information.
+
+**Usage:** `restic version`
+
+#### Positional Arguments
+
+None.
+
+#### Command-Specific Flags
+
+None (only global flags apply).
+
+---
+
+## Quick Reference: Commands Requiring Positional Arguments
+
+| Command | Required Positional Args |
+|---------|------------------------|
+| `backup` | At least one source (FILE/DIR, --files-from*, or --stdin) |
+| `cat` | object type + ID |
+| `diff` | snapshotID snapshotID (exactly two) |
+| `dump` | snapshotID file (exactly two) |
+| `find` | PATTERN... (one or more) |
+| `key remove` | ID (exactly one) |
+| `list` | object type (exactly one) |
+| `ls` | snapshotID (exactly one) |
+| `mount` | mountpoint (exactly one) |
+| `repair packs` | packIDs... (one or more) |
+| `restore` | snapshotID (exactly one) |
+
+## Quick Reference: Required Flags
+
+| Command | Required Flags |
+|---------|---------------|
+| `restore` | `--target` (`-t`) β€” directory to extract data to |
+| `copy` | `--from-repo` (or `$RESTIC_FROM_REPOSITORY`) β€” source repository |
+| All commands needing repo access | `--repo` (`-r`) or `$RESTIC_REPOSITORY` (or `--repository-file` / `$RESTIC_REPOSITORY_FILE`) |
+| All commands needing repo access | A password source: interactive prompt, `--password-file`, `--password-command`, or `$RESTIC_PASSWORD` |