Proxy option captures also flags defined before it
lordofthelake opened 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 requiremy-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!