From d35a1cc5cc30ed889faf0f734cae07a8a2b2aa01 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 6 Oct 2026 04:41:45 -0700 Subject: [PATCH 1/2] fix(modules): root flags before a reserved subcommand under an alias The reserved-token rule (row 2) decides whether a reserved root subcommand is the leading token or a module positional by looking at args[1]. With a root flag first, 'wheels --timing cfml x' had args[1] = --timing, so 'cfml' was treated as a module positional and the whole command went to the module: the expression never ran. The leading position now skips root options and the separate value of --env/-e/--envfile/--timeout; a reserved token there is a root subcommand the user invoked directly. Reserved tokens later in a module command are still rewritten through 'modules run'. Fork-only: follows row 2 (upstream PR cybersonic/LuCLI#124). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- CHANGELOG.md | 2 + FORK.md | 1 + src/main/java/org/lucee/lucli/LuCLI.java | 25 +++++++++++- .../lucli/LuCLIModuleHelpPreprocessTest.java | 38 +++++++++++++++++++ 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb6c567..4397b93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to this project will be documented in this file. ## Unreleased +- **Fix: Root Flags Before a Root Subcommand Under an Aliased Binary:** `wheels --timing cfml 'x'` (any root option such as `--timing`, `--verbose`, `-e prod`, `--timeout=30` before a reserved root subcommand) was handed to the module, because the reserved-token rule treated the subcommand as a module positional when it wasn't at index 1. The leading position now skips root options and their separate values, so the root subcommand runs with the flags as it does without them. Reserved tokens later in a module command are still rewritten as before. Adds `LuCLIModuleHelpPreprocessTest` cases. + - **Feature: Modules Can Tell an MCP `tools/call` From a Terminal Call:** During `tools/call`, the module function now receives a reserved `__lucliMcpCall=true` argument, so a module can apply a different policy to MCP clients (for example refusing a command meant for a person at the terminal). The argument is runtime-owned: any argument of that name (any case, with or without `--`) supplied by an MCP client or on the command line is removed before the module runs, so it can't be forged or suppressed, and a terminal invocation never carries it. The positional binder never fills a declared parameter of that name from a terminal `argN`. Documented in the MCP module servers guide. Adds coverage in `McpCommandTest` (`toolsCallCarriesARuntimeOwnedMcpMarker`, `terminalInvocationNeverCarriesTheMcpMarker`, `declaredMarkerParameterIsNeverBoundFromATerminalPositional`). - **Change: Dev Servers Listen on `127.0.0.1` by Default:** Servers used to listen on every network interface (Tomcat connectors had no `address`, Jetty no `jetty.http.host`, Docker published the port on all host interfaces), so a `lucli server start` was reachable from the whole local network. A new `lucee.json` key `bindAddress` (default `127.0.0.1`) now sets the HTTP and HTTPS connectors' `address` and that of every other connector (AJP, Nio2, APR, ...) that server.xml leaves without one, Jetty's `jetty.http.host`, the host side of Docker's `-p`, and JMX monitoring (`jmxremote.host` plus a matching RMI hostname and port). Unauthenticated JMX is refused on a non-loopback bind unless authentication is configured in `jvm.additionalArgs`, and the schema's documented `monitoring.enabled` default is corrected to `false`. Opt in to other interfaces with `"bindAddress": "0.0.0.0"`, `server start|run --host 0.0.0.0`, or the one-shot `bindAddress=0.0.0.0` override; the startup output shows the listen address and warns when it is all interfaces. `host` keeps its meaning (URL and certificate hostname). Adds coverage in `TomcatServerXmlPatcherTest`, `DockerRuntimeProviderTest`, `JettyBaseConfigGeneratorTest`, `LuceeServerConfigTest` and `ServerConfigHelperTest`. diff --git a/FORK.md b/FORK.md index 4336b50..d74b6b9 100644 --- a/FORK.md +++ b/FORK.md @@ -83,6 +83,7 @@ No release is tagged for this queue yet; tagging needs the Wheels release owner' | Order | Change | Purpose | |---|---|---| | 8 | wheels-dev/LuCLI [#19](https://github.com/wheels-dev/LuCLI/pull/19) (issue [#17](https://github.com/wheels-dev/LuCLI/issues/17)) | A module function run by an MCP `tools/call` receives the runtime-owned `__lucliMcpCall=true` argument; any caller-supplied argument of that name is dropped, and a terminal call never carries it | +| 12 | wheels-dev/LuCLI PR (this change; fork-only: follows row 2 / upstream PR [cybersonic/LuCLI#124](https://github.com/cybersonic/LuCLI/pull/124)) | Root options before a reserved root subcommand under an aliased binary run that subcommand instead of the module (`wheels --timing cfml 'x'`) | Upstream (row 8): issue [cybersonic/LuCLI#133](https://github.com/cybersonic/LuCLI/issues/133), PR [cybersonic/LuCLI#134](https://github.com/cybersonic/LuCLI/pull/134) (a `cherry-pick -x` of diff --git a/src/main/java/org/lucee/lucli/LuCLI.java b/src/main/java/org/lucee/lucli/LuCLI.java index 318358c..e283f4a 100644 --- a/src/main/java/org/lucee/lucli/LuCLI.java +++ b/src/main/java/org/lucee/lucli/LuCLI.java @@ -769,6 +769,22 @@ private static String[] preprocessModuleHelp(String[] args) { return rewriteModuleHelpOrReserved(args); } + /** Root options that take their value as the next token (when not written --opt=value). */ + static final Set ROOT_OPTIONS_WITH_VALUE = Set.of("--env", "-e", "--envfile", "--timeout"); + + /** + * Index of the first token after the module name ({@code args[0]}) that isn't a root + * option or a root option's separate value. Returns {@code args.length} when there is none. + */ + static int leadingPositionAfterRootOptions(String[] args) { + int i = 1; + while (i < args.length && args[i].startsWith("-") && !"--help".equals(args[i]) && !"-h".equals(args[i])) { + boolean takesNext = ROOT_OPTIONS_WITH_VALUE.contains(args[i]); + i += takesNext ? 2 : 1; + } + return Math.min(i, args.length); + } + /** * Rewrite a module invocation whose args would otherwise be intercepted by * picocli's root subcommands. Assumes {@code args[0]} is already an @@ -809,9 +825,14 @@ static String[] rewriteModuleHelpOrReserved(String[] args) { // user invoked directly, e.g. `wheels server`) — only positionals at // args[2..] belong to the module, so a reserved token there means // picocli would hijack it to a root subcommand. + // The leading position is the first token after the module name that isn't a + // root option: `wheels --timing cfml 'x'` invokes the root `cfml` subcommand + // with --timing, exactly like `wheels cfml 'x'` (it used to be handed to the + // module because `cfml` sat at index 2). + int lead = leadingPositionAfterRootOptions(args); boolean hasReservedPositional = false; - if (!RESERVED_ROOT_SUBCOMMANDS.contains(args[1])) { - for (int i = 2; i < args.length; i++) { + if (lead < args.length && !RESERVED_ROOT_SUBCOMMANDS.contains(args[lead])) { + for (int i = lead + 1; i < args.length; i++) { if (RESERVED_ROOT_SUBCOMMANDS.contains(args[i])) { hasReservedPositional = true; break; diff --git a/src/test/java/org/lucee/lucli/LuCLIModuleHelpPreprocessTest.java b/src/test/java/org/lucee/lucli/LuCLIModuleHelpPreprocessTest.java index 6aff4a3..008bde8 100644 --- a/src/test/java/org/lucee/lucli/LuCLIModuleHelpPreprocessTest.java +++ b/src/test/java/org/lucee/lucli/LuCLIModuleHelpPreprocessTest.java @@ -1,6 +1,7 @@ package org.lucee.lucli; import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; @@ -83,4 +84,41 @@ void rootSubcommandAtLeadingPosition_isLeftUnchanged() { LuCLI.rewriteModuleHelpOrReserved(new String[]{"wheels", "server", "start"}) ); } + + // Root options before a reserved root subcommand: the subcommand is still the + // leading token, so it runs as a root command (e.g. `wheels --timing cfml 'x'`). + @Test + void rootFlagBeforeReservedSubcommand_isNotHandedToTheModule() { + String[] args = {"wheels", "--timing", "cfml", "writeOutput(1)"}; + assertArrayEquals(args, LuCLI.rewriteModuleHelpOrReserved(args)); + } + + @Test + void rootOptionWithSeparateValueBeforeReservedSubcommand_isNotHandedToTheModule() { + String[] args = {"wheels", "--env", "prod", "server", "start"}; + assertArrayEquals(args, LuCLI.rewriteModuleHelpOrReserved(args)); + } + + @Test + void rootOptionWithInlineValueBeforeReservedSubcommand_isNotHandedToTheModule() { + String[] args = {"wheels", "--timeout=30", "-v", "run", "x.cfm"}; + assertArrayEquals(args, LuCLI.rewriteModuleHelpOrReserved(args)); + } + + @Test + void rootFlagBeforeModuleSubcommand_reservedPositionalStillRewritten() { + assertArrayEquals( + new String[]{"modules", "run", "wheels", "--verbose", "generate", "controller", "StaticPages", "help"}, + LuCLI.rewriteModuleHelpOrReserved(new String[]{"wheels", "--verbose", "generate", "controller", "StaticPages", "help"}) + ); + } + + @Test + void leadingPositionSkipsRootOptionsAndTheirValues() { + assertEquals(3, LuCLI.leadingPositionAfterRootOptions(new String[]{"wheels", "-t", "--verbose", "cfml"})); + assertEquals(3, LuCLI.leadingPositionAfterRootOptions(new String[]{"wheels", "-e", "prod", "server"})); + assertEquals(2, LuCLI.leadingPositionAfterRootOptions(new String[]{"wheels", "--envfile=.env", "repl"})); + assertEquals(2, LuCLI.leadingPositionAfterRootOptions(new String[]{"wheels", "--env"})); + assertEquals(1, LuCLI.leadingPositionAfterRootOptions(new String[]{"wheels", "--help"})); + } } From a0e50585a32272f64f8972a4784a4ed39af09b7e Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 6 Oct 2026 04:42:41 -0700 Subject: [PATCH 2/2] docs(fork): link row 12 to #24 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- FORK.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FORK.md b/FORK.md index d74b6b9..fb11b9d 100644 --- a/FORK.md +++ b/FORK.md @@ -83,7 +83,7 @@ No release is tagged for this queue yet; tagging needs the Wheels release owner' | Order | Change | Purpose | |---|---|---| | 8 | wheels-dev/LuCLI [#19](https://github.com/wheels-dev/LuCLI/pull/19) (issue [#17](https://github.com/wheels-dev/LuCLI/issues/17)) | A module function run by an MCP `tools/call` receives the runtime-owned `__lucliMcpCall=true` argument; any caller-supplied argument of that name is dropped, and a terminal call never carries it | -| 12 | wheels-dev/LuCLI PR (this change; fork-only: follows row 2 / upstream PR [cybersonic/LuCLI#124](https://github.com/cybersonic/LuCLI/pull/124)) | Root options before a reserved root subcommand under an aliased binary run that subcommand instead of the module (`wheels --timing cfml 'x'`) | +| 12 | wheels-dev/LuCLI [#24](https://github.com/wheels-dev/LuCLI/pull/24) (fork-only: follows row 2 / upstream PR [cybersonic/LuCLI#124](https://github.com/cybersonic/LuCLI/pull/124)) | Root options before a reserved root subcommand under an aliased binary run that subcommand instead of the module (`wheels --timing cfml 'x'`) | Upstream (row 8): issue [cybersonic/LuCLI#133](https://github.com/cybersonic/LuCLI/issues/133), PR [cybersonic/LuCLI#134](https://github.com/cybersonic/LuCLI/pull/134) (a `cherry-pick -x` of