Detailed changes
@@ -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>`
@@ -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
+}
@@ -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)
+ }
+ })
+ }
+}
@@ -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
+}
@@ -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)
+}
@@ -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"
@@ -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
+)
@@ -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=
@@ -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") != ""
+}
@@ -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
+`
@@ -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
+}
@@ -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)
+ }
+}
@@ -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
+}
@@ -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)
+ }
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -0,0 +1,7 @@
+package main
+
+import "git.secluded.site/pika/cmd"
+
+func main() {
+ cmd.Execute()
+}
@@ -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"]
@@ -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` |