refact "cscli lapi" (#2825)

This commit is contained in:
mmetc 2024-02-09 17:39:50 +01:00 committed by GitHub
parent 332af5dd8d
commit 58a1d7164f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 167 additions and 106 deletions

View file

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"slices"
"sort" "sort"
"strings" "strings"
@ -13,7 +14,6 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
"slices"
"github.com/crowdsecurity/go-cs-lib/version" "github.com/crowdsecurity/go-cs-lib/version"
@ -29,15 +29,27 @@ import (
const LAPIURLPrefix = "v1" const LAPIURLPrefix = "v1"
func runLapiStatus(cmd *cobra.Command, args []string) error { type cliLapi struct {
password := strfmt.Password(csConfig.API.Client.Credentials.Password) cfg configGetter
apiurl, err := url.Parse(csConfig.API.Client.Credentials.URL) }
login := csConfig.API.Client.Credentials.Login
func NewCLILapi(cfg configGetter) *cliLapi {
return &cliLapi{
cfg: cfg,
}
}
func (cli *cliLapi) status() error {
cfg := cli.cfg()
password := strfmt.Password(cfg.API.Client.Credentials.Password)
login := cfg.API.Client.Credentials.Login
apiurl, err := url.Parse(cfg.API.Client.Credentials.URL)
if err != nil { if err != nil {
return fmt.Errorf("parsing api url: %w", err) return fmt.Errorf("parsing api url: %w", err)
} }
hub, err := require.Hub(csConfig, nil, nil) hub, err := require.Hub(cfg, nil, nil)
if err != nil { if err != nil {
return err return err
} }
@ -54,13 +66,14 @@ func runLapiStatus(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("init default client: %w", err) return fmt.Errorf("init default client: %w", err)
} }
t := models.WatcherAuthRequest{ t := models.WatcherAuthRequest{
MachineID: &login, MachineID: &login,
Password: &password, Password: &password,
Scenarios: scenarios, Scenarios: scenarios,
} }
log.Infof("Loaded credentials from %s", csConfig.API.Client.CredentialsFilePath) log.Infof("Loaded credentials from %s", cfg.API.Client.CredentialsFilePath)
log.Infof("Trying to authenticate with username %s on %s", login, apiurl) log.Infof("Trying to authenticate with username %s on %s", login, apiurl)
_, _, err = Client.Auth.AuthenticateWatcher(context.Background(), t) _, _, err = Client.Auth.AuthenticateWatcher(context.Background(), t)
@ -69,26 +82,15 @@ func runLapiStatus(cmd *cobra.Command, args []string) error {
} }
log.Infof("You can successfully interact with Local API (LAPI)") log.Infof("You can successfully interact with Local API (LAPI)")
return nil return nil
} }
func runLapiRegister(cmd *cobra.Command, args []string) error { func (cli *cliLapi) register(apiURL string, outputFile string, machine string) error {
flags := cmd.Flags() var err error
apiURL, err := flags.GetString("url") lapiUser := machine
if err != nil { cfg := cli.cfg()
return err
}
outputFile, err := flags.GetString("file")
if err != nil {
return err
}
lapiUser, err := flags.GetString("machine")
if err != nil {
return err
}
if lapiUser == "" { if lapiUser == "" {
lapiUser, err = generateID("") lapiUser, err = generateID("")
@ -96,12 +98,15 @@ func runLapiRegister(cmd *cobra.Command, args []string) error {
return fmt.Errorf("unable to generate machine id: %w", err) return fmt.Errorf("unable to generate machine id: %w", err)
} }
} }
password := strfmt.Password(generatePassword(passwordLength)) password := strfmt.Password(generatePassword(passwordLength))
if apiURL == "" { if apiURL == "" {
if csConfig.API.Client == nil || csConfig.API.Client.Credentials == nil || csConfig.API.Client.Credentials.URL == "" { if cfg.API.Client == nil || cfg.API.Client.Credentials == nil || cfg.API.Client.Credentials.URL == "" {
return fmt.Errorf("no Local API URL. Please provide it in your configuration or with the -u parameter") return fmt.Errorf("no Local API URL. Please provide it in your configuration or with the -u parameter")
} }
apiURL = csConfig.API.Client.Credentials.URL
apiURL = cfg.API.Client.Credentials.URL
} }
/*URL needs to end with /, but user doesn't care*/ /*URL needs to end with /, but user doesn't care*/
if !strings.HasSuffix(apiURL, "/") { if !strings.HasSuffix(apiURL, "/") {
@ -111,10 +116,12 @@ func runLapiRegister(cmd *cobra.Command, args []string) error {
if !strings.HasPrefix(apiURL, "http://") && !strings.HasPrefix(apiURL, "https://") { if !strings.HasPrefix(apiURL, "http://") && !strings.HasPrefix(apiURL, "https://") {
apiURL = "http://" + apiURL apiURL = "http://" + apiURL
} }
apiurl, err := url.Parse(apiURL) apiurl, err := url.Parse(apiURL)
if err != nil { if err != nil {
return fmt.Errorf("parsing api url: %w", err) return fmt.Errorf("parsing api url: %w", err)
} }
_, err = apiclient.RegisterClient(&apiclient.Config{ _, err = apiclient.RegisterClient(&apiclient.Config{
MachineID: lapiUser, MachineID: lapiUser,
Password: password, Password: password,
@ -130,138 +137,142 @@ func runLapiRegister(cmd *cobra.Command, args []string) error {
log.Printf("Successfully registered to Local API (LAPI)") log.Printf("Successfully registered to Local API (LAPI)")
var dumpFile string var dumpFile string
if outputFile != "" { if outputFile != "" {
dumpFile = outputFile dumpFile = outputFile
} else if csConfig.API.Client.CredentialsFilePath != "" { } else if cfg.API.Client.CredentialsFilePath != "" {
dumpFile = csConfig.API.Client.CredentialsFilePath dumpFile = cfg.API.Client.CredentialsFilePath
} else { } else {
dumpFile = "" dumpFile = ""
} }
apiCfg := csconfig.ApiCredentialsCfg{ apiCfg := csconfig.ApiCredentialsCfg{
Login: lapiUser, Login: lapiUser,
Password: password.String(), Password: password.String(),
URL: apiURL, URL: apiURL,
} }
apiConfigDump, err := yaml.Marshal(apiCfg) apiConfigDump, err := yaml.Marshal(apiCfg)
if err != nil { if err != nil {
return fmt.Errorf("unable to marshal api credentials: %w", err) return fmt.Errorf("unable to marshal api credentials: %w", err)
} }
if dumpFile != "" { if dumpFile != "" {
err = os.WriteFile(dumpFile, apiConfigDump, 0o600) err = os.WriteFile(dumpFile, apiConfigDump, 0o600)
if err != nil { if err != nil {
return fmt.Errorf("write api credentials to '%s' failed: %w", dumpFile, err) return fmt.Errorf("write api credentials to '%s' failed: %w", dumpFile, err)
} }
log.Printf("Local API credentials written to '%s'", dumpFile) log.Printf("Local API credentials written to '%s'", dumpFile)
} else { } else {
fmt.Printf("%s\n", string(apiConfigDump)) fmt.Printf("%s\n", string(apiConfigDump))
} }
log.Warning(ReloadMessage()) log.Warning(ReloadMessage())
return nil return nil
} }
func NewLapiStatusCmd() *cobra.Command { func (cli *cliLapi) newStatusCmd() *cobra.Command {
cmdLapiStatus := &cobra.Command{ cmdLapiStatus := &cobra.Command{
Use: "status", Use: "status",
Short: "Check authentication to Local API (LAPI)", Short: "Check authentication to Local API (LAPI)",
Args: cobra.MinimumNArgs(0), Args: cobra.MinimumNArgs(0),
DisableAutoGenTag: true, DisableAutoGenTag: true,
RunE: runLapiStatus, RunE: func(cmd *cobra.Command, args []string) error {
return cli.status()
},
} }
return cmdLapiStatus return cmdLapiStatus
} }
func NewLapiRegisterCmd() *cobra.Command { func (cli *cliLapi) newRegisterCmd() *cobra.Command {
cmdLapiRegister := &cobra.Command{ var (
apiURL string
outputFile string
machine string
)
cmd := &cobra.Command{
Use: "register", Use: "register",
Short: "Register a machine to Local API (LAPI)", Short: "Register a machine to Local API (LAPI)",
Long: `Register your machine to the Local API (LAPI). Long: `Register your machine to the Local API (LAPI).
Keep in mind the machine needs to be validated by an administrator on LAPI side to be effective.`, Keep in mind the machine needs to be validated by an administrator on LAPI side to be effective.`,
Args: cobra.MinimumNArgs(0), Args: cobra.MinimumNArgs(0),
DisableAutoGenTag: true, DisableAutoGenTag: true,
RunE: runLapiRegister, RunE: func(_ *cobra.Command, _ []string) error {
return cli.register(apiURL, outputFile, machine)
},
} }
flags := cmdLapiRegister.Flags() flags := cmd.Flags()
flags.StringP("url", "u", "", "URL of the API (ie. http://127.0.0.1)") flags.StringVarP(&apiURL, "url", "u", "", "URL of the API (ie. http://127.0.0.1)")
flags.StringP("file", "f", "", "output file destination") flags.StringVarP(&outputFile, "file", "f", "", "output file destination")
flags.String("machine", "", "Name of the machine to register with") flags.StringVar(&machine, "machine", "", "Name of the machine to register with")
return cmdLapiRegister return cmd
} }
func NewLapiCmd() *cobra.Command { func (cli *cliLapi) NewCommand() *cobra.Command {
cmdLapi := &cobra.Command{ cmd := &cobra.Command{
Use: "lapi [action]", Use: "lapi [action]",
Short: "Manage interaction with Local API (LAPI)", Short: "Manage interaction with Local API (LAPI)",
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
DisableAutoGenTag: true, DisableAutoGenTag: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error { PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
if err := csConfig.LoadAPIClient(); err != nil { if err := cli.cfg().LoadAPIClient(); err != nil {
return fmt.Errorf("loading api client: %w", err) return fmt.Errorf("loading api client: %w", err)
} }
return nil return nil
}, },
} }
cmdLapi.AddCommand(NewLapiRegisterCmd()) cmd.AddCommand(cli.newRegisterCmd())
cmdLapi.AddCommand(NewLapiStatusCmd()) cmd.AddCommand(cli.newStatusCmd())
cmdLapi.AddCommand(NewLapiContextCmd()) cmd.AddCommand(cli.newContextCmd())
return cmdLapi return cmd
} }
func AddContext(key string, values []string) error { func (cli *cliLapi) addContext(key string, values []string) error {
cfg := cli.cfg()
if err := alertcontext.ValidateContextExpr(key, values); err != nil { if err := alertcontext.ValidateContextExpr(key, values); err != nil {
return fmt.Errorf("invalid context configuration :%s", err) return fmt.Errorf("invalid context configuration: %w", err)
} }
if _, ok := csConfig.Crowdsec.ContextToSend[key]; !ok {
csConfig.Crowdsec.ContextToSend[key] = make([]string, 0) if _, ok := cfg.Crowdsec.ContextToSend[key]; !ok {
cfg.Crowdsec.ContextToSend[key] = make([]string, 0)
log.Infof("key '%s' added", key) log.Infof("key '%s' added", key)
} }
data := csConfig.Crowdsec.ContextToSend[key]
data := cfg.Crowdsec.ContextToSend[key]
for _, val := range values { for _, val := range values {
if !slices.Contains(data, val) { if !slices.Contains(data, val) {
log.Infof("value '%s' added to key '%s'", val, key) log.Infof("value '%s' added to key '%s'", val, key)
data = append(data, val) data = append(data, val)
} }
csConfig.Crowdsec.ContextToSend[key] = data
cfg.Crowdsec.ContextToSend[key] = data
} }
if err := csConfig.Crowdsec.DumpContextConfigFile(); err != nil {
if err := cfg.Crowdsec.DumpContextConfigFile(); err != nil {
return err return err
} }
return nil return nil
} }
func NewLapiContextCmd() *cobra.Command { func (cli *cliLapi) newContextAddCmd() *cobra.Command {
cmdContext := &cobra.Command{ var (
Use: "context [command]", keyToAdd string
Short: "Manage context to send with alerts", valuesToAdd []string
DisableAutoGenTag: true, )
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if err := csConfig.LoadCrowdsec(); err != nil {
fileNotFoundMessage := fmt.Sprintf("failed to open context file: open %s: no such file or directory", csConfig.Crowdsec.ConsoleContextPath)
if err.Error() != fileNotFoundMessage {
return fmt.Errorf("unable to load CrowdSec agent configuration: %w", err)
}
}
if csConfig.DisableAgent {
return errors.New("agent is disabled and lapi context can only be used on the agent")
}
return nil cmd := &cobra.Command{
},
Run: func(cmd *cobra.Command, args []string) {
printHelp(cmd)
},
}
var keyToAdd string
var valuesToAdd []string
cmdContextAdd := &cobra.Command{
Use: "add", Use: "add",
Short: "Add context to send with alerts. You must specify the output key with the expr value you want", Short: "Add context to send with alerts. You must specify the output key with the expr value you want",
Example: `cscli lapi context add --key source_ip --value evt.Meta.source_ip Example: `cscli lapi context add --key source_ip --value evt.Meta.source_ip
@ -269,18 +280,18 @@ cscli lapi context add --key file_source --value evt.Line.Src
cscli lapi context add --value evt.Meta.source_ip --value evt.Meta.target_user cscli lapi context add --value evt.Meta.source_ip --value evt.Meta.target_user
`, `,
DisableAutoGenTag: true, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(_ *cobra.Command, _ []string) error {
hub, err := require.Hub(csConfig, nil, nil) hub, err := require.Hub(cli.cfg(), nil, nil)
if err != nil { if err != nil {
return err return err
} }
if err = alertcontext.LoadConsoleContext(csConfig, hub); err != nil { if err = alertcontext.LoadConsoleContext(cli.cfg(), hub); err != nil {
return fmt.Errorf("while loading context: %w", err) return fmt.Errorf("while loading context: %w", err)
} }
if keyToAdd != "" { if keyToAdd != "" {
if err := AddContext(keyToAdd, valuesToAdd); err != nil { if err := cli.addContext(keyToAdd, valuesToAdd); err != nil {
return err return err
} }
return nil return nil
@ -290,7 +301,7 @@ cscli lapi context add --value evt.Meta.source_ip --value evt.Meta.target_user
keySlice := strings.Split(v, ".") keySlice := strings.Split(v, ".")
key := keySlice[len(keySlice)-1] key := keySlice[len(keySlice)-1]
value := []string{v} value := []string{v}
if err := AddContext(key, value); err != nil { if err := cli.addContext(key, value); err != nil {
return err return err
} }
} }
@ -298,31 +309,37 @@ cscli lapi context add --value evt.Meta.source_ip --value evt.Meta.target_user
return nil return nil
}, },
} }
cmdContextAdd.Flags().StringVarP(&keyToAdd, "key", "k", "", "The key of the different values to send")
cmdContextAdd.Flags().StringSliceVar(&valuesToAdd, "value", []string{}, "The expr fields to associate with the key")
cmdContextAdd.MarkFlagRequired("value")
cmdContext.AddCommand(cmdContextAdd)
cmdContextStatus := &cobra.Command{ flags := cmd.Flags()
flags.StringVarP(&keyToAdd, "key", "k", "", "The key of the different values to send")
flags.StringSliceVar(&valuesToAdd, "value", []string{}, "The expr fields to associate with the key")
cmd.MarkFlagRequired("value")
return cmd
}
func (cli *cliLapi) newContextStatusCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "status", Use: "status",
Short: "List context to send with alerts", Short: "List context to send with alerts",
DisableAutoGenTag: true, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(_ *cobra.Command, _ []string) error {
hub, err := require.Hub(csConfig, nil, nil) cfg := cli.cfg()
hub, err := require.Hub(cfg, nil, nil)
if err != nil { if err != nil {
return err return err
} }
if err = alertcontext.LoadConsoleContext(csConfig, hub); err != nil { if err = alertcontext.LoadConsoleContext(cfg, hub); err != nil {
return fmt.Errorf("while loading context: %w", err) return fmt.Errorf("while loading context: %w", err)
} }
if len(csConfig.Crowdsec.ContextToSend) == 0 { if len(cfg.Crowdsec.ContextToSend) == 0 {
fmt.Println("No context found on this agent. You can use 'cscli lapi context add' to add context to your alerts.") fmt.Println("No context found on this agent. You can use 'cscli lapi context add' to add context to your alerts.")
return nil return nil
} }
dump, err := yaml.Marshal(csConfig.Crowdsec.ContextToSend) dump, err := yaml.Marshal(cfg.Crowdsec.ContextToSend)
if err != nil { if err != nil {
return fmt.Errorf("unable to show context status: %w", err) return fmt.Errorf("unable to show context status: %w", err)
} }
@ -332,10 +349,14 @@ cscli lapi context add --value evt.Meta.source_ip --value evt.Meta.target_user
return nil return nil
}, },
} }
cmdContext.AddCommand(cmdContextStatus)
return cmd
}
func (cli *cliLapi) newContextDetectCmd() *cobra.Command {
var detectAll bool var detectAll bool
cmdContextDetect := &cobra.Command{
cmd := &cobra.Command{
Use: "detect", Use: "detect",
Short: "Detect available fields from the installed parsers", Short: "Detect available fields from the installed parsers",
Example: `cscli lapi context detect --all Example: `cscli lapi context detect --all
@ -343,6 +364,7 @@ cscli lapi context detect crowdsecurity/sshd-logs
`, `,
DisableAutoGenTag: true, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg := cli.cfg()
if !detectAll && len(args) == 0 { if !detectAll && len(args) == 0 {
log.Infof("Please provide parsers to detect or --all flag.") log.Infof("Please provide parsers to detect or --all flag.")
printHelp(cmd) printHelp(cmd)
@ -355,13 +377,13 @@ cscli lapi context detect crowdsecurity/sshd-logs
return fmt.Errorf("failed to init expr helpers: %w", err) return fmt.Errorf("failed to init expr helpers: %w", err)
} }
hub, err := require.Hub(csConfig, nil, nil) hub, err := require.Hub(cfg, nil, nil)
if err != nil { if err != nil {
return err return err
} }
csParsers := parser.NewParsers(hub) csParsers := parser.NewParsers(hub)
if csParsers, err = parser.LoadParsers(csConfig, csParsers); err != nil { if csParsers, err = parser.LoadParsers(cfg, csParsers); err != nil {
return fmt.Errorf("unable to load parsers: %w", err) return fmt.Errorf("unable to load parsers: %w", err)
} }
@ -418,47 +440,85 @@ cscli lapi context detect crowdsecurity/sshd-logs
return nil return nil
}, },
} }
cmdContextDetect.Flags().BoolVarP(&detectAll, "all", "a", false, "Detect evt field for all installed parser") cmd.Flags().BoolVarP(&detectAll, "all", "a", false, "Detect evt field for all installed parser")
cmdContext.AddCommand(cmdContextDetect)
cmdContextDelete := &cobra.Command{ return cmd
}
func (cli *cliLapi) newContextDeleteCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "delete", Use: "delete",
DisableAutoGenTag: true, DisableAutoGenTag: true,
RunE: func(_ *cobra.Command, _ []string) error { RunE: func(_ *cobra.Command, _ []string) error {
filePath := csConfig.Crowdsec.ConsoleContextPath filePath := cli.cfg().Crowdsec.ConsoleContextPath
if filePath == "" { if filePath == "" {
filePath = "the context file" filePath = "the context file"
} }
fmt.Printf("Command \"delete\" is deprecated, please manually edit %s.", filePath) fmt.Printf("Command 'delete' is deprecated, please manually edit %s.", filePath)
return nil return nil
}, },
} }
cmdContext.AddCommand(cmdContextDelete)
return cmdContext return cmd
} }
func detectStaticField(GrokStatics []parser.ExtraField) []string { func (cli *cliLapi) newContextCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "context [command]",
Short: "Manage context to send with alerts",
DisableAutoGenTag: true,
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
cfg := cli.cfg()
if err := cfg.LoadCrowdsec(); err != nil {
fileNotFoundMessage := fmt.Sprintf("failed to open context file: open %s: no such file or directory", cfg.Crowdsec.ConsoleContextPath)
if err.Error() != fileNotFoundMessage {
return fmt.Errorf("unable to load CrowdSec agent configuration: %w", err)
}
}
if cfg.DisableAgent {
return errors.New("agent is disabled and lapi context can only be used on the agent")
}
return nil
},
Run: func(cmd *cobra.Command, _ []string) {
printHelp(cmd)
},
}
cmd.AddCommand(cli.newContextAddCmd())
cmd.AddCommand(cli.newContextStatusCmd())
cmd.AddCommand(cli.newContextDetectCmd())
cmd.AddCommand(cli.newContextDeleteCmd())
return cmd
}
func detectStaticField(grokStatics []parser.ExtraField) []string {
ret := make([]string, 0) ret := make([]string, 0)
for _, static := range GrokStatics { for _, static := range grokStatics {
if static.Parsed != "" { if static.Parsed != "" {
fieldName := fmt.Sprintf("evt.Parsed.%s", static.Parsed) fieldName := fmt.Sprintf("evt.Parsed.%s", static.Parsed)
if !slices.Contains(ret, fieldName) { if !slices.Contains(ret, fieldName) {
ret = append(ret, fieldName) ret = append(ret, fieldName)
} }
} }
if static.Meta != "" { if static.Meta != "" {
fieldName := fmt.Sprintf("evt.Meta.%s", static.Meta) fieldName := fmt.Sprintf("evt.Meta.%s", static.Meta)
if !slices.Contains(ret, fieldName) { if !slices.Contains(ret, fieldName) {
ret = append(ret, fieldName) ret = append(ret, fieldName)
} }
} }
if static.TargetByName != "" { if static.TargetByName != "" {
fieldName := static.TargetByName fieldName := static.TargetByName
if !strings.HasPrefix(fieldName, "evt.") { if !strings.HasPrefix(fieldName, "evt.") {
fieldName = "evt." + fieldName fieldName = "evt." + fieldName
} }
if !slices.Contains(ret, fieldName) { if !slices.Contains(ret, fieldName) {
ret = append(ret, fieldName) ret = append(ret, fieldName)
} }
@ -526,6 +586,7 @@ func detectSubNode(node parser.Node, parserCTX parser.UnixParserCtx) []string {
} }
} }
} }
if subnode.Grok.RegexpName != "" { if subnode.Grok.RegexpName != "" {
grokCompiled, err := parserCTX.Grok.Get(subnode.Grok.RegexpName) grokCompiled, err := parserCTX.Grok.Get(subnode.Grok.RegexpName)
if err == nil { if err == nil {

View file

@ -241,7 +241,7 @@ It is meant to allow you to manage bans, parsers/scenarios/etc, api and generall
cmd.AddCommand(NewCLIBouncers(cli.cfg).NewCommand()) cmd.AddCommand(NewCLIBouncers(cli.cfg).NewCommand())
cmd.AddCommand(NewCLIMachines(cli.cfg).NewCommand()) cmd.AddCommand(NewCLIMachines(cli.cfg).NewCommand())
cmd.AddCommand(NewCLICapi().NewCommand()) cmd.AddCommand(NewCLICapi().NewCommand())
cmd.AddCommand(NewLapiCmd()) cmd.AddCommand(NewCLILapi(cli.cfg).NewCommand())
cmd.AddCommand(NewCompletionCmd()) cmd.AddCommand(NewCompletionCmd())
cmd.AddCommand(NewConsoleCmd()) cmd.AddCommand(NewConsoleCmd())
cmd.AddCommand(NewCLIExplain().NewCommand()) cmd.AddCommand(NewCLIExplain().NewCommand())