From 502e6a17e400f2454502268d1a18717ad0d4a401 Mon Sep 17 00:00:00 2001 From: Brennan Lamey Date: Wed, 22 Jan 2025 11:24:12 -0600 Subject: [PATCH 1/4] kwil-cli: fix generate-key command --- cmd/kwil-cli/cmds/utils/generate_key.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/kwil-cli/cmds/utils/generate_key.go b/cmd/kwil-cli/cmds/utils/generate_key.go index 72eb3baa2..69bd40173 100644 --- a/cmd/kwil-cli/cmds/utils/generate_key.go +++ b/cmd/kwil-cli/cmds/utils/generate_key.go @@ -38,7 +38,7 @@ func generateKeyCmd() *cobra.Command { pubKeyBts := pubKey.Bytes() pubKeyHex := hex.EncodeToString(pubKeyBts) - address, err := auth.EthSecp256k1Authenticator{}.Identifier(pubKeyBts) + address, err := auth.EthSecp256k1Authenticator{}.Identifier(crypto.EthereumAddressFromPubKey(pubKey.(*crypto.Secp256k1PublicKey))) if err != nil { return display.PrintErr(cmd, err) } @@ -89,7 +89,7 @@ func (p *privateKeyFileRes) MarshalText() (text []byte, err error) { bts = append(bts, p.PrivateKeyPath...) bts = append(bts, []byte("\nPublic key: ")...) bts = append(bts, p.PublicKey...) - bts = append(bts, []byte("\nAddress: ")...) + bts = append(bts, []byte("\nAddress: ")...) bts = append(bts, p.Address...) return bts, nil } @@ -108,9 +108,9 @@ func (p *privateKeyRes) MarshalJSON() ([]byte, error) { func (p *privateKeyRes) MarshalText() (text []byte, err error) { bts := []byte("Private key: ") bts = append(bts, p.PrivateKey...) - bts = append(bts, []byte("\nPublic key: ")...) + bts = append(bts, []byte("\nPublic key: ")...) bts = append(bts, p.PublicKey...) - bts = append(bts, []byte("\nAddress: ")...) + bts = append(bts, []byte("\nAddress: ")...) bts = append(bts, p.Address...) return bts, nil } From 5598b974b057fd34b59ea2d1827b66c6f289b0da Mon Sep 17 00:00:00 2001 From: Brennan Lamey Date: Wed, 22 Jan 2025 14:13:38 -0600 Subject: [PATCH 2/4] kwil-cli: small formatting changes --- cmd/kwil-cli/cmds/call-action.go | 17 ++++++++++++++-- cmd/kwil-cli/cmds/exec-sql.go | 2 +- cmd/kwil-cli/cmds/query.go | 23 ++++++++++++++++----- cmd/kwil-cli/cmds/tableprint.go | 27 +++++++++---------------- cmd/kwil-cli/cmds/utils/generate_key.go | 2 +- test/setup/jsonrpc_cli_driver.go | 2 +- 6 files changed, 45 insertions(+), 28 deletions(-) diff --git a/cmd/kwil-cli/cmds/call-action.go b/cmd/kwil-cli/cmds/call-action.go index d364198e1..6253dcccc 100644 --- a/cmd/kwil-cli/cmds/call-action.go +++ b/cmd/kwil-cli/cmds/call-action.go @@ -150,12 +150,25 @@ func (r *respCall) MarshalJSON() ([]byte, error) { return bts, nil } +func getStringRows(v [][]any) [][]string { + var rows [][]string + for _, r := range v { + var row []string + for _, c := range r { + row = append(row, fmt.Sprintf("%v", c)) + } + rows = append(rows, row) + } + + return rows +} + func (r *respCall) MarshalText() (text []byte, err error) { if !r.PrintLogs { - return recordsToTable(r.Data.QueryResult.ExportToStringMap(), r.tableConf), nil + return recordsToTable(r.Data.QueryResult.ColumnNames, getStringRows(r.Data.QueryResult.Values), r.tableConf), nil } - bts := recordsToTable(r.Data.QueryResult.ExportToStringMap(), r.tableConf) + bts := recordsToTable(r.Data.QueryResult.ColumnNames, getStringRows(r.Data.QueryResult.Values), r.tableConf) if len(r.Data.Logs) > 0 { bts = append(bts, []byte("\n\nLogs:")...) diff --git a/cmd/kwil-cli/cmds/exec-sql.go b/cmd/kwil-cli/cmds/exec-sql.go index 3d60fab8b..a7c374609 100644 --- a/cmd/kwil-cli/cmds/exec-sql.go +++ b/cmd/kwil-cli/cmds/exec-sql.go @@ -104,7 +104,7 @@ func execSQLCmd() *cobra.Command { }, } - cmd.Flags().StringVarP(&sqlStmt, "statement", "s", "", "the SQL statement to execute") + cmd.Flags().StringVarP(&sqlStmt, "stmt", "s", "", "the SQL statement to execute") cmd.Flags().StringVarP(&sqlFilepath, "file", "f", "", "the file containing the SQL statement(s) to execute") cmd.Flags().StringArrayVarP(¶ms, "param", "p", nil, `the parameters to pass to the SQL statement. format: "key:type=value"`) common.BindTxFlags(cmd) diff --git a/cmd/kwil-cli/cmds/query.go b/cmd/kwil-cli/cmds/query.go index 25a25ad4b..239ccff1b 100644 --- a/cmd/kwil-cli/cmds/query.go +++ b/cmd/kwil-cli/cmds/query.go @@ -34,15 +34,27 @@ kwil-cli query "SELECT * FROM my_table WHERE id = $id" --param id:int=1` func queryCmd() *cobra.Command { var namedParams []string var gwAuth, rpcAuth bool + var stmt string cmd := &cobra.Command{ Use: "query", Short: "Execute a SELECT statement against the database", Long: queryLong, Example: queryExample, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if len(args) != 1 { - return display.PrintErr(cmd, fmt.Errorf("SELECT statement must be the only argument")) + var sqlStmt string + switch { + case stmt != "" && len(args) == 0: + sqlStmt = stmt + case stmt == "" && len(args) == 1: + sqlStmt = args[0] + case stmt != "" && len(args) == 1: + return display.PrintErr(cmd, fmt.Errorf("cannot provide both a --stmt flag and an argument")) + case stmt == "" && len(args) == 0: + return display.PrintErr(cmd, fmt.Errorf("no SQL statement provided")) + default: + return display.PrintErr(cmd, fmt.Errorf("unexpected error")) } tblConf, err := getTableConfig(cmd) @@ -69,13 +81,13 @@ func queryCmd() *cobra.Command { return display.PrintErr(cmd, err) } - _, err = parse.Parse(args[0]) + _, err = parse.Parse(sqlStmt) if err != nil { return display.PrintErr(cmd, fmt.Errorf("failed to parse SQL statement: %s", err)) } return client.DialClient(cmd.Context(), cmd, dialFlags, func(ctx context.Context, cl clientType.Client, conf *config.KwilCliConfig) error { - res, err := cl.Query(ctx, args[0], params) + res, err := cl.Query(ctx, sqlStmt, params) if err != nil { return display.PrintErr(cmd, err) } @@ -85,6 +97,7 @@ func queryCmd() *cobra.Command { }, } + cmd.Flags().StringVarP(&stmt, "stmt", "s", "", "the SELECT statement to execute") cmd.Flags().StringArrayVarP(&namedParams, "param", "p", nil, `named parameters that will be used in the query. format: "key:type=value"`) cmd.Flags().BoolVar(&rpcAuth, "rpc-auth", false, "signals that the call is being made to a kwil node and should be authenticated with the private key") cmd.Flags().BoolVar(&gwAuth, "gateway-auth", false, "signals that the call is being made to a gateway and should be authenticated with the private key") @@ -106,5 +119,5 @@ func (r *respRelations) MarshalJSON() ([]byte, error) { } func (r *respRelations) MarshalText() ([]byte, error) { - return recordsToTable(r.Data.ExportToStringMap(), r.conf), nil + return recordsToTable(r.Data.ColumnNames, getStringRows(r.Data.Values), r.conf), nil } diff --git a/cmd/kwil-cli/cmds/tableprint.go b/cmd/kwil-cli/cmds/tableprint.go index 6f289bcc8..7a620795e 100644 --- a/cmd/kwil-cli/cmds/tableprint.go +++ b/cmd/kwil-cli/cmds/tableprint.go @@ -2,7 +2,6 @@ package cmds import ( "bytes" - "sort" "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" @@ -56,39 +55,31 @@ func (t *tableConfig) apply(table *tablewriter.Table) { // recordsToTable converts records to a formatted table structure // that can be printed -func recordsToTable(data []map[string]string, c *tableConfig) []byte { +func recordsToTable(columns []string, rows [][]string, c *tableConfig) []byte { if c == nil { c = &tableConfig{} } - if len(data) == 0 { + if len(rows) == 0 { return []byte("No data to display.") } // collect headers - headers := make([]string, 0, len(data[0])) - for k := range data[0] { - headers = append(headers, k) - } - - // keep the headers in a sorted order - sort.Strings(headers) var buf bytes.Buffer table := tablewriter.NewWriter(&buf) - table.SetHeader(headers) + table.SetHeader(columns) table.SetAutoFormatHeaders(false) table.SetBorders( tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) c.apply(table) - for _, row := range data { - rs := make([]string, 0, len(headers)) - for _, h := range headers { - v := row[h] - if c.maxRowWidth > 0 && len(v) > c.maxRowWidth { - v = v[:c.maxRowWidth] + "..." + for _, row := range rows { + rs := make([]string, 0, len(columns)) + for _, col := range row { + if c.maxRowWidth > 0 && len(col) > c.maxRowWidth { + col = col[:c.maxRowWidth] + "..." } - rs = append(rs, v) + rs = append(rs, col) } table.Append(rs) } diff --git a/cmd/kwil-cli/cmds/utils/generate_key.go b/cmd/kwil-cli/cmds/utils/generate_key.go index 69bd40173..a5cb91e6b 100644 --- a/cmd/kwil-cli/cmds/utils/generate_key.go +++ b/cmd/kwil-cli/cmds/utils/generate_key.go @@ -38,7 +38,7 @@ func generateKeyCmd() *cobra.Command { pubKeyBts := pubKey.Bytes() pubKeyHex := hex.EncodeToString(pubKeyBts) - address, err := auth.EthSecp256k1Authenticator{}.Identifier(crypto.EthereumAddressFromPubKey(pubKey.(*crypto.Secp256k1PublicKey))) + address, err := auth.EthSecp256k1Authenticator{}.Identifier(auth.GetUserSigner(pk).CompactID()) if err != nil { return display.PrintErr(cmd, err) } diff --git a/test/setup/jsonrpc_cli_driver.go b/test/setup/jsonrpc_cli_driver.go index 27a225acf..54435d50a 100644 --- a/test/setup/jsonrpc_cli_driver.go +++ b/test/setup/jsonrpc_cli_driver.go @@ -312,7 +312,7 @@ func stringifyCLIArg(a any) string { } func (j *jsonRPCCLIDriver) ExecuteSQL(ctx context.Context, sql string, params map[string]any, opts ...client.TxOpt) (types.Hash, error) { - args := append([]string{"exec-sql"}, "--statement", sql) + args := append([]string{"exec-sql"}, "--stmt", sql) for k, v := range params { encoded, err := types.EncodeValue(v) if err != nil { From 420e683cda1bcd0e24f532f2141c91cb687bd207 Mon Sep 17 00:00:00 2001 From: Brennan Lamey Date: Thu, 23 Jan 2025 16:40:35 -0600 Subject: [PATCH 3/4] fix namespace function error --- node/engine/functions.go | 13 ++++++++++--- node/engine/interpreter/interpreter_test.go | 11 +++++++++++ node/engine/interpreter/planner.go | 2 +- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/node/engine/functions.go b/node/engine/functions.go index bf62c56c9..40ade78ef 100644 --- a/node/engine/functions.go +++ b/node/engine/functions.go @@ -279,18 +279,25 @@ var ( }, "array_length": &ScalarFunctionDefinition{ ValidateArgsFunc: func(args []*types.DataType) (*types.DataType, error) { - if len(args) != 1 { - return nil, wrapErrArgumentNumber(1, len(args)) + if len(args) < 1 || len(args) > 2 { + return nil, fmt.Errorf("invalid number of arguments: expected 1 or 2, got %d", len(args)) } if !args[0].IsArray { return nil, fmt.Errorf("%w: expected argument to be an array, got %s", ErrType, args[0].String()) } + if len(args) == 2 && !args[1].Equals(types.IntType) { + return nil, wrapErrArgumentType(types.IntType, args[1]) + } + return types.IntType, nil }, PGFormatFunc: func(inputs []string) (string, error) { - return fmt.Sprintf("array_length(%s, 1)", inputs[0]), nil + if len(inputs) == 1 { + return fmt.Sprintf("array_length(%s, 1)", inputs[0]), nil + } + return fmt.Sprintf("array_length(%s, %s)", inputs[0], inputs[1]), nil }, }, "array_remove": &ScalarFunctionDefinition{ diff --git a/node/engine/interpreter/interpreter_test.go b/node/engine/interpreter/interpreter_test.go index ca6c8d0cd..282417f22 100644 --- a/node/engine/interpreter/interpreter_test.go +++ b/node/engine/interpreter/interpreter_test.go @@ -1761,6 +1761,17 @@ func Test_Actions(t *testing.T) { action: "call_get_null", }, // TODO: test that actions returning nulls to other actions does not error + { + // this is a regression test + name: "special functions called in new namespaces works as expected", + stmt: []string{ + `CREATE NAMESPACE test;`, + `{test} create action get_uuid() public view returns (uuid) {return uuid_generate_kwil('a');}`, + }, + namespace: "test", + action: "get_uuid", + results: [][]any{{mustUUID("819ab751-e64c-5259-bbae-4d36f25bdd84")}}, + }, } db, err := newTestDB() diff --git a/node/engine/interpreter/planner.go b/node/engine/interpreter/planner.go index 80bd5af5a..b0e7890ae 100644 --- a/node/engine/interpreter/planner.go +++ b/node/engine/interpreter/planner.go @@ -1956,7 +1956,7 @@ func (i *interpreterPlanner) VisitCreateNamespaceStatement(p0 *parse.CreateNames } exec.interpreter.namespaces[p0.Namespace] = &namespace{ - availableFunctions: make(map[string]*executable), + availableFunctions: copyBuiltinExecutables(), tables: make(map[string]*engine.Table), onDeploy: func(*executionContext) error { return nil }, onUndeploy: func(*executionContext) error { return nil }, From 5875b97aaff148862f3e59e4933827d784356b21 Mon Sep 17 00:00:00 2001 From: Brennan Lamey Date: Thu, 23 Jan 2025 16:44:12 -0600 Subject: [PATCH 4/4] added help command formatting --- app/root.go | 17 +--- app/shared/generate/generate.go | 7 +- app/shared/help.go | 14 ++- app/shared/wrap.go | 170 ++++++++++++++++++++++++++++++++ cmd/kwil-cli/cmds/root.go | 3 + 5 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 app/shared/wrap.go diff --git a/app/root.go b/app/root.go index 2e7a3186d..eedee7f8d 100644 --- a/app/root.go +++ b/app/root.go @@ -78,22 +78,7 @@ func RootCmd() *cobra.Command { cmd.AddCommand(verCmd.NewVersionCmd()) // Apply the custom help function to the current command - shared.SetSanitizedHelpFunc(cmd) - - // Recursively apply to all subcommands - for _, subCmd := range cmd.Commands() { - applySanitizedHelpFuncRecursively(subCmd) - } + shared.ApplySanitizedHelpFuncRecursively(cmd) return cmd } - -func applySanitizedHelpFuncRecursively(cmd *cobra.Command) { - // Apply the custom help function to the current command - shared.SetSanitizedHelpFunc(cmd) - - // Recursively apply to all subcommands - for _, subCmd := range cmd.Commands() { - applySanitizedHelpFuncRecursively(subCmd) - } -} diff --git a/app/shared/generate/generate.go b/app/shared/generate/generate.go index 9636f36fd..8ce3732e5 100644 --- a/app/shared/generate/generate.go +++ b/app/shared/generate/generate.go @@ -13,6 +13,7 @@ import ( type writeFunc func(string) (*os.File, error) func WriteDocs(cmd *cobra.Command, dir string) error { + return writeCmd(cmd, ".", 0, func(s string) (*os.File, error) { fullPath := filepath.Join(dir, s) err := os.MkdirAll(filepath.Dir(fullPath), 0755) @@ -71,7 +72,7 @@ func createIndexFile(cmd *cobra.Command, dir string, write writeFunc, first bool file.WriteString(header.String() + "\n\n") - return doc.GenMarkdownCustom(cmd, file, linkHandler(dir)) + return doc.GenMarkdownCustom(cmd, file, linkHandler()) } // createCmdFile creates a file for the command, and writes the command's documentation to it. @@ -94,10 +95,10 @@ func createCmdFile(cmd *cobra.Command, dir string, idx int, write writeFunc) err file.WriteString(header.String() + "\n\n") - return doc.GenMarkdownCustom(cmd, file, linkHandler(dir)) + return doc.GenMarkdownCustom(cmd, file, linkHandler()) } -func linkHandler(dir string) func(string) string { // dir string unused -- what is it? +func linkHandler() func(string) string { return func(s string) string { // trying just linking ids?? s = strings.TrimSuffix(s, ".md") diff --git a/app/shared/help.go b/app/shared/help.go index b82ef96e7..17e2cb4a3 100644 --- a/app/shared/help.go +++ b/app/shared/help.go @@ -18,8 +18,10 @@ func SetSanitizedHelpFunc(cmd *cobra.Command) { originalHelpFunc := cmd.HelpFunc() cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { - cmd.Short = removeBackticks(cmd.Short) - cmd.Long = removeBackticks(cmd.Long) + cmd.Short = wrapTextToTerminalWidth(removeBackticks(cmd.Short)) + cmd.Long = wrapTextToTerminalWidth(removeBackticks(cmd.Long)) + wrapFlags(cmd.Flags()) + wrapFlags(cmd.PersistentFlags()) // Delegate to the original HelpFunc to avoid recursion if originalHelpFunc != nil { @@ -32,3 +34,11 @@ func SetSanitizedHelpFunc(cmd *cobra.Command) { } }) } + +func ApplySanitizedHelpFuncRecursively(cmd *cobra.Command) { + SetSanitizedHelpFunc(cmd) + + for _, subCmd := range cmd.Commands() { + ApplySanitizedHelpFuncRecursively(subCmd) + } +} diff --git a/app/shared/wrap.go b/app/shared/wrap.go new file mode 100644 index 000000000..c4ef7b8fc --- /dev/null +++ b/app/shared/wrap.go @@ -0,0 +1,170 @@ +package shared + +import ( + "os" + "strings" + + "github.com/spf13/pflag" + "golang.org/x/term" +) + +// wrapTextToTerminalWidth wraps text by detecting the current +// terminal width (columns) and using that as the wrap limit. +// If it can't get the terminal width, it will wrap it to 80 +func wrapTextToTerminalWidth(text string) string { + // Get the terminal size from standard output. + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + width = 80 + } + + return WrapText(text, width) +} + +// WrapText wraps text by the specified width +func WrapText(text string, width int) string { + return wrapText(text, width-2) // for safety, sometimes terminal still doesn't wrap properly +} + +// wrapFlag wraps all flag descriptions. It does this by accounting for the characters +// that are to the left of the flag description, as well as the terminal width. +// If the width can't be determined, it won't wrap the flags. +func wrapFlags(f *pflag.FlagSet) { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + return + } + + /* example unwrapped output: + Flags: + --csv string CSV file containing the parameters to pass to the action + -m, --csv-mapping stringArray mapping of CSV columns to action parameters. format: "csv_column:action_param_name" OR "csv_column:action_param_position" + -h, --help help for exec-action + -n, --namespace string namespace to execute the action in + -N, --nonce int nonce override (-1 means request from server) (default -1) + -p, --param stringArray named parameters that will override any positional or CSV parameters. format: "name:type=value" + --sync synchronous broadcast (wait for it to be included in a block) + + Global Flags: + -Y, --assume-yes Assume yes for all prompts + --chain-id string the expected/intended Kwil Chain ID + -c, --config string the path to the Kwil CLI persistent global settings file (default "/Users/brennanlamey/.kwil-cli/config.json") + --output string the format for command output - either 'text' or 'json' (default "text") + --private-key string the private key of the wallet that will be used for signing + --provider string the Kwil provider RPC endpoint (default "http://127.0.0.1:8484") + -S, --silence Silence logs + */ + + // we first find the widest flag (shorthand, long, and type). + // Cobra adjusts the width of all flags to the widest flag. + var widest int + f.VisitAll(func(f *pflag.Flag) { + length := 2 // initial offset value + if f.Shorthand != "" { + length += 2 // -x, + } + if f.Shorthand != "" && f.Name != "" { + length += 2 // account for the comma and space between: -x, --name + } + if f.Name != "" { + length += len(f.Name) + 2 // --name + } + if f.Value.Type() != "" { + length += len(f.Value.Type()) + 1 // type, plus the space between type and name + } + // an additional 3 spaces, between the end and the start of the description + length += 3 + + if length > widest { + widest = length + } + }) + + wrapTo := width - widest - 4 + // now we wrap the descriptions + f.VisitAll(func(f *pflag.Flag) { + if f.Usage == "" { + return + } + + str := f.Usage + + f.Usage = wrapLine(str, wrapTo) + }) +} + +// wrapLine wraps a single paragraph (with no \n) to the given width. +func wrapLine(text string, width int) string { + words := strings.Fields(text) + if len(words) == 0 { + return "" + } + + var sb strings.Builder + lineLen := 0 + + for i, w := range words { + // First word on a line + if i == 0 { + sb.WriteString(w) + lineLen = len(w) + continue + } + // Check if adding this word + 1 space exceeds width + if lineLen+1+len(w) > width { + sb.WriteString("\n") + sb.WriteString(w) + lineLen = len(w) + } else { + sb.WriteString(" ") + sb.WriteString(w) + lineLen += 1 + len(w) + } + } + + return sb.String() +} + +// wrapText removes single line breaks (replacing them with spaces), +// preserves double breaks (or lines starting with '-') as separators, +// and wraps each "paragraph" to the given width. +func wrapText(text string, width int) string { + // Split the original text by lines + lines := strings.Split(text, "\n") + + var resultParts []string + var currentParagraph []string + + // Flush the current paragraph: join with spaces, wrap, add to results + flushParagraph := func() { + if len(currentParagraph) > 0 { + joined := strings.Join(currentParagraph, " ") + wrapped := wrapLine(joined, width) + resultParts = append(resultParts, wrapped) + currentParagraph = nil + } + } + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // If line is blank or starts with '-', we preserve it as-is (separator). + if trimmed == "" || strings.HasPrefix(trimmed, "-") { + // Flush whatever paragraph we have so far + flushParagraph() + // Then just keep this line as its own entry (unwrapped). + // For blank lines, 'trimmed' == "", but we append the original line + // so it remains a visual blank line. Or if it's "-something", keep it as is. + resultParts = append(resultParts, line) + } else { + // Accumulate into our current paragraph + currentParagraph = append(currentParagraph, trimmed) + } + } + + // Flush last paragraph if needed + flushParagraph() + + // Rejoin everything with newlines + return strings.Join(resultParts, "\n") +} diff --git a/cmd/kwil-cli/cmds/root.go b/cmd/kwil-cli/cmds/root.go index 29d395f5a..1b3ef668f 100644 --- a/cmd/kwil-cli/cmds/root.go +++ b/cmd/kwil-cli/cmds/root.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/kwilteam/kwil-db/app/custom" + "github.com/kwilteam/kwil-db/app/shared" "github.com/kwilteam/kwil-db/app/shared/bind" "github.com/kwilteam/kwil-db/app/shared/display" "github.com/kwilteam/kwil-db/app/shared/version" @@ -72,5 +73,7 @@ func NewRootCmd() *cobra.Command { queryCmd(), ) + shared.ApplySanitizedHelpFuncRecursively(rootCmd) + return rootCmd }