replit/clui

Support for ordered (unnamed) arguments

akramhussein opened this issue Β· 5 comments

First, let me say what a fantastic project this is and it's super easy to use πŸ™. Thank you for building and sharing.

I wanted to get your thoughts on how I could extend the @replit/clui-input library to support ordered unnamed arguments before and possibly after named arguments (not as important).

I've tried to read through all the code first and before I began tinkering wanted to check to my thinking - maybe it's already possible and you can point me in the right direction.

At the moment, the input structure is:

COMMAND [SUBCOMMAND] [--KEY [VALUE]]

Where:

  • There can be 0 to N subcommands to each (sub)command.
  • There can be 0 to N named arguments (--key value) per (sub)command which can be optional and typed.
  • Argument value must be preceded by a key.
  • Argument value not always required - if flag or
  • (Sub)commands and argument options can be resolved at runtime if they are async.

What I'd like to support in my application is:

COMMAND [SUBCOMMAND] [ARG] [--KEY [VALUE]]

The difference being the there is no requirement to identify the key and a free form value is valid for ARG (can be typed and required like named arguments), so it doesn't need to be from an options list.

In theory, this is already achievable if you:

  1. Don't provide options to the argument and enter them free-form, but still requires --KEY before.
  2. Treat ordered unnamed arguments as subcommands, but this means the values must all be known upfront. If the user wanted to enter COMMAND SUBCOMMAND foo, you would need to know foo in advance. I'd like to allow the user to enter a free form value (string, number, boolean) without specifying the key.
  3. Use ordered unnamed arguments after named arguments/the AST is exhausted and manually parse them after receiving an update from the library - loses the clean coupling with specific (sub)commands and forces user to always put named arguments before is a bit awkward when compared to normal CLI.

I imagine you already considered this and it's absence is due to the complexity of handling it, but I figured I'd check anyways.

At the top-level interface, one possible proposal would be to pass a config as such:

  const root: ICommand = {
    commands: {
      user: {
        args: {
          info: {
            type: 'boolean',
          },
          name: {
            type: 'string',
            key: false
          },
        },
      },
    },
  };

Where key: boolean indictes if it requires the --KEY before and if not, it is expected in the order it appears in the config. This however creates the situation of type: boolean + key: false = neither key or value are required.

Therefore, flags could be changed to an explicity property such as argType: 'flag' | 'named' | 'unnamed to combine them all.

Alternatively, it could be it's own property such as orderedArgs (for lack of better name):

  const root: ICommand = {
    commands: {
      user: {
        orderedArgs: {
          nickname: {
            type: 'string',
            required: true
          },
          email: {
          	type: 'string'
          }
        },
        args: {
          info: {
            type: 'boolean',
          },
          name: {
            type: 'string',
          },
        },
      },
    },
  };

From here it's possible the configuration object interfaces used would be almost identical to ICommandArgs except that IArg would not need options. However, since options is already optional, it could use the same interfaces outside the AST and for ASTNodeKind but have a different nodes:

export interface IOrderedArgKeyNode {
  kind: 'ARG_KEY';
  parent: IParamNode;
  token: IToken;
  name: string;
}

export interface IOrderedArgValueNode {
  kind: 'ARG_VALUE';
  parent: IParamNode;
  token: IToken;
}

export interface IOrderedArgNode {
  kind: 'ARG';
  ref: IArg;
  parent: ICmdNode;
  key: IArgKeyNode;
  value?: IArgValueNode;
}

If it would be clearer, interfaces could be more specific outside and inside the AST. For example:

export type ASTNodeKind =
  ...
  | 'ORDERED_ARG'
  | 'ORDERED_ARG_KEY'
  | 'ORDERED_ARG_VALUE'
  ...


export interface IOrderedArgs<D = any> {
  type?: OrderedArgTypeDef;
  required?: true;
  data?: D;
}

....etc

My intuition is taking the simplest path here of just augmenting the current IArg interface by setting a property to identify if its a flag, named or unnamed would be best and then the focus would be on the logic in parser.ts to change the logic for identifying arguments in all 3 styles.

Do you think this is something that is straightforward or would it fundamentally break how you've designed the parsing/AST/options system?

I'd appreciate any input you can provide and will glady create a PR if I can make progress.

I hope this makes, but if not please let me know.

moudy commented

Interesting thoughts! I need to think about this for a bit.

  1. Treat ordered unnamed arguments as subcommands, but this means the values must all be known upfront. If the user wanted to enter COMMAND SUBCOMMAND foo, you would need to know foo in advance. I'd like to allow the user to enter a free form value (string, number, boolean) without specifying the key.

I think I would want to explore this option more before adding this as a feature. Maybe there could be higher-level utility functions that wrap the existing logic.

Glad you are finding this useful! I'm curious, what are building with it (if you.can share)?

I'm curious, what are building with it (if you.can share)?

Using it for a data processing system with a command line in React app.

higher-level utility functions

I wonder if that could work with a proxy?

The proxy's job would be to intermediate between 2 representations - UI representation and the clui lib representation - which might just be handling the index positions and then calling update function as it is now but passing a succinct representation and the full representation - just so when you want to act on the final state of the UI, you have a proper object where all commands/args are keyed and typed.

For example:

UI:COMMAND SUBCOMMAND ARG_VAL ARG_VAL --ARG_KEY ARG_VAL

PROXY: πŸ”ΌπŸ”½

clui: COMMAND SUBCOMMAND --ARG_KEY ARG_VAL --ARG_KEY ARG_VAL --ARG_KEY ARG_VAL

But you might still need to declare somewhere the position of the argument as well as explicitly or implicitly that a key is/isn't required?

moudy commented

If I'm understanding your request correctly I think it's possible to do this by nesting commands functions.

const root: ICommand = {
    commands: {
      user: {
        commands: {
          post: {
            args: sharedArgs,
            run: () =>
              // handle 'user post --tag a'
              null,

            commands: async (val?: string) => {
              if (!val) {
                return {};
              }

              return {
                [val]: {
                  commands: {
                    preview: {
                      run: () =>
                        // handle 'user post --tag a preview' or 'user post "user input" --tag a preview'
                        null,
                    },
                  },
                  args: sharedArgs,
                  run: () =>
                    // handle 'user post "user input" --tag a'
                    null,
                },
              };
            },
          },
        },
      },
    },
  };

// input "user post --tag a preview" would give you:
[
      {
        "name": "user"
      },
      {
        "args": {
          "tag": "a"
        },
        "name": "post"
      },
      {
        "name": "preview"
      }
]



// input: "user post "user input" --tag a preview"  would give you:
[
      {
        "name": "user"
      },
      {
        "name": "post"
      },
      {
        "args": {
          "tag": "a"
        },
        "name": "\"user input\""
      },
      {
        "name": "preview"
      }
]

I made a branch demonstrating the above code in a test. Is this what you're trying to achieve?

Defining commands like this is not ideal so I was thinking that there could be a utility function that does the same thing as above but with a nicer API.

I've run some tests and so far it looks good! I'm going to keep testing with it and will let you know but definitely feels like it could work and especially with some utility functions.

Sorry for the delay getting back.

Looks good.

One thing to note is it's valid to re-use sharedArgs, e.g. 'user post --tag a "user input" --tag a preview' is valid. This isn't a big issue because that would be true of a traditional CLI and you'd take the last one as final choice, but worth noting as if you are using autocomplete it will keep coming up (unless you filter when using).