arcanis/clipanion

Proxy option captures also flags defined before it

Closed this issue · 4 comments

When using clipanion 3.0.0-rc.11, given the following command definition:

import { Command, Option, Cli } from "clipanion";

class ProxiedCommand extends Command {
  static paths = [Command.Default];

  configPath = Option.String("-C,--config", {
    description: "configuration to load",
  });

  proxy = Option.Proxy();

  async execute(): Promise<void> {
    const { stdout } = this.context;
    stdout.write(`Configuration path was: ${this.configPath}.\n`);
    stdout.write(`Proxied arguments were: ${this.proxy}\n`);
  }
}

const [, , ...args] = process.argv;

const cli = new Cli();
cli.register(ProxiedCommand);
cli.runExit(args, Cli.defaultContext);

The behavior that I would have expected, according to the documentation, was:

$ ./proxied-command -C configPath command 1 2 3
Configuration path was: configPath.
Proxied arguments were: command,1,2,3

But the actual result was:

$ ./proxied-command -C configPath command 1 2 3
Configuration path was: undefined.
Proxied arguments were: -C,configPath,command,1,2,3

Is the expected behavior or a bug?

When using proxies, non-proxy arguments must be specified before the last path component (and thus require at least one path component). This is because we otherwise have no way to know whether -C was intended for the command or whatever it proxies (while it's perhaps not an issue for options like -C, it starts being a bit weird when you have things like --version, --verbose, or --help).

I see. It makes sense, although it was not obvious from the docs.

What I was trying to achieve here is to have a modular CLI where plugins that defined more commands could be loaded at runtime from a configuration file.

So my attempt so far looked something like this simplified version:

import { Command, Option, Cli } from "clipanion";
import path from "path";

class ConfigLoaderCommand extends Command {
  static paths = [Command.Default];

  configPath = Option.String("-C,--config", {
    description: "configuration to load",
  });

  args = Option.Proxy();

  async execute(): Promise<void> {
    // load the configuration from the current folder
    const config = require(path.resolve(this.configPath ?? "mycommand.config.js"))
    
    // build a CLI instance with all the plugins loaded
    const innerCli = new Cli()
    for(const command of config.plugins) {
    	cli.register(command);
    }
    
    // pass everything down to the built instance
    await innerCli.runExit(args, { ...this.context});
  }
}

const [, , ...args] = process.argv;

const cli = new Cli();
cli.register(ConfigLoaderCommand);
cli.runExit(args, Cli.defaultContext);

This did the job, except for the inability of reading -C (unless you want to parse it manually before handing over the other args to Clipanion).

I had made a version that uses Option.Rest(), which didn't have this problem, but has trouble with --help and other built-in flags.

Do you have any suggestions about what could be a better way of achieving this with Clipanion? Really loved the library so far, but hit a bit of a wall here. If there was an option of stopping the automatic handling of --help and similar from the outer CLI command, I guess Rest() would have worked here..? (Is there a way to disable that?)

You have a few options, but with their own drawbacks:

  • You can add a path; so instead of my-cli -C config arg1 arg2 ... you require my-cli -C config run arg1 arg2 ...
  • You can parse the -C option yourself (but it's not super pretty, of course)
  • You can use Option.Rest, and require your users to explicitly pass -- if they wish to proxy option arguments

Generally though, there's no way to keep parsing options once we may be inside a proxy, especially since we don't know for sure how the nested arguments will be parsed (for instance, if I write --foo -C config, perhaps the nested CLI would interpret it as --foo=-C config rather than --foo -C=config).

Thanks for taking the time for the detailed response.

I ended up adding a light layer pre-parsing using arg and I'm doing some work to augment the help command so that my "global options" are shown there as well. It's not super pretty, but I think that it's probably the most transparent option for the end-user.

I'm going to close this issue because as it is it's not actionable. Thanks for the help!