From dfa07b5f3a79badd90e2b3b928cfe6107d0fcd02 Mon Sep 17 00:00:00 2001
From: Goutte <antoine@goutenoir.com>
Date: Tue, 4 Apr 2023 04:17:45 +0200
Subject: [PATCH] feat(i18n): implement localization using gettext files

- Recipe to extract new translations from the Go code: `make i18n_extract`
- Embedded `MO` files
- Detect language from environment variables
- Some strings were pluralized
---
 Makefile              |   6 +-
 args.go               |  15 ++--
 args_test.go          |   8 +-
 cobra.go              |   3 +-
 command.go            |  57 ++++++++------
 command_test.go       |  19 ++++-
 completions.go        |  19 +++--
 flag_groups.go        |   3 +-
 go.mod                |   4 +-
 go.sum                |  27 +++++++
 locales/README.md     |  38 ++++++++++
 locales/default.pot   | 163 +++++++++++++++++++++++++++++++++++++++
 locales/default/en.mo | Bin 0 -> 3185 bytes
 locales/default/en.po | 172 ++++++++++++++++++++++++++++++++++++++++++
 locales/default/fr.mo | Bin 0 -> 3415 bytes
 locales/default/fr.po | 172 ++++++++++++++++++++++++++++++++++++++++++
 localizer.go          | 138 +++++++++++++++++++++++++++++++++
 localizer_test.go     | 161 +++++++++++++++++++++++++++++++++++++++
 18 files changed, 955 insertions(+), 50 deletions(-)
 create mode 100644 locales/README.md
 create mode 100644 locales/default.pot
 create mode 100644 locales/default/en.mo
 create mode 100644 locales/default/en.po
 create mode 100644 locales/default/fr.mo
 create mode 100644 locales/default/fr.po
 create mode 100644 localizer.go
 create mode 100644 localizer_test.go

diff --git a/Makefile b/Makefile
index 0da8d7aa..53262b6e 100644
--- a/Makefile
+++ b/Makefile
@@ -21,7 +21,7 @@ lint:
 
 test: install_deps
 	$(info ******************** running tests ********************)
-	go test -v ./...
+	LANGUAGE="en" go test -v ./...
 
 richtest: install_deps
 	$(info ******************** running tests with kyoh86/richgo ********************)
@@ -33,3 +33,7 @@ install_deps:
 
 clean:
 	rm -rf $(BIN)
+
+i18n_extract:
+	$(info ******************** extracting translation files ********************)
+	xgotext -v -in . -out locales
diff --git a/args.go b/args.go
index ed1e70ce..6170a58e 100644
--- a/args.go
+++ b/args.go
@@ -16,6 +16,7 @@ package cobra
 
 import (
 	"fmt"
+	"github.com/leonelquinteros/gotext"
 	"strings"
 )
 
@@ -33,7 +34,7 @@ func legacyArgs(cmd *Command, args []string) error {
 
 	// root command with subcommands, do subcommand checking.
 	if !cmd.HasParent() && len(args) > 0 {
-		return fmt.Errorf("unknown command %q for %q%s", args[0], cmd.CommandPath(), cmd.findSuggestions(args[0]))
+		return fmt.Errorf(gotext.Get("LegacyArgsValidationError"), args[0], cmd.CommandPath(), cmd.findSuggestions(args[0]))
 	}
 	return nil
 }
@@ -41,7 +42,7 @@ func legacyArgs(cmd *Command, args []string) error {
 // NoArgs returns an error if any args are included.
 func NoArgs(cmd *Command, args []string) error {
 	if len(args) > 0 {
-		return fmt.Errorf("unknown command %q for %q", args[0], cmd.CommandPath())
+		return fmt.Errorf(gotext.Get("NoArgsValidationError"), args[0], cmd.CommandPath())
 	}
 	return nil
 }
@@ -58,7 +59,7 @@ func OnlyValidArgs(cmd *Command, args []string) error {
 		}
 		for _, v := range args {
 			if !stringInSlice(v, validArgs) {
-				return fmt.Errorf("invalid argument %q for %q%s", v, cmd.CommandPath(), cmd.findSuggestions(args[0]))
+				return fmt.Errorf(gotext.Get("OnlyValidArgsValidationError"), v, cmd.CommandPath(), cmd.findSuggestions(args[0]))
 			}
 		}
 	}
@@ -74,7 +75,7 @@ func ArbitraryArgs(cmd *Command, args []string) error {
 func MinimumNArgs(n int) PositionalArgs {
 	return func(cmd *Command, args []string) error {
 		if len(args) < n {
-			return fmt.Errorf("requires at least %d arg(s), only received %d", n, len(args))
+			return fmt.Errorf(gotext.GetN("MinimumNArgsValidationError", "MinimumNArgsValidationErrorPlural", n), n, len(args))
 		}
 		return nil
 	}
@@ -84,7 +85,7 @@ func MinimumNArgs(n int) PositionalArgs {
 func MaximumNArgs(n int) PositionalArgs {
 	return func(cmd *Command, args []string) error {
 		if len(args) > n {
-			return fmt.Errorf("accepts at most %d arg(s), received %d", n, len(args))
+			return fmt.Errorf(gotext.GetN("MaximumNArgsValidationError", "MaximumNArgsValidationErrorPlural", n), n, len(args))
 		}
 		return nil
 	}
@@ -94,7 +95,7 @@ func MaximumNArgs(n int) PositionalArgs {
 func ExactArgs(n int) PositionalArgs {
 	return func(cmd *Command, args []string) error {
 		if len(args) != n {
-			return fmt.Errorf("accepts %d arg(s), received %d", n, len(args))
+			return fmt.Errorf(gotext.GetN("ExactArgsValidationError", "ExactArgsValidationErrorPlural", n), n, len(args))
 		}
 		return nil
 	}
@@ -104,7 +105,7 @@ func ExactArgs(n int) PositionalArgs {
 func RangeArgs(min int, max int) PositionalArgs {
 	return func(cmd *Command, args []string) error {
 		if len(args) < min || len(args) > max {
-			return fmt.Errorf("accepts between %d and %d arg(s), received %d", min, max, len(args))
+			return fmt.Errorf(gotext.GetN("RangeArgsValidationError", "RangeArgsValidationErrorPlural", max), min, max, len(args))
 		}
 		return nil
 	}
diff --git a/args_test.go b/args_test.go
index 90d174cc..c156b475 100644
--- a/args_test.go
+++ b/args_test.go
@@ -68,7 +68,7 @@ func minimumNArgsWithLessArgs(err error, t *testing.T) {
 		t.Fatal("Expected an error")
 	}
 	got := err.Error()
-	expected := "requires at least 2 arg(s), only received 1"
+	expected := "requires at least 2 args, only received 1"
 	if got != expected {
 		t.Fatalf("Expected %q, got %q", expected, got)
 	}
@@ -79,7 +79,7 @@ func maximumNArgsWithMoreArgs(err error, t *testing.T) {
 		t.Fatal("Expected an error")
 	}
 	got := err.Error()
-	expected := "accepts at most 2 arg(s), received 3"
+	expected := "accepts at most 2 args, received 3"
 	if got != expected {
 		t.Fatalf("Expected %q, got %q", expected, got)
 	}
@@ -90,7 +90,7 @@ func exactArgsWithInvalidCount(err error, t *testing.T) {
 		t.Fatal("Expected an error")
 	}
 	got := err.Error()
-	expected := "accepts 2 arg(s), received 3"
+	expected := "accepts 2 args, received 3"
 	if got != expected {
 		t.Fatalf("Expected %q, got %q", expected, got)
 	}
@@ -101,7 +101,7 @@ func rangeArgsWithInvalidCount(err error, t *testing.T) {
 		t.Fatal("Expected an error")
 	}
 	got := err.Error()
-	expected := "accepts between 2 and 4 arg(s), received 1"
+	expected := "accepts between 2 and 4 args, received 1"
 	if got != expected {
 		t.Fatalf("Expected %q, got %q", expected, got)
 	}
diff --git a/cobra.go b/cobra.go
index d9cd2414..38581704 100644
--- a/cobra.go
+++ b/cobra.go
@@ -19,6 +19,7 @@ package cobra
 
 import (
 	"fmt"
+	"github.com/leonelquinteros/gotext"
 	"io"
 	"os"
 	"reflect"
@@ -234,7 +235,7 @@ func stringInSlice(a string, list []string) bool {
 // CheckErr prints the msg with the prefix 'Error:' and exits with error code 1. If the msg is nil, it does nothing.
 func CheckErr(msg interface{}) {
 	if msg != nil {
-		fmt.Fprintln(os.Stderr, "Error:", msg)
+		fmt.Fprintln(os.Stderr, gotext.Get("Error")+":", msg)
 		os.Exit(1)
 	}
 }
diff --git a/command.go b/command.go
index 6904bfba..1c3e048a 100644
--- a/command.go
+++ b/command.go
@@ -21,8 +21,10 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"github.com/leonelquinteros/gotext"
 	"io"
 	"os"
+	"path/filepath"
 	"sort"
 	"strings"
 
@@ -46,6 +48,12 @@ type Group struct {
 	Title string
 }
 
+// CommandUsageTemplateData is the data passed to the template of command usage
+type CommandUsageTemplateData struct {
+	*Command
+	I18n *i18nCommandGlossary
+}
+
 // Command is just that, a command for your application.
 // E.g.  'go run ...' - 'run' is the command. Cobra requires
 // you to define the usage and description as part of your command
@@ -438,7 +446,11 @@ func (c *Command) UsageFunc() (f func(*Command) error) {
 	return func(c *Command) error {
 		c.mergePersistentFlags()
 		fn := c.getUsageTemplateFunc()
-		err := fn(c.OutOrStderr(), c)
+		data := CommandUsageTemplateData{
+			Command: c,
+			I18n:    getCommandGlossary(),
+		}
+		err := fn(c.OutOrStderr(), data)
 		if err != nil {
 			c.PrintErrln(err)
 		}
@@ -774,7 +786,7 @@ func (c *Command) findSuggestions(arg string) string {
 	}
 	var sb strings.Builder
 	if suggestions := c.SuggestionsFor(arg); len(suggestions) > 0 {
-		sb.WriteString("\n\nDid you mean this?\n")
+		sb.WriteString("\n\n" + gotext.Get("DidYouMeanThis") + "\n")
 		for _, s := range suggestions {
 			_, _ = fmt.Fprintf(&sb, "\t%v\n", s)
 		}
@@ -895,7 +907,7 @@ func (c *Command) execute(a []string) (err error) {
 	}
 
 	if len(c.Deprecated) > 0 {
-		c.Printf("Command %q is deprecated, %s\n", c.Name(), c.Deprecated)
+		c.Printf(gotext.Get("CommandDeprecatedWarning")+"\n", c.Name(), c.Deprecated)
 	}
 
 	// initialize help and version flag at the last point possible to allow for user
@@ -1118,7 +1130,7 @@ func (c *Command) ExecuteC() (cmd *Command, err error) {
 		}
 		if !c.SilenceErrors {
 			c.PrintErrln(c.ErrPrefix(), err.Error())
-			c.PrintErrf("Run '%v --help' for usage.\n", c.CommandPath())
+			c.PrintErrf(gotext.Get("RunHelpTip")+"\n", c.CommandPath())
 		}
 		return c, err
 	}
@@ -1184,7 +1196,7 @@ func (c *Command) ValidateRequiredFlags() error {
 	})
 
 	if len(missingFlagNames) > 0 {
-		return fmt.Errorf(`required flag(s) "%s" not set`, strings.Join(missingFlagNames, `", "`))
+		return fmt.Errorf(gotext.GetN("FlagNotSetError", "FlagNotSetErrorPlural", len(missingFlagNames)), strings.Join(missingFlagNames, `", "`))
 	}
 	return nil
 }
@@ -1208,10 +1220,10 @@ func (c *Command) checkCommandGroups() {
 func (c *Command) InitDefaultHelpFlag() {
 	c.mergePersistentFlags()
 	if c.Flags().Lookup(helpFlagName) == nil {
-		usage := "help for "
+		usage := gotext.Get("HelpFor") + " "
 		name := c.DisplayName()
 		if name == "" {
-			usage += "this command"
+			usage += gotext.Get("ThisCommand")
 		} else {
 			usage += name
 		}
@@ -1231,9 +1243,9 @@ func (c *Command) InitDefaultVersionFlag() {
 
 	c.mergePersistentFlags()
 	if c.Flags().Lookup("version") == nil {
-		usage := "version for "
+		usage := gotext.Get("VersionFor") + " "
 		if c.Name() == "" {
-			usage += "this command"
+			usage += gotext.Get("ThisCommand")
 		} else {
 			usage += c.DisplayName()
 		}
@@ -1256,10 +1268,9 @@ func (c *Command) InitDefaultHelpCmd() {
 
 	if c.helpCommand == nil {
 		c.helpCommand = &Command{
-			Use:   "help [command]",
-			Short: "Help about any command",
-			Long: `Help provides help for any command in the application.
-Simply type ` + c.DisplayName() + ` help [path to command] for full details.`,
+			Use:   fmt.Sprintf("help [%s]", gotext.Get("command")),
+			Short: gotext.Get("CommandHelpShort"),
+			Long:  fmt.Sprintf(gotext.Get("CommandHelpLong"), c.DisplayName()+fmt.Sprintf(" help [%s]", gotext.Get("command"))),
 			ValidArgsFunction: func(c *Command, args []string, toComplete string) ([]string, ShellCompDirective) {
 				var completions []string
 				cmd, _, e := c.Root().Find(args)
@@ -1282,7 +1293,7 @@ Simply type ` + c.DisplayName() + ` help [path to command] for full details.`,
 			Run: func(c *Command, args []string) {
 				cmd, _, e := c.Root().Find(args)
 				if cmd == nil || e != nil {
-					c.Printf("Unknown help topic %#q\n", args)
+					c.Printf(gotext.Get("CommandHelpUnknownTopicError")+"\n", args)
 					CheckErr(c.Root().Usage())
 				} else {
 					cmd.InitDefaultHelpFlag()    // make possible 'help' flag to be shown
@@ -1923,35 +1934,35 @@ type tmplFunc struct {
 	fn   func(io.Writer, interface{}) error
 }
 
-var defaultUsageTemplate = `Usage:{{if .Runnable}}
+var defaultUsageTemplate = `{{.I18n.SectionUsage}}:{{if .Runnable}}
   {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
   {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
 
-Aliases:
+{{.I18n.SectionAliases}}:
   {{.NameAndAliases}}{{end}}{{if .HasExample}}
 
-Examples:
+{{.I18n.SectionExamples}}:
 {{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
 
-Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
+{{.I18n.SectionAvailableCommands}}:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
   {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
 
 {{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
   {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
 
-Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
+{{.I18n.SectionAdditionalCommands}}:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
   {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
 
-Flags:
+{{.I18n.SectionFlags}}:
 {{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
 
-Global Flags:
+{{.I18n.SectionGlobalFlags}}:
 {{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
 
-Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
+{{.I18n.SectionAdditionalHelpTopics}}:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
   {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
 
-Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
+{{.I18n.Use}} "{{.CommandPath}} [command] --help" {{.I18n.ForInfoAboutCommand}}.{{end}}
 `
 
 // defaultUsageFunc is equivalent to executing defaultUsageTemplate. The two should be changed in sync.
diff --git a/command_test.go b/command_test.go
index 0b0d6c66..b50afda5 100644
--- a/command_test.go
+++ b/command_test.go
@@ -850,6 +850,21 @@ func TestPersistentFlagsOnChild(t *testing.T) {
 	}
 }
 
+func TestRequiredFlag(t *testing.T) {
+	c := &Command{Use: "c", Run: emptyRun}
+	c.Flags().String("foo1", "", "")
+	assertNoErr(t, c.MarkFlagRequired("foo1"))
+
+	expected := fmt.Sprintf("required flag %q is not set", "foo1")
+
+	_, err := executeCommand(c)
+	got := err.Error()
+
+	if got != expected {
+		t.Errorf("Expected error: %q, got: %q", expected, got)
+	}
+}
+
 func TestRequiredFlags(t *testing.T) {
 	c := &Command{Use: "c", Run: emptyRun}
 	c.Flags().String("foo1", "", "")
@@ -858,7 +873,7 @@ func TestRequiredFlags(t *testing.T) {
 	assertNoErr(t, c.MarkFlagRequired("foo2"))
 	c.Flags().String("bar", "", "")
 
-	expected := fmt.Sprintf("required flag(s) %q, %q not set", "foo1", "foo2")
+	expected := fmt.Sprintf("required flags %q, %q are not set", "foo1", "foo2")
 
 	_, err := executeCommand(c)
 	got := err.Error()
@@ -885,7 +900,7 @@ func TestPersistentRequiredFlags(t *testing.T) {
 
 	parent.AddCommand(child)
 
-	expected := fmt.Sprintf("required flag(s) %q, %q, %q, %q not set", "bar1", "bar2", "foo1", "foo2")
+	expected := fmt.Sprintf("required flags %q, %q, %q, %q are not set", "bar1", "bar2", "foo1", "foo2")
 
 	_, err := executeCommand(parent, "child")
 	if err.Error() != expected {
diff --git a/completions.go b/completions.go
index cd899c73..f1e7b2b6 100644
--- a/completions.go
+++ b/completions.go
@@ -16,6 +16,7 @@ package cobra
 
 import (
 	"fmt"
+	"github.com/leonelquinteros/gotext"
 	"os"
 	"regexp"
 	"strconv"
@@ -50,7 +51,7 @@ type flagCompError struct {
 }
 
 func (e *flagCompError) Error() string {
-	return "Subcommand '" + e.subCommand + "' does not support flag '" + e.flagName + "'"
+	return fmt.Sprintf(gotext.Get("CompletionSubcommandUnsupportedFlagError"), e.subCommand, e.flagName)
 }
 
 const (
@@ -99,7 +100,6 @@ const (
 	// Constants for the completion command
 	compCmdName              = "completion"
 	compCmdNoDescFlagName    = "no-descriptions"
-	compCmdNoDescFlagDesc    = "disable completion descriptions"
 	compCmdNoDescFlagDefault = false
 )
 
@@ -213,9 +213,8 @@ func (c *Command) initCompleteCmd(args []string) {
 		Hidden:                true,
 		DisableFlagParsing:    true,
 		Args:                  MinimumNArgs(1),
-		Short:                 "Request shell completion choices for the specified command-line",
-		Long: fmt.Sprintf("%[2]s is a special command that is used by the shell completion logic\n%[1]s",
-			"to request completion choices for the specified command-line.", ShellCompRequestCmd),
+		Short:                 gotext.Get("CompletionCommandShellShort"),
+		Long:                  fmt.Sprintf(gotext.Get("CompletionCommandShellLong"), ShellCompRequestCmd),
 		Run: func(cmd *Command, args []string) {
 			finalCmd, completions, directive, err := cmd.getCompletions(args)
 			if err != nil {
@@ -267,7 +266,7 @@ func (c *Command) initCompleteCmd(args []string) {
 
 			// Print some helpful info to stderr for the user to understand.
 			// Output from stderr must be ignored by the completion script.
-			fmt.Fprintf(finalCmd.ErrOrStderr(), "Completion ended with directive: %s\n", directive.string())
+			fmt.Fprintf(finalCmd.ErrOrStderr(), fmt.Sprintf(gotext.Get("CompletionCommandShellDirectiveTip"), directive.string())+"\n")
 		},
 	}
 	c.AddCommand(completeCmd)
@@ -773,7 +772,7 @@ You will need to start a new shell for this setup to take effect.
 		},
 	}
 	if haveNoDescFlag {
-		bash.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, compCmdNoDescFlagDesc)
+		bash.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, gotext.Get("CompletionSubcommandNoDescFlagDesc"))
 	}
 
 	zsh := &Command{
@@ -812,7 +811,7 @@ You will need to start a new shell for this setup to take effect.
 		},
 	}
 	if haveNoDescFlag {
-		zsh.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, compCmdNoDescFlagDesc)
+		zsh.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, gotext.Get("CompletionSubcommandNoDescFlagDesc"))
 	}
 
 	fish := &Command{
@@ -837,7 +836,7 @@ You will need to start a new shell for this setup to take effect.
 		},
 	}
 	if haveNoDescFlag {
-		fish.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, compCmdNoDescFlagDesc)
+		fish.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, gotext.Get("CompletionSubcommandNoDescFlagDesc"))
 	}
 
 	powershell := &Command{
@@ -863,7 +862,7 @@ to your powershell profile.
 		},
 	}
 	if haveNoDescFlag {
-		powershell.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, compCmdNoDescFlagDesc)
+		powershell.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, gotext.Get("CompletionSubcommandNoDescFlagDesc"))
 	}
 
 	completionCmd.AddCommand(bash, zsh, fish, powershell)
diff --git a/flag_groups.go b/flag_groups.go
index 560612fd..4eea0e1e 100644
--- a/flag_groups.go
+++ b/flag_groups.go
@@ -16,6 +16,7 @@ package cobra
 
 import (
 	"fmt"
+	"github.com/leonelquinteros/gotext"
 	"sort"
 	"strings"
 
@@ -201,7 +202,7 @@ func validateExclusiveFlagGroups(data map[string]map[string]bool) error {
 
 		// Sort values, so they can be tested/scripted against consistently.
 		sort.Strings(set)
-		return fmt.Errorf("if any flags in the group [%v] are set none of the others can be; %v were all set", flagList, set)
+		return fmt.Errorf(gotext.Get("ExclusiveFlagsValidationError"), flagList, set)
 	}
 	return nil
 }
diff --git a/go.mod b/go.mod
index 3959690c..64443192 100644
--- a/go.mod
+++ b/go.mod
@@ -1,10 +1,12 @@
 module github.com/spf13/cobra
 
-go 1.15
+go 1.16
 
 require (
 	github.com/cpuguy83/go-md2man/v2 v2.0.6
 	github.com/inconshreveable/mousetrap v1.1.0
+	github.com/leonelquinteros/gotext v1.5.3-0.20231003122255-12a99145a351
 	github.com/spf13/pflag v1.0.5
+	golang.org/x/text v0.4.0
 	gopkg.in/yaml.v3 v3.0.1
 )
diff --git a/go.sum b/go.sum
index 1be80282..84bef12f 100644
--- a/go.sum
+++ b/go.sum
@@ -2,10 +2,37 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/leonelquinteros/gotext v1.5.3-0.20231003122255-12a99145a351 h1:Rk+RkO4xEZMkEok69CbeA6cgXKyVCsgF3qGGGR46pd8=
+github.com/leonelquinteros/gotext v1.5.3-0.20231003122255-12a99145a351/go.mod h1:qQRISjoonXYFdRGrTG1LARQ38Gpibad0IPeB4hpvyyM=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/locales/README.md b/locales/README.md
new file mode 100644
index 00000000..90da71d5
--- /dev/null
+++ b/locales/README.md
@@ -0,0 +1,38 @@
+# Locales
+
+Localization uses embedded _gettext_ files, defaulting to English
+when locale cannot be guessed from environment variables.
+
+
+## Development Flow
+
+1. Add calls to `gotext.Get(…)` somewhere in the codebase
+2. Run `make i18n_extract`
+3. Update the `PO` files with some software like [Poedit]
+4. Make sure your software has also updated the `MO` files
+
+[Poedit]: https://poedit.net/
+
+## Overview
+
+### POT files
+
+The `*.pot` file(s) are automatically generated by the following command :
+
+    make i18n_extract
+
+They are named `<domain>.pot`, and when the domain is not specified, it is `default`.
+
+### PO & MO files
+
+The actual translation files, in _gettext_ format (`*.po` and `*.mo`), are in the directory `<domain>/`.
+They are named `<language>.po` and `<language>.mo`.
+
+The supported `<language>` formats are :
+- [ISO 639-3](https://fr.wikipedia.org/wiki/ISO_639-3) _(eg: eng, fra, …)_
+- [BCP 47](https://fr.wiktionary.org/wiki/Wiktionnaire:BCP_47/language-2)  _(eg: en, fr, …)_
+
+The `*.po` files are plain text, and are the authoritative sources of translations.
+
+The `*.mo` files are the ones actually packaged in cobra as embedded files, because they are smaller.
+
diff --git a/locales/default.pot b/locales/default.pot
new file mode 100644
index 00000000..6644be0b
--- /dev/null
+++ b/locales/default.pot
@@ -0,0 +1,163 @@
+msgid ""
+msgstr ""
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: \n"
+"X-Generator: xgotext\n"
+
+#: command.go:891
+msgid "CommandDeprecatedWarning"
+msgstr ""
+
+#: command.go:1249
+msgid "CommandHelpLong"
+msgstr ""
+
+#: command.go:1248
+msgid "CommandHelpShort"
+msgstr ""
+
+#: command.go:1272
+msgid "CommandHelpUnknownTopicError"
+msgstr ""
+
+#: completions.go:250
+msgid "CompletionCommandShellDirectiveTip"
+msgstr ""
+
+#: completions.go:203
+msgid "CompletionCommandShellLong"
+msgstr ""
+
+#: completions.go:202
+msgid "CompletionCommandShellShort"
+msgstr ""
+
+#: completions.go:745
+#: completions.go:784
+#: completions.go:809
+#: completions.go:835
+msgid "CompletionSubcommandNoDescFlagDesc"
+msgstr ""
+
+#: completions.go:52
+msgid "CompletionSubcommandUnsupportedFlagError"
+msgstr ""
+
+#: command.go:770
+msgid "DidYouMeanThis"
+msgstr ""
+
+#: cobra.go:234
+msgid "Error"
+msgstr ""
+
+#: args.go:98
+msgid "ExactArgsValidationError"
+msgid_plural "ExactArgsValidationErrorPlural"
+msgstr[0] ""
+msgstr[1] ""
+
+#: flag_groups.go:205
+msgid "ExclusiveFlagsValidationError"
+msgstr ""
+
+#: command.go:1176
+msgid "FlagNotSetError"
+msgid_plural "FlagNotSetErrorPlural"
+msgstr[0] ""
+msgstr[1] ""
+
+#: localizer.go:66
+msgid "ForInfoAboutCommand"
+msgstr ""
+
+#: command.go:1200
+msgid "HelpFor"
+msgstr ""
+
+#: args.go:37
+msgid "LegacyArgsValidationError"
+msgstr ""
+
+#: args.go:88
+msgid "MaximumNArgsValidationError"
+msgid_plural "MaximumNArgsValidationErrorPlural"
+msgstr[0] ""
+msgstr[1] ""
+
+#: args.go:78
+msgid "MinimumNArgsValidationError"
+msgid_plural "MinimumNArgsValidationErrorPlural"
+msgstr[0] ""
+msgstr[1] ""
+
+#: args.go:45
+msgid "NoArgsValidationError"
+msgstr ""
+
+#: args.go:62
+msgid "OnlyValidArgsValidationError"
+msgstr ""
+
+#: args.go:108
+msgid "RangeArgsValidationError"
+msgid_plural "RangeArgsValidationErrorPlural"
+msgstr[0] ""
+msgstr[1] ""
+
+#: command.go:1110
+msgid "RunHelpTip"
+msgstr ""
+
+#: localizer.go:61
+msgid "SectionAdditionalCommands"
+msgstr ""
+
+#: localizer.go:64
+msgid "SectionAdditionalHelpTopics"
+msgstr ""
+
+#: localizer.go:58
+msgid "SectionAliases"
+msgstr ""
+
+#: localizer.go:60
+msgid "SectionAvailableCommands"
+msgstr ""
+
+#: localizer.go:59
+msgid "SectionExamples"
+msgstr ""
+
+#: localizer.go:62
+msgid "SectionFlags"
+msgstr ""
+
+#: localizer.go:63
+msgid "SectionGlobalFlags"
+msgstr ""
+
+#: localizer.go:57
+msgid "SectionUsage"
+msgstr ""
+
+#: command.go:1202
+#: command.go:1224
+msgid "ThisCommand"
+msgstr ""
+
+#: localizer.go:65
+msgid "Use"
+msgstr ""
+
+#: command.go:1222
+msgid "VersionFor"
+msgstr ""
+
+#: command.go:1247
+#: command.go:1249
+msgid "command"
+msgstr ""
\ No newline at end of file
diff --git a/locales/default/en.mo b/locales/default/en.mo
new file mode 100644
index 0000000000000000000000000000000000000000..b7ab9e04ed1205546edb24722622907e60988406
GIT binary patch
literal 3185
zcmb7`NpBoQ6vr!&unbE`0)#D4aWaaD+=ESo2sTz^c}a|H$FV&L0dlD6u9+!PQ`PRO
z9^2vo%7vUbAaOvz4NiOlPKXN<XO18a$P%9b#F78&WoEoZq?FDtRj=ype$F4q4}Zb1
z&f<Fs-`j^6djtI70sMtE`XFP^fn(rN@E!0e@I8?3-v)=kyWk7p_uz}*&&Bzl;4t<_
z4m0*7SObrMli-`+G<XX92&{r%f+xTq!H2<L!RNu>z)A4$f)}9V8SK|V^5avG^nD9f
zz~4c7_ZLY19C|3Xe+r~{MnH;Z7JMA6gU7)}u}{Iru)hN$1ojz7dOruruW!JA!0$k^
z|1}P8fIooGg2x`oISih`{u1~J7=o9;ZSWQF?4yjG2WLRCZ$Y@s?tmA;yWlnOCy?Sl
z0VmFa*FdOXU6A7Y7`y_01zrcYL5hC@L6CoSkj@j3>hudpdEN%e-+w`h>nM^zew+kJ
z|9O!7xd7r1d#l*b75fE{@^ce}3U&)5JqkoCWp7AdxlZCAa4pL{ozY$DpVPD%Sk!(<
zZMG<Xv?vaWo$S#f|7cMjDF@U?w9q09>B<(xS}ux%Vy5*pK8l+b_0bdfC|}eY$M8|F
zQarRczL)V)-?M4k=?D|e$T*Rq@G`m~5~ED36kL%yUa&j&t8JV3!3%YB+t>}WW@8o3
zCW%c*7;EX3HHCrIw$yq?!H8E~xu)U+u9UX+dEGNx$X3%vn0vouXQT_~v}h3rH21I6
zjZ5Pge#?mN<zCLH=%!5<r7&x4<yd|=yD36Hm9*S-p;aU(hI<Q^b(#pxW;a8fIs`}h
z_dKS<CF@tE&z0@k7dqx_GH;r8s$o-KB#u!9aLyKFON3kdIkqS^RVVE%-KVUuxv0#+
zuMd_Lub1q8R<9bpm8EY#;fgRVc|U@})Jke77t}ke)JE1!MUf&3T_nrxm1Iw-G5a#D
zgp>VkS12tSTJF|?MxZ9{UCioQ8n~=&L+BkxbthVqQ74t9t2@cA%f!J&s$iIRD_c(N
zhX^2;kAgxv&MV7TgB96TbT^p64<e2hg!92#B8=0*+ax<NtrS**H7Pm+`*AK!WpRFS
zwy$ukHd>js#!CYoTQT%`x#?evwNPe)hi#EK=_l)JbHT;_J+jr5Nib_d8(|8J^NS7T
zD|rC|#L#i$+{9VKyUEulc>d4HVZ%>P^08MZD(?oDrIAVQ@3NIB5Wi3xt&LU6fx)Zm
zTsa=~Ce8@2x~$82oY=04q~jPCF>l(03$w+Gv~gv)Z%Z!XSSySwB&t?fRVdLG_XrSL
zvM0?{YiReVs;lLC1^qk?>SD#j$_;sH4_-a9J}^0u&`3snL-{t3%CR+0p27^=7u>}%
zR6_UM^KIctk~+L=z$f_X5J+~T7+GA~mI^E0@<gtu(s_Da%EGo)A%ds4v&Uqhsi3XM
zK%kY8HMUYb-J|^fDPWOu)b4|(k@GN7G3C@T*PE;ST-BZ9k%d#ndR|OW-oz}+L<|Fz
zZ`qW0Fhb$8a+iuBD#B34-tlV0Mba8UxS_;ojBvnq%G^$&YG!pJ6UcL3@m6Bfn7?1`
z-hwhDReHE%B)82hPZr5a91r0_Lr(B&mv2a16UaQSGRiax80BscRoxI(DT`;f)bUJA
zMoKvqqhoQPFqe}~)^@z;r?M`rY*&`%zX(eKwK^%NuA^_tu+b$Qi^|_CH9PO@N>LFI
zdeCz%F(Z1aEXMARR3CH|8u#2i(08UxmwpveCDRV($-ZGVq~DOzWWE@x^TCDR_v)@7
zG3?P|T^<CK+;dqPQyQ=})}Q)(I-jU+@aziBj_ki8I4(y0R5|1OFM88GpW*pD=lLd|
W<kZh)B^izKZa&J%7gqd8vi|_~Ab<@3

literal 0
HcmV?d00001

diff --git a/locales/default/en.po b/locales/default/en.po
new file mode 100644
index 00000000..a676bb5c
--- /dev/null
+++ b/locales/default/en.po
@@ -0,0 +1,172 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: \n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: en\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"X-Generator: Poedit 3.0.1\n"
+
+#: command.go:891
+msgid "CommandDeprecatedWarning"
+msgstr "Command %q is deprecated, %s"
+
+#: command.go:1249
+msgid "CommandHelpLong"
+msgstr ""
+"Help provides help for any command in the application.\n"
+"Simply type %s for full details."
+
+#: command.go:1248
+msgid "CommandHelpShort"
+msgstr "Help about any command"
+
+#: command.go:1272
+msgid "CommandHelpUnknownTopicError"
+msgstr "Unknown help topic %#q"
+
+#: completions.go:250
+msgid "CompletionCommandShellDirectiveTip"
+msgstr "Completion ended with directive: %s"
+
+#: completions.go:203
+msgid "CompletionCommandShellLong"
+msgstr ""
+"%s is a special command that is used by the shell completion logic\n"
+"to request completion choices for the specified command-line."
+
+#: completions.go:202
+msgid "CompletionCommandShellShort"
+msgstr "Request shell completion choices for the specified command-line"
+
+#: completions.go:745 completions.go:784 completions.go:809 completions.go:835
+msgid "CompletionSubcommandNoDescFlagDesc"
+msgstr "disable completion descriptions"
+
+#: completions.go:52
+msgid "CompletionSubcommandUnsupportedFlagError"
+msgstr "subcommand '%s' does not support flag '%s'"
+
+#: command.go:770
+msgid "DidYouMeanThis"
+msgstr "Did you mean this?"
+
+#: cobra.go:234
+msgid "Error"
+msgstr "Error"
+
+#: args.go:98
+msgid "ExactArgsValidationError"
+msgid_plural "ExactArgsValidationErrorPlural"
+msgstr[0] "accepts %d arg, received %d"
+msgstr[1] "accepts %d args, received %d"
+
+#: flag_groups.go:205
+msgid "ExclusiveFlagsValidationError"
+msgstr ""
+"if any flags in the group [%v] are set none of the others can be; %v were "
+"all set"
+
+#: command.go:1176
+msgid "FlagNotSetError"
+msgid_plural "FlagNotSetErrorPlural"
+msgstr[0] "required flag \"%s\" is not set"
+msgstr[1] "required flags \"%s\" are not set"
+
+#: localizer.go:66
+msgid "ForInfoAboutCommand"
+msgstr "for more information about a command"
+
+#: command.go:1200
+msgid "HelpFor"
+msgstr "help for"
+
+#: args.go:37
+msgid "LegacyArgsValidationError"
+msgstr "unknown command %q for %q%s"
+
+#: args.go:88
+msgid "MaximumNArgsValidationError"
+msgid_plural "MaximumNArgsValidationErrorPlural"
+msgstr[0] "accepts at most %d arg, received %d"
+msgstr[1] "accepts at most %d args, received %d"
+
+#: args.go:78
+msgid "MinimumNArgsValidationError"
+msgid_plural "MinimumNArgsValidationErrorPlural"
+msgstr[0] "requires at least %d arg, only received %d"
+msgstr[1] "requires at least %d args, only received %d"
+
+#: args.go:45
+msgid "NoArgsValidationError"
+msgstr "unknown command %q for %q"
+
+#: args.go:62
+msgid "OnlyValidArgsValidationError"
+msgstr "invalid argument %q for %q%s"
+
+#: args.go:108
+msgid "RangeArgsValidationError"
+msgid_plural "RangeArgsValidationErrorPlural"
+msgstr[0] "accepts between %d and %d arg, received %d"
+msgstr[1] "accepts between %d and %d args, received %d"
+
+#: command.go:1110
+msgid "RunHelpTip"
+msgstr "Run '%v --help' for usage."
+
+#: localizer.go:61
+msgid "SectionAdditionalCommands"
+msgstr "Additional Commands"
+
+#: localizer.go:64
+msgid "SectionAdditionalHelpTopics"
+msgstr "Additional Help Topics"
+
+#: localizer.go:58
+msgid "SectionAliases"
+msgstr "Aliases"
+
+#: localizer.go:60
+msgid "SectionAvailableCommands"
+msgstr "Available Commands"
+
+#: localizer.go:59
+msgid "SectionExamples"
+msgstr "Examples"
+
+#: localizer.go:62
+msgid "SectionFlags"
+msgstr "Flags"
+
+#: localizer.go:63
+msgid "SectionGlobalFlags"
+msgstr "Global Flags"
+
+#: localizer.go:57
+msgid "SectionUsage"
+msgstr "Usage"
+
+#: command.go:1202 command.go:1224
+msgid "ThisCommand"
+msgstr "this command"
+
+#: localizer.go:65
+msgid "Use"
+msgstr "Use"
+
+#: command.go:1222
+msgid "VersionFor"
+msgstr "version for"
+
+#: command.go:1247 command.go:1249
+msgid "command"
+msgstr "command"
+
+#~ msgid "PathToCommand"
+#~ msgstr "path to command"
diff --git a/locales/default/fr.mo b/locales/default/fr.mo
new file mode 100644
index 0000000000000000000000000000000000000000..da9097e8f646c14dcd2f8fe2c315ceac6cbf75a2
GIT binary patch
literal 3415
zcmb7`%WoVt9LHT~DJ<{uE)-1KRa%kRHdGZQC7{r3+CpAxb_0}`ytB^6?W{eqXO>O5
zae)&DBvdNI1%V*q$RE%{5dQ!N4j>Lls0VIHJtFb_d1kVkWK&T~v!Aj3^Y7)KJ^RPL
z-JdhGLwKIT^U^NHPJr*+h99(Jw=?!QI11hmz6L%3UI0n{5;z2Y0`3QY0G|YZ&d2`*
z52L?lH)HpK6>twY4!!`M1`mKAf@Sb3_z-vv+zb8!J^}s)j)OOH9*2@g&|d+`kB>pp
z_YGJAe+OyZb&&kobw_6Z07&tSfD}&^ya%j<`@lxtPr<v<{|H0~>{F2Reg=|XUx7Ek
zZ^47$ml#aJYv2*^#GRR)_ras+cfqUR_h1ct^{&jXE8ubTe+0>|>mXEmV^hFh7(ENl
zf)~ImAnE%DgeV(=lTU*q;3D`sNOE6;l$U?=`TYp;J@j7$p98-DDc+yJd%<5pq>B9o
zQeOWC;VRpONs6}w(!TElN$wy>`*{Q;xsxE4u!+1s3!;?#7}+V>V|an<KKC?6x~YDi
zrA}X?^7DBBDZkVxo<n$OKd6yiYR}-IJ*GOMh7$3)p?9juqLJTJTht!L^QeEz;)T1u
zUT6;<#6z(mb-r=@QjRHpcG|StLN_O6Y-K1C*?dD-t#qrHI4h%g+HCGGEE}8j3)J-`
zZ9dR76RWUlt+6y2M>0`H=LQy*WfV;+7)ew|)>OQMNMUQh>J78`+(Oz2Gw)~3q;%m_
zBwEBi%{`I2c4-{LZ`mZd%*#pDe9NRWQs~;Uax5FHc14&>*p^!qk!lKx;pP+bQEElR
zs@*V39fBkM+ZNN{tVtGRk}2EL&vi^0drmKziH1p&JaLS60OM?0wnVr($gvsGRqeDr
zdy8|q%^9V4UcK{NzIxUSvO1@uRiC~=ljntQ$y=GoO`T6Q<$`Kwfy&6}iDpv~MU*GY
zZJ+T=s4#nTkrGb!x*ee+(TL<$9Vi4U;tfH+uZ4j#k!gr%(@@=smSj{(#n#oGWQ)=|
zxJWw~X4T5(t+|K*f^*FvpB>|+`MKb{>?o2ACUFokG%Z{b)U41h5{a>XM7L5{32IWb
z`}$*i$(Cl$%~X2|M=Qrlr;SdehK|)3`V!eqj>eHt`V<eBg>^C+uh*u6lRY`IwIpp&
z)uCzP78v6v8!9Pf8xSCdwj1L*_66_8pFhR3*YU%ezsyIEoGQH;oRM1E%-wk-u|NEH
z<yd93#HIz$6Cincg-e(4=K5M}*VjU|z9vU_+4)-MOD4q?gjva>VWEH+BU}%wwm-mp
zCB<DLd)hL=IF1x<FyyaNs)<-$=EG$-oUJtt<Uv`UYRSDuSWd9*5e!$@Tt3?~C{pgy
zixNHz(~`n(wvZ2c9p!^7Tp>|fr)(lkOaQSM8p#DN!e!Zkb{-4MBf*=+)yKyu(6aLh
z=vU5NOsNovEKO9T+{Wr*u~j>HO6k<jM6%@5LwQW0!AjVZ4hEN&sHYQ$-8QO=OgN`d
z=Pt{Mw=FHw&=+AZ8Jl(ZMYJ4_CF^TYzuhVC#z3tM>2|nPaXIGBNoQ3|iE<2qIFmZc
z9({>qwr!bD_$cWnQs__##>@LzwrJ8w$;&|p3w>&NC_}|xp(|XbmWeQwae_K)a$#E|
zye$+Gj$Pqau78(ee2a)1C__2O%sT#dx$`a@)d`oyMPDrH7b|x-$)F0>*YZl1h_UeA
z%9T_($%o2r2o+6-fTE*Q;ILJa_3O%oT`ny%^PA)y^I69+q%BeScG$K*1AM0v-Z)Tf
zBjGrWc^&6Rt-OK~EpiA&Uj6^b<z~?$J)86HgF@MngKeX<yV)k~Bs^I4%>rVmgS3ql
z)7_(VhraEDqJ@O@b%VqIKO){PKG?OYb7IwBNgW;p_%9T1xtTWZo=Wk@3blv(Tgxu$
z2iJ>5-(NrI(Fd$gY*M+{Xn(ZvFGOY^t-1Vo<>c813%at;R45aik0N8N?r$7v8BQpD
Lg8UV+Q6cO<3Owi`

literal 0
HcmV?d00001

diff --git a/locales/default/fr.po b/locales/default/fr.po
new file mode 100644
index 00000000..9bb83c22
--- /dev/null
+++ b/locales/default/fr.po
@@ -0,0 +1,172 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: \n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: fr\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+"X-Generator: Poedit 3.0.1\n"
+
+#: command.go:891
+msgid "CommandDeprecatedWarning"
+msgstr "La commande %q est dépréciée, %s"
+
+#: command.go:1249
+msgid "CommandHelpLong"
+msgstr ""
+"Help fournit de l'aide pour n'importe quelle commande de l'application.\n"
+"Tapez '%s' pour obtenir une aide détaillée."
+
+#: command.go:1248
+msgid "CommandHelpShort"
+msgstr "Obtenir de l'aide au sujet d'une commande"
+
+#: command.go:1272
+msgid "CommandHelpUnknownTopicError"
+msgstr "Sujet d'aide %#q inconnu"
+
+#: completions.go:250
+msgid "CompletionCommandShellDirectiveTip"
+msgstr "Auto-complétion achevée par la directive : %s"
+
+#: completions.go:203
+msgid "CompletionCommandShellLong"
+msgstr ""
+"%s est une commande spéciale utilisée par l'auto-complétion de la console\n"
+"pour récupérer les différents choix possibles pour une certaine commande."
+
+#: completions.go:202
+msgid "CompletionCommandShellShort"
+msgstr ""
+"Obtenir les différentes possibilités d'auto-complétion pour une certaine "
+"commande"
+
+#: completions.go:745 completions.go:784 completions.go:809 completions.go:835
+msgid "CompletionSubcommandNoDescFlagDesc"
+msgstr "désactiver les desriptions"
+
+#: completions.go:52
+msgid "CompletionSubcommandUnsupportedFlagError"
+msgstr "la sous-commande '%s' ne comprend pas l'option '%s'"
+
+#: command.go:770
+msgid "DidYouMeanThis"
+msgstr "Vouliez-vous dire ceci ?"
+
+#: cobra.go:234
+msgid "Error"
+msgstr "Erreur"
+
+#: args.go:98
+msgid "ExactArgsValidationError"
+msgid_plural "ExactArgsValidationErrorPlural"
+msgstr[0] "accepte %d arg, mais en a reçu %d"
+msgstr[1] "accepte %d args, mais en a reçu %d"
+
+#: flag_groups.go:205
+msgid "ExclusiveFlagsValidationError"
+msgstr "les options [%v] sont exclusives, mais les options %v ont été fournies"
+
+#: command.go:1176
+msgid "FlagNotSetError"
+msgid_plural "FlagNotSetErrorPlural"
+msgstr[0] "l'option requise \"%s\" n'est pas présente"
+msgstr[1] "les options requises \"%s\" ne sont pas présentes"
+
+#: localizer.go:66
+msgid "ForInfoAboutCommand"
+msgstr "pour plus d'information au sujet d'une commande"
+
+#: command.go:1200
+msgid "HelpFor"
+msgstr "aide pour"
+
+#: args.go:37
+msgid "LegacyArgsValidationError"
+msgstr "commande %q inconnue pour %q%s"
+
+#: args.go:88
+msgid "MaximumNArgsValidationError"
+msgid_plural "MaximumNArgsValidationErrorPlural"
+msgstr[0] "accepte au plus %d arg, mais en a reçu %d"
+msgstr[1] "accepte au plus %d args, mais en a reçu %d"
+
+#: args.go:78
+msgid "MinimumNArgsValidationError"
+msgid_plural "MinimumNArgsValidationErrorPlural"
+msgstr[0] "requiert au moins %d arg, mais en a reçu %d"
+msgstr[1] "requiert au moins %d args, mais en a reçu %d"
+
+#: args.go:45
+msgid "NoArgsValidationError"
+msgstr "commande %q inconnue pour %q"
+
+#: args.go:62
+msgid "OnlyValidArgsValidationError"
+msgstr "argument %q invalide pour %q%s"
+
+#: args.go:108
+msgid "RangeArgsValidationError"
+msgid_plural "RangeArgsValidationErrorPlural"
+msgstr[0] "accepte entre %d et %d arg, mais en a reçu %d"
+msgstr[1] "accepte entre %d et %d args, mais en a reçu %d"
+
+#: command.go:1110
+msgid "RunHelpTip"
+msgstr "Essayez '%v --help' pour obtenir de l'aide."
+
+#: localizer.go:61
+msgid "SectionAdditionalCommands"
+msgstr "Commandes Connexes"
+
+#: localizer.go:64
+msgid "SectionAdditionalHelpTopics"
+msgstr "Autres Sujets"
+
+#: localizer.go:58
+msgid "SectionAliases"
+msgstr "Alias"
+
+#: localizer.go:60
+msgid "SectionAvailableCommands"
+msgstr "Commandes Disponibles"
+
+#: localizer.go:59
+msgid "SectionExamples"
+msgstr "Exemples"
+
+#: localizer.go:62
+msgid "SectionFlags"
+msgstr "Options"
+
+#: localizer.go:63
+msgid "SectionGlobalFlags"
+msgstr "Options Globales"
+
+#: localizer.go:57
+msgid "SectionUsage"
+msgstr "Usage"
+
+#: command.go:1202 command.go:1224
+msgid "ThisCommand"
+msgstr "cette commande"
+
+#: localizer.go:65
+msgid "Use"
+msgstr "Utiliser"
+
+#: command.go:1222
+msgid "VersionFor"
+msgstr "version pour"
+
+#: command.go:1247 command.go:1249
+msgid "command"
+msgstr "commande"
+
+#~ msgid "PathToCommand"
+#~ msgstr "command"
diff --git a/localizer.go b/localizer.go
new file mode 100644
index 00000000..90a94a83
--- /dev/null
+++ b/localizer.go
@@ -0,0 +1,138 @@
+package cobra
+
+import (
+	"embed"
+	"fmt"
+	"os"
+
+	"github.com/leonelquinteros/gotext"
+	"golang.org/x/text/language"
+)
+
+var defaultLanguage = language.English
+
+// envVariablesHoldingLocale is sorted by decreasing priority.
+// These environment variables are expected to hold a parsable locale (fr_FR, es, en-US, …)
+var envVariablesHoldingLocale = []string{
+	"LANGUAGE",
+	"LC_ALL",
+	"LC_MESSAGES",
+	"LANG",
+}
+
+// availableLocalizationDomains holds all the domains used in localization.
+// Each domain MUST have its own locales/<domain>.pot file and locales/<domain>/ dir.
+// Therefore, please only use short, ^[a-z]+$ strings as domains.
+var availableLocalizationDomains = []string{
+	"default",
+}
+
+// localeFS points to an embedded filesystem of binary gettext translation files.
+// For performance and smaller builds, only the binary MO files are included.
+// Their sibling PO files should still be considered their authoritative source.
+//
+//go:embed locales/*/*.mo
+var localeFS embed.FS
+
+// i18nCommandGlossary wraps the translated strings passed to the command usage template.
+// This is used in CommandUsageTemplateData.
+type i18nCommandGlossary struct {
+	SectionUsage                string
+	SectionAliases              string
+	SectionExamples             string
+	SectionAvailableCommands    string
+	SectionAdditionalCommands   string
+	SectionFlags                string
+	SectionGlobalFlags          string
+	SectionAdditionalHelpTopics string
+	Use                         string
+	ForInfoAboutCommand         string
+}
+
+var commonCommandGlossary *i18nCommandGlossary
+
+func getCommandGlossary() *i18nCommandGlossary {
+	if commonCommandGlossary == nil {
+		commonCommandGlossary = &i18nCommandGlossary{
+			SectionUsage:                gotext.Get("SectionUsage"),
+			SectionAliases:              gotext.Get("SectionAliases"),
+			SectionExamples:             gotext.Get("SectionExamples"),
+			SectionAvailableCommands:    gotext.Get("SectionAvailableCommands"),
+			SectionAdditionalCommands:   gotext.Get("SectionAdditionalCommands"),
+			SectionFlags:                gotext.Get("SectionFlags"),
+			SectionGlobalFlags:          gotext.Get("SectionGlobalFlags"),
+			SectionAdditionalHelpTopics: gotext.Get("SectionAdditionalHelpTopics"),
+			Use:                         gotext.Get("Use"),
+			ForInfoAboutCommand:         gotext.Get("ForInfoAboutCommand"),
+		}
+	}
+	return commonCommandGlossary
+}
+
+func setupLocalization() {
+	for _, localeIdentifier := range detectLangs() {
+		locale := gotext.NewLocale("", localeIdentifier)
+
+		allDomainsFound := true
+		for _, domain := range availableLocalizationDomains {
+
+			//localeFilepath := fmt.Sprintf("locales/%s/%s.po", domain, localeIdentifier)
+			localeFilepath := fmt.Sprintf("locales/%s/%s.mo", domain, localeIdentifier)
+			localeFile, err := localeFS.ReadFile(localeFilepath)
+			if err != nil {
+				allDomainsFound = false
+				break
+			}
+
+			//translator := gotext.NewPo()
+			translator := gotext.NewMo()
+			translator.Parse(localeFile)
+
+			locale.AddTranslator(domain, translator)
+		}
+
+		if !allDomainsFound {
+			continue
+		}
+
+		gotext.SetStorage(locale)
+		break
+	}
+}
+
+func detectLangs() []string {
+	var detectedLangs []string
+
+	// From environment
+	for _, envKey := range envVariablesHoldingLocale {
+		lang := os.Getenv(envKey)
+		if lang != "" {
+			detectedLang := language.Make(lang)
+			appendLang(&detectedLangs, detectedLang)
+		}
+	}
+
+	// Lastly, from defaults
+	appendLang(&detectedLangs, defaultLanguage)
+
+	return detectedLangs
+}
+
+func appendLang(langs *[]string, lang language.Tag) {
+	if lang.IsRoot() {
+		return
+	}
+
+	langString := lang.String()
+	*langs = append(*langs, langString)
+
+	langBase, confidentInBase := lang.Base()
+	if confidentInBase != language.No {
+		*langs = append(*langs, langBase.ISO3())
+		*langs = append(*langs, langBase.String())
+	}
+}
+
+func init() {
+	setupLocalization()
+}
diff --git a/localizer_test.go b/localizer_test.go
new file mode 100644
index 00000000..16efdc01
--- /dev/null
+++ b/localizer_test.go
@@ -0,0 +1,161 @@
+package cobra
+
+import (
+	"github.com/leonelquinteros/gotext"
+	"os"
+	"testing"
+)
+
+// resetLocalization resets to the vendor defaults
+// Ideally this would be done using gotext.SetStorage(nil)
+func resetLocalization() {
+	locale := gotext.NewLocale("/usr/local/share/locale", "en_US")
+	locale.AddDomain("default")
+	locale.SetDomain("default")
+	gotext.SetStorage(locale)
+}
+
+func TestLocalization(t *testing.T) {
+	tests := []struct {
+		rule                string
+		env                 map[string]string
+		expectedLanguage    string
+		message             string
+		expectedTranslation string
+	}{
+		{
+			rule:             "default language is english",
+			expectedLanguage: "en",
+		},
+		{
+			rule: "section example (en)",
+			env: map[string]string{
+				"LANGUAGE": "en",
+			},
+			expectedLanguage:    "en",
+			message:             "SectionExamples",
+			expectedTranslation: "Examples",
+		},
+		{
+			rule: "section example (fr)",
+			env: map[string]string{
+				"LANGUAGE": "fr",
+			},
+			expectedLanguage:    "fr",
+			message:             "SectionExamples",
+			expectedTranslation: "Exemples",
+		},
+		{
+			rule:                "untranslated string stays as-is",
+			message:             "AtelophobiacCoder",
+			expectedTranslation: "AtelophobiacCoder",
+		},
+		{
+			rule: "fr_FR falls back to fr",
+			env: map[string]string{
+				"LANGUAGE": "fr_FR",
+			},
+			expectedLanguage: "fr",
+		},
+		{
+			rule: "fr-FR falls back to fr",
+			env: map[string]string{
+				"LANGUAGE": "fr-FR",
+			},
+			expectedLanguage: "fr",
+		},
+		{
+			rule: "fr_FR@UTF-8 falls back to fr",
+			env: map[string]string{
+				"LANGUAGE": "fr_FR@UTF-8",
+			},
+			expectedLanguage: "fr",
+		},
+		{
+			rule: "fr_FR.UTF-8 falls back to fr",
+			env: map[string]string{
+				"LANGUAGE": "fr_FR.UTF-8",
+			},
+			expectedLanguage: "fr",
+		},
+		{
+			rule: "LANGUAGE > LC_ALL",
+			env: map[string]string{
+				"LANGUAGE":    "fr",
+				"LC_ALL":      "en",
+				"LC_MESSAGES": "en",
+				"LANG":        "en",
+			},
+			expectedLanguage: "fr",
+		},
+		{
+			rule: "LC_ALL > LC_MESSAGES",
+			env: map[string]string{
+				"LC_ALL":      "fr",
+				"LC_MESSAGES": "en",
+				"LANG":        "en",
+			},
+			expectedLanguage: "fr",
+		},
+		{
+			rule: "LC_MESSAGES > LANG",
+			env: map[string]string{
+				"LC_MESSAGES": "fr",
+				"LANG":        "en",
+			},
+			expectedLanguage: "fr",
+		},
+		{
+			rule: "LANG is supported",
+			env: map[string]string{
+				"LANG": "fr",
+			},
+			expectedLanguage: "fr",
+		},
+		{
+			rule: "Fall back to another env if a language is not supported",
+			env: map[string]string{
+				"LANGUAGE": "xx",
+				"LC_ALL":   "fr",
+			},
+			expectedLanguage: "fr",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.rule, func(t *testing.T) {
+			// I. Prepare the environment
+			os.Clearenv()
+			if tt.env != nil {
+				for envKey, envValue := range tt.env {
+					err := os.Setenv(envKey, envValue)
+					if err != nil {
+						t.Errorf("os.Setenv() failed for %s=%s", envKey, envValue)
+						return
+					}
+				}
+			}
+
+			// II. Run the initialization of localization
+			resetLocalization()
+			setupLocalization()
+
+			// III. Assert that language was detected correctly
+			if tt.expectedLanguage != "" {
+				actualLanguage := gotext.GetLanguage()
+				if actualLanguage != tt.expectedLanguage {
+					t.Errorf("Expected language `%v' but got `%v'.", tt.expectedLanguage, actualLanguage)
+					return
+				}
+			}
+
+			// IV. Assert that the message was translated adequately
+			if tt.message != "" {
+				actualTranslation := gotext.Get(tt.message)
+				if actualTranslation != tt.expectedTranslation {
+					t.Errorf("Expected translation `%v' but got `%v'.", tt.expectedTranslation, actualTranslation)
+					return
+				}
+			}
+		})
+	}
+}